Compare commits

..

23 Commits

Author SHA1 Message Date
Yuna Seol bd5d18c210 chore: remove unnecessary empty ignore files and plan.md 2026-04-10 13:42:51 -04:00
Yuna Seol 4f9293e8e9 fix(ui): always show context window warning on terminal overflow
This removes the early return in the `handleContextWindowWillOverflowEvent` hook. If the context window truly overflows and the message is blocked, we must tell the user why, regardless of their proactive warning settings. This prevents a silent block where the CLI appears to just swallow the user's input.
2026-04-10 13:32:18 -04:00
Keith Guerin 9b51ccf82a fix(cli): resolve CI test failures and stabilize mocks 2026-03-24 23:15:54 -07:00
Keith Guerin c47bca8374 fix(cli): address CI build and lint failures by using stable geminiClient reference 2026-03-24 17:20:13 -07:00
Keith Guerin 2c83cf9603 fix(cli): address CI build and lint failures 2026-03-24 15:53:55 -07:00
Keith Guerin 84432ddaa8 fix(ci): address final test and build issues after rebase 2026-03-24 14:02:02 -07:00
Keith Guerin 05d29d68aa fix(ci): address final test and build issues after rebase 2026-03-23 23:13:30 -07:00
Keith Guerin 99ddc299ad feat(ui): refine context messaging with window terminology and large prompt override 2026-03-23 22:24:11 -07:00
Keith Guerin cd4c8821cb fix(ci): address lint, build and test failures after merge 2026-03-23 22:24:11 -07:00
Keith Guerin 8d0c2a3acb fix(cli): resolve CI build and lint failures by fixing TS types and formatting 2026-03-23 22:24:11 -07:00
Keith Guerin 184a9abb59 docs(settings): regenerate schema and docs, and fix lint issue 2026-03-23 22:24:11 -07:00
Keith Guerin 4b2c117af3 fix(cli): resolve merge conflicts and align with async test helpers 2026-03-23 22:24:11 -07:00
Keith Guerin 511cbc54c9 fix(cli): resolve merge conflicts and align with async test helpers 2026-03-23 22:23:26 -07:00
Keith Guerin 692c34c834 fix(cli): fix missing awaits in tests after merge 2026-03-23 22:22:02 -07:00
Keith Guerin 3be1de915f fix(cli): resolve compilation errors and update test mocks 2026-03-23 22:22:02 -07:00
Keith Guerin 16485afa96 feat(ui): finalize compression UX with threshold hint and large prompt override 2026-03-23 22:21:11 -07:00
Keith Guerin 375afc7199 feat(ui): restore threshold hint and thin arrow to compression message 2026-03-23 22:21:11 -07:00
Keith Guerin fd8d218911 docs(ui): refine documentation and test eslint-disable for context UI changes 2026-03-23 22:20:51 -07:00
Keith Guerin 1de0367faa docs(ui): update settings.md and settingsSchema descriptions for context UI changes 2026-03-23 22:20:15 -07:00
Keith Guerin b6231f561b docs(settings): rename context compression setting to use 'Messages' for clarity 2026-03-23 22:19:16 -07:00
Keith Guerin 252dbeb39a feat(ui): add showContextCompression setting and simplify message wording 2026-03-23 22:19:16 -07:00
Keith Guerin 39fb7b11a8 feat(ui): finalize context and compression messaging with symbolic arrow and refined wording 2026-03-23 22:17:47 -07:00
Keith Guerin eb3e540f3f feat(ui): redesign context and compression UI for a more seamless experience
- Added ui.showContextWindowWarning setting (default false).
- Implemented forced auto-compression on context overflow when warning is disabled.
- Redesigned compression messages to be subtle (gray, no icon, left border).
- Removed automatic context usage percentage from the minimal/focus UI.
- Changed ui.hideContextSummary default to true.
- Updated and verified all relevant tests.

Note: Includes a behavioral change where the CLI now attempts to force-compress
history when the context window is full rather than blocking by default.
2026-03-23 22:03:31 -07:00
211 changed files with 3469 additions and 10514 deletions
-66
View File
@@ -1,66 +0,0 @@
---
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
@@ -1,281 +0,0 @@
#!/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 -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
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false --silent
- name: '🔗 Install latest core package'
working-directory: '${{ inputs.working-directory }}'
@@ -221,7 +221,7 @@ runs:
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_CLI_PACKAGE_NAME}" \
--no-tag
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false --silent
- name: 'Get a2a-server Token'
uses: './.github/actions/npm-auth-token'
@@ -246,7 +246,7 @@ runs:
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_A2A_PACKAGE_NAME}" \
--no-tag
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false --silent
- name: '🔬 Verify NPM release by version'
uses: './.github/actions/verify-release'
-6
View File
@@ -158,12 +158,6 @@ 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'
+2 -2
View File
@@ -323,8 +323,8 @@ fi
#### Formatting
To separately format the code in this project, run the following command from
the root directory:
To separately format the code in this project by running the following command
from the root directory:
```bash
npm run format
+364 -355
View File
@@ -1,6 +1,6 @@
# Preview release: v0.36.0-preview.0
# Preview release: v0.35.0-preview.5
Released: March 24, 2026
Released: March 23, 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,366 +13,375 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **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.
- **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.
## What's Changed
- 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
[#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
- 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
@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
[#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
[#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
[#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
[#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
[#23310](https://github.com/google-gemini/gemini-cli/pull/23310)
- fix(core): enable global session and persistent approval for web_fetch 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
[#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
[#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
[#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
[#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
[#23572](https://github.com/google-gemini/gemini-cli/pull/23572)
- fix(telemetry): patch memory leak and enforce logPrompts privacy by
[#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
[#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)
[#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)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.0
https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.5
+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.
### 5. LXC/LXD (Linux only, experimental)
### 4. 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
+35 -33
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. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `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` |
@@ -46,38 +46,40 @@ they appear in the UI.
### UI
| UI Label | Setting | Description | Default |
| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
| UI Label | Setting | Description | Default |
| ------------------------------------ | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `true` |
| Show Context Window Warning | `ui.showContextWindowWarning` | Show a warning message when the context window limit is nearly reached. If disabled, the CLI will attempt to automatically compress the history when the limit is reached. | `false` |
| Show Context Compression Messages | `ui.showContextCompression` | Show a message in the chat history when it is compressed. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
### IDE
+11 -101
View File
@@ -51,13 +51,12 @@ 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. 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). |
| 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). |
### Single-subagent example
@@ -89,95 +88,6 @@ 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
@@ -194,7 +104,7 @@ Gemini CLI supports the following authentication types:
| `apiKey` | Send a static API key as an HTTP header. |
| `http` | HTTP authentication (Bearer token, Basic credentials, or any IANA-registered scheme). |
| `google-credentials` | Google Application Default Credentials (ADC). Automatically selects access or identity tokens. |
| `oauth` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. |
| `oauth2` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. |
### Dynamic values
@@ -353,7 +263,7 @@ hosts:
Requests to any other host will be rejected with an error. If your agent is
hosted on a different domain, use one of the other auth types (`apiKey`, `http`,
or `oauth`).
or `oauth2`).
#### Examples
@@ -387,7 +297,7 @@ auth:
---
```
### OAuth 2.0 (`oauth`)
### OAuth 2.0 (`oauth2`)
Performs an interactive OAuth 2.0 Authorization Code flow with PKCE. On first
use, Gemini CLI opens your browser for sign-in and persists the resulting tokens
@@ -395,7 +305,7 @@ for subsequent requests.
| Field | Type | Required | Description |
| :------------------ | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | string | Yes | Must be `oauth`. |
| `type` | string | Yes | Must be `oauth2`. |
| `client_id` | string | Yes\* | OAuth client ID. Required for interactive auth. |
| `client_secret` | string | No\* | OAuth client secret. Required by most authorization servers (confidential clients). Can be omitted for public clients that don't require a secret. |
| `scopes` | string[] | No | Requested scopes. Can also be discovered from the agent card. |
@@ -408,7 +318,7 @@ kind: remote
name: oauth-agent
agent_card_url: https://example.com/.well-known/agent.json
auth:
type: oauth
type: oauth2
client_id: my-client-id.apps.example.com
---
```
+14 -44
View File
@@ -143,8 +143,7 @@ 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. A custom directory
requires a policy to allow write access in Plan Mode.
specified, defaults to the system temporary directory.
- **Default:** `undefined`
- **Requires restart:** Yes
@@ -264,6 +263,16 @@ their corresponding top-level category object in your `settings.json` file.
- **`ui.hideContextSummary`** (boolean):
- **Description:** Hide the context summary (GEMINI.md, MCP servers) above the
input.
- **Default:** `true`
- **`ui.showContextWindowWarning`** (boolean):
- **Description:** Show a warning message when the context window limit is
nearly reached. If disabled, the CLI will attempt to automatically compress
the history when the limit is reached.
- **Default:** `false`
- **`ui.showContextCompression`** (boolean):
- **Description:** Show a message in the chat history when it is compressed.
- **Default:** `false`
- **`ui.footer.items`** (array):
@@ -646,11 +655,6 @@ 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"
@@ -855,12 +859,6 @@ 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"
}
]
},
@@ -985,17 +983,6 @@ 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": [
@@ -1008,15 +995,7 @@ their corresponding top-level category object in your `settings.json` file.
]
},
"flash-lite": {
"default": "gemini-2.5-flash-lite",
"contexts": [
{
"condition": {
"useGemini3_1FlashLite": true
},
"target": "gemini-3.1-flash-lite-preview"
}
]
"default": "gemini-2.5-flash-lite"
}
}
```
@@ -1246,11 +1225,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Disable user input on browser window during automation.
- **Default:** `true`
- **`agents.browser.maxActionsPerTask`** (number):
- **Description:** The maximum number of tool calls allowed per browser task.
Enforcement is hard: the agent will be terminated when the limit is reached.
- **Default:** `100`
- **`agents.browser.confirmSensitiveActions`** (boolean):
- **Description:** Require manual confirmation for sensitive browser actions
(e.g., fill_form, evaluate_script).
@@ -1576,7 +1550,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `true`
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.worktrees`** (boolean):
@@ -2377,13 +2351,9 @@ can be based on the base sandbox image:
```dockerfile
FROM gemini-cli-sandbox
# 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.
# Add your custom dependencies or configurations here
# For example:
# USER root
# RUN apt-get update && apt-get install -y some-package
# USER node
# COPY ./my-config /app/my-config
```
+22 -55
View File
@@ -63,62 +63,29 @@ details.
## Available tools
The following sections list all available tools, categorized by their primary
function. For detailed parameter information, see the linked documentation for
each tool.
The following table lists all available tools, categorized by their primary
function.
### 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. |
| 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` |
## Under the hood
@@ -1,94 +0,0 @@
# Research Proposal: Cloud State Sync
This document explores the architectural shift of moving the global `~/.gemini`
folder to the cloud. The goal is to enable a shared data store for plans,
settings, and configurations across devices, instances, and sessions while
maintaining performance, security, and portability.
## Objective
Transition Gemini CLI from a local-first state management system to a
distributed, synchronized agent. This allows you to start a task on one machine
(e.g., a local laptop) and resume it seamlessly on another (e.g., a remote
workstation or a different office machine).
## Data Categorization
Not all data within `~/.gemini` should be treated equally. We categorize the
contents to determine the most effective synchronization strategy.
- **Static and Configuration:** `settings.json`, `projects.json`, and `policies/`.
These are small files with low-frequency updates, making them ideal for simple
cloud synchronization.
- **Sensitive and Identity:** `google_accounts.json` and `installation_id`. These
contain refresh tokens and unique identifiers that require high-grade,
client-side encryption before leaving the device.
- **High-Volume and Ephemeral:** `history/`, `tmp/`, and `antigravity/` (browser
profiles). These are large or frequently updated. Naive synchronization would
be slow and resource-intensive.
- **Computed and Environment-Specific:** `extension_integrity.json` and
`trustedFolders.json`. These often contain absolute local paths that may not
be valid across different machines or operating systems.
## Architectural Approaches
We are evaluating three primary methods for implementing cloud synchronization.
### Virtual Filesystem (VFS) Layer
Implement an abstraction layer for Node.js `fs` calls that transparently
redirects operations to a cloud provider like GCS or S3.
- **Pros:** Requires minimal changes to existing high-level logic.
- **Cons:** Network latency on every file operation could degrade the user
experience. Requires a robust local LRU (Least Recently Used) cache.
### Git-Ops and Snapshot Syncing
Automatically commit and push state changes to a private, hidden repository.
- **Pros:** Provides built-in versioning, conflict resolution, and portability.
- **Cons:** High overhead for frequent, small writes, such as session history
updates.
### Centralized State Server (The "Brain")
Communicate with a lightweight service (gRPC or WebSockets) that acts as the
single source of truth for the agent's state.
- **Pros:** Enables real-time synchronization across active sessions.
- **Cons:** Requires a hosted service and a persistent internet connection.
## Technical Considerations
The following table outlines the primary technical challenges and proposed
solutions for a cloud-synced state.
| Dimension | Challenge | Potential Solution |
| :--- | :--- | :--- |
| **Performance** | Shell startup latency is critical. | **Stale-While-Revalidate:** Load local cache instantly; sync in the background. |
| **Security** | Secrets management in the cloud. | **Zero-Knowledge Encryption:** Encrypt secrets client-side with a user passphrase. |
| **Portability** | Inconsistent absolute paths. | **Path Normalization:** Store paths relative to `$HOME` or use UUIDs. |
| **Conflicts** | Simultaneous edits on multiple devices. | **CRDTs:** Use Conflict-free Replicated Data Types for plans and history. |
## Hybrid Strategy Proposal
A tiered synchronization model balances performance with portability.
1. **Tier 1 (Cloud Native):** Sync settings, plans, and global memories
immediately via a structured API.
2. **Tier 2 (Lazy Sync):** Upload session history and logs at the end of a
session or when explicitly resuming on another device.
3. **Tier 3 (Local Only):** Keep large caches, browser profiles, and
environment-specific integrity checks local to the device.
## Next Steps
To move forward with this proposal, we must perform the following actions:
1. Audit the current `~/.gemini` directory to determine the typical data
volume, particularly within the `history/` folder.
2. Identify all files within `~/.gemini` that contain environment-dependent
absolute paths.
3. Prototype a synchronization provider using a private Git repository or a
dedicated cloud storage bucket.
+1 -1
View File
@@ -79,7 +79,7 @@ export function appEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
}
// Render the app!
await rig.render();
rig.render();
// Wait for initial ready state
await rig.waitForIdle();
-50
View File
@@ -136,32 +136,6 @@ describe('plan_mode', () => {
expect(wasToolCalled, 'Expected exit_plan_mode tool to be called').toBe(
true,
);
const toolLogs = rig.readToolLogs();
const exitPlanCall = toolLogs.find(
(log) => log.toolRequest.name === 'exit_plan_mode',
);
expect(
exitPlanCall,
'Expected to find exit_plan_mode in tool logs',
).toBeDefined();
const args = JSON.parse(exitPlanCall!.toolRequest.args);
expect(args.plan_filename, 'plan_filename should be a string').toBeTypeOf(
'string',
);
expect(args.plan_filename, 'plan_filename should end with .md').toMatch(
/\.md$/,
);
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('/');
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('\\');
assertModelHasOutput(result);
},
});
@@ -225,30 +199,6 @@ describe('plan_mode', () => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const exitPlanCall = toolLogs.find(
(log) => log.toolRequest.name === 'exit_plan_mode',
);
expect(
exitPlanCall,
'Expected to find exit_plan_mode in tool logs',
).toBeDefined();
const args = JSON.parse(exitPlanCall!.toolRequest.args);
expect(args.plan_filename, 'plan_filename should be a string').toBeTypeOf(
'string',
);
expect(args.plan_filename, 'plan_filename should end with .md').toMatch(
/\.md$/,
);
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('/');
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('\\');
// Check if plan was written
const planWrite = toolLogs.find(
(log) =>
-82
View File
@@ -1,82 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import path from 'node:path';
import fs from 'node:fs/promises';
describe('redundant_casts', () => {
evalTest('USUALLY_PASSES', {
name: 'should not add redundant or unsafe casts when modifying typescript code',
files: {
'src/cast_example.ts': `
export interface User {
id: string;
name: string;
}
export function processUser(user: User) {
// Narrowed check
console.log("Processing user: " + user.name);
}
export function handleUnknown(data: unknown) {
// Goal: log data.id if it exists
console.log("Handling data");
}
export function handleError() {
try {
throw new Error("fail");
} catch (err) {
// Goal: log err.message
console.error("Error happened");
}
}
`,
},
prompt: `
1. In src/cast_example.ts, update processUser to return the name in uppercase.
2. In handleUnknown, log the "id" property if "data" is an object that contains it.
3. In handleError, log the error message from "err".
`,
assert: async (rig) => {
const filePath = path.join(rig.testDir!, 'src/cast_example.ts');
const content = await fs.readFile(filePath, 'utf-8');
// 1. Redundant Cast Check (Same type)
// Bad: (user.name as string).toUpperCase()
expect(content, 'Should not cast a known string to string').not.toContain(
'as string',
);
// 2. Unsafe Cast Check (Unknown object)
// Bad: (data as any).id or (data as {id: string}).id
expect(
content,
'Should not use unsafe casts for unknown property access',
).not.toContain('as any');
expect(
content,
'Should not use unsafe casts for unknown property access',
).not.toContain('as {');
// 3. Unsafe Cast Check (Error handling)
// Bad: (err as Error).message
// Good: if (err instanceof Error) { ... }
expect(
content,
'Should prefer instanceof over casting for errors',
).not.toContain('as Error');
// Verify implementation
expect(content).toContain('toUpperCase()');
expect(content).toContain('message');
expect(content).toContain('id');
},
});
});
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('Sandbox recovery', () => {
evalTest('USUALLY_PASSES', {
name: 'attempts to use additional_permissions when operation not permitted',
prompt:
'Run ./script.sh. It will fail with "Operation not permitted". When it does, you must retry running it by passing the appropriate additional_permissions.',
files: {
'script.sh':
'#!/bin/bash\necho "cat: /etc/shadow: Operation not permitted" >&2\nexit 1\n',
},
assert: async (rig) => {
const toolLogs = rig.readToolLogs();
const shellCalls = toolLogs.filter(
(log) =>
log.toolRequest?.name === 'run_shell_command' &&
log.toolRequest?.args?.includes('script.sh'),
);
// The agent should have tried running the command.
expect(
shellCalls.length,
'Agent should have called run_shell_command',
).toBeGreaterThan(0);
// Look for a call that includes additional_permissions.
const hasAdditionalPermissions = shellCalls.some((call) => {
const args =
typeof call.toolRequest.args === 'string'
? JSON.parse(call.toolRequest.args)
: call.toolRequest.args;
return args.additional_permissions !== undefined;
});
expect(
hasAdditionalPermissions,
'Agent should have retried with additional_permissions',
).toBe(true);
},
});
});
+32 -17
View File
@@ -9,7 +9,27 @@ import path from 'node:path';
import { describe, expect } from 'vitest';
import { evalTest, TEST_AGENTS } from './test-helper.js';
import { evalTest } from './test-helper.js';
const DOCS_AGENT_DEFINITION = `---
name: docs-agent
description: An agent with expertise in updating documentation.
tools:
- read_file
- write_file
---
You are the docs agent. Update documentation clearly and accurately.
`;
const TEST_AGENT_DEFINITION = `---
name: test-agent
description: An agent with expertise in writing and updating tests.
tools:
- read_file
- write_file
---
You are the test agent. Add or update tests.
`;
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
@@ -42,12 +62,12 @@ describe('subagent eval test cases', () => {
},
prompt: 'Please update README.md with a description of this library.',
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
'.gemini/agents/docs-agent.md': DOCS_AGENT_DEFINITION,
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
},
assert: async (rig, _result) => {
await rig.expectToolCallSuccess([TEST_AGENTS.DOCS_AGENT.name]);
await rig.expectToolCallSuccess(['docs-agent']);
},
});
@@ -72,7 +92,7 @@ describe('subagent eval test cases', () => {
prompt:
'Rename the exported function in index.ts from add to sum and update the file directly.',
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
'.gemini/agents/docs-agent.md': DOCS_AGENT_DEFINITION,
'index.ts': INDEX_TS,
},
assert: async (rig, _result) => {
@@ -82,11 +102,9 @@ describe('subagent eval test cases', () => {
}>;
expect(updatedIndex).toContain('export const sum =');
expect(
toolLogs.some(
(l) => l.toolRequest.name === TEST_AGENTS.DOCS_AGENT.name,
),
).toBe(false);
expect(toolLogs.some((l) => l.toolRequest.name === 'docs-agent')).toBe(
false,
);
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
false,
);
@@ -115,7 +133,7 @@ describe('subagent eval test cases', () => {
},
prompt: 'Please add a small test file that verifies add(1, 2) returns 3.',
files: {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'.gemini/agents/test-agent.md': TEST_AGENT_DEFINITION,
'index.ts': INDEX_TS,
'package.json': JSON.stringify(
{
@@ -132,7 +150,7 @@ describe('subagent eval test cases', () => {
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess([TEST_AGENTS.TESTING_AGENT.name]);
await rig.expectToolCallSuccess(['test-agent']);
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
false,
);
@@ -160,8 +178,8 @@ describe('subagent eval test cases', () => {
prompt:
'Add a short README description for this library and also add a test file that verifies add(1, 2) returns 3.',
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
'.gemini/agents/docs-agent.md': DOCS_AGENT_DEFINITION,
'.gemini/agents/test-agent.md': TEST_AGENT_DEFINITION,
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
'package.json': JSON.stringify(
@@ -180,10 +198,7 @@ describe('subagent eval test cases', () => {
}>;
const readme = readProjectFile(rig, 'README.md');
await rig.expectToolCallSuccess([
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
]);
await rig.expectToolCallSuccess(['docs-agent', 'test-agent']);
expect(readme).not.toContain('TODO: update the README.');
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
false,
+6
View File
@@ -63,6 +63,9 @@ describe.skipIf(!chromeAvailable)('browser-policy', () => {
rig.setup('browser-policy-skip-confirmation', {
fakeResponsesPath: join(__dirname, 'browser-policy.responses'),
settings: {
experimental: {
enableAgents: true,
},
agents: {
overrides: {
browser_agent: {
@@ -180,6 +183,9 @@ priority = 200
rig.setup('browser-session-warning', {
fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
settings: {
experimental: {
enableAgents: true,
},
general: {
enableAutoUpdateNotification: false,
},
@@ -48,7 +48,8 @@ describe('Interactive Mode', () => {
true,
);
await run.expectText('Chat history compressed', 5000);
await run.expectText('Context compressed', 5000);
await run.expectText('Adjust threshold', 5000);
});
// TODO: Context compression is broken and doesn't include the system
+2 -2
View File
@@ -6,9 +6,9 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import { TestRig, skipFlaky } from './test-helper.js';
import { TestRig } from './test-helper.js';
describe.skipIf(skipFlaky)('Ctrl+C exit', () => {
describe('Ctrl+C exit', () => {
let rig: TestRig;
beforeEach(() => {
+3 -7
View File
@@ -34,20 +34,16 @@ describe('extension install', () => {
writeFileSync(testServerPath, extension);
try {
const result = await rig.runCommand(
['--debug', 'extensions', 'install', `${rig.testDir!}`],
['extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension-install');
const listResult = await rig.runCommand([
'--debug',
'extensions',
'list',
]);
const listResult = await rig.runCommand(['extensions', 'list']);
expect(listResult).toContain('test-extension-install');
writeFileSync(testServerPath, extensionUpdate);
const updateResult = await rig.runCommand(
['--debug', 'extensions', 'update', `test-extension-install`],
['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(
['--debug', 'extensions', 'install', `${rig.testDir!}`],
['extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
+58 -92
View File
@@ -4,8 +4,10 @@
* 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 } from './test-helper.js';
import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
@@ -34,24 +36,28 @@ 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',
args: 'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
stdin:
'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');
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)',
toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'),
).toBeUndefined();
expect(lsLog?.toolRequest.success).toBe(true);
checkModelOutputContent(result, {
expectedContent: ['Plan Mode', 'read-only'],
testName: 'Plan Mode restrictions test',
@@ -78,11 +84,23 @@ describe('Plan Mode', () => {
},
});
await rig.run({
// 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({
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) =>
@@ -90,25 +108,7 @@ describe('Plan Mode', () => {
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan.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.md',
).toBeDefined();
expect(
planWrite?.toolRequest.success,
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
).toBe(true);
expect(planWrite?.toolRequest.success).toBe(true);
});
it('should deny write_file to non-plans directory in plan mode', async () => {
@@ -131,11 +131,19 @@ describe('Plan Mode', () => {
},
});
await rig.run({
// 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({
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) =>
@@ -143,11 +151,10 @@ 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,
'Expected write_file to non-plans dir to fail',
).toBe(false);
expect(writeLog.toolRequest.success).toBe(false);
}
});
@@ -162,69 +169,28 @@ 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',
args: 'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
stdin:
'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);
});
});
+1 -7
View File
@@ -183,17 +183,11 @@ describe('Policy Engine Headless Mode', () => {
responsesFile: 'policy-headless-shell-denied.responses',
promptCommand: ECHO_PROMPT,
policyContent: `
[[rule]]
toolName = "run_shell_command"
commandPrefix = "echo"
decision = "deny"
priority = 100
[[rule]]
toolName = "run_shell_command"
commandPrefix = "node"
decision = "allow"
priority = 90
priority = 100
`,
expectAllowed: false,
expectedDenialString: 'Tool execution denied by policy',
+3 -9
View File
@@ -58,18 +58,12 @@ function getDisallowedFileReadCommand(testFile: string): {
const quotedPath = `"${testFile}"`;
switch (shell) {
case 'powershell':
return {
command: `powershell -Command "Get-Content ${quotedPath}"`,
tool: 'powershell',
};
return { command: `Get-Content ${quotedPath}`, tool: 'Get-Content' };
case 'cmd':
return { command: `cmd /c type ${quotedPath}`, tool: 'cmd' };
return { command: `type ${quotedPath}`, tool: 'type' };
case 'bash':
default:
return {
command: `node -e "console.log(require('fs').readFileSync('${testFile}', 'utf8'))"`,
tool: 'node',
};
return { command: `cat ${quotedPath}`, tool: 'cat' };
}
}
+21 -21
View File
@@ -8696,9 +8696,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.2.tgz",
"integrity": "sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==",
"funding": [
{
"type": "github",
@@ -8711,9 +8711,9 @@
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.9",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz",
"integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==",
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.3.tgz",
"integrity": "sha512-Ymnuefk6VzAhT3SxLzVUw+nMio/wB1NGypHkgetwtXcK1JfryaHk4DWQFGVwQ9XgzyS5iRZ7C2ZGI4AMsdMZ6A==",
"funding": [
{
"type": "github",
@@ -8722,9 +8722,9 @@
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.2"
"fast-xml-builder": "^1.1.2",
"path-expression-matcher": "^1.1.3",
"strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -8900,9 +8900,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true,
"license": "ISC"
},
@@ -13200,9 +13200,9 @@
}
},
"node_modules/path-expression-matcher": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
"funding": [
{
"type": "github",
@@ -15465,9 +15465,9 @@
}
},
"node_modules/strnum": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
"funding": [
{
"type": "github",
@@ -16469,9 +16469,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
"integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.19.0.tgz",
"integrity": "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -29,7 +29,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
PRIORITY_YOLO_ALLOW_ALL: 998,
Config: vi.fn().mockImplementation((params) => {
const mockConfig = {
...params,
@@ -342,11 +341,11 @@ describe('loadConfig', () => {
);
});
it('should default enableAgents to true when not provided', async () => {
it('should default enableAgents to false when not provided', async () => {
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableAgents: true,
enableAgents: false,
}),
);
});
+1 -1
View File
@@ -128,7 +128,7 @@ export async function loadConfig(
interactive: !isHeadlessMode(),
enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
enableAgents: settings.experimental?.enableAgents ?? true,
enableAgents: settings.experimental?.enableAgents ?? false,
};
const fileService = new FileDiscoveryService(workspaceDir, {
+5 -178
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,6 +111,7 @@ vi.mock(
}),
})),
logToolCall: vi.fn(),
isWithinRoot: vi.fn().mockReturnValue(true),
LlmRole: {
MAIN: 'main',
SUBAGENT: 'subagent',
@@ -133,7 +134,6 @@ vi.mock(
Cancelled: 'cancelled',
AwaitingApproval: 'awaiting_approval',
},
processSingleFileContent: vi.fn(),
};
},
);
@@ -177,10 +177,6 @@ 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;
},
@@ -195,7 +191,6 @@ 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);
@@ -653,7 +648,6 @@ 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),
@@ -663,10 +657,6 @@ 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() {
@@ -1366,6 +1356,7 @@ describe('Session', () => {
(fs.stat as unknown as Mock).mockResolvedValue({
isDirectory: () => false,
});
(isWithinRoot as unknown as Mock).mockReturnValue(true);
const stream = createMockStream([
{
@@ -1423,6 +1414,7 @@ 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(() => ({
@@ -1476,172 +1468,6 @@ 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({
@@ -1840,6 +1666,7 @@ describe('Session', () => {
(fs.stat as unknown as Mock).mockResolvedValue({
isDirectory: () => true,
});
(isWithinRoot as unknown as Mock).mockReturnValue(true);
const stream = createMockStream([
{
+27 -249
View File
@@ -47,7 +47,6 @@ import {
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
getDisplayString,
processSingleFileContent,
type AgentLoopContext,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
@@ -74,17 +73,6 @@ 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,
@@ -312,7 +300,6 @@ export class GeminiAgent {
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
cwd,
);
config.setFileSystemService(acpFileSystemService);
}
@@ -370,6 +357,16 @@ export class GeminiAgent {
const { sessionData, sessionPath } =
await sessionSelector.resolveSession(sessionId);
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
);
config.setFileSystemService(acpFileSystemService);
}
const clientHistory = convertSessionToClientHistory(sessionData.messages);
const geminiClient = config.getGeminiClient();
@@ -443,19 +440,7 @@ export class GeminiAgent {
throw acp.RequestError.authRequired();
}
// 3. Set the ACP FileSystemService (if supported) before config initialization
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
cwd,
);
config.setFileSystemService(acpFileSystemService);
}
// 4. Now that we are authenticated, it is safe to initialize the config
// 3. Now that we are authenticated, it is safe to initialize the config
// which starts the MCP servers and other heavy resources.
await config.initialize();
startupProfiler.flush(config);
@@ -1023,12 +1008,10 @@ export class Session {
},
};
const output = RequestPermissionResponseSchema.parse(
await this.connection.requestPermission(params),
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const output = await this.connection.requestPermission(params);
const outcome =
output.outcome.outcome === 'cancelled'
output.outcome.outcome === CoreToolCallStatus.Cancelled
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
@@ -1239,11 +1222,6 @@ 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(
@@ -1266,197 +1244,28 @@ export class Session {
}
let currentPathSpec = pathName;
let resolvedSuccessfully = false;
let readDirectly = false;
try {
const absolutePath = path.resolve(
this.context.config.getTargetDir(),
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) {
if (isWithinRoot(absolutePath, this.context.config.getTargetDir())) {
const stats = await fs.stat(absolutePath);
if (stats.isDirectory()) {
currentPathSpec = pathName.endsWith('/')
? `${pathName}**`
: `${pathName}/**`;
this.debug(
`Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`,
`Path ${pathName} resolved to directory, using glob: ${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;
} else {
this.debug(`Path ${pathName} resolved to file: ${currentPathSpec}`);
}
resolvedSuccessfully = true;
} else {
this.debug(
`Path ${pathName} access disallowed: ${validationError}. Skipping.`,
`Path ${pathName} is outside the project directory. 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') {
@@ -1516,9 +1325,7 @@ export class Session {
}
}
if (resolvedSuccessfully) {
if (!readDirectly) {
pathSpecsToRead.push(currentPathSpec);
}
pathSpecsToRead.push(currentPathSpec);
atPathToResolvedSpecMap.set(pathName, currentPathSpec);
contentLabelsForDisplay.push(pathName);
}
@@ -1579,11 +1386,7 @@ export class Session {
const processedQueryParts: Part[] = [{ text: initialQueryText }];
if (
pathSpecsToRead.length === 0 &&
embeddedContext.length === 0 &&
directContents.length === 0
) {
if (pathSpecsToRead.length === 0 && embeddedContext.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 }];
@@ -1675,30 +1478,6 @@ 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 ---',
@@ -1846,7 +1625,6 @@ function toPermissionOptions(
case 'info':
case 'ask_user':
case 'exit_plan_mode':
case 'sandbox_expansion':
break;
default: {
const unreachable: never = confirmation;
+12 -135
View File
@@ -4,25 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
} from 'vitest';
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { AcpFileSystemService } from './fileSystemService.js';
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
import type { FileSystemService } from '@google/gemini-cli-core';
import os from 'node:os';
vi.mock('node:os', () => ({
default: {
homedir: vi.fn(),
},
}));
describe('AcpFileSystemService', () => {
let mockConnection: Mocked<AgentSideConnection>;
@@ -40,19 +25,13 @@ describe('AcpFileSystemService', () => {
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
};
vi.mocked(os.homedir).mockReturnValue('/home/user');
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('readTextFile', () => {
it.each([
{
capability: true,
path: '/path/to/file',
desc: 'connection if capability exists and file is inside root',
desc: 'connection if capability exists',
setup: () => {
mockConnection.readTextFile.mockResolvedValue({ content: 'content' });
},
@@ -66,7 +45,6 @@ describe('AcpFileSystemService', () => {
},
{
capability: false,
path: '/path/to/file',
desc: 'fallback if capability missing',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
@@ -78,72 +56,19 @@ describe('AcpFileSystemService', () => {
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
{
capability: true,
path: '/outside/file',
desc: 'fallback if capability exists but file is outside root',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
},
verify: () => {
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
'/outside/file',
);
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
{
capability: true,
path: '/home/user/.gemini/tmp/file.md',
root: '/home/user',
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
},
verify: () => {
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
'/home/user/.gemini/tmp/file.md',
);
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
])(
'should use $desc',
async ({ capability, path, root, setup, verify }) => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: capability, writeTextFile: true },
mockFallback,
root || '/path/to',
);
setup();
const result = await service.readTextFile(path);
expect(result).toBe('content');
verify();
},
);
it('should throw normalized ENOENT error when readTextFile encounters "Resource not found"', async () => {
])('should use $desc', async ({ capability, setup, verify }) => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: true, writeTextFile: true },
{ readTextFile: capability, writeTextFile: true },
mockFallback,
'/path/to',
);
mockConnection.readTextFile.mockRejectedValue(
new Error('Resource not found for document'),
);
setup();
await expect(
service.readTextFile('/path/to/missing'),
).rejects.toMatchObject({
code: 'ENOENT',
message: 'Resource not found for document',
});
const result = await service.readTextFile('/path/to/file');
expect(result).toBe('content');
verify();
});
});
@@ -151,8 +76,7 @@ describe('AcpFileSystemService', () => {
it.each([
{
capability: true,
path: '/path/to/file',
desc: 'connection if capability exists and file is inside root',
desc: 'connection if capability exists',
verify: () => {
expect(mockConnection.writeTextFile).toHaveBeenCalledWith({
path: '/path/to/file',
@@ -164,7 +88,6 @@ describe('AcpFileSystemService', () => {
},
{
capability: false,
path: '/path/to/file',
desc: 'fallback if capability missing',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
@@ -174,63 +97,17 @@ describe('AcpFileSystemService', () => {
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
{
capability: true,
path: '/outside/file',
desc: 'fallback if capability exists but file is outside root',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
'/outside/file',
'content',
);
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
{
capability: true,
path: '/home/user/.gemini/tmp/file.md',
root: '/home/user',
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
'/home/user/.gemini/tmp/file.md',
'content',
);
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
])('should use $desc', async ({ capability, path, root, verify }) => {
])('should use $desc', async ({ capability, verify }) => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ writeTextFile: capability, readTextFile: true },
mockFallback,
root || '/path/to',
);
await service.writeTextFile(path, 'content');
await service.writeTextFile('/path/to/file', 'content');
verify();
});
it('should throw normalized ENOENT error when writeTextFile encounters "Resource not found"', async () => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: true, writeTextFile: true },
mockFallback,
'/path/to',
);
mockConnection.writeTextFile.mockRejectedValue(
new Error('Resource not found for directory'),
);
await expect(
service.writeTextFile('/path/to/missing', 'content'),
).rejects.toMatchObject({
code: 'ENOENT',
message: 'Resource not found for directory',
});
});
});
});
+15 -53
View File
@@ -4,82 +4,44 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { isWithinRoot, type FileSystemService } from '@google/gemini-cli-core';
import type { FileSystemService } from '@google/gemini-cli-core';
import type * as acp from '@agentclientprotocol/sdk';
import os from 'node:os';
import path from 'node:path';
/**
* ACP client-based implementation of FileSystemService
*/
export class AcpFileSystemService implements FileSystemService {
private readonly geminiDir = path.join(os.homedir(), '.gemini');
constructor(
private readonly connection: acp.AgentSideConnection,
private readonly sessionId: string,
private readonly capabilities: acp.FileSystemCapabilities,
private readonly fallback: FileSystemService,
private readonly root: string,
) {}
private shouldUseFallback(filePath: string): boolean {
// Files inside the global CLI directory must always use the native file system,
// even if the user runs the CLI directly from their home directory (which
// would make the IDE's project root overlap with the global directory).
return (
!isWithinRoot(filePath, this.root) ||
isWithinRoot(filePath, this.geminiDir)
);
}
private normalizeFileSystemError(err: unknown): never {
const errorMessage = err instanceof Error ? err.message : String(err);
if (
errorMessage.includes('Resource not found') ||
errorMessage.includes('ENOENT') ||
errorMessage.includes('does not exist') ||
errorMessage.includes('No such file')
) {
const newErr = new Error(errorMessage) as NodeJS.ErrnoException;
newErr.code = 'ENOENT';
throw newErr;
}
throw err;
}
async readTextFile(filePath: string): Promise<string> {
if (!this.capabilities.readTextFile || this.shouldUseFallback(filePath)) {
if (!this.capabilities.readTextFile) {
return this.fallback.readTextFile(filePath);
}
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const response = await this.connection.readTextFile({
path: filePath,
sessionId: this.sessionId,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const response = await this.connection.readTextFile({
path: filePath,
sessionId: this.sessionId,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.content;
} catch (err: unknown) {
this.normalizeFileSystemError(err);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.content;
}
async writeTextFile(filePath: string, content: string): Promise<void> {
if (!this.capabilities.writeTextFile || this.shouldUseFallback(filePath)) {
if (!this.capabilities.writeTextFile) {
return this.fallback.writeTextFile(filePath, content);
}
try {
await this.connection.writeTextFile({
path: filePath,
content,
sessionId: this.sessionId,
});
} catch (err: unknown) {
this.normalizeFileSystemError(err);
}
await this.connection.writeTextFile({
path: filePath,
content,
sessionId: this.sessionId,
});
}
}
+46
View File
@@ -2103,6 +2103,52 @@ describe('loadCliConfig compressionThreshold', () => {
});
});
describe('loadCliConfig showContextWindowWarning', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should pass showContextWindowWarning from settings to config (true)', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
ui: {
showContextWindowWarning: true,
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getShowContextWindowWarning()).toBe(true);
});
it('should pass showContextWindowWarning from settings to config (false)', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
ui: {
showContextWindowWarning: false,
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getShowContextWindowWarning()).toBe(false);
});
it('should default to false if not in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getShowContextWindowWarning()).toBe(false);
});
});
describe('loadCliConfig useRipgrep', () => {
beforeEach(() => {
vi.resetAllMocks();
+2
View File
@@ -955,6 +955,8 @@ export async function loadCliConfig(
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
showContextWindowWarning: settings.ui?.showContextWindowWarning,
showContextCompression: settings.ui?.showContextCompression,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
@@ -400,7 +400,7 @@ describe('SettingsSchema', () => {
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(true);
expect(setting.default).toBe(false);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(false);
expect(setting.description).toBe('Enable local and remote subagents.');
+23 -14
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. A custom directory requires a policy to allow write access in Plan Mode.',
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.',
showInDialog: true,
},
modelRouting: {
@@ -575,11 +575,31 @@ const SETTINGS_SCHEMA = {
label: 'Hide Context Summary',
category: 'UI',
requiresRestart: false,
default: false,
default: true,
description:
'Hide the context summary (GEMINI.md, MCP servers) above the input.',
showInDialog: true,
},
showContextWindowWarning: {
type: 'boolean',
label: 'Show Context Window Warning',
category: 'UI',
requiresRestart: false,
default: false,
description:
'Show a warning message when the context window limit is nearly reached. If disabled, the CLI will attempt to automatically compress the history when the limit is reached.',
showInDialog: true,
},
showContextCompression: {
type: 'boolean',
label: 'Show Context Compression Messages',
category: 'UI',
requiresRestart: false,
default: false,
description:
'Show a message in the chat history when it is compressed.',
showInDialog: true,
},
footer: {
type: 'object',
label: 'Footer',
@@ -1208,16 +1228,6 @@ const SETTINGS_SCHEMA = {
'Disable user input on browser window during automation.',
showInDialog: false,
},
maxActionsPerTask: {
type: 'number',
label: 'Max Actions Per Task',
category: 'Advanced',
requiresRestart: false,
default: 100,
description:
'The maximum number of tool calls allowed per browser task. Enforcement is hard: the agent will be terminated when the limit is reached.',
showInDialog: false,
},
confirmSensitiveActions: {
type: 'boolean',
label: 'Confirm Sensitive Actions',
@@ -1942,7 +1952,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Agents',
category: 'Experimental',
requiresRestart: true,
default: true,
default: false,
description: 'Enable local and remote subagents.',
showInDialog: false,
},
@@ -3024,7 +3034,6 @@ 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,62 +528,6 @@ 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,7 +32,6 @@ import {
ValidationRequiredError,
type AdminControlsSettings,
debugLogger,
isHeadlessMode,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
@@ -297,7 +296,6 @@ 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);
@@ -613,17 +611,8 @@ 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,7 +65,6 @@ 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);
+9 -29
View File
@@ -700,10 +700,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Derive auth state variables for backward compatibility with UIStateContext
const isAuthDialogOpen = authState === AuthState.Updating;
// TODO: Consider handling other auth types that should also skip the blocking screen
const isAuthenticating =
authState === AuthState.Unauthenticated &&
settings.merged.security.auth.selectedType !== AuthType.USE_GEMINI;
const isAuthenticating = authState === AuthState.Unauthenticated;
// Session browser and resume functionality
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
@@ -1303,8 +1300,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
return;
}
const isMcpOrConfigReady = isConfigInitialized && isMcpReady;
if ((isSlash && isConfigInitialized) || (isIdle && isMcpOrConfigReady)) {
if (isSlash || (isIdle && isMcpReady)) {
if (!isSlash) {
const permissions = await checkPermissions(submittedValue, config);
if (permissions.length > 0) {
@@ -1327,12 +1323,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
void submitQuery(submittedValue);
} else {
// Check messageQueue.length === 0 to only notify on the first queued item
if (isIdle && !isMcpOrConfigReady && messageQueue.length === 0) {
if (isIdle && !isMcpReady && messageQueue.length === 0) {
coreEvents.emitFeedback(
'info',
!isConfigInitialized
? 'Initializing... Prompts will be queued.'
: 'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
);
}
addMessage(submittedValue);
@@ -1356,7 +1350,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
refreshStatic,
reset,
handleHintSubmit,
isConfigInitialized,
triggerExpandHint,
],
);
@@ -1387,28 +1380,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
* - Any future streaming states not explicitly allowed
*/
const isInputActive =
isConfigInitialized &&
!initError &&
!isProcessing &&
!isResuming &&
!!slashCommands &&
(streamingState === StreamingState.Idle ||
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation) &&
!proQuotaRequest &&
!copyModeEnabled;
!proQuotaRequest;
const [controlsHeight, setControlsHeight] = useState(0);
const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0);
useLayoutEffect(() => {
if (!copyModeEnabled && controlsHeight > 0) {
setLastNonCopyControlsHeight(controlsHeight);
}
}, [copyModeEnabled, controlsHeight]);
const stableControlsHeight =
copyModeEnabled && lastNonCopyControlsHeight > 0
? lastNonCopyControlsHeight
: controlsHeight;
useLayoutEffect(() => {
if (mainControlsRef.current) {
@@ -1420,10 +1402,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [buffer, terminalWidth, terminalHeight, controlsHeight, isInputActive]);
// Compute available terminal height based on stable controls measurement
// Compute available terminal height based on controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight - stableControlsHeight - backgroundShellHeight - 1,
terminalHeight - controlsHeight - backgroundShellHeight - 1,
);
config.setShellExecutionConfig({
@@ -2282,7 +2264,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
contextFileNames,
errorCount,
availableTerminalHeight,
stableControlsHeight,
mainAreaWidth,
staticAreaMaxItemHeight,
staticExtraHeight,
@@ -2404,7 +2385,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
contextFileNames,
errorCount,
availableTerminalHeight,
stableControlsHeight,
mainAreaWidth,
staticAreaMaxItemHeight,
staticExtraHeight,
@@ -37,8 +37,9 @@ Tips for getting started:
Notifications
Notifications
Composer
"
`;
@@ -102,8 +103,9 @@ exports[`App > Snapshots > renders with dialogs visible 1`] = `
Notifications
Notifications
DialogManager
"
`;
@@ -145,8 +147,9 @@ HistoryItemDisplay
Notifications
Notifications
Composer
"
`;
@@ -1,266 +1,271 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="666" viewBox="0 0 920 666">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="700" viewBox="0 0 920 700">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="666" fill="#000000" />
<rect width="920" height="700" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#141414" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#141414" />
<rect x="9" y="17" width="18" height="17" fill="#141414" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="324" height="17" fill="#141414" />
<text x="27" y="19" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
<rect x="351" y="17" width="549" height="17" fill="#141414" />
<text x="0" y="2" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="19" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
<rect x="0" y="34" width="900" height="17" fill="#141414" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<text x="0" y="53" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="70" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#ffffaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Action Required</text>
<text x="882" y="70" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="87" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="51" width="9" height="17" fill="#141414" />
<rect x="9" y="51" width="18" height="17" fill="#141414" />
<text x="9" y="53" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="51" width="324" height="17" fill="#141414" />
<text x="27" y="53" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
<rect x="351" y="51" width="549" height="17" fill="#141414" />
<rect x="0" y="68" width="900" height="17" fill="#141414" />
<text x="0" y="70" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<text x="0" y="87" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">?</text>
<text x="45" y="104" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs" font-weight="bold">Edit</text>
<text x="90" y="104" fill="#afafaf" textLength="774" lengthAdjust="spacingAndGlyphs">packages/.../InputPrompt.tsx: return kittyProtocolSupporte... =&gt; return kittyProto</text>
<text x="864" y="104" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="104" fill="#ffffaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Action Required</text>
<text x="882" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="138" fill="#afafaf" textLength="414" lengthAdjust="spacingAndGlyphs">... first 44 lines hidden (Ctrl+O to show) ...</text>
<text x="18" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">?</text>
<text x="45" y="138" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs" font-weight="bold">Edit</text>
<text x="90" y="138" fill="#afafaf" textLength="774" lengthAdjust="spacingAndGlyphs">packages/.../InputPrompt.tsx: return kittyProtocolSupporte... =&gt; return kittyProto</text>
<text x="864" y="138" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="155" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">45</text>
<text x="63" y="155" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="155" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line45</text>
<text x="171" y="155" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="155" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="155" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="172" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">46</text>
<text x="63" y="172" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="172" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line46</text>
<text x="171" y="172" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="172" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="172" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="18" y="172" fill="#afafaf" textLength="414" lengthAdjust="spacingAndGlyphs">... first 44 lines hidden (Ctrl+O to show) ...</text>
<text x="882" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="189" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">47</text>
<text x="18" y="189" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">45</text>
<text x="63" y="189" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="189" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line47</text>
<text x="117" y="189" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line45</text>
<text x="171" y="189" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="189" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="189" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="189" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="206" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">48</text>
<text x="18" y="206" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">46</text>
<text x="63" y="206" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="206" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line48</text>
<text x="117" y="206" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line46</text>
<text x="171" y="206" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="206" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="206" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="206" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="223" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">49</text>
<text x="18" y="223" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">47</text>
<text x="63" y="223" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="223" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line49</text>
<text x="117" y="223" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line47</text>
<text x="171" y="223" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="223" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="223" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="223" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="240" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">50</text>
<text x="18" y="240" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">48</text>
<text x="63" y="240" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line50</text>
<text x="117" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line48</text>
<text x="171" y="240" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="240" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="240" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="240" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="257" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">51</text>
<text x="18" y="257" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">49</text>
<text x="63" y="257" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="257" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line51</text>
<text x="117" y="257" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line49</text>
<text x="171" y="257" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="257" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="257" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="274" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">52</text>
<text x="18" y="274" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">50</text>
<text x="63" y="274" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="274" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line52</text>
<text x="117" y="274" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line50</text>
<text x="171" y="274" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="274" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="274" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="291" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">53</text>
<text x="18" y="291" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">51</text>
<text x="63" y="291" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="291" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line53</text>
<text x="117" y="291" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line51</text>
<text x="171" y="291" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="291" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="291" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="308" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">54</text>
<text x="18" y="308" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">52</text>
<text x="63" y="308" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="308" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line54</text>
<text x="117" y="308" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line52</text>
<text x="171" y="308" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="308" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="308" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="325" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">55</text>
<text x="18" y="325" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">53</text>
<text x="63" y="325" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="325" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line55</text>
<text x="117" y="325" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line53</text>
<text x="171" y="325" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="325" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="325" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="325" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="342" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">56</text>
<text x="18" y="342" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">54</text>
<text x="63" y="342" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="342" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line56</text>
<text x="117" y="342" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line54</text>
<text x="171" y="342" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="342" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="342" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="342" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="359" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">57</text>
<text x="18" y="359" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">55</text>
<text x="63" y="359" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="359" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line57</text>
<text x="117" y="359" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line55</text>
<text x="171" y="359" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="359" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="359" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="359" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="376" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">58</text>
<text x="18" y="376" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">56</text>
<text x="63" y="376" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="376" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line58</text>
<text x="117" y="376" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line56</text>
<text x="171" y="376" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="376" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="376" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="376" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="393" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">59</text>
<text x="18" y="393" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">57</text>
<text x="63" y="393" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="393" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line59</text>
<text x="117" y="393" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line57</text>
<text x="171" y="393" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="393" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="393" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="393" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="410" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">60</text>
<text x="18" y="410" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">58</text>
<text x="63" y="410" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="410" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line60</text>
<text x="117" y="410" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line58</text>
<text x="171" y="410" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="410" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="410" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="410" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="425" width="18" height="17" fill="#5f0000" />
<text x="18" y="427" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="425" width="9" height="17" fill="#5f0000" />
<rect x="45" y="425" width="9" height="17" fill="#5f0000" />
<text x="45" y="427" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
<rect x="54" y="425" width="9" height="17" fill="#5f0000" />
<rect x="63" y="425" width="9" height="17" fill="#5f0000" />
<rect x="72" y="425" width="54" height="17" fill="#5f0000" />
<text x="72" y="427" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="425" width="234" height="17" fill="#5f0000" />
<text x="126" y="427" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="18" y="427" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">59</text>
<text x="63" y="427" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="427" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line59</text>
<text x="171" y="427" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="427" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="427" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="442" width="18" height="17" fill="#005f00" />
<text x="18" y="444" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="442" width="9" height="17" fill="#005f00" />
<rect x="45" y="442" width="9" height="17" fill="#005f00" />
<text x="45" y="444" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
<rect x="54" y="442" width="9" height="17" fill="#005f00" />
<rect x="63" y="442" width="9" height="17" fill="#005f00" />
<rect x="72" y="442" width="54" height="17" fill="#005f00" />
<text x="72" y="444" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="442" width="234" height="17" fill="#005f00" />
<text x="126" y="444" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="18" y="444" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">60</text>
<text x="63" y="444" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="444" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line60</text>
<text x="171" y="444" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="444" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="444" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">62</text>
<text x="63" y="461" fill="#e5e5e5" textLength="180" lengthAdjust="spacingAndGlyphs"> buffer: TextBuffer;</text>
<rect x="18" y="459" width="18" height="17" fill="#5f0000" />
<text x="18" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="459" width="9" height="17" fill="#5f0000" />
<rect x="45" y="459" width="9" height="17" fill="#5f0000" />
<text x="45" y="461" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
<rect x="54" y="459" width="9" height="17" fill="#5f0000" />
<rect x="63" y="459" width="9" height="17" fill="#5f0000" />
<rect x="72" y="459" width="54" height="17" fill="#5f0000" />
<text x="72" y="461" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="459" width="234" height="17" fill="#5f0000" />
<text x="126" y="461" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="882" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="478" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">63</text>
<text x="72" y="478" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">onSubmit</text>
<text x="144" y="478" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">: (</text>
<text x="171" y="478" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">value</text>
<text x="216" y="478" fill="#e5e5e5" textLength="18" lengthAdjust="spacingAndGlyphs">: </text>
<text x="234" y="478" fill="#00cdcd" textLength="54" lengthAdjust="spacingAndGlyphs">string</text>
<text x="288" y="478" fill="#e5e5e5" textLength="45" lengthAdjust="spacingAndGlyphs">) =&gt; </text>
<text x="333" y="478" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">void</text>
<text x="369" y="478" fill="#e5e5e5" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<rect x="18" y="476" width="18" height="17" fill="#005f00" />
<text x="18" y="478" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="476" width="9" height="17" fill="#005f00" />
<rect x="45" y="476" width="9" height="17" fill="#005f00" />
<text x="45" y="478" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
<rect x="54" y="476" width="9" height="17" fill="#005f00" />
<rect x="63" y="476" width="9" height="17" fill="#005f00" />
<rect x="72" y="476" width="54" height="17" fill="#005f00" />
<text x="72" y="478" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="476" width="234" height="17" fill="#005f00" />
<text x="126" y="478" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="882" y="478" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="495" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Apply this change?</text>
<text x="18" y="495" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">62</text>
<text x="63" y="495" fill="#e5e5e5" textLength="180" lengthAdjust="spacingAndGlyphs"> buffer: TextBuffer;</text>
<text x="882" y="495" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">63</text>
<text x="72" y="512" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">onSubmit</text>
<text x="144" y="512" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">: (</text>
<text x="171" y="512" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">value</text>
<text x="216" y="512" fill="#e5e5e5" textLength="18" lengthAdjust="spacingAndGlyphs">: </text>
<text x="234" y="512" fill="#00cdcd" textLength="54" lengthAdjust="spacingAndGlyphs">string</text>
<text x="288" y="512" fill="#e5e5e5" textLength="45" lengthAdjust="spacingAndGlyphs">) =&gt; </text>
<text x="333" y="512" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">void</text>
<text x="369" y="512" fill="#e5e5e5" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="512" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="527" width="9" height="17" fill="#001a00" />
<text x="18" y="529" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="527" width="9" height="17" fill="#001a00" />
<rect x="36" y="527" width="18" height="17" fill="#001a00" />
<text x="36" y="529" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="54" y="527" width="9" height="17" fill="#001a00" />
<rect x="63" y="527" width="90" height="17" fill="#001a00" />
<text x="63" y="529" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="153" y="527" width="288" height="17" fill="#001a00" />
<text x="18" y="529" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Apply this change?</text>
<text x="882" y="529" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="546" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">2.</text>
<text x="63" y="546" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">Allow for this session</text>
<text x="882" y="546" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="563" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">3.</text>
<text x="63" y="563" fill="#ffffff" textLength="378" lengthAdjust="spacingAndGlyphs">Allow for this file in all future sessions</text>
<rect x="18" y="561" width="9" height="17" fill="#001a00" />
<text x="18" y="563" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="561" width="9" height="17" fill="#001a00" />
<rect x="36" y="561" width="18" height="17" fill="#001a00" />
<text x="36" y="563" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="54" y="561" width="9" height="17" fill="#001a00" />
<rect x="63" y="561" width="90" height="17" fill="#001a00" />
<text x="63" y="563" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="153" y="561" width="288" height="17" fill="#001a00" />
<text x="882" y="563" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="580" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">4.</text>
<text x="63" y="580" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">Modify with external editor</text>
<text x="36" y="580" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">2.</text>
<text x="63" y="580" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">Allow for this session</text>
<text x="882" y="580" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="597" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="597" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">5.</text>
<text x="63" y="597" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">No, suggest changes (esc)</text>
<text x="36" y="597" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">3.</text>
<text x="63" y="597" fill="#ffffff" textLength="378" lengthAdjust="spacingAndGlyphs">Allow for this file in all future sessions</text>
<text x="882" y="597" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="614" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="614" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">4.</text>
<text x="63" y="614" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">Modify with external editor</text>
<text x="882" y="614" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╰─────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="631" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="631" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">5.</text>
<text x="63" y="631" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">No, suggest changes (esc)</text>
<text x="882" y="631" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="648" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╰─────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="891" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

@@ -1,7 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"3. Ask coding questions, edit code or run commands
4. Be specific for the best results
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Can you edit InputPrompt.tsx for me?
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -9,9 +11,9 @@ exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation bo
│ │
│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │
│ │
│ ... first 44 lines hidden (Ctrl+O to show) ... │
│ 45 const line45 = true; │
│ 46 const line46 = true; │
│ ... first 44 lines hidden (Ctrl+O to show) ... │
│ 45 const line45 = true; │
│ 46 const line46 = true; │
│ 47 const line47 = true; │█
│ 48 const line48 = true; │█
│ 49 const line49 = true; │█
@@ -4,28 +4,42 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
CompressionStatus,
type ChatCompressionInfo,
type GeminiClient,
} from '@google/gemini-cli-core';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import * as Core from '@google/gemini-cli-core';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { compressCommand } from './compressCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = (await importOriginal()) as any;
return {
...actual,
tokenLimit: vi.fn(),
};
});
describe('compressCommand', () => {
let context: ReturnType<typeof createMockCommandContext>;
let mockTryCompressChat: ReturnType<typeof vi.fn>;
afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
mockTryCompressChat = vi.fn();
vi.mocked(Core.tokenLimit).mockReturnValue(1000);
context = createMockCommandContext({
services: {
agentContext: {
config: {
getModel: () => 'test-model',
getContextWindowCompressionThreshold: () => 0.2,
},
geminiClient: {
tryCompressChat: mockTryCompressChat,
} as unknown as GeminiClient,
} as unknown as Core.GeminiClient,
},
},
});
@@ -36,9 +50,10 @@ describe('compressCommand', () => {
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
beforePercentage: null,
afterPercentage: null,
compressionStatus: null,
isManual: true,
},
};
await compressCommand.action!(context, '');
@@ -54,9 +69,9 @@ describe('compressCommand', () => {
});
it('should set pending item, call tryCompressChat, and add result on success', async () => {
const compressedResult: ChatCompressionInfo = {
const compressedResult: Core.ChatCompressionInfo = {
originalTokenCount: 200,
compressionStatus: CompressionStatus.COMPRESSED,
compressionStatus: Core.CompressionStatus.COMPRESSED,
newTokenCount: 100,
};
mockTryCompressChat.mockResolvedValue(compressedResult);
@@ -68,8 +83,9 @@ describe('compressCommand', () => {
compression: {
isPending: true,
compressionStatus: null,
originalTokenCount: null,
newTokenCount: null,
beforePercentage: null,
afterPercentage: null,
isManual: true,
},
});
@@ -83,9 +99,11 @@ describe('compressCommand', () => {
type: MessageType.COMPRESSION,
compression: {
isPending: false,
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount: 200,
newTokenCount: 100,
compressionStatus: Core.CompressionStatus.COMPRESSED,
beforePercentage: 20,
afterPercentage: 10,
isManual: true,
thresholdPercentage: 20,
},
},
expect.any(Number),
+40 -11
View File
@@ -6,6 +6,7 @@
import { MessageType, type HistoryItemCompression } from '../types.js';
import { CommandKind, type SlashCommand } from './types.js';
import { tokenLimit, type CompressionStatus } from '@google/gemini-cli-core';
export const compressCommand: SlashCommand = {
name: 'compress',
@@ -14,7 +15,21 @@ export const compressCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const { ui } = context;
const { ui, services } = context;
const agentContext = services.agentContext;
if (!agentContext) {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Agent context not found.',
},
Date.now(),
);
return;
}
const config = agentContext.config;
if (ui.pendingItem) {
ui.addItem(
{
@@ -30,29 +45,43 @@ export const compressCommand: SlashCommand = {
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
beforePercentage: null,
afterPercentage: null,
compressionStatus: null,
isManual: true,
},
};
try {
ui.setPendingItem(pendingMessage);
const promptId = `compress-${Date.now()}`;
const compressed =
await context.services.agentContext?.geminiClient?.tryCompressChat(
promptId,
true,
);
const compressed = await agentContext.geminiClient.tryCompressChat(
promptId,
true,
);
if (compressed) {
const limit = tokenLimit(config.getModel());
const threshold = config.getContextWindowCompressionThreshold();
const beforePercentage = Math.round(
(compressed.originalTokenCount / limit) * 100,
);
const afterPercentage = Math.round(
(compressed.newTokenCount / limit) * 100,
);
ui.addItem(
{
type: MessageType.COMPRESSION,
compression: {
isPending: false,
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
beforePercentage,
afterPercentage,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
compressionStatus: Number(
compressed.compressionStatus,
) as unknown as CompressionStatus,
isManual: true,
thresholdPercentage: Math.round(threshold * 100),
},
} as HistoryItemCompression,
Date.now(),
+1 -1
View File
@@ -108,7 +108,7 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo?.isUpdating && (
{updateInfo && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
@@ -17,11 +17,7 @@ import {
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
import {
ApprovalMode,
tokenLimit,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { ApprovalMode, CoreToolCallStatus } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
import { TransientMessageType } from '../../utils/events.js';
@@ -733,37 +729,6 @@ describe('Composer', () => {
expect(output).toContain('Press Esc again to rewind.');
expect(output).not.toContain('ContextSummaryDisplay');
});
it('shows context usage bleed-through when over 60%', async () => {
const model = 'gemini-2.5-pro';
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
currentModel: model,
sessionStats: {
sessionId: 'test-session',
sessionStartTime: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metrics: {} as any,
lastPromptTokenCount: Math.floor(tokenLimit(model) * 0.7),
promptCount: 0,
},
});
const settings = createMockSettings({
ui: {
footer: { hideContextPercentage: false },
},
});
const { lastFrame } = await renderComposer(uiState, settings);
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// StatusDisplay (which contains ContextUsageDisplay) should bleed through in minimal mode
expect(lastFrame()).toContain('StatusDisplay');
expect(lastFrame()).toContain('70% used');
});
});
describe('Error Details Display', () => {
+429 -25
View File
@@ -4,8 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, useIsScreenReaderEnabled } from 'ink';
import { useState, useEffect } from 'react';
import {
ApprovalMode,
checkExhaustive,
CoreToolCallStatus,
isUserVisibleHook,
} from '@google/gemini-cli-core';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useState, useEffect, useMemo } from 'react';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
@@ -14,18 +20,26 @@ 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 { 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 { 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();
@@ -40,17 +54,43 @@ 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 { hasPendingActionRequired, shouldCollapseDuringApproval } =
useComposerStatus();
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 isPassiveShortcutsHelpState =
uiState.isInputActive &&
uiState.streamingState === 'idle' &&
uiState.streamingState === StreamingState.Idle &&
!hasPendingActionRequired;
const { setShortcutsHelpVisible } = uiActions;
@@ -67,19 +107,389 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const showShortcutsHelp =
uiState.shortcutsHelpVisible &&
uiState.streamingState === 'idle' &&
uiState.streamingState === 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 showMinimalToast = hasToast;
const miniMode_ShowApprovalMode =
Boolean(modeContentObj) && !hideUiDetailsForSuggestions;
const miniMode_ShowToast = hasToast;
const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks;
const miniMode_ShowTip = showTipLine;
// Composite Mini Mode Triggers
const showRow1_MiniMode =
miniMode_ShowToast ||
miniMode_ShowStatus ||
miniMode_ShowShortcuts ||
miniMode_ShowTip;
const showRow2_MiniMode = miniMode_ShowApprovalMode;
// 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 && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
</Box>
</Box>
)}
</Box>
);
};
return (
<Box
@@ -88,8 +498,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexGrow={0}
flexShrink={0}
>
{uiState.isResuming && (
<ConfigInitDisplay message="Resuming session..." />
{(!uiState.slashCommands ||
!uiState.isConfigInitialized ||
uiState.isResuming) && (
<ConfigInitDisplay
message={uiState.isResuming ? 'Resuming session...' : undefined}
/>
)}
{showUiDetails && (
@@ -100,21 +514,14 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{showShortcutsHelp && <ShortcutsHelp />}
{(showUiDetails || showMinimalToast) && (
{(showUiDetails || miniMode_ShowToast) && (
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
<ToastDisplay />
</Box>
)}
<Box width="100%" flexDirection="column">
<StatusRow
showUiDetails={showUiDetails}
isNarrow={isNarrow}
terminalWidth={terminalWidth}
hideContextSummary={hideContextSummary}
hideUiDetailsForSuggestions={hideUiDetailsForSuggestions}
hasPendingActionRequired={hasPendingActionRequired}
/>
{renderStatusRow()}
</Box>
{showUiDetails && uiState.showErrorDetails && (
@@ -146,7 +553,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
commandContext={uiState.commandContext}
shellModeActive={uiState.shellModeActive}
setShellModeActive={uiActions.setShellModeActive}
approvalMode={uiState.showApprovalModeIndicator}
approvalMode={showApprovalModeIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused}
vimHandleInput={uiActions.vimHandleInput}
@@ -165,15 +572,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
streamingState={uiState.streamingState}
suggestionsPosition={suggestionsPosition}
onSuggestionsVisibilityChange={setSuggestionsVisible}
copyModeEnabled={uiState.copyModeEnabled}
/>
)}
{showUiDetails &&
!settings.merged.ui.hideFooter &&
!isScreenReaderEnabled && (
<Footer copyModeEnabled={uiState.copyModeEnabled} />
)}
!isScreenReaderEnabled && <Footer />}
</Box>
);
};
@@ -12,14 +12,16 @@ import { theme } from '../semantic-colors.js';
export const CopyModeWarning: React.FC = () => {
const { copyModeEnabled } = useUIState();
if (!copyModeEnabled) {
return null;
}
return (
<Box height={1}>
{copyModeEnabled && (
<Text color={theme.status.warning}>
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other
key to exit.
</Text>
)}
<Box>
<Text color={theme.status.warning}>
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other key
to exit.
</Text>
</Box>
);
};
@@ -80,6 +80,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
const pathError = await validatePlanPath(
planPath,
config.storage.getPlansDir(),
config.getTargetDir(),
);
if (ignore) return;
if (pathError) {
+2 -18
View File
@@ -175,18 +175,12 @@ interface FooterColumn {
isHighPriority: boolean;
}
export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
copyModeEnabled = false,
}) => {
export const Footer: React.FC = () => {
const uiState = useUIState();
const config = useConfig();
const settings = useSettings();
const { vimEnabled, vimMode } = useVimMode();
if (copyModeEnabled) {
return <Box height={1} />;
}
const {
model,
targetDir,
@@ -359,17 +353,7 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
break;
}
case 'memory-usage': {
addCol(
id,
header,
() => (
<MemoryUsageDisplay
color={itemColor}
isActive={!uiState.copyModeEnabled}
/>
),
10,
);
addCol(id, header, () => <MemoryUsageDisplay color={itemColor} />, 10);
break;
}
case 'session-id': {
@@ -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 { useKeypress, type Key } from '../hooks/useKeypress.js';
import type { Key } from '../hooks/useKeypress.js';
import {
appEvents,
AppEvent,
@@ -163,18 +163,6 @@ 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);
@@ -2782,54 +2770,6 @@ 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,
+8 -13
View File
@@ -119,7 +119,6 @@ export interface InputPromptProps {
popAllMessages?: () => string | undefined;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
copyModeEnabled?: boolean;
}
// The input content, input container, and input suggestions list may have different widths
@@ -213,7 +212,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
popAllMessages,
suggestionsPosition = 'below',
setBannerVisible,
copyModeEnabled = false,
}) => {
const isHelpDismissKey = useIsHelpDismissKey();
const keyMatchers = useKeyMatchers();
@@ -333,8 +331,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isShellSuggestionsVisible,
} = completion;
const showCursor =
focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
// Notify parent component about escape prompt state changes
useEffect(() => {
@@ -686,9 +683,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
const isGenerating =
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation;
if (
key.name === 'escape' &&
(streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation)
) {
return false;
}
const isPlainTab =
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
@@ -873,12 +874,6 @@ 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;
}
@@ -11,18 +11,13 @@ import { theme } from '../semantic-colors.js';
import process from 'node:process';
import { formatBytes } from '../utils/formatters.js';
export const MemoryUsageDisplay: React.FC<{
color?: string;
isActive?: boolean;
}> = ({ color = theme.text.primary, isActive = true }) => {
export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
color = theme.text.primary,
}) => {
const [memoryUsage, setMemoryUsage] = useState<string>('');
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(color);
useEffect(() => {
if (!isActive) {
return;
}
const updateMemory = () => {
const usage = process.memoryUsage().rss;
setMemoryUsage(formatBytes(usage));
@@ -30,11 +25,10 @@ export const MemoryUsageDisplay: React.FC<{
usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color,
);
};
const intervalId = setInterval(updateMemory, 2000);
updateMemory(); // Initial update
return () => clearInterval(intervalId);
}, [color, isActive]);
}, [color]);
return (
<Box>
@@ -53,7 +53,6 @@ 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();
@@ -64,7 +63,6 @@ describe('<ModelDialog />', () => {
getHasAccessToPreviewModel: () => boolean;
getIdeMode: () => boolean;
getGemini31LaunchedSync: () => boolean;
getGemini31FlashLiteLaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getUserTier: () => UserTierId | undefined;
@@ -76,7 +74,6 @@ describe('<ModelDialog />', () => {
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
getIdeMode: () => false,
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getUserTier: mockGetUserTier,
@@ -87,7 +84,6 @@ 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);
@@ -135,7 +131,6 @@ describe('<ModelDialog />', () => {
mockGetProModelNoAccessSync.mockReturnValue(true);
mockGetProModelNoAccess.mockResolvedValue(true);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
mockGetDisplayString.mockImplementation((val: string) => val);
@@ -468,7 +463,6 @@ 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();
+2 -11
View File
@@ -63,8 +63,6 @@ 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;
@@ -88,7 +86,6 @@ 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)) {
@@ -213,10 +210,7 @@ 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 &&
!useGemini31FlashLite
)
if (id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL && !useGemini31)
return false;
return true;
@@ -224,13 +218,11 @@ 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,
@@ -292,7 +284,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
},
];
if (isFreeTier && useGemini31FlashLite) {
if (isFreeTier) {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
@@ -312,7 +304,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
}, [
shouldShowPreviewModels,
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
hasAccessToProModel,
config,
@@ -92,7 +92,6 @@ const buildModelRows = (
config: Config,
quotas?: RetrieveUserQuotaResponse,
useGemini3_1 = false,
useGemini3_1FlashLite = false,
useCustomToolModel = false,
) => {
const getBaseModelName = (name: string) => name.replace('-001', '');
@@ -125,12 +124,7 @@ const buildModelRows = (
?.filter(
(b) =>
b.modelId &&
isActiveModel(
b.modelId,
useGemini3_1,
useGemini3_1FlashLite,
useCustomToolModel,
) &&
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
!usedModelNames.has(getDisplayString(b.modelId, config)),
)
.map((bucket) => ({
@@ -158,7 +152,6 @@ const ModelUsageTable: React.FC<{
pooledLimit?: number;
pooledResetTime?: string;
useGemini3_1?: boolean;
useGemini3_1FlashLite?: boolean;
useCustomToolModel?: boolean;
}> = ({
models,
@@ -171,7 +164,6 @@ const ModelUsageTable: React.FC<{
pooledLimit,
pooledResetTime,
useGemini3_1,
useGemini3_1FlashLite,
useCustomToolModel,
}) => {
const { stdout } = useStdout();
@@ -181,7 +173,6 @@ const ModelUsageTable: React.FC<{
config,
quotas,
useGemini3_1,
useGemini3_1FlashLite,
useCustomToolModel,
);
@@ -550,8 +541,6 @@ 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;
@@ -708,7 +697,6 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
pooledLimit={pooledLimit}
pooledResetTime={pooledResetTime}
useGemini3_1={useGemini3_1}
useGemini3_1FlashLite={useGemini3_1FlashLite}
useCustomToolModel={useCustomToolModel}
/>
{renderFooter()}
@@ -72,7 +72,9 @@ const createMockConfig = (overrides = {}) => ({
const renderStatusDisplay = async (
props: { hideContextSummary: boolean } = { hideContextSummary: false },
uiState: UIState = createMockUIState(),
settings = createMockSettings(),
settings = createMockSettings({
ui: { hideContextSummary: true },
}),
config = createMockConfig(),
) => {
const result = await render(
@@ -97,16 +99,21 @@ describe('StatusDisplay', () => {
vi.restoreAllMocks();
});
it('renders nothing by default if context summary is hidden via props', async () => {
const { lastFrame, unmount } = await renderStatusDisplay({
hideContextSummary: true,
});
it('renders nothing by default', async () => {
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders ContextSummaryDisplay by default', async () => {
const { lastFrame, unmount } = await renderStatusDisplay();
it('renders ContextSummaryDisplay when hideContextSummary is false', async () => {
const settings = createMockSettings({
ui: { hideContextSummary: false },
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
undefined,
settings,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -118,34 +125,6 @@ describe('StatusDisplay', () => {
unmount();
});
it('renders HookStatusDisplay when hooks are active', async () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('does NOT render HookStatusDisplay if notifications are disabled in settings', async () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const settings = createMockSettings({
hooksConfig: { notifications: false },
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
settings,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('hides ContextSummaryDisplay if configured in settings', async () => {
const settings = createMockSettings({
ui: { hideContextSummary: true },
@@ -163,9 +142,13 @@ describe('StatusDisplay', () => {
const uiState = createMockUIState({
backgroundShellCount: 3,
});
const settings = createMockSettings({
ui: { hideContextSummary: false },
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
settings,
);
expect(lastFrame()).toContain('Shells: 3');
unmount();
@@ -1,424 +0,0 @@
/**
* @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>
);
};
@@ -47,7 +47,6 @@ describe('ToolConfirmationQueue', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getApprovalMode: () => 'default',
getDisableAlwaysAllow: () => false,
getModel: () => 'gemini-pro',
getDebugMode: () => false,
@@ -1,11 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `
"Mock Context Summary Display (Skills: 2, Shells: 0)
"
`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `
exports[`StatusDisplay > renders ContextSummaryDisplay when hideContextSummary is false 1`] = `
"Mock Context Summary Display (Skills: 2, Shells: 0)
"
`;
@@ -11,17 +11,22 @@ import {
} from './CompressionMessage.js';
import { CompressionStatus } from '@google/gemini-cli-core';
import { type CompressionProps } from '../../types.js';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
describe('<CompressionMessage />', () => {
afterEach(() => {
vi.restoreAllMocks();
});
const createCompressionProps = (
overrides: Partial<CompressionProps> = {},
): CompressionDisplayProps => ({
compression: {
isPending: false,
originalTokenCount: null,
newTokenCount: null,
beforePercentage: null,
afterPercentage: null,
compressionStatus: CompressionStatus.COMPRESSED,
isManual: true,
...overrides,
},
});
@@ -29,9 +34,10 @@ describe('<CompressionMessage />', () => {
describe('pending state', () => {
it('renders pending message when compression is in progress', async () => {
const props = createCompressionProps({ isPending: true });
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Compressing chat history');
@@ -43,56 +49,28 @@ describe('<CompressionMessage />', () => {
it('renders success message when tokens are reduced', async () => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: 100,
newTokenCount: 50,
beforePercentage: 22,
afterPercentage: 6,
compressionStatus: CompressionStatus.COMPRESSED,
thresholdPercentage: 50,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).not.toContain('✦');
expect(output).toContain(
'Chat history compressed from 100 to 50 tokens.',
'Context compressed (22% → 6%). Adjust threshold (50%) in /settings.',
);
unmount();
});
it.each([
{ original: 50000, newTokens: 25000 }, // Large compression
{ original: 700000, newTokens: 350000 }, // Very large compression
])(
'renders success message for large successful compression (from $original to $newTokens)',
async ({ original, newTokens }) => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: original,
newTokenCount: newTokens,
compressionStatus: CompressionStatus.COMPRESSED,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).toContain(
`compressed from ${original} to ${newTokens} tokens`,
);
expect(output).not.toContain('Skipping compression');
expect(output).not.toContain('did not reduce size');
unmount();
},
);
});
describe('skipped compression (tokens increased or same)', () => {
it('renders skip message when compression would increase token count', async () => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: 50,
newTokenCount: 75,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
@@ -101,121 +79,12 @@ describe('<CompressionMessage />', () => {
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).not.toContain('✦');
expect(output).toContain(
'Compression was not beneficial for this history size.',
);
unmount();
});
it('renders skip message when token counts are equal', async () => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: 50,
newTokenCount: 50,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain(
'Compression was not beneficial for this history size.',
);
unmount();
});
});
describe('message content validation', () => {
it.each([
{
original: 200,
newTokens: 80,
expected: 'compressed from 200 to 80 tokens',
},
{
original: 500,
newTokens: 150,
expected: 'compressed from 500 to 150 tokens',
},
{
original: 1500,
newTokens: 400,
expected: 'compressed from 1500 to 400 tokens',
},
])(
'displays correct compression statistics (from $original to $newTokens)',
async ({ original, newTokens, expected }) => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: original,
newTokenCount: newTokens,
compressionStatus: CompressionStatus.COMPRESSED,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain(expected);
unmount();
},
);
it.each([
{ original: 50, newTokens: 60 }, // Increased
{ original: 100, newTokens: 100 }, // Same
{ original: 49999, newTokens: 50000 }, // Just under 50k threshold
])(
'shows skip message for small histories when new tokens >= original tokens ($original -> $newTokens)',
async ({ original, newTokens }) => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: original,
newTokenCount: newTokens,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain(
'Compression was not beneficial for this history size.',
);
expect(output).not.toContain('compressed from');
unmount();
},
);
it.each([
{ original: 50000, newTokens: 50100 }, // At 50k threshold
{ original: 700000, newTokens: 710000 }, // Large history case
{ original: 100000, newTokens: 100000 }, // Large history, same count
])(
'shows compression failure message for large histories when new tokens >= original tokens ($original -> $newTokens)',
async ({ original, newTokens }) => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: original,
newTokenCount: newTokens,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain('compression did not reduce size');
expect(output).not.toContain('compressed from');
expect(output).not.toContain('Compression was not beneficial');
unmount();
},
);
});
describe('failure states', () => {
@@ -229,9 +98,9 @@ describe('<CompressionMessage />', () => {
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).not.toContain('✦');
expect(output).toContain(
'Chat history compression failed: the model returned an empty summary.',
'Chat history compression failed: empty summary.',
);
unmount();
});
@@ -22,11 +22,13 @@ export interface CompressionDisplayProps {
export function CompressionMessage({
compression,
}: CompressionDisplayProps): React.JSX.Element {
const { isPending, originalTokenCount, newTokenCount, compressionStatus } =
compression;
const originalTokens = originalTokenCount ?? 0;
const newTokens = newTokenCount ?? 0;
const {
isPending,
beforePercentage,
afterPercentage,
compressionStatus,
thresholdPercentage,
} = compression;
const getCompressionText = () => {
if (isPending) {
@@ -34,20 +36,19 @@ export function CompressionMessage({
}
switch (compressionStatus) {
case CompressionStatus.COMPRESSED:
return `Chat history compressed from ${originalTokens} to ${newTokens} tokens.`;
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
// For smaller histories (< 50k tokens), compression overhead likely exceeds benefits
if (originalTokens < 50000) {
return 'Compression was not beneficial for this history size.';
case CompressionStatus.COMPRESSED: {
let text = `Context compressed (${beforePercentage}% → ${afterPercentage}%).`;
if (thresholdPercentage != null) {
text += ` Adjust threshold (${thresholdPercentage}%) in /settings.`;
}
// For larger histories where compression should work but didn't,
// this suggests an issue with the compression process itself
return 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.';
return text;
}
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
return 'Compression was not beneficial for this history size.';
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
return 'Could not compress chat history due to a token counting error.';
case CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY:
return 'Chat history compression failed: the model returned an empty summary.';
return 'Chat history compression failed: empty summary.';
case CompressionStatus.NOOP:
return 'Nothing to compress.';
default:
@@ -58,20 +59,13 @@ export function CompressionMessage({
const text = getCompressionText();
return (
<Box flexDirection="row">
<Box marginRight={1}>
{isPending ? (
<CliSpinner type="dots" />
) : (
<Text color={theme.text.accent}></Text>
)}
</Box>
<Box flexDirection="row" paddingLeft={1} marginBottom={1}>
<Box marginRight={1}>{isPending && <CliSpinner type="dots" />}</Box>
<Box>
<Text
color={
compression.isPending ? theme.text.accent : theme.status.success
}
color={theme.text.secondary}
aria-label={SCREEN_READER_MODEL_PREFIX}
italic
>
{text}
</Text>
@@ -22,7 +22,6 @@ describe('ToolConfirmationMessage Redirection', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
it('should display redirection warning and tip for redirected commands', async () => {
@@ -153,7 +153,7 @@ export const SubagentProgressDisplay: React.FC<
})}
</Box>
{progress.result && (
{progress.state === 'completed' && progress.result && (
<Box flexDirection="column" marginTop={1}>
{progress.terminateReason && progress.terminateReason !== 'GOAL' && (
<Box marginBottom={1}>
@@ -164,7 +164,7 @@ export const SubagentProgressDisplay: React.FC<
)}
<MarkdownDisplay
text={safeJsonToMarkdown(progress.result)}
isPending={progress.state !== 'completed'}
isPending={false}
terminalWidth={terminalWidth}
/>
</Box>
@@ -40,7 +40,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
it('should not display urls if prompt and url are the same', async () => {
@@ -325,7 +324,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
@@ -347,7 +345,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => false,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
@@ -383,7 +380,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
@@ -410,7 +406,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
@@ -452,7 +447,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
@@ -479,7 +473,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
@@ -506,7 +499,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
@@ -15,7 +15,6 @@ import {
type ToolConfirmationPayload,
ToolConfirmationOutcome,
type EditorType,
ApprovalMode,
hasRedirection,
debugLogger,
} from '@google/gemini-cli-core';
@@ -315,31 +314,6 @@ export const ToolConfirmationMessage: React.FC<
key: 'No, suggest changes (esc)',
});
}
} else if (confirmationDetails.type === 'sandbox_expansion') {
options.push({
label: 'Allow once',
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Allow once',
});
if (isTrustedFolder) {
options.push({
label: 'Allow for this session',
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Allow for this session',
});
if (allowPermanentApproval) {
options.push({
label: 'Allow for all future sessions',
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: 'Allow for all future sessions',
});
}
}
options.push({
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
key: 'No, suggest changes (esc)',
});
} else if (confirmationDetails.type === 'exec') {
options.push({
label: 'Allow once',
@@ -572,8 +546,6 @@ export const ToolConfirmationMessage: React.FC<
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
}
} else if (confirmationDetails.type === 'sandbox_expansion') {
question = `Allow sandbox expansion for: '${sanitizeForDisplay(confirmationDetails.rootCommand)}'?`;
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
@@ -601,52 +573,6 @@ export const ToolConfirmationMessage: React.FC<
/>
);
}
} else if (confirmationDetails.type === 'sandbox_expansion') {
const { additionalPermissions } = confirmationDetails;
const readPaths = additionalPermissions?.fileSystem?.read || [];
const writePaths = additionalPermissions?.fileSystem?.write || [];
const network = additionalPermissions?.network;
bodyContent = (
<Box flexDirection="column" padding={1}>
<Text color={theme.text.secondary} italic>
The agent is requesting additional sandbox permissions to execute
this command:
</Text>
<Box paddingY={1}>
<Text color={theme.text.secondary}>
{sanitizeForDisplay(confirmationDetails.command)}
</Text>
</Box>
{network && (
<Box>
<Text color={theme.status.warning}> Network Access</Text>
</Box>
)}
{readPaths.length > 0 && (
<Box flexDirection="column">
<Text color={theme.status.success}> Read Access:</Text>
{readPaths.map((p, i) => (
<Text key={i} color={theme.text.secondary}>
{' '}
{sanitizeForDisplay(p)}
</Text>
))}
</Box>
)}
{writePaths.length > 0 && (
<Box flexDirection="column">
<Text color={theme.status.error}> Write Access:</Text>
{writePaths.map((p, i) => (
<Text key={i} color={theme.text.secondary}>
{' '}
{sanitizeForDisplay(p)}
</Text>
))}
</Box>
)}
</Box>
);
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
@@ -661,8 +587,7 @@ export const ToolConfirmationMessage: React.FC<
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode = null;
const isAutoEdit = config.getApprovalMode() === ApprovalMode.AUTO_EDIT;
if (containsRedirection && !isAutoEdit) {
if (containsRedirection) {
// Calculate lines needed for Note and Tip
const safeWidth = Math.max(terminalWidth, 1);
const noteLength =
@@ -812,7 +737,6 @@ export const ToolConfirmationMessage: React.FC<
isTrustedFolder,
allowPermanentApproval,
settings,
config,
]);
const bodyOverflowDirection: 'top' | 'bottom' =
@@ -14,7 +14,6 @@ import {
useKeypressContext,
ESC_TIMEOUT,
FAST_RETURN_TIMEOUT,
KeypressPriority,
type Key,
} from './KeypressContext.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
@@ -260,48 +259,6 @@ 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(() =>
@@ -180,7 +180,6 @@ export interface UIState {
contextFileNames: string[];
errorCount: number;
availableTerminalHeight: number | undefined;
stableControlsHeight: number;
mainAreaWidth: number;
staticAreaMaxItemHeight: number;
staticExtraHeight: number;
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -20,7 +20,7 @@ import {
useCommandCompletion,
CompletionMode,
} from './useCommandCompletion.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { CommandContext } 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,11 +72,7 @@ const setupMocks = ({
shellSuggestions = [],
isLoading = false,
isPerfectMatch = false,
slashCompletionRange = {
completionStart: 0,
completionEnd: 0,
getCommandFromSuggestion: () => undefined,
},
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
shellCompletionRange = {
completionStart: 0,
completionEnd: 0,
@@ -89,13 +85,7 @@ const setupMocks = ({
shellSuggestions?: Suggestion[];
isLoading?: boolean;
isPerfectMatch?: boolean;
slashCompletionRange?: {
completionStart: number;
completionEnd: number;
getCommandFromSuggestion: (
suggestion: Suggestion,
) => SlashCommand | undefined;
};
slashCompletionRange?: { completionStart: number; completionEnd: number };
shellCompletionRange?: {
completionStart: number;
completionEnd: number;
@@ -481,15 +471,10 @@ describe('useCommandCompletion', () => {
});
describe('handleAutocomplete', () => {
it('should complete a partial command and NOT add a space if it has an action', async () => {
it('should complete a partial command', async () => {
setupMocks({
slashSuggestions: [{ label: 'memory', value: 'memory' }],
slashCompletionRange: {
completionStart: 1,
completionEnd: 4,
getCommandFromSuggestion: () =>
({ action: vi.fn() }) as unknown as SlashCommand,
},
slashCompletionRange: { completionStart: 1, completionEnd: 4 },
});
const { result } = await renderCommandCompletionHook('/mem');
@@ -502,40 +487,12 @@ describe('useCommandCompletion', () => {
result.current.handleAutocomplete(0);
});
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 ');
expect(result.current.textBuffer.text).toBe('/memory ');
});
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');
@@ -560,11 +517,7 @@ describe('useCommandCompletion', () => {
insertValue: 'resume list',
},
],
slashCompletionRange: {
completionStart: 1,
completionEnd: 5,
getCommandFromSuggestion: () => undefined,
},
slashCompletionRange: { completionStart: 1, completionEnd: 5 },
});
const { result } = await renderCommandCompletionHook('/resu');
@@ -586,11 +539,6 @@ 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);
@@ -611,11 +559,6 @@ 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');
@@ -636,11 +579,6 @@ describe('useCommandCompletion', () => {
atSuggestions: [
{ label: 'src\\components\\', value: 'src\\components\\' },
],
slashCompletionRange: {
completionStart: 0,
completionEnd: 0,
getCommandFromSuggestion: () => undefined,
},
});
const { result } = await renderCommandCompletionHook('@src\\comp');
@@ -656,33 +594,6 @@ 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({
@@ -994,11 +905,6 @@ 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,17 +1,16 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 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 { toCodePoints } from '../utils/textUtils.js';
import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js';
import { useAtCompletion } from './useAtCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
import { useShellCompletion } from './useShellCompletion.js';
@@ -437,23 +436,10 @@ 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('\\') &&
shouldAddSpace
!suggestionText.endsWith('\\')
) {
suggestionText += ' ';
}
@@ -1,110 +0,0 @@
/**
* @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,
};
};
+193 -169
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-explicit-any -- TODO: Refactor to remove any usage */
import {
describe,
it,
@@ -32,10 +32,7 @@ import type {
Config,
EditorType,
AnyToolInvocation,
AnyDeclarativeTool,
SpanMetadata,
CompletedToolCall,
ToolCallRequestInfo,
} from '@google/gemini-cli-core';
import {
CoreToolCallStatus,
@@ -52,19 +49,15 @@ import {
MCPDiscoveryState,
GeminiCliOperation,
getPlanModeExitMessage,
CompressionStatus,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type {
SlashCommandProcessorResult,
HistoryItemWithoutId,
HistoryItem,
} from '../types.js';
import type { SlashCommandProcessorResult } from '../types.js';
import { MessageType, StreamingState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { theme } from '../semantic-colors.js';
// --- MOCKS ---
const mockSendMessageStream = vi
@@ -145,6 +138,7 @@ const mockRunInDevTraceSpan = vi.hoisted(() =>
};
return await fn({
metadata,
endSpan: vi.fn(),
});
}),
);
@@ -249,10 +243,8 @@ describe('useGeminiStream', () => {
let mockMarkToolsAsSubmitted: Mock;
let handleAtCommandSpy: MockInstance;
const emptyHistory: HistoryItem[] = [];
let capturedOnComplete:
| ((tools: CompletedToolCall[]) => Promise<void>)
| null = null;
const emptyHistory: any[] = [];
let capturedOnComplete: any = null;
const mockGetPreferredEditor = vi.fn(() => 'vscode' as EditorType);
const mockOnAuthError = vi.fn();
const mockPerformMemoryRefresh = vi.fn(() => Promise.resolve());
@@ -332,6 +324,9 @@ describe('useGeminiStream', () => {
})),
getIdeMode: vi.fn(() => false),
getEnableHooks: vi.fn(() => false),
getShowContextWindowWarning: vi.fn(() => false),
getShowContextCompression: vi.fn(() => false),
getContextWindowCompressionThreshold: vi.fn(() => 0.2),
} as unknown as Config;
beforeEach(() => {
@@ -411,17 +406,13 @@ describe('useGeminiStream', () => {
lastToolCalls,
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
(
updater:
| TrackedToolCall[]
| ((prev: TrackedToolCall[]) => TrackedToolCall[]),
) => {
(updater: any) => {
lastToolCalls =
typeof updater === 'function' ? updater(lastToolCalls) : updater;
rerender({ ...initialProps, toolCalls: lastToolCalls });
},
(signal: AbortSignal) => {
mockCancelAllToolCalls(signal);
(...args: any[]) => {
mockCancelAllToolCalls(...args);
lastToolCalls = lastToolCalls.map((tc) => {
if (
tc.status === CoreToolCallStatus.AwaitingApproval ||
@@ -888,7 +879,7 @@ describe('useGeminiStream', () => {
const fn = spanArgs[1];
const metadata = { attributes: {} };
await act(async () => {
await fn({ metadata });
await fn({ metadata, endSpan: vi.fn() });
});
expect(metadata).toMatchObject({
input: sentParts,
@@ -982,7 +973,7 @@ describe('useGeminiStream', () => {
});
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
const stopExecutionToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'stop-call',
@@ -1054,7 +1045,7 @@ describe('useGeminiStream', () => {
});
it('should add a compact suppressed-error note before STOP_EXECUTION terminal info in low verbosity mode', async () => {
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
const stopExecutionToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'stop-call',
@@ -1081,10 +1072,9 @@ describe('useGeminiStream', () => {
} as unknown as TrackedCompletedToolCall,
];
const lowVerbositySettings = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockLoadedSettings,
...(mockLoadedSettings as any),
merged: {
...mockLoadedSettings.merged,
...(mockLoadedSettings.merged as any),
ui: { errorVerbosity: 'low' },
},
} as LoadedSettings;
@@ -1935,120 +1925,6 @@ describe('useGeminiStream', () => {
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
});
});
it('should record client-initiated tool calls in GeminiChat history', async () => {
const { result, client: mockGeminiClient } = await renderTestHook();
mockHandleSlashCommand.mockResolvedValue({
type: 'schedule_tool',
toolName: 'activate_skill',
toolArgs: { name: 'test-skill' },
});
await act(async () => {
await result.current.submitQuery('/test-skill');
});
// Simulate tool completion
const completedTool = {
request: {
callId: 'test-call-id',
name: 'activate_skill',
args: { name: 'test-skill' },
isClientInitiated: true,
},
status: CoreToolCallStatus.Success,
invocation: {
getDescription: () => 'Activating skill test-skill',
},
tool: {
isOutputMarkdown: true,
},
response: {
responseParts: [
{
functionResponse: {
name: 'activate_skill',
response: { content: 'skill instructions' },
},
},
],
},
} as unknown as TrackedCompletedToolCall;
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([completedTool]);
}
});
// Verify that the tool call and response were added to GeminiChat history
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
role: 'model',
parts: [
{
functionCall: {
name: 'activate_skill',
args: { name: 'test-skill' },
},
},
],
});
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: completedTool.response.responseParts,
});
});
it('should NOT record other client-initiated tool calls (like save_memory) in history', async () => {
const { result, client: mockGeminiClient } = await renderTestHook();
mockHandleSlashCommand.mockResolvedValue({
type: 'schedule_tool',
toolName: 'save_memory',
toolArgs: { fact: 'test fact' },
});
await act(async () => {
await result.current.submitQuery('/memory add "test fact"');
});
// Simulate tool completion
const completedTool = {
request: {
callId: 'test-call-id',
name: 'save_memory',
args: { fact: 'test fact' },
isClientInitiated: true,
},
status: CoreToolCallStatus.Success,
invocation: {
getDescription: () => 'Saving memory',
},
tool: {
isOutputMarkdown: true,
},
response: {
responseParts: [
{
functionResponse: {
name: 'save_memory',
response: { success: true },
},
},
],
},
} as unknown as TrackedCompletedToolCall;
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([completedTool]);
}
});
// Verify that addHistory was NOT called
expect(mockGeminiClient.addHistory).not.toHaveBeenCalled();
});
});
describe('Memory Refresh on save_memory', () => {
@@ -2076,7 +1952,7 @@ describe('useGeminiStream', () => {
displayName: 'save_memory',
description: 'Saves memory',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
} as any,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
@@ -2150,8 +2026,7 @@ describe('useGeminiStream', () => {
);
const testConfig = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
...(mockConfig as any),
getContentGenerator: vi.fn(),
getContentGeneratorConfig: vi.fn(() => ({
authType: mockAuthType,
@@ -2316,7 +2191,7 @@ describe('useGeminiStream', () => {
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
@@ -2357,7 +2232,7 @@ describe('useGeminiStream', () => {
displayName: 'write_file',
description: 'Write file',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
@@ -2474,22 +2349,32 @@ describe('useGeminiStream', () => {
it.each([
{
name: 'without suggestion when remaining tokens are > 75% of limit',
name: 'add a message when remaining tokens overflow, regardless of showContextWindowWarning setting',
requestTokens: 20,
remainingTokens: 80,
shouldShow: false, // The setting itself is false
expectedMessage:
'Sending this message (20 tokens) might exceed the context window limit (80 tokens left).',
'Context 20% full. Message may exceed window. Reduce size or /compress.',
},
{
name: 'with suggestion when remaining tokens are < 75% of limit',
name: 'add a message when showContextWindowWarning is true',
requestTokens: 30,
remainingTokens: 70,
shouldShow: true,
expectedMessage:
'Sending this message (30 tokens) might exceed the context window limit (70 tokens left). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
'Context 30% full. Message may exceed window. Reduce size or /compress.',
},
])(
'should add message $name',
async ({ requestTokens, remainingTokens, expectedMessage }) => {
'should $name',
async ({
requestTokens,
remainingTokens,
shouldShow,
expectedMessage,
}) => {
vi.mocked(mockConfig.getShowContextWindowWarning).mockReturnValue(
shouldShow,
);
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
@@ -2509,10 +2394,12 @@ describe('useGeminiStream', () => {
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith({
type: 'info',
text: expectedMessage,
});
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expectedMessage,
}),
);
});
},
);
@@ -2566,8 +2453,12 @@ describe('useGeminiStream', () => {
});
});
it('should add informational messages when ChatCompressed event is received', async () => {
it('should add informational messages when ChatCompressed event is received and showContextCompression is true', async () => {
vi.mocked(tokenLimit).mockReturnValue(10000);
vi.mocked(
mockConfig.getContextWindowCompressionThreshold,
).mockReturnValue(0.2);
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(true);
// Setup mock to return a stream with ChatCompressed event
mockSendMessageStream.mockReturnValue(
(async function* () {
@@ -2576,7 +2467,22 @@ describe('useGeminiStream', () => {
value: {
originalTokenCount: 1000,
newTokenCount: 500,
compressionStatus: 'compressed',
compressionStatus: CompressionStatus.COMPRESSED,
},
};
yield {
type: ServerGeminiEventType.Content,
value: 'Response after compression',
};
yield {
type: ServerGeminiEventType.Finished,
value: {
finishReason: 'STOP',
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
},
},
};
})(),
@@ -2593,10 +2499,129 @@ describe('useGeminiStream', () => {
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Context compressed from 10% to 5%.',
secondaryText: 'Change threshold in /settings.',
color: theme.status.warning,
type: 'compression',
compression: {
isPending: false,
beforePercentage: 10,
afterPercentage: 5,
compressionStatus: CompressionStatus.COMPRESSED,
isManual: false,
thresholdPercentage: 20,
},
}),
expect.any(Number),
);
});
});
it('should NOT add informational messages when ChatCompressed event is received and showContextCompression is false', async () => {
vi.mocked(tokenLimit).mockReturnValue(10000);
vi.mocked(
mockConfig.getContextWindowCompressionThreshold,
).mockReturnValue(0.2);
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(false);
// Setup mock to return a stream with ChatCompressed event
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.ChatCompressed,
value: {
originalTokenCount: 1000,
newTokenCount: 500,
compressionStatus: CompressionStatus.COMPRESSED,
},
};
yield {
type: ServerGeminiEventType.Content,
value: 'Response after compression',
};
yield {
type: ServerGeminiEventType.Finished,
value: {
finishReason: 'STOP',
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
},
},
};
})(),
);
const { result } = await renderHookWithDefaults();
// Submit a query
await act(async () => {
await result.current.submitQuery('Test compression');
});
// Check that NO compression message was added
await waitFor(() => {
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({
type: 'compression',
}),
);
});
});
it('should add informational messages when ChatCompressed event is received with a large prompt even if showContextCompression is false', async () => {
vi.mocked(tokenLimit).mockReturnValue(10000);
vi.mocked(
mockConfig.getContextWindowCompressionThreshold,
).mockReturnValue(0.2); // 20%
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(false);
// Setup mock to return a stream with ChatCompressed event and a large requestTokenCount (25%)
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.ChatCompressed,
value: {
originalTokenCount: 1000,
newTokenCount: 500,
compressionStatus: CompressionStatus.COMPRESSED,
requestTokenCount: 2500, // 25% > 20%
},
};
yield {
type: ServerGeminiEventType.Content,
value: 'Response after compression',
};
yield {
type: ServerGeminiEventType.Finished,
value: {
finishReason: 'STOP',
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
},
},
};
})(),
);
const { result } = await renderHookWithDefaults();
// Submit a query
await act(async () => {
await result.current.submitQuery('Test large prompt compression');
});
// Check that compression message WAS added despite the setting
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'compression',
compression: expect.objectContaining({
beforePercentage: 10,
afterPercentage: 5,
compressionStatus: CompressionStatus.COMPRESSED,
isManual: false,
thresholdPercentage: 20,
}),
}),
expect.any(Number),
);
@@ -2702,14 +2727,14 @@ describe('useGeminiStream', () => {
it('should flush pending text rationale before scheduling tool calls to ensure correct history order', async () => {
const addItemOrder: string[] = [];
let capturedOnComplete: (tools: CompletedToolCall[]) => Promise<void>;
let capturedOnComplete: any;
const mockScheduleToolCalls = vi.fn(async (requests) => {
addItemOrder.push('scheduleToolCalls_START');
// Simulate tools completing and triggering onComplete immediately.
// This mimics the behavior that caused the regression where tool results
// were added to history during the await scheduleToolCalls(...) block.
const tools = requests.map((r: ToolCallRequestInfo) => ({
const tools = requests.map((r: any) => ({
request: r,
status: CoreToolCallStatus.Success,
tool: { displayName: r.name, name: r.name },
@@ -2724,7 +2749,7 @@ describe('useGeminiStream', () => {
addItemOrder.push('scheduleToolCalls_END');
});
mockAddItem.mockImplementation((item: HistoryItemWithoutId) => {
mockAddItem.mockImplementation((item: any) => {
addItemOrder.push(`addItem:${item.type}`);
});
@@ -2954,10 +2979,9 @@ describe('useGeminiStream', () => {
describe('Thought Reset', () => {
it('should keep full thinking entries in history when mode is full', async () => {
const fullThinkingSettings: LoadedSettings = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockLoadedSettings,
...(mockLoadedSettings as any),
merged: {
...mockLoadedSettings.merged,
...(mockLoadedSettings.merged as any),
ui: { inlineThinkingMode: 'full' },
},
} as unknown as LoadedSettings;
@@ -4036,7 +4060,7 @@ describe('useGeminiStream', () => {
const spanMetadata = {} as SpanMetadata;
await act(async () => {
await userPromptCall![1]({ metadata: spanMetadata });
await userPromptCall![1]({ metadata: spanMetadata, endSpan: vi.fn() });
});
expect(spanMetadata.input).toBe('telemetry test query');
});
+85 -84
View File
@@ -38,22 +38,20 @@ import {
GeminiCliOperation,
getPlanModeExitMessage,
isBackgroundExecutionData,
type CompressionStatus,
Kind,
ACTIVATE_SKILL_TOOL_NAME,
} from '@google/gemini-cli-core';
import type {
Config,
EditorType,
GeminiClient,
ServerGeminiChatCompressedEvent,
ServerGeminiContentEvent as ContentEvent,
ServerGeminiFinishedEvent,
ServerGeminiStreamEvent as GeminiEvent,
ThoughtSummary,
ToolCallRequestInfo,
ToolCallResponseInfo,
GeminiErrorEventValue,
RetryAttemptPayload,
type Config,
type EditorType,
type GeminiClient,
type ServerGeminiChatCompressedEvent,
type ServerGeminiContentEvent as ContentEvent,
type ServerGeminiFinishedEvent,
type ServerGeminiStreamEvent as GeminiEvent,
type ThoughtSummary,
type ToolCallRequestInfo,
type ToolCallResponseInfo,
type GeminiErrorEventValue,
type RetryAttemptPayload,
} from '@google/gemini-cli-core';
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import type {
@@ -61,7 +59,6 @@ import type {
HistoryItemThinking,
HistoryItemWithoutId,
HistoryItemToolGroup,
HistoryItemInfo,
IndividualToolCallDisplay,
SlashCommandProcessorResult,
HistoryItemModel,
@@ -257,6 +254,8 @@ export const useGeminiStream = (
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
useStateAndRef<boolean>(true);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const handleCompletedToolsRef =
useRef<(completedTools: TrackedToolCall[]) => Promise<void>>(undefined);
const { startNewPrompt, getPromptCount } = useSessionStats();
const storage = config.storage;
const logger = useLogger(storage);
@@ -302,22 +301,23 @@ export const useGeminiStream = (
}),
);
}
// Clear the live-updating display now that the final state is in history.
setToolCallsForDisplay([]);
// Record tool calls with full metadata before sending responses.
try {
const currentModel =
config.getGeminiClient().getCurrentSequenceModel() ??
config.getModel();
config
.getGeminiClient()
.getChat()
.recordCompletedToolCalls(
currentModel,
completedToolCallsFromScheduler,
);
(typeof geminiClient.getCurrentSequenceModel === 'function'
? geminiClient.getCurrentSequenceModel()
: undefined) ?? config.getModel();
const chat =
typeof geminiClient.getChat === 'function'
? geminiClient.getChat()
: undefined;
chat?.recordCompletedToolCalls(
currentModel,
completedToolCallsFromScheduler,
);
await recordToolCallInteractions(
config,
@@ -330,7 +330,7 @@ export const useGeminiStream = (
}
// Handle tool response submission immediately when tools complete
await handleCompletedTools(
await handleCompletedToolsRef.current?.(
completedToolCallsFromScheduler as TrackedToolCall[],
);
}
@@ -549,9 +549,11 @@ export const useGeminiStream = (
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) {
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;
return (
tc.status !== 'scheduled' &&
tc.status !== 'validating' &&
tc.status !== 'awaiting_approval'
);
});
if (
@@ -1145,21 +1147,43 @@ export const useGeminiStream = (
}
const limit = tokenLimit(config.getModel());
const originalPercentage = Math.round(
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
);
const newPercentage = Math.round(
((eventValue?.newTokenCount ?? 0) / limit) * 100,
);
const beforePercentage =
eventValue?.originalTokenCount != null
? Math.round((eventValue.originalTokenCount / limit) * 100)
: null;
const afterPercentage =
eventValue?.newTokenCount != null
? Math.round((eventValue.newTokenCount / limit) * 100)
: null;
const threshold = config.getContextWindowCompressionThreshold();
const isLargePrompt =
eventValue?.requestTokenCount != null &&
eventValue.requestTokenCount / limit > threshold;
if (!config.getShowContextCompression() && !isLargePrompt) {
return;
}
addItem(
{
type: MessageType.INFO,
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
secondaryText: `Change threshold in /settings.`,
color: theme.status.warning,
marginBottom: 1,
} as HistoryItemInfo,
type: 'compression',
compression: {
isPending: false,
beforePercentage,
afterPercentage,
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
compressionStatus: eventValue
? (Number(
eventValue.compressionStatus,
) as unknown as CompressionStatus)
: null,
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
isManual: false,
thresholdPercentage: Math.round(threshold * 100),
},
timestamp: new Date(userMessageTimestamp),
} as HistoryItemWithoutId,
userMessageTimestamp,
);
},
@@ -1182,16 +1206,11 @@ export const useGeminiStream = (
onCancelSubmit(true);
const limit = tokenLimit(config.getModel());
const usedPercentage = Math.round(
((limit - remainingTokenCount) / limit) * 100,
);
const isMoreThan25PercentUsed =
limit > 0 && remainingTokenCount < limit * 0.75;
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the context window limit (${remainingTokenCount.toLocaleString()} tokens left).`;
if (isMoreThan25PercentUsed) {
text +=
' Please try reducing the size of your message or use the `/compress` command to compress the chat history.';
}
const text = `Context ${usedPercentage}% full. Message may exceed window. Reduce size or /compress.`;
addItem({
type: 'info',
@@ -1538,8 +1557,7 @@ export const useGeminiStream = (
setLoopDetectionConfirmationRequest(null);
if (result.userSelection === 'disable') {
config
.getGeminiClient()
geminiClient
.getLoopDetectionService()
.disableForSession();
addItem({
@@ -1657,7 +1675,7 @@ export const useGeminiStream = (
) {
let awaitingApprovalCalls = toolCalls.filter(
(call): call is TrackedWaitingToolCall =>
call.status === 'awaiting_approval' && !call.request.forcedAsk,
call.status === 'awaiting_approval',
);
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
@@ -1715,42 +1733,24 @@ export const useGeminiStream = (
},
);
// Check if all tools in the batch are in a terminal state
const allTerminal = completedToolCallsFromScheduler.every(
(tc) =>
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled',
);
if (!allTerminal) {
return;
}
// Finalize any client-initiated tools as soon as they are done.
const clientTools = completedAndReadyToSubmitTools.filter(
(t) => t.request.isClientInitiated,
);
if (clientTools.length > 0) {
markToolsAsSubmitted(clientTools.map((t) => t.request.callId));
if (geminiClient) {
for (const tool of clientTools) {
// Only manually record skill activations in the chat history.
// Other client-initiated tools (like save_memory) update the system
// prompt/context and don't strictly need to be in the history.
if (tool.request.name !== ACTIVATE_SKILL_TOOL_NAME) {
continue;
}
// Add both the call (model turn) and the result (user turn) to history.
// Client-initiated calls are essentially "synthetic" turns that let
// subsequent model calls understand what just happened in the UI.
await geminiClient.addHistory({
role: 'model',
parts: [
{
functionCall: {
name: tool.request.name,
args: tool.request.args,
},
},
],
});
await geminiClient.addHistory({
role: 'user',
parts: tool.response.responseParts,
});
}
}
}
// Identify new, successful save_memory calls that we haven't processed yet.
@@ -1906,6 +1906,7 @@ export const useGeminiStream = (
setIsResponding,
],
);
handleCompletedToolsRef.current = handleCompletedTools;
const pendingHistoryItems = useMemo(
() =>
+2 -4
View File
@@ -66,11 +66,11 @@ export const usePhraseCycler = (
if (shouldShowFocusHint || isWaiting) {
// These are handled by the return value directly for immediate feedback
return clearTimers;
return;
}
if (!isActive || (!showTips && !showWit)) {
return clearTimers;
return;
}
const wittyPhrasesList =
@@ -101,7 +101,6 @@ 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);
@@ -133,7 +132,6 @@ 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 2026 Google LLC
* Copyright 2025 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,6 +513,53 @@ 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({
@@ -547,7 +594,7 @@ describe('useSlashCompletion', () => {
unmount();
});
it('should suggest the command itself instead of subcommands when a parent command is fully typed without a trailing space', async () => {
it('should suggest subcommands when a parent command is fully typed without a trailing space', async () => {
const slashCommands = [
createTestCommand({
name: 'chat',
@@ -571,47 +618,18 @@ describe('useSlashCompletion', () => {
await resolveMatch();
await waitFor(() => {
// 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);
// 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);
});
unmount();
});
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2026 Google LLC
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -54,6 +54,8 @@ interface CommandParserResult {
partial: string;
currentLevel: readonly SlashCommand[] | undefined;
leafCommand: SlashCommand | null;
exactMatchAsParent: SlashCommand | undefined;
usedPrefixParentDescent: boolean;
isArgumentCompletion: boolean;
}
@@ -69,6 +71,8 @@ function useCommandParser(
partial: '',
currentLevel: slashCommands,
leafCommand: null,
exactMatchAsParent: undefined,
usedPrefixParentDescent: false,
isArgumentCompletion: false,
};
}
@@ -86,6 +90,7 @@ function useCommandParser(
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
let leafCommand: SlashCommand | null = null;
let usedPrefixParentDescent = false;
for (const part of commandPathParts) {
if (!currentLevel) {
@@ -110,6 +115,60 @@ 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 &&
@@ -123,6 +182,8 @@ function useCommandParser(
partial,
currentLevel,
leafCommand,
exactMatchAsParent,
usedPrefixParentDescent,
isArgumentCompletion,
};
}, [query, slashCommands]);
@@ -282,9 +343,19 @@ 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,
};
@@ -313,7 +384,7 @@ function useCommandSuggestions(
description: 'Browse auto-saved chats',
commandKind: CommandKind.BUILT_IN,
sectionTitle: 'auto',
submitValue: `/${canonicalParentName}`,
submitValue: `/${leafCommand.name}`,
};
setSuggestions([autoSectionSuggestion, ...finalSuggestions]);
return;
@@ -356,10 +427,12 @@ function useCompletionPositions(
return { start: -1, end: -1 };
}
const { hasTrailingSpace, partial } = parserResult;
const { hasTrailingSpace, partial, exactMatchAsParent } = parserResult;
// Set completion start/end positions
if (hasTrailingSpace) {
if (parserResult.usedPrefixParentDescent) {
return { start: 1, end: query.length };
} else if (hasTrailingSpace || exactMatchAsParent) {
return { start: query.length, end: query.length };
} else if (partial) {
if (parserResult.isArgumentCompletion) {
@@ -388,7 +461,12 @@ function usePerfectMatch(
return { isPerfectMatch: false };
}
if (leafCommand && partial === '' && leafCommand.action) {
if (
leafCommand &&
partial === '' &&
leafCommand.action &&
!parserResult.usedPrefixParentDescent
) {
return { isPerfectMatch: true };
}
@@ -31,7 +31,6 @@ export const DefaultAppLayout: React.FC = () => {
flexDirection="column"
width={uiState.terminalWidth}
height={isAlternateBuffer ? terminalHeight : undefined}
paddingBottom={isAlternateBuffer ? 1 : undefined}
flexShrink={0}
flexGrow={0}
overflow="hidden"
@@ -63,9 +62,6 @@ export const DefaultAppLayout: React.FC = () => {
flexShrink={0}
flexGrow={0}
width={uiState.terminalWidth}
height={
uiState.copyModeEnabled ? uiState.stableControlsHeight : undefined
}
>
<Notifications />
<CopyModeWarning />
+4 -2
View File
@@ -138,9 +138,11 @@ export interface IndividualToolCallDisplay {
export interface CompressionProps {
isPending: boolean;
originalTokenCount: number | null;
newTokenCount: number | null;
beforePercentage: number | null;
afterPercentage: number | null;
compressionStatus: CompressionStatus | null;
isManual: boolean;
thresholdPercentage?: number | null;
}
/**
@@ -1,236 +0,0 @@
/**
* @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');
});
});
});
+5 -13
View File
@@ -13,7 +13,6 @@ interface ConsolePatcherParams {
onNewMessage?: (message: Omit<ConsoleMessageItem, 'id'>) => void;
debugMode: boolean;
stderr?: boolean;
interactive?: boolean;
}
export class ConsolePatcher {
@@ -50,19 +49,12 @@ export class ConsolePatcher {
private patchConsoleMethod =
(type: 'log' | 'warn' | 'error' | 'debug' | 'info') =>
(...args: unknown[]) => {
// 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;
}
}
// 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) {
if (this.params.stderr) {
if (type !== 'debug' || this.params.debugMode) {
this.originalConsoleError(this.formatArgs(args));
} else {
}
} else {
if (type !== 'debug' || this.params.debugMode) {
this.params.onNewMessage?.({
type,
content: this.formatArgs(args),
-1
View File
@@ -27,7 +27,6 @@ export interface UpdateInfo {
export interface UpdateObject {
message: string;
update: UpdateInfo;
isUpdating?: boolean;
}
/**
@@ -197,9 +197,7 @@ describe('handleAutoUpdate', () => {
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
...mockUpdateInfo,
message: 'An update is available!\nPlease update manually.',
isUpdating: false,
});
expect(mockSpawn).not.toHaveBeenCalled();
});
@@ -238,9 +236,7 @@ describe('handleAutoUpdate', () => {
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
...mockUpdateInfo,
message: 'An update is available!\nCannot determine update command.',
isUpdating: false,
});
expect(mockSpawn).not.toHaveBeenCalled();
});
@@ -257,9 +253,7 @@ describe('handleAutoUpdate', () => {
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
...mockUpdateInfo,
message: 'An update is available!\nThis is an additional message.',
isUpdating: false,
});
});
+5 -10
View File
@@ -102,22 +102,17 @@ export function handleAutoUpdate(
combinedMessage += `\n${installationInfo.updateMessage}`;
}
updateEventEmitter.emit('update-received', {
message: combinedMessage,
});
if (
!installationInfo.updateCommand ||
!settings.merged.general.enableAutoUpdate
) {
updateEventEmitter.emit('update-received', {
...info,
message: combinedMessage,
isUpdating: false,
});
return;
}
updateEventEmitter.emit('update-received', {
...info,
message: combinedMessage,
isUpdating: true,
});
if (_updateInProgress) {
return;
}
+1 -4
View File
@@ -66,10 +66,7 @@ beforeEach(() => {
? stackLines.slice(lastReactFrameIndex + 1).join('\n')
: stackLines.slice(1).join('\n');
if (
relevantStack.includes('OverflowContext.tsx') ||
relevantStack.includes('useTimedMessage.ts')
) {
if (relevantStack.includes('OverflowContext.tsx')) {
return;
}
@@ -26,15 +26,15 @@ function compileWindowsSandbox() {
const srcHelperPath = path.resolve(
__dirname,
'../src/sandbox/windows/GeminiSandbox.exe',
'../src/services/scripts/GeminiSandbox.exe',
);
const distHelperPath = path.resolve(
__dirname,
'../dist/src/sandbox/windows/GeminiSandbox.exe',
'../dist/src/services/scripts/GeminiSandbox.exe',
);
const sourcePath = path.resolve(
__dirname,
'../src/sandbox/windows/GeminiSandbox.cs',
'../src/services/scripts/GeminiSandbox.cs',
);
if (!fs.existsSync(sourcePath)) {
@@ -128,10 +128,7 @@ describe('A2AClientManager', () => {
describe('getInstance / dispatcher initialization', () => {
it('should use UndiciAgent when no proxy is configured', async () => {
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
await manager.loadAgent('TestAgent', 'http://test.agent/card');
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
.calls[0][0];
@@ -156,10 +153,7 @@ describe('A2AClientManager', () => {
} as Config;
manager = new A2AClientManager(mockConfigWithProxy);
await manager.loadAgent('TestProxyAgent', {
type: 'url',
url: 'http://test.proxy.agent/card',
});
await manager.loadAgent('TestProxyAgent', 'http://test.proxy.agent/card');
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
.calls[0][0];
@@ -178,40 +172,28 @@ describe('A2AClientManager', () => {
describe('loadAgent', () => {
it('should create and cache an A2AClient', async () => {
const agentCard = await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
const agentCard = await manager.loadAgent(
'TestAgent',
'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', {
type: 'url',
url: 'http://test.agent/card',
});
await manager.loadAgent('TestAgent', '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', {
type: 'url',
url: 'http://test.agent/card',
});
await manager.loadAgent('TestAgent', 'http://test.agent/card');
await expect(
manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
}),
manager.loadAgent('TestAgent', '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', {
type: 'url',
url: 'http://test.agent/card',
});
await manager.loadAgent('TestAgent', 'http://test.agent/card');
expect(createAuthenticatingFetchWithRetry).not.toHaveBeenCalled();
});
@@ -222,7 +204,7 @@ describe('A2AClientManager', () => {
};
await manager.loadAgent(
'TestAgent',
{ type: 'url', url: 'http://test.agent/card' },
'http://test.agent/card',
customAuthHandler as unknown as AuthenticationHandler,
);
@@ -239,7 +221,7 @@ describe('A2AClientManager', () => {
};
await manager.loadAgent(
'AuthCardAgent',
{ type: 'url', url: 'http://authcard.agent/card' },
'http://authcard.agent/card',
customAuthHandler as unknown as AuthenticationHandler,
);
@@ -270,7 +252,7 @@ describe('A2AClientManager', () => {
await manager.loadAgent(
'AuthCardAgent401',
{ type: 'url', url: 'http://authcard.agent/card' },
'http://authcard.agent/card',
customAuthHandler as unknown as AuthenticationHandler,
);
@@ -285,65 +267,19 @@ describe('A2AClientManager', () => {
});
it('should log a debug message upon loading an agent', async () => {
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
await manager.loadAgent('TestAgent', 'http://test.agent/card');
expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining("Loaded agent 'TestAgent'"),
);
});
it('should clear the cache', async () => {
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
await manager.loadAgent('TestAgent', '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')),
@@ -353,10 +289,7 @@ describe('A2AClientManager', () => {
);
await expect(
manager.loadAgent('FailAgent', {
type: 'url',
url: 'http://fail.agent',
}),
manager.loadAgent('FailAgent', 'http://fail.agent'),
).rejects.toThrow('Resolution failed');
});
@@ -371,10 +304,7 @@ describe('A2AClientManager', () => {
);
await expect(
manager.loadAgent('FailAgent', {
type: 'url',
url: 'http://fail.agent',
}),
manager.loadAgent('FailAgent', 'http://fail.agent'),
).rejects.toThrow('Factory failed');
});
});
@@ -388,10 +318,7 @@ describe('A2AClientManager', () => {
describe('sendMessageStream', () => {
beforeEach(async () => {
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
await manager.loadAgent('TestAgent', 'http://test.agent/card');
});
it('should send a message and return a stream', async () => {
@@ -506,10 +433,7 @@ describe('A2AClientManager', () => {
describe('getTask', () => {
beforeEach(async () => {
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
await manager.loadAgent('TestAgent', 'http://test.agent/card');
});
it('should get a task from the correct agent', async () => {
@@ -538,10 +462,7 @@ describe('A2AClientManager', () => {
describe('cancelTask', () => {
beforeEach(async () => {
await manager.loadAgent('TestAgent', {
type: 'url',
url: 'http://test.agent/card',
});
await manager.loadAgent('TestAgent', 'http://test.agent/card');
});
it('should cancel a task on the correct agent', async () => {
+4 -22
View File
@@ -26,7 +26,6 @@ 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';
@@ -86,7 +85,7 @@ export class A2AClientManager {
*/
async loadAgent(
name: string,
options: AgentCardLoadOptions,
agentCardUrl: string,
authHandler?: AuthenticationHandler,
): Promise<AgentCard> {
if (this.clients.has(name) && this.agentCards.has(name)) {
@@ -120,24 +119,7 @@ export class A2AClientManager {
};
const resolver = new DefaultAgentCardResolver({ fetchImpl: cardFetch });
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, '');
}
const rawCard = await resolver.resolve(agentCardUrl, '');
// TODO: Remove normalizeAgentCard once @a2a-js/sdk handles
// proto field name aliases (supportedInterfaces → additionalInterfaces,
// protocolBinding → transport).
@@ -171,12 +153,12 @@ export class A2AClientManager {
this.agentCards.set(name, agentCard);
debugLogger.debug(
`[A2AClientManager] Loaded agent '${name}' from ${urlIdentifier}`,
`[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`,
);
return agentCard;
} catch (error: unknown) {
throw classifyAgentError(name, urlIdentifier, error);
throw classifyAgentError(name, agentCardUrl, error);
}
}
+1 -1
View File
@@ -403,7 +403,7 @@ describe('a2aUtils', () => {
const output = reassembler.toString();
expect(output).toBe(
'Analyzing...Processing...\n\nArtifact (Code):\nprint("Done")',
'Analyzing...\n\nProcessing...\n\nArtifact (Code):\nprint("Done")',
);
});
+2 -25
View File
@@ -16,7 +16,6 @@ import type {
AgentInterface,
} from '@a2a-js/sdk';
import type { SendMessageResult } from './a2a-client-manager.js';
import type { SubagentActivityItem } from './types.js';
export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`;
@@ -124,39 +123,17 @@ export class A2AResultReassembler {
private pushMessage(message: Message | undefined) {
if (!message) return;
const text = extractPartsText(message.parts, '');
const text = extractPartsText(message.parts, '\n');
if (text && this.messageLog[this.messageLog.length - 1] !== text) {
this.messageLog.push(text);
}
}
/**
* Returns an array of activity items representing the current reassembled state.
*/
toActivityItems(): SubagentActivityItem[] {
const isAuthRequired = this.messageLog.includes(AUTH_REQUIRED_MSG);
return [
isAuthRequired
? {
id: 'auth-required',
type: 'thought',
content: AUTH_REQUIRED_MSG,
status: 'running',
}
: {
id: 'pending',
type: 'thought',
content: 'Working...',
status: 'running',
},
];
}
/**
* Returns a human-readable string representation of the current reassembled state.
*/
toString(): string {
const joinedMessages = this.messageLog.join('');
const joinedMessages = this.messageLog.join('\n\n');
const artifactsOutput = Array.from(this.artifacts.keys())
.map((id) => {
+9 -306
View File
@@ -19,9 +19,6 @@ import {
DEFAULT_MAX_TIME_MINUTES,
DEFAULT_MAX_TURNS,
type LocalAgentDefinition,
type RemoteAgentDefinition,
getAgentCardLoadOptions,
getRemoteAgentTargetUrl,
} from './types.js';
describe('loader', () => {
@@ -235,75 +232,6 @@ 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
@@ -314,99 +242,6 @@ Body`);
/Name must be a valid slug/,
);
});
describe('error formatting and kind inference', () => {
it('should only show local agent errors when kind is inferred as local (via kind field)', async () => {
const filePath = await writeAgentMarkdown(`---
kind: local
name: invalid-local
# missing description
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('description: Required');
expect(error.message).not.toContain('Remote Agent');
});
it('should only show local agent errors when kind is inferred as local (via local-specific keys)', async () => {
const filePath = await writeAgentMarkdown(`---
name: invalid-local
# missing description
tools:
- run_shell_command
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('description: Required');
expect(error.message).not.toContain('Remote Agent');
});
it('should only show remote agent errors when kind is inferred as remote (via kind field)', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: invalid-remote
# missing agent_card_url
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('agent_card_url: Required');
expect(error.message).not.toContain('Local Agent');
});
it('should only show remote agent errors when kind is inferred as remote (via remote-specific keys)', async () => {
const filePath = await writeAgentMarkdown(`---
name: invalid-remote
auth:
type: apiKey
key: my_key
# missing agent_card_url
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('agent_card_url: Required');
expect(error.message).not.toContain('Local Agent');
});
it('should show errors for both types when kind cannot be inferred', async () => {
const filePath = await writeAgentMarkdown(`---
name: invalid-unknown
# missing description and missing agent_card_url, no specific keys
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('(Local Agent)');
expect(error.message).toContain('(Remote Agent)');
expect(error.message).toContain('description: Required');
expect(error.message).toContain('agent_card_url: Required');
});
it('should format errors without a stray colon when the path is empty (e.g. strict object with unknown keys)', async () => {
const filePath = await writeAgentMarkdown(`---
kind: local
name: my-agent
description: test
unknown_field: true
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain(
"Unrecognized key(s) in object: 'unknown_field'",
);
expect(error.message).not.toContain(': Unrecognized key(s)');
expect(error.message).not.toContain('Required');
});
});
});
describe('markdownToAgentDefinition', () => {
@@ -537,40 +372,6 @@ 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', () => {
@@ -816,7 +617,7 @@ kind: remote
name: oauth2-agent
agent_card_url: https://example.com/card
auth:
type: oauth
type: oauth2
client_id: $MY_OAUTH_CLIENT_ID
scopes:
- read
@@ -829,7 +630,7 @@ auth:
kind: 'remote',
name: 'oauth2-agent',
auth: {
type: 'oauth',
type: 'oauth2',
client_id: '$MY_OAUTH_CLIENT_ID',
scopes: ['read', 'write'],
},
@@ -842,7 +643,7 @@ kind: remote
name: oauth2-full-agent
agent_card_url: https://example.com/card
auth:
type: oauth
type: oauth2
client_id: my-client-id
client_secret: my-client-secret
scopes:
@@ -858,7 +659,7 @@ auth:
kind: 'remote',
name: 'oauth2-full-agent',
auth: {
type: 'oauth',
type: 'oauth2',
client_id: 'my-client-id',
client_secret: 'my-client-secret',
scopes: ['openid', 'profile'],
@@ -874,7 +675,7 @@ kind: remote
name: oauth2-minimal-agent
agent_card_url: https://example.com/card
auth:
type: oauth
type: oauth2
---
`);
const result = await parseAgentMarkdown(filePath);
@@ -883,7 +684,7 @@ auth:
kind: 'remote',
name: 'oauth2-minimal-agent',
auth: {
type: 'oauth',
type: 'oauth2',
},
});
});
@@ -894,7 +695,7 @@ kind: remote
name: invalid-oauth2-agent
agent_card_url: https://example.com/card
auth:
type: oauth
type: oauth2
client_id: my-client
authorization_url: not-a-valid-url
---
@@ -908,7 +709,7 @@ kind: remote
name: invalid-oauth2-agent
agent_card_url: https://example.com/card
auth:
type: oauth
type: oauth2
client_id: my-client
token_url: not-a-valid-url
---
@@ -922,7 +723,7 @@ auth:
name: 'oauth2-convert-agent',
agent_card_url: 'https://example.com/card',
auth: {
type: 'oauth' as const,
type: 'oauth2' as const,
client_id: '$MY_CLIENT_ID',
scopes: ['read'],
authorization_url: 'https://auth.example.com/authorize',
@@ -943,103 +744,5 @@ auth:
},
});
});
it('should throw an error for an unknown auth type in markdownToAgentDefinition', () => {
const markdown = {
kind: 'remote' as const,
name: 'unknown-auth-agent',
agent_card_url: 'https://example.com/card',
auth: {
type: 'apiKey' as const,
key: 'some-key',
},
};
// Mutate the object at runtime to bypass TypeScript compile-time checks cleanly
Object.assign(markdown.auth, { type: 'some-unknown-type' });
expect(() => markdownToAgentDefinition(markdown)).toThrow(
/Unknown auth type: some-unknown-type/,
);
});
});
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();
});
});
});
+195 -168
View File
@@ -12,7 +12,6 @@ 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';
@@ -22,6 +21,79 @@ import { isValidToolName } from '../tools/tool-names.js';
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
import { getErrorMessage } from '../utils/errors.js';
/**
* DTO for Markdown parsing - represents the structure from frontmatter.
*/
interface FrontmatterBaseAgentDefinition {
name: string;
display_name?: string;
}
interface FrontmatterMCPServerConfig {
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
http_url?: string;
headers?: Record<string, string>;
tcp?: string;
type?: 'sse' | 'http';
timeout?: number;
trust?: boolean;
description?: string;
include_tools?: string[];
exclude_tools?: string[];
}
interface FrontmatterLocalAgentDefinition
extends FrontmatterBaseAgentDefinition {
kind: 'local';
description: string;
tools?: string[];
mcp_servers?: Record<string, FrontmatterMCPServerConfig>;
system_prompt: string;
model?: string;
temperature?: number;
max_turns?: number;
timeout_mins?: number;
}
/**
* Authentication configuration for remote agents in frontmatter format.
*/
interface FrontmatterAuthConfig {
type: 'apiKey' | 'http' | 'google-credentials' | 'oauth2';
// API Key
key?: string;
name?: string;
// HTTP
scheme?: string;
token?: string;
username?: string;
password?: string;
value?: string;
// Google Credentials
scopes?: string[];
// OAuth2
client_id?: string;
client_secret?: string;
authorization_url?: string;
token_url?: string;
}
interface FrontmatterRemoteAgentDefinition
extends FrontmatterBaseAgentDefinition {
kind: 'remote';
description?: string;
agent_card_url: string;
auth?: FrontmatterAuthConfig;
}
type FrontmatterAgentDefinition =
| FrontmatterLocalAgentDefinition
| FrontmatterRemoteAgentDefinition;
/**
* Error thrown when an agent definition is invalid or cannot be loaded.
*/
@@ -87,13 +159,15 @@ const localAgentSchema = z
})
.strict();
type FrontmatterLocalAgentDefinition = z.infer<typeof localAgentSchema> & {
system_prompt: string;
};
// Base fields shared by all auth configs.
/**
* Base fields shared by all auth configs.
*/
const baseAuthFields = {};
/**
* API Key auth schema.
* Supports sending key in header, query parameter, or cookie.
*/
const apiKeyAuthSchema = z.object({
...baseAuthFields,
type: z.literal('apiKey'),
@@ -101,6 +175,11 @@ const apiKeyAuthSchema = z.object({
name: z.string().optional(),
});
/**
* HTTP auth schema (Bearer or Basic).
* Note: Validation for scheme-specific fields is applied in authConfigSchema
* since discriminatedUnion doesn't support refined schemas directly.
*/
const httpAuthSchema = z.object({
...baseAuthFields,
type: z.literal('http'),
@@ -111,15 +190,22 @@ const httpAuthSchema = z.object({
value: z.string().min(1).optional(),
});
/**
* Google Credentials auth schema.
*/
const googleCredentialsAuthSchema = z.object({
...baseAuthFields,
type: z.literal('google-credentials'),
scopes: z.array(z.string()).optional(),
});
/**
* OAuth2 auth schema.
* authorization_url and token_url can be discovered from the agent card if omitted.
*/
const oauth2AuthSchema = z.object({
...baseAuthFields,
type: z.literal('oauth'),
type: z.literal('oauth2'),
client_id: z.string().optional(),
client_secret: z.string().optional(),
scopes: z.array(z.string()).optional(),
@@ -136,16 +222,18 @@ const authConfigSchema = z
])
.superRefine((data, ctx) => {
if (data.type === 'http') {
if (data.value) return;
if (data.scheme === 'Bearer') {
if (!data.token) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bearer scheme requires "token"',
path: ['token'],
});
}
} else if (data.scheme === 'Basic') {
if (data.value) {
// Raw mode - only scheme and value are needed
return;
}
if (data.scheme === 'Bearer' && !data.token) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bearer scheme requires "token"',
path: ['token'],
});
}
if (data.scheme === 'Basic') {
if (!data.username) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -160,129 +248,55 @@ const authConfigSchema = z
path: ['password'],
});
}
} else {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `HTTP scheme "${data.scheme}" requires "value"`,
path: ['value'],
});
}
}
});
type FrontmatterAuthConfig = z.infer<typeof authConfigSchema>;
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({
const remoteAgentSchema = z
.object({
kind: z.literal('remote').optional().default('remote'),
name: nameSchema,
description: z.string().optional(),
display_name: z.string().optional(),
agent_card_url: z.string().url(),
agent_card_json: z.undefined().optional(),
auth: authConfigSchema.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 =
| FrontmatterLocalAgentDefinition
| FrontmatterRemoteAgentDefinition;
// Use a Zod union to automatically discriminate between local and remote
// agent types.
const agentUnionOptions = [
{ label: 'Local Agent' },
{ label: 'Remote Agent' },
{ label: 'Remote Agent' },
];
{ schema: localAgentSchema, label: 'Local Agent' },
{ schema: remoteAgentSchema, label: 'Remote Agent' },
] as const;
const remoteAgentsListSchema = z.array(remoteAgentSchema);
const markdownFrontmatterSchema = z.union([
localAgentSchema,
remoteAgentUrlSchema,
remoteAgentJsonSchema,
agentUnionOptions[0].schema,
agentUnionOptions[1].schema,
]);
function guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined {
if (typeof rawInput !== 'object' || rawInput === null) return undefined;
const input = rawInput as Partial<FrontmatterLocalAgentDefinition> &
Partial<FrontmatterRemoteAgentDefinition>;
if (input.kind === 'local') return 'local';
if (input.kind === 'remote') return 'remote';
const hasLocalKeys =
'tools' in input ||
'mcp_servers' in input ||
'model' in input ||
'temperature' in input ||
'max_turns' in input ||
'timeout_mins' 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';
return undefined;
}
function formatZodError(
error: z.ZodError,
context: string,
rawInput?: unknown,
): string {
const intendedKind = rawInput ? guessIntendedKind(rawInput) : undefined;
const formatIssues = (issues: z.ZodIssue[], unionPrefix?: string): string[] =>
issues.flatMap((i) => {
function formatZodError(error: z.ZodError, context: string): string {
const issues = error.issues
.map((i) => {
// Handle union errors specifically to give better context
if (i.code === z.ZodIssueCode.invalid_union) {
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 [];
if (intendedKind === 'remote' && label === 'Local Agent') return [];
return formatIssues(unionError.issues, label);
});
return i.unionErrors
.map((unionError, index) => {
const label =
agentUnionOptions[index]?.label ?? `Agent type #${index + 1}`;
const unionIssues = unionError.issues
.map((u) => `${u.path.join('.')}: ${u.message}`)
.join(', ');
return `(${label}) ${unionIssues}`;
})
.join('\n');
}
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}`;
return `${i.path.join('.')}: ${i.message}`;
})
.join('\n');
return `${context}:\n${issues}`;
}
/**
@@ -329,7 +343,8 @@ export async function parseAgentMarkdown(
} catch (error) {
throw new AgentLoadError(
filePath,
`YAML frontmatter parsing failed: ${getErrorMessage(error)}`,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
`YAML frontmatter parsing failed: ${(error as Error).message}`,
);
}
@@ -353,7 +368,7 @@ export async function parseAgentMarkdown(
if (!result.success) {
throw new AgentLoadError(
filePath,
`Validation failed: ${formatZodError(result.error, 'Agent Definition', rawFrontmatter)}`,
`Validation failed: ${formatZodError(result.error, 'Agent Definition')}`,
);
}
@@ -368,14 +383,17 @@ export async function parseAgentMarkdown(
];
}
// Local agent validation
// Validate tools
// Construct the local agent definition
return [
{
...frontmatter,
kind: 'local',
system_prompt: body.trim(),
},
];
const agentDef: FrontmatterLocalAgentDefinition = {
...frontmatter,
kind: 'local',
system_prompt: body.trim(),
};
return [agentDef];
}
/**
@@ -385,9 +403,15 @@ export async function parseAgentMarkdown(
function convertFrontmatterAuthToConfig(
frontmatter: FrontmatterAuthConfig,
): A2AAuthConfig {
const base = {};
switch (frontmatter.type) {
case 'apiKey':
if (!frontmatter.key) {
throw new Error('Internal error: API key missing after validation.');
}
return {
...base,
type: 'apiKey',
key: frontmatter.key,
name: frontmatter.name,
@@ -395,13 +419,20 @@ function convertFrontmatterAuthToConfig(
case 'google-credentials':
return {
...base,
type: 'google-credentials',
scopes: frontmatter.scopes,
};
case 'http':
case 'http': {
if (!frontmatter.scheme) {
throw new Error(
'Internal error: HTTP scheme missing after validation.',
);
}
if (frontmatter.value) {
return {
...base,
type: 'http',
scheme: frontmatter.scheme,
value: frontmatter.value,
@@ -409,27 +440,40 @@ function convertFrontmatterAuthToConfig(
}
switch (frontmatter.scheme) {
case 'Bearer':
// Token is required by schema validation
if (!frontmatter.token) {
throw new Error(
'Internal error: Bearer token missing after validation.',
);
}
return {
...base,
type: 'http',
scheme: 'Bearer',
token: frontmatter.token!,
token: frontmatter.token,
};
case 'Basic':
// Username/password are required by schema validation
if (!frontmatter.username || !frontmatter.password) {
throw new Error(
'Internal error: Basic auth credentials missing after validation.',
);
}
return {
...base,
type: 'http',
scheme: 'Basic',
username: frontmatter.username!,
password: frontmatter.password!,
username: frontmatter.username,
password: frontmatter.password,
};
default:
default: {
// Other IANA schemes without a value should not reach here after validation
throw new Error(`Unknown HTTP scheme: ${frontmatter.scheme}`);
}
}
}
case 'oauth':
case 'oauth2':
return {
...base,
type: 'oauth2',
client_id: frontmatter.client_id,
client_secret: frontmatter.client_secret,
@@ -439,12 +483,8 @@ function convertFrontmatterAuthToConfig(
};
default: {
const exhaustive: never = frontmatter;
const raw: unknown = exhaustive;
if (typeof raw === 'object' && raw !== null && 'type' in raw) {
throw new Error(`Unknown auth type: ${String(raw['type'])}`);
}
throw new Error('Unknown auth type');
const exhaustive: never = frontmatter.type;
throw new Error(`Unknown auth type: ${exhaustive}`);
}
}
}
@@ -475,41 +515,25 @@ export function markdownToAgentDefinition(
};
if (markdown.kind === 'remote') {
const base: RemoteAgentDefinition = {
return {
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
const modelName = markdown.model || 'inherit';
const mcpServers: Record<string, MCPServerConfig> = {};
if (markdown.mcp_servers) {
if (markdown.kind === 'local' && markdown.mcp_servers) {
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
mcpServers[name] = new MCPServerConfig(
config.command,
@@ -582,13 +606,15 @@ export async function loadAgentsFromDirectory(
dirEntries = await fs.readdir(dir, { withFileTypes: true });
} catch (error) {
// If directory doesn't exist, just return empty
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return result;
}
result.errors.push(
new AgentLoadError(
dir,
`Could not list directory: ${getErrorMessage(error)}`,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
`Could not list directory: ${(error as Error).message}`,
),
);
return result;
@@ -618,7 +644,8 @@ export async function loadAgentsFromDirectory(
result.errors.push(
new AgentLoadError(
filePath,
`Unexpected error: ${getErrorMessage(error)}`,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
`Unexpected error: ${(error as Error).message}`,
),
);
}
@@ -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 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.`
)}\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.`
: '';
return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.${allowedDomainsInstruction}
@@ -112,7 +112,6 @@ Some errors are unrecoverable and retrying will never help. When you see ANY of
- "Could not connect to Chrome" or "Failed to connect to Chrome" or "Timed out connecting to Chrome" Include the full error message with its remediation steps in your summary verbatim. Do NOT paraphrase or omit instructions.
- "Browser closed" or "Target closed" or "Session closed" The browser process has terminated. Include the error and tell the user to try again.
- "net::ERR_" network errors on the SAME URL after 2 retries the site is unreachable. Report the URL and error.
- "reached maximum action limit" You have performed too many actions in this task. Stop immediately and report this limit to the user.
- Any error that appears IDENTICALLY 3+ times in a row it will not resolve by retrying.
Do NOT keep retrying terminal errors. Report them with actionable remediation steps and exit immediately.
@@ -379,19 +379,9 @@ 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',
annotations: { readOnlyHint: true },
},
{ name: 'take_snapshot', description: 'Take snapshot' },
{ name: 'take_screenshot', description: 'Take screenshot' },
{ name: 'list_pages', description: 'list all pages' },
]);
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
@@ -477,7 +467,6 @@ 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,12 +120,13 @@ export async function createBrowserAgentDefinition(
}
// Reduce noise for read-only tools in default mode
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]) {
const readOnlyTools = [
'take_snapshot',
'take_screenshot',
'list_pages',
'list_network_requests',
];
for (const toolName of readOnlyTools) {
if (availableToolNames.includes(toolName)) {
const rule = generateAllowRules(toolName);
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
@@ -272,76 +272,6 @@ 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', () => {
@@ -767,28 +697,4 @@ describe('BrowserManager', () => {
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
});
describe('Rate limiting', () => {
it('should terminate task when maxActionsPerTask is reached', async () => {
const limitedConfig = makeFakeConfig({
agents: {
browser: {
maxActionsPerTask: 3,
},
},
});
const manager = new BrowserManager(limitedConfig);
// First 3 calls should succeed
await manager.callTool('take_snapshot', {});
await manager.callTool('take_snapshot', { some: 'args' });
await manager.callTool('take_snapshot', { other: 'args' });
await manager.callTool('take_snapshot', { other: 'new args' });
// 4th call should throw
await expect(manager.callTool('take_snapshot', {})).rejects.toThrow(
/maximum action limit \(3\)/,
);
});
});
});
@@ -97,10 +97,6 @@ export class BrowserManager {
private mcpTransport: StdioClientTransport | undefined;
private discoveredTools: McpTool[] = [];
/** State for action rate limiting */
private actionCounter = 0;
private readonly maxActionsPerTask: number;
/**
* Whether to inject the automation overlay.
* Always false in headless mode (no visible window to decorate).
@@ -112,8 +108,6 @@ export class BrowserManager {
const browserConfig = config.getBrowserAgentConfig();
this.shouldInjectOverlay = !browserConfig?.customConfig?.headless;
this.shouldDisableInput = config.shouldDisableBrowserUserInput();
this.maxActionsPerTask =
browserConfig?.customConfig.maxActionsPerTask ?? 100;
}
/**
@@ -157,16 +151,6 @@ export class BrowserManager {
throw signal.reason ?? new Error('Operation cancelled');
}
// Hard enforcement of per-action rate limit
if (this.actionCounter > this.maxActionsPerTask) {
const error = new Error(
`Browser agent reached maximum action limit (${this.maxActionsPerTask}). ` +
`Task terminated to prevent runaway execution. To config the limit, use maxActionsPerTask in the settings.`,
);
throw error;
}
this.actionCounter++;
const errorMessage = this.checkNavigationRestrictions(toolName, args);
if (errorMessage) {
return {
@@ -610,65 +594,29 @@ export class BrowserManager {
try {
const parsedUrl = new URL(url);
const urlHostname = parsedUrl.hostname;
const urlHostname = parsedUrl.hostname.replace(/\.$/, '');
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);
for (const domainPattern of allowedDomains) {
if (domainPattern.startsWith('*.')) {
const baseDomain = domainPattern.substring(2);
if (
embeddedUrl.protocol === 'http:' ||
embeddedUrl.protocol === 'https:'
urlHostname === baseDomain ||
urlHostname.endsWith(`.${baseDomain}`)
) {
const embeddedHostname = embeddedUrl.hostname.replace(/\.$/, '');
if (!this.isDomainAllowed(embeddedHostname, allowedDomains)) {
return `Tool '${toolName}' is not permitted: an embedded URL targets a disallowed domain.`;
}
return undefined;
}
} else {
if (urlHostname === domainPattern) {
return undefined;
}
} 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 false;
return `Tool '${toolName}' is not permitted for the requested URL/domain based on your current browser settings.`;
}
/**
+1 -3
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, combinedSignal);
await this.tryCompressChat(chat, promptId);
const { functionCalls } = await promptIdContext.run(promptId, async () =>
this.callModel(chat, currentMessage, combinedSignal, promptId),
@@ -810,7 +810,6 @@ 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;
@@ -821,7 +820,6 @@ 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',
{ type: 'url', url: 'https://example.com/card' },
'https://example.com/card',
mockHandler,
);
expect(registry.getDefinition('RemoteAgentWithAuth')).toEqual(
+4 -14
View File
@@ -4,12 +4,10 @@
* 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';
@@ -164,14 +162,7 @@ export class AgentRegistry {
if (!agent.metadata) {
agent.metadata = {};
}
agent.metadata.hash =
agent.agentCardUrl ??
(agent.agentCardJson
? crypto
.createHash('sha256')
.update(agent.agentCardJson)
.digest('hex')
: undefined);
agent.metadata.hash = agent.agentCardUrl;
}
if (!agent.metadata?.hash) {
@@ -452,13 +443,12 @@ 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,
targetUrl: definition.agentCardUrl,
agentCardUrl: remoteDef.agentCardUrl,
});
if (!provider) {
@@ -471,7 +461,7 @@ export class AgentRegistry {
const agentCard = await clientManager.loadAgent(
remoteDef.name,
getAgentCardLoadOptions(remoteDef),
remoteDef.agentCardUrl,
authHandler,
);
@@ -525,7 +515,7 @@ export class AgentRegistry {
if (this.config.getDebugMode()) {
debugLogger.log(
`[AgentRegistry] Registered remote agent '${definition.name}' with card: ${definition.agentCardUrl ?? 'inline JSON'}`,
`[AgentRegistry] Registered remote agent '${definition.name}' with card: ${definition.agentCardUrl}`,
);
}
this.agents.set(definition.name, definition);

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