diff --git a/.gemini/skills/docs-writer/SKILL.md b/.gemini/skills/docs-writer/SKILL.md
index 13fc91765e..d7cf7b81be 100644
--- a/.gemini/skills/docs-writer/SKILL.md
+++ b/.gemini/skills/docs-writer/SKILL.md
@@ -45,6 +45,10 @@ Write precisely to ensure your instructions are unambiguous.
specific verbs.
- **Examples:** Use meaningful names in examples; avoid placeholders like
"foo" or "bar."
+- **Quota and limit terminology:** For any content involving resource capacity
+ or using the word "quota" or "limit", strictly adhere to the guidelines in
+ the `quota-limit-style-guide.md` resource file. Generally, Use "quota" for the
+ administrative bucket and "limit" for the numerical ceiling.
### Formatting and syntax
Apply consistent formatting to make documentation visually organized and
@@ -114,6 +118,8 @@ documentation.
reflects existing code.
- **Structure:** Apply "Structure (New Docs)" rules (BLUF, headings, etc.) when
adding new sections to existing pages.
+- **Headers**: If you change a header, you must check for links that lead to
+ that header and update them.
- **Tone:** Ensure the tone is active and engaging. Use "you" and contractions.
- **Clarity:** Correct awkward wording, spelling, and grammar. Rephrase
sentences to make them easier for users to understand.
@@ -129,7 +135,8 @@ and that all links are functional.
technical behavior.
2. **Self-review:** Re-read changes for formatting, correctness, and flow.
3. **Link check:** Verify all new and existing links leading to or from modified
- pages.
+ pages. If you changed a header, ensure that any links that lead to it are
+ updated.
4. **Format:** Once all changes are complete, ask to execute `npm run format`
to ensure consistent formatting across the project. If the user confirms,
execute the command.
diff --git a/.gemini/skills/docs-writer/quota-limit-style-guide.md b/.gemini/skills/docs-writer/quota-limit-style-guide.md
new file mode 100644
index 0000000000..b26c160cb5
--- /dev/null
+++ b/.gemini/skills/docs-writer/quota-limit-style-guide.md
@@ -0,0 +1,61 @@
+# Style Guide: Quota vs. Limit
+
+This guide defines the usage of "quota," "limit," and related terms in
+user-facing interfaces.
+
+## TL;DR
+
+- **`quota`**: The administrative "bucket." Use for settings, billing, and
+ requesting increases. (e.g., "Adjust your storage **quota**.")
+- **`limit`**: The real-time numerical "ceiling." Use for error messages when a
+ user is blocked. (e.g., "You've reached your request **limit**.")
+- **When blocked, combine them:** Explain the **limit** that was hit and the
+ **quota** that is the remedy. (e.g., "You've reached the request **limit** for
+ your developer **quota**.")
+- **Related terms:** Use `usage` for consumption tracking, `restriction` for
+ fixed rules, and `reset` for when a limit refreshes.
+
+---
+
+## Detailed Guidelines
+
+### Definitions
+
+- **Quota is the "what":** It identifies the category of resource being managed
+ (e.g., storage quota, GPU quota, request/prompt quota).
+- **Limit is the "how much":** It defines the numerical boundary.
+
+Use **quota** when referring to the administrative concept or the request for
+more. Use **limit** when discussing the specific point of exhaustion.
+
+### When to use "quota"
+
+Use this term for **account management, billing, and settings.** It describes
+the entitlement the user has purchased or been assigned.
+
+**Examples:**
+
+- **Navigation label:** Quota and usage
+- **Contextual help:** Your **usage quota** is managed by your organization. To
+ request an increase, contact your administrator.
+
+### When to use "limit"
+
+Use this term for **real-time feedback, notifications, and error messages.** It
+identifies the specific wall the user just hit.
+
+**Examples:**
+
+- **Error message:** You’ve reached the 50-request-per-minute **limit**.
+- **Inline warning:** Input exceeds the 32k token **limit**.
+
+### How to use both together
+
+When a user is blocked, combine both terms to explain the **event** (limit) and
+the **remedy** (quota).
+
+**Example:**
+
+- **Heading:** Daily usage limit reached
+- **Body:** You've reached the maximum daily capacity for your developer quota.
+ To continue working today, upgrade your quota.
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 201d46a66d..0da8dd1a0b 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -14,4 +14,9 @@
# Docs have a dedicated approver group in addition to maintainers
/docs/ @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs
-/README.md @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs
\ No newline at end of file
+/README.md @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs
+
+# Prompt contents, tool definitions, and evals require reviews from prompt approvers
+/packages/core/src/prompts/ @google-gemini/gemini-cli-prompt-approvers
+/packages/core/src/tools/ @google-gemini/gemini-cli-prompt-approvers
+/evals/ @google-gemini/gemini-cli-prompt-approvers
diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml
index 8f062205cb..70a413f13a 100644
--- a/.github/actions/publish-release/action.yml
+++ b/.github/actions/publish-release/action.yml
@@ -192,6 +192,13 @@ runs:
INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}'
+ - name: '📦 Prepare bundled CLI for npm release'
+ if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/'"
+ working-directory: '${{ inputs.working-directory }}'
+ shell: 'bash'
+ run: |
+ node ${{ github.workspace }}/scripts/prepare-npm-release.js
+
- name: 'Get CLI Token'
uses: './.github/actions/npm-auth-token'
id: 'cli-token'
diff --git a/.github/actions/push-sandbox/action.yml b/.github/actions/push-sandbox/action.yml
index e2d1ac942c..bab85af453 100644
--- a/.github/actions/push-sandbox/action.yml
+++ b/.github/actions/push-sandbox/action.yml
@@ -44,6 +44,8 @@ runs:
- name: 'npm build'
shell: 'bash'
run: 'npm run build'
+ - name: 'Set up QEMU'
+ uses: 'docker/setup-qemu-action@v3'
- name: 'Set up Docker Buildx'
uses: 'docker/setup-buildx-action@v3'
- name: 'Log in to GitHub Container Registry'
@@ -69,16 +71,19 @@ runs:
env:
INPUTS_GITHUB_REF_NAME: '${{ inputs.github-ref-name }}'
INPUTS_GITHUB_SHA: '${{ inputs.github-sha }}'
+ # We build amd64 just so we can verify it.
+ # We build and push both amd64 and arm64 in the publish step.
- name: 'build'
id: 'docker_build'
shell: 'bash'
env:
GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
GEMINI_SANDBOX: 'docker'
+ BUILD_SANDBOX_FLAGS: '--platform linux/amd64 --load'
STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
run: |-
npm run build:sandbox -- \
- --image google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG} \
+ --image "google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG}" \
--output-file final_image_uri.txt
echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT
- name: 'verify'
@@ -92,10 +97,14 @@ runs:
- name: 'publish'
shell: 'bash'
if: "${{ inputs.dry-run != 'true' }}"
- run: |-
- docker push "${STEPS_DOCKER_BUILD_OUTPUTS_URI}"
env:
- STEPS_DOCKER_BUILD_OUTPUTS_URI: '${{ steps.docker_build.outputs.uri }}'
+ GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
+ GEMINI_SANDBOX: 'docker'
+ BUILD_SANDBOX_FLAGS: '--platform linux/amd64,linux/arm64 --push'
+ STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
+ run: |-
+ npm run build:sandbox -- \
+ --image "google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG}"
- name: 'Create issue on failure'
if: |-
${{ failure() }}
diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml
index 3633c5027b..8d714b34b0 100644
--- a/.github/workflows/chained_e2e.yml
+++ b/.github/workflows/chained_e2e.yml
@@ -264,6 +264,27 @@ jobs:
run: 'npm run build'
shell: 'pwsh'
+ - name: 'Ensure Chrome is available'
+ shell: 'pwsh'
+ run: |
+ $chromePaths = @(
+ "${env:ProgramFiles}\Google\Chrome\Application\chrome.exe",
+ "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe"
+ )
+ $chromeExists = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
+ if (-not $chromeExists) {
+ Write-Host 'Chrome not found, installing via Chocolatey...'
+ choco install googlechrome -y --no-progress --ignore-checksums
+ }
+ $installed = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
+ if ($installed) {
+ Write-Host "Chrome found at: $installed"
+ & $installed --version
+ } else {
+ Write-Error 'Chrome installation failed'
+ exit 1
+ }
+
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml
index fe4c52292a..1cab2abaa9 100644
--- a/.github/workflows/gemini-automated-issue-triage.yml
+++ b/.github/workflows/gemini-automated-issue-triage.yml
@@ -121,6 +121,7 @@ jobs:
'area/security',
'area/platform',
'area/extensions',
+ 'area/documentation',
'area/unknown'
];
const labelNames = labels.map(label => label.name).filter(name => allowedLabels.includes(name));
@@ -255,6 +256,14 @@ jobs:
"Issues with a specific extension."
"Feature request for the extension ecosystem."
+ area/documentation
+ - Description: Issues related to user-facing documentation and other content on the documentation website.
+ - Example Issues:
+ "A typo in a README file."
+ "DOCS: A command is not working as described in the documentation."
+ "A request for a new documentation page."
+ "Instructions missing for skills feature"
+
area/unknown
- Description: Issues that do not clearly fit into any other defined area/ category, or where information is too limited to make a determination. Use this when no other area is appropriate.
diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml
index 25b0cdf4ec..50dd56883e 100644
--- a/.github/workflows/gemini-scheduled-issue-triage.yml
+++ b/.github/workflows/gemini-scheduled-issue-triage.yml
@@ -63,7 +63,7 @@ jobs:
echo '🔍 Finding issues missing area labels...'
NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
- --search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/unknown' --limit 100 --json number,title,body)"
+ --search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
echo '🔍 Finding issues missing kind labels...'
NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
@@ -204,6 +204,7 @@ jobs:
Categorization Guidelines (Area):
area/agent: Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality
area/core: User Interface, OS Support, Core Functionality
+ area/documentation: End-user and contributor-facing documentation, website-related
area/enterprise: Telemetry, Policy, Quota / Licensing
area/extensions: Gemini CLI extensions capability
area/non-interactive: GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation
diff --git a/.github/workflows/unassign-inactive-assignees.yml b/.github/workflows/unassign-inactive-assignees.yml
new file mode 100644
index 0000000000..dd09f0feaf
--- /dev/null
+++ b/.github/workflows/unassign-inactive-assignees.yml
@@ -0,0 +1,315 @@
+name: 'Unassign Inactive Issue Assignees'
+
+# This workflow runs daily and scans every open "help wanted" issue that has
+# one or more assignees. For each assignee it checks whether they have a
+# non-draft pull request (open and ready for review, or already merged) that
+# is linked to the issue. Draft PRs are intentionally excluded so that
+# contributors cannot reset the check by opening a no-op PR. If no
+# qualifying PR is found within 7 days of assignment the assignee is
+# automatically removed and a friendly comment is posted so that other
+# contributors can pick up the work.
+# Maintainers, org members, and collaborators (anyone with write access or
+# above) are always exempted and will never be auto-unassigned.
+
+on:
+ schedule:
+ - cron: '0 9 * * *' # Every day at 09:00 UTC
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: 'Run in dry-run mode (no changes will be applied)'
+ required: false
+ default: false
+ type: 'boolean'
+
+concurrency:
+ group: '${{ github.workflow }}'
+ cancel-in-progress: true
+
+defaults:
+ run:
+ shell: 'bash'
+
+jobs:
+ unassign-inactive-assignees:
+ if: "github.repository == 'google-gemini/gemini-cli'"
+ runs-on: 'ubuntu-latest'
+ permissions:
+ issues: 'write'
+
+ steps:
+ - name: 'Generate GitHub App Token'
+ id: 'generate_token'
+ uses: 'actions/create-github-app-token@v2'
+ with:
+ app-id: '${{ secrets.APP_ID }}'
+ private-key: '${{ secrets.PRIVATE_KEY }}'
+
+ - name: 'Unassign inactive assignees'
+ uses: 'actions/github-script@v7'
+ env:
+ DRY_RUN: '${{ inputs.dry_run }}'
+ with:
+ github-token: '${{ steps.generate_token.outputs.token }}'
+ script: |
+ const dryRun = process.env.DRY_RUN === 'true';
+ if (dryRun) {
+ core.info('DRY RUN MODE ENABLED: No changes will be applied.');
+ }
+
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const GRACE_PERIOD_DAYS = 7;
+ const now = new Date();
+
+ let maintainerLogins = new Set();
+ const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs'];
+
+ for (const team_slug of teams) {
+ try {
+ const members = await github.paginate(github.rest.teams.listMembersInOrg, {
+ org: owner,
+ team_slug,
+ });
+ for (const m of members) maintainerLogins.add(m.login.toLowerCase());
+ core.info(`Fetched ${members.length} members from team ${team_slug}.`);
+ } catch (e) {
+ core.warning(`Could not fetch team ${team_slug}: ${e.message}`);
+ }
+ }
+
+ const isGooglerCache = new Map();
+ const isGoogler = async (login) => {
+ if (isGooglerCache.has(login)) return isGooglerCache.get(login);
+ try {
+ for (const org of ['googlers', 'google']) {
+ try {
+ await github.rest.orgs.checkMembershipForUser({ org, username: login });
+ isGooglerCache.set(login, true);
+ return true;
+ } catch (e) {
+ if (e.status !== 404) throw e;
+ }
+ }
+ } catch (e) {
+ core.warning(`Could not check org membership for ${login}: ${e.message}`);
+ }
+ isGooglerCache.set(login, false);
+ return false;
+ };
+
+ const permissionCache = new Map();
+ const isPrivilegedUser = async (login) => {
+ if (maintainerLogins.has(login.toLowerCase())) return true;
+
+ if (permissionCache.has(login)) return permissionCache.get(login);
+
+ try {
+ const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
+ owner,
+ repo,
+ username: login,
+ });
+ const privileged = ['admin', 'maintain', 'write', 'triage'].includes(data.permission);
+ permissionCache.set(login, privileged);
+ if (privileged) {
+ core.info(` @${login} is a repo collaborator (${data.permission}) — exempt.`);
+ return true;
+ }
+ } catch (e) {
+ if (e.status !== 404) {
+ core.warning(`Could not check permission for ${login}: ${e.message}`);
+ }
+ }
+
+ const googler = await isGoogler(login);
+ permissionCache.set(login, googler);
+ return googler;
+ };
+
+ core.info('Fetching open "help wanted" issues with assignees...');
+
+ const issues = await github.paginate(github.rest.issues.listForRepo, {
+ owner,
+ repo,
+ state: 'open',
+ labels: 'help wanted',
+ per_page: 100,
+ });
+
+ const assignedIssues = issues.filter(
+ (issue) => !issue.pull_request && issue.assignees && issue.assignees.length > 0
+ );
+
+ core.info(`Found ${assignedIssues.length} assigned "help wanted" issues.`);
+
+ let totalUnassigned = 0;
+
+ let timelineEvents = [];
+ try {
+ timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, {
+ owner,
+ repo,
+ issue_number: issue.number,
+ per_page: 100,
+ mediaType: { previews: ['mockingbird'] },
+ });
+ } catch (err) {
+ core.warning(`Could not fetch timeline for issue #${issue.number}: ${err.message}`);
+ continue;
+ }
+
+ const assignedAtMap = new Map();
+
+ for (const event of timelineEvents) {
+ if (event.event === 'assigned' && event.assignee) {
+ const login = event.assignee.login.toLowerCase();
+ const at = new Date(event.created_at);
+ assignedAtMap.set(login, at);
+ } else if (event.event === 'unassigned' && event.assignee) {
+ assignedAtMap.delete(event.assignee.login.toLowerCase());
+ }
+ }
+
+ const linkedPRAuthorSet = new Set();
+ const seenPRKeys = new Set();
+
+ for (const event of timelineEvents) {
+ if (
+ event.event !== 'cross-referenced' ||
+ !event.source ||
+ event.source.type !== 'pull_request' ||
+ !event.source.issue ||
+ !event.source.issue.user ||
+ !event.source.issue.number ||
+ !event.source.issue.repository
+ ) continue;
+
+ const prOwner = event.source.issue.repository.owner.login;
+ const prRepo = event.source.issue.repository.name;
+ const prNumber = event.source.issue.number;
+ const prAuthor = event.source.issue.user.login.toLowerCase();
+ const prKey = `${prOwner}/${prRepo}#${prNumber}`;
+
+ if (seenPRKeys.has(prKey)) continue;
+ seenPRKeys.add(prKey);
+
+ try {
+ const { data: pr } = await github.rest.pulls.get({
+ owner: prOwner,
+ repo: prRepo,
+ pull_number: prNumber,
+ });
+
+ const isReady = (pr.state === 'open' && !pr.draft) ||
+ (pr.state === 'closed' && pr.merged_at !== null);
+
+ core.info(
+ ` PR ${prKey} by @${prAuthor}: ` +
+ `state=${pr.state}, draft=${pr.draft}, merged=${!!pr.merged_at} → ` +
+ (isReady ? 'qualifies' : 'does NOT qualify (draft or closed without merge)')
+ );
+
+ if (isReady) linkedPRAuthorSet.add(prAuthor);
+ } catch (err) {
+ core.warning(`Could not fetch PR ${prKey}: ${err.message}`);
+ }
+ }
+
+ const assigneesToRemove = [];
+
+ for (const assignee of issue.assignees) {
+ const login = assignee.login.toLowerCase();
+
+ if (await isPrivilegedUser(assignee.login)) {
+ core.info(` @${assignee.login}: privileged user — skipping.`);
+ continue;
+ }
+
+ const assignedAt = assignedAtMap.get(login);
+
+ if (!assignedAt) {
+ core.warning(
+ `No 'assigned' event found for @${login} on issue #${issue.number}; ` +
+ `falling back to issue creation date (${issue.created_at}).`
+ );
+ assignedAtMap.set(login, new Date(issue.created_at));
+ }
+ const resolvedAssignedAt = assignedAtMap.get(login);
+
+ const daysSinceAssignment = (now - resolvedAssignedAt) / (1000 * 60 * 60 * 24);
+
+ core.info(
+ ` @${login}: assigned ${daysSinceAssignment.toFixed(1)} day(s) ago, ` +
+ `ready-for-review PR: ${linkedPRAuthorSet.has(login) ? 'yes' : 'no'}`
+ );
+
+ if (daysSinceAssignment < GRACE_PERIOD_DAYS) {
+ core.info(` → within grace period, skipping.`);
+ continue;
+ }
+
+ if (linkedPRAuthorSet.has(login)) {
+ core.info(` → ready-for-review PR found, keeping assignment.`);
+ continue;
+ }
+
+ core.info(` → no ready-for-review PR after ${GRACE_PERIOD_DAYS} days, will unassign.`);
+ assigneesToRemove.push(assignee.login);
+ }
+
+ if (assigneesToRemove.length === 0) {
+ continue;
+ }
+
+ if (!dryRun) {
+ try {
+ await github.rest.issues.removeAssignees({
+ owner,
+ repo,
+ issue_number: issue.number,
+ assignees: assigneesToRemove,
+ });
+ } catch (err) {
+ core.warning(
+ `Failed to unassign ${assigneesToRemove.join(', ')} from issue #${issue.number}: ${err.message}`
+ );
+ continue;
+ }
+
+ const mentionList = assigneesToRemove.map((l) => `@${l}`).join(', ');
+ const commentBody =
+ `👋 ${mentionList} — it has been more than ${GRACE_PERIOD_DAYS} days since ` +
+ `you were assigned to this issue and we could not find a pull request ` +
+ `ready for review.\n\n` +
+ `To keep the backlog moving and ensure issues stay accessible to all ` +
+ `contributors, we require a PR that is open and ready for review (not a ` +
+ `draft) within ${GRACE_PERIOD_DAYS} days of assignment.\n\n` +
+ `We are automatically unassigning you so that other contributors can pick ` +
+ `this up. If you are still actively working on this, please:\n` +
+ `1. Re-assign yourself by commenting \`/assign\`.\n` +
+ `2. Open a PR (not a draft) linked to this issue (e.g. \`Fixes #${issue.number}\`) ` +
+ `within ${GRACE_PERIOD_DAYS} days so the automation knows real progress is being made.\n\n` +
+ `Thank you for your contribution — we hope to see a PR from you soon! 🙏`;
+
+ try {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: issue.number,
+ body: commentBody,
+ });
+ } catch (err) {
+ core.warning(
+ `Failed to post comment on issue #${issue.number}: ${err.message}`
+ );
+ }
+ }
+
+ totalUnassigned += assigneesToRemove.length;
+ core.info(
+ ` ${dryRun ? '[DRY RUN] Would have unassigned' : 'Unassigned'}: ${assigneesToRemove.join(', ')}`
+ );
+ }
+
+ core.info(`\nDone. Total assignees ${dryRun ? 'that would be' : ''} unassigned: ${totalUnassigned}`);
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 3661ecf9c2..3197edbbfc 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -7,6 +7,9 @@
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
diff --git a/README.md b/README.md
index 02dd4988f0..46aa6604c2 100644
--- a/README.md
+++ b/README.md
@@ -301,7 +301,7 @@ gemini
### Tools & Extensions
-- [**Built-in Tools Overview**](./docs/tools/index.md)
+- [**Built-in Tools Overview**](./docs/reference/tools.md)
- [File System Operations](./docs/tools/file-system.md)
- [Shell Commands](./docs/tools/shell.md)
- [Web Fetch & Search](./docs/tools/web-fetch.md)
@@ -323,8 +323,7 @@ gemini
- [**Enterprise Guide**](./docs/cli/enterprise.md) - Deploy and manage in a
corporate environment.
- [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking.
-- [**Tools API Development**](./docs/reference/tools-api.md) - Create custom
- tools.
+- [**Tools reference**](./docs/reference/tools.md) - Built-in tools overview.
- [**Local development**](./docs/local-development.md) - Local development
tooling.
diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md
index 537e9d1aee..33c179072a 100644
--- a/docs/changelogs/index.md
+++ b/docs/changelogs/index.md
@@ -18,6 +18,30 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
+## Announcements: v0.32.0 - 2026-03-03
+
+- **Generalist Agent:** The generalist agent is now enabled to improve task
+ delegation and routing
+ ([#19665](https://github.com/google-gemini/gemini-cli/pull/19665) by
+ @joshualitt).
+- **Model Steering in Workspace:** Added support for model steering directly in
+ the workspace
+ ([#20343](https://github.com/google-gemini/gemini-cli/pull/20343) by
+ @joshualitt).
+- **Plan Mode Enhancements:** Users can now open and modify plans in an external
+ editor, and the planning workflow has been adapted to handle complex tasks
+ more effectively with multi-select options
+ ([#20348](https://github.com/google-gemini/gemini-cli/pull/20348) by @Adib234,
+ [#20465](https://github.com/google-gemini/gemini-cli/pull/20465) by @jerop).
+- **Interactive Shell Autocompletion:** Introduced interactive shell
+ autocompletion for a more seamless experience
+ ([#20082](https://github.com/google-gemini/gemini-cli/pull/20082) by
+ @mrpmohiburrahman).
+- **Parallel Extension Loading:** Extensions are now loaded in parallel to
+ improve startup times
+ ([#20229](https://github.com/google-gemini/gemini-cli/pull/20229) by
+ @scidomino).
+
## Announcements: v0.31.0 - 2026-02-27
- **Gemini 3.1 Pro Preview:** Gemini CLI now supports the new Gemini 3.1 Pro
diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md
index 760e070bd9..d5d13717c7 100644
--- a/docs/changelogs/latest.md
+++ b/docs/changelogs/latest.md
@@ -1,6 +1,6 @@
-# Latest stable release: v0.31.0
+# Latest stable release: v0.32.1
-Released: February 27, 2026
+Released: March 4, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,405 +11,198 @@ npm install -g @google/gemini-cli
## Highlights
-- **Gemini 3.1 Pro Preview:** Gemini CLI now supports the new Gemini 3.1 Pro
- Preview model.
-- **Experimental Browser Agent:** We've introduced a new experimental browser
- agent to directly interact with web pages and retrieve context.
-- **Policy Engine Updates:** The policy engine has been expanded to support
- project-level policies, MCP server wildcards, and tool annotation matching,
- providing greater control over tool executions.
-- **Web Fetch Enhancements:** A new experimental direct web fetch tool has been
- implemented, alongside rate-limiting features for enhanced security.
-- **Improved Plan Mode:** Plan Mode now includes support for custom storage
- directories, automatic model switching, and summarizing work after execution.
+- **Plan Mode Enhancements**: Significant updates to Plan Mode, including the
+ ability to open and modify plans in an external editor, adaptations for
+ complex tasks with multi-select options, and integration tests for plan mode.
+- **Agent and Steering Improvements**: The generalist agent has been enabled to
+ enhance task delegation, model steering is now supported directly within the
+ workspace, and contiguous parallel admission is enabled for `Kind.Agent`
+ tools.
+- **Interactive Shell**: Interactive shell autocompletion has been introduced,
+ significantly enhancing the user experience.
+- **Core Stability and Performance**: Extensions are now loaded in parallel,
+ fetch timeouts have been increased, robust A2A streaming reassembly was
+ implemented, and orphaned processes when terminal closes have been prevented.
+- **Billing and Quota Handling**: Implemented G1 AI credits overage flow with
+ billing telemetry and added support for quota error fallbacks across all
+ authentication types.
## What's Changed
-- Use ranged reads and limited searches and fuzzy editing improvements by
- @gundermanc in
- [#19240](https://github.com/google-gemini/gemini-cli/pull/19240)
-- Fix bottom border color by @jacob314 in
- [#19266](https://github.com/google-gemini/gemini-cli/pull/19266)
-- Release note generator fix by @g-samroberts in
- [#19363](https://github.com/google-gemini/gemini-cli/pull/19363)
-- test(evals): add behavioral tests for tool output masking by @NTaylorMullen in
- [#19172](https://github.com/google-gemini/gemini-cli/pull/19172)
-- docs: clarify preflight instructions in GEMINI.md by @NTaylorMullen in
- [#19377](https://github.com/google-gemini/gemini-cli/pull/19377)
-- feat(cli): add gemini --resume hint on exit by @Mag1ck in
- [#16285](https://github.com/google-gemini/gemini-cli/pull/16285)
-- fix: optimize height calculations for ask_user dialog by @jackwotherspoon in
- [#19017](https://github.com/google-gemini/gemini-cli/pull/19017)
-- feat(cli): add Alt+D for forward word deletion by @scidomino in
- [#19300](https://github.com/google-gemini/gemini-cli/pull/19300)
-- Disable failing eval test by @chrstnb in
- [#19455](https://github.com/google-gemini/gemini-cli/pull/19455)
-- fix(cli): support legacy onConfirm callback in ToolActionsContext by
+- fix(patch): cherry-pick 0659ad1 to release/v0.32.0-pr-21042 to patch version
+ v0.32.0 and create version 0.32.1 by @gemini-cli-robot in
+ [#21048](https://github.com/google-gemini/gemini-cli/pull/21048)
+- feat(plan): add integration tests for plan mode by @Adib234 in
+ [#20214](https://github.com/google-gemini/gemini-cli/pull/20214)
+- fix(acp): update auth handshake to spec by @skeshive in
+ [#19725](https://github.com/google-gemini/gemini-cli/pull/19725)
+- feat(core): implement robust A2A streaming reassembly and fix task continuity
+ by @adamfweidman in
+ [#20091](https://github.com/google-gemini/gemini-cli/pull/20091)
+- feat(cli): load extensions in parallel by @scidomino in
+ [#20229](https://github.com/google-gemini/gemini-cli/pull/20229)
+- Plumb the maxAttempts setting through Config args by @kevinjwang1 in
+ [#20239](https://github.com/google-gemini/gemini-cli/pull/20239)
+- fix(cli): skip 404 errors in setup-github file downloads by @h30s in
+ [#20287](https://github.com/google-gemini/gemini-cli/pull/20287)
+- fix(cli): expose model.name setting in settings dialog for persistence by
+ @achaljhawar in
+ [#19605](https://github.com/google-gemini/gemini-cli/pull/19605)
+- docs: remove legacy cmd examples in favor of powershell by @scidomino in
+ [#20323](https://github.com/google-gemini/gemini-cli/pull/20323)
+- feat(core): Enable model steering in workspace. by @joshualitt in
+ [#20343](https://github.com/google-gemini/gemini-cli/pull/20343)
+- fix: remove trailing comma in issue triage workflow settings json by @Nixxx19
+ in [#20265](https://github.com/google-gemini/gemini-cli/pull/20265)
+- feat(core): implement task tracker foundation and service by @anj-s in
+ [#19464](https://github.com/google-gemini/gemini-cli/pull/19464)
+- test: support tests that include color information by @jacob314 in
+ [#20220](https://github.com/google-gemini/gemini-cli/pull/20220)
+- feat(core): introduce Kind.Agent for sub-agent classification by @abhipatel12
+ in [#20369](https://github.com/google-gemini/gemini-cli/pull/20369)
+- Changelog for v0.30.0 by @gemini-cli-robot in
+ [#20252](https://github.com/google-gemini/gemini-cli/pull/20252)
+- Update changelog workflow to reject nightly builds by @g-samroberts in
+ [#20248](https://github.com/google-gemini/gemini-cli/pull/20248)
+- Changelog for v0.31.0-preview.0 by @gemini-cli-robot in
+ [#20249](https://github.com/google-gemini/gemini-cli/pull/20249)
+- feat(cli): hide workspace policy update dialog and auto-accept by default by
+ @Abhijit-2592 in
+ [#20351](https://github.com/google-gemini/gemini-cli/pull/20351)
+- feat(core): rename grep_search include parameter to include_pattern by
@SandyTao520 in
- [#19369](https://github.com/google-gemini/gemini-cli/pull/19369)
-- chore(deps): bump tar from 7.5.7 to 7.5.8 by @.github/dependabot.yml[bot] in
- [#19367](https://github.com/google-gemini/gemini-cli/pull/19367)
-- fix(plan): allow safe fallback when experiment setting for plan is not enabled
- but approval mode at startup is plan by @Adib234 in
- [#19439](https://github.com/google-gemini/gemini-cli/pull/19439)
-- Add explicit color-convert dependency by @chrstnb in
- [#19460](https://github.com/google-gemini/gemini-cli/pull/19460)
-- feat(devtools): migrate devtools package into monorepo by @SandyTao520 in
- [#18936](https://github.com/google-gemini/gemini-cli/pull/18936)
-- fix(core): clarify plan mode constraints and exit mechanism by @jerop in
- [#19438](https://github.com/google-gemini/gemini-cli/pull/19438)
-- feat(cli): add macOS run-event notifications (interactive only) by
- @LyalinDotCom in
- [#19056](https://github.com/google-gemini/gemini-cli/pull/19056)
-- Changelog for v0.29.0 by @gemini-cli-robot in
- [#19361](https://github.com/google-gemini/gemini-cli/pull/19361)
-- fix(ui): preventing empty history items from being added by @devr0306 in
- [#19014](https://github.com/google-gemini/gemini-cli/pull/19014)
-- Changelog for v0.30.0-preview.0 by @gemini-cli-robot in
- [#19364](https://github.com/google-gemini/gemini-cli/pull/19364)
-- feat(core): add support for MCP progress updates by @NTaylorMullen in
- [#19046](https://github.com/google-gemini/gemini-cli/pull/19046)
-- fix(core): ensure directory exists before writing conversation file by
- @godwiniheuwa in
- [#18429](https://github.com/google-gemini/gemini-cli/pull/18429)
-- fix(ui): move margin from top to bottom in ToolGroupMessage by @imadraude in
- [#17198](https://github.com/google-gemini/gemini-cli/pull/17198)
-- fix(cli): treat unknown slash commands as regular input instead of showing
- error by @skyvanguard in
- [#17393](https://github.com/google-gemini/gemini-cli/pull/17393)
-- feat(core): experimental in-progress steering hints (2 of 2) by @joshualitt in
- [#19307](https://github.com/google-gemini/gemini-cli/pull/19307)
-- docs(plan): add documentation for plan mode command by @Adib234 in
- [#19467](https://github.com/google-gemini/gemini-cli/pull/19467)
-- fix(core): ripgrep fails when pattern looks like ripgrep flag by @syvb in
- [#18858](https://github.com/google-gemini/gemini-cli/pull/18858)
-- fix(cli): disable auto-completion on Shift+Tab to preserve mode cycling by
- @NTaylorMullen in
- [#19451](https://github.com/google-gemini/gemini-cli/pull/19451)
-- use issuer instead of authorization_endpoint for oauth discovery by
- @garrettsparks in
- [#17332](https://github.com/google-gemini/gemini-cli/pull/17332)
-- feat(cli): include `/dir add` directories in @ autocomplete suggestions by
- @jasmeetsb in [#19246](https://github.com/google-gemini/gemini-cli/pull/19246)
-- feat(admin): Admin settings should only apply if adminControlsApplicable =
- true and fetch errors should be fatal by @skeshive in
- [#19453](https://github.com/google-gemini/gemini-cli/pull/19453)
-- Format strict-development-rules command by @g-samroberts in
- [#19484](https://github.com/google-gemini/gemini-cli/pull/19484)
-- feat(core): centralize compatibility checks and add TrueColor detection by
+ [#20328](https://github.com/google-gemini/gemini-cli/pull/20328)
+- feat(plan): support opening and modifying plan in external editor by @Adib234
+ in [#20348](https://github.com/google-gemini/gemini-cli/pull/20348)
+- feat(cli): implement interactive shell autocompletion by @mrpmohiburrahman in
+ [#20082](https://github.com/google-gemini/gemini-cli/pull/20082)
+- fix(core): allow /memory add to work in plan mode by @Jefftree in
+ [#20353](https://github.com/google-gemini/gemini-cli/pull/20353)
+- feat(core): add HTTP 499 to retryable errors and map to RetryableQuotaError by
+ @bdmorgan in [#20432](https://github.com/google-gemini/gemini-cli/pull/20432)
+- feat(core): Enable generalist agent by @joshualitt in
+ [#19665](https://github.com/google-gemini/gemini-cli/pull/19665)
+- Updated tests in TableRenderer.test.tsx to use SVG snapshots by @devr0306 in
+ [#20450](https://github.com/google-gemini/gemini-cli/pull/20450)
+- Refactor Github Action per b/485167538 by @google-admin in
+ [#19443](https://github.com/google-gemini/gemini-cli/pull/19443)
+- fix(github): resolve actionlint and yamllint regressions from #19443 by @jerop
+ in [#20467](https://github.com/google-gemini/gemini-cli/pull/20467)
+- fix: action var usage by @galz10 in
+ [#20492](https://github.com/google-gemini/gemini-cli/pull/20492)
+- feat(core): improve A2A content extraction by @adamfweidman in
+ [#20487](https://github.com/google-gemini/gemini-cli/pull/20487)
+- fix(cli): support quota error fallbacks for all authentication types by
+ @sehoon38 in [#20475](https://github.com/google-gemini/gemini-cli/pull/20475)
+- fix(core): flush transcript for pure tool-call responses to ensure BeforeTool
+ hooks see complete state by @krishdef7 in
+ [#20419](https://github.com/google-gemini/gemini-cli/pull/20419)
+- feat(plan): adapt planning workflow based on complexity of task by @jerop in
+ [#20465](https://github.com/google-gemini/gemini-cli/pull/20465)
+- fix: prevent orphaned processes from consuming 100% CPU when terminal closes
+ by @yuvrajangadsingh in
+ [#16965](https://github.com/google-gemini/gemini-cli/pull/16965)
+- feat(core): increase fetch timeout and fix [object Object] error
+ stringification by @bdmorgan in
+ [#20441](https://github.com/google-gemini/gemini-cli/pull/20441)
+- [Gemma x Gemini CLI] Add an Experimental Gemma Router that uses a LiteRT-LM
+ shim into the Composite Model Classifier Strategy by @sidwan02 in
+ [#17231](https://github.com/google-gemini/gemini-cli/pull/17231)
+- docs(plan): update documentation regarding supporting editing of plan files
+ during plan approval by @Adib234 in
+ [#20452](https://github.com/google-gemini/gemini-cli/pull/20452)
+- test(cli): fix flaky ToolResultDisplay overflow test by @jwhelangoog in
+ [#20518](https://github.com/google-gemini/gemini-cli/pull/20518)
+- ui(cli): reduce length of Ctrl+O hint by @jwhelangoog in
+ [#20490](https://github.com/google-gemini/gemini-cli/pull/20490)
+- fix(ui): correct styled table width calculations by @devr0306 in
+ [#20042](https://github.com/google-gemini/gemini-cli/pull/20042)
+- Avoid overaggressive unescaping by @scidomino in
+ [#20520](https://github.com/google-gemini/gemini-cli/pull/20520)
+- feat(telemetry) Instrument traces with more attributes and make them available
+ to OTEL users by @heaventourist in
+ [#20237](https://github.com/google-gemini/gemini-cli/pull/20237)
+- Add support for policy engine in extensions by @chrstnb in
+ [#20049](https://github.com/google-gemini/gemini-cli/pull/20049)
+- Docs: Update to Terms of Service & FAQ by @jkcinouye in
+ [#20488](https://github.com/google-gemini/gemini-cli/pull/20488)
+- Fix bottom border rendering for search and add a regression test. by @jacob314
+ in [#20517](https://github.com/google-gemini/gemini-cli/pull/20517)
+- fix(core): apply retry logic to CodeAssistServer for all users by @bdmorgan in
+ [#20507](https://github.com/google-gemini/gemini-cli/pull/20507)
+- Fix extension MCP server env var loading by @chrstnb in
+ [#20374](https://github.com/google-gemini/gemini-cli/pull/20374)
+- feat(ui): add 'ctrl+o' hint to truncated content message by @jerop in
+ [#20529](https://github.com/google-gemini/gemini-cli/pull/20529)
+- Fix flicker showing message to press ctrl-O again to collapse. by @jacob314 in
+ [#20414](https://github.com/google-gemini/gemini-cli/pull/20414)
+- fix(cli): hide shortcuts hint while model is thinking or the user has typed a
+ prompt + add debounce to avoid flicker by @jacob314 in
+ [#19389](https://github.com/google-gemini/gemini-cli/pull/19389)
+- feat(plan): update planning workflow to encourage multi-select with
+ descriptions of options by @Adib234 in
+ [#20491](https://github.com/google-gemini/gemini-cli/pull/20491)
+- refactor(core,cli): useAlternateBuffer read from config by @psinha40898 in
+ [#20346](https://github.com/google-gemini/gemini-cli/pull/20346)
+- fix(cli): ensure dialogs stay scrolled to bottom in alternate buffer mode by
+ @jacob314 in [#20527](https://github.com/google-gemini/gemini-cli/pull/20527)
+- fix(core): revert auto-save of policies to user space by @Abhijit-2592 in
+ [#20531](https://github.com/google-gemini/gemini-cli/pull/20531)
+- Demote unreliable test. by @gundermanc in
+ [#20571](https://github.com/google-gemini/gemini-cli/pull/20571)
+- fix(core): handle optional response fields from code assist API by @sehoon38
+ in [#20345](https://github.com/google-gemini/gemini-cli/pull/20345)
+- fix(cli): keep thought summary when loading phrases are off by @LyalinDotCom
+ in [#20497](https://github.com/google-gemini/gemini-cli/pull/20497)
+- feat(cli): add temporary flag to disable workspace policies by @Abhijit-2592
+ in [#20523](https://github.com/google-gemini/gemini-cli/pull/20523)
+- Disable expensive and scheduled workflows on personal forks by @dewitt in
+ [#20449](https://github.com/google-gemini/gemini-cli/pull/20449)
+- Moved markdown parsing logic to a separate util file by @devr0306 in
+ [#20526](https://github.com/google-gemini/gemini-cli/pull/20526)
+- fix(plan): prevent agent from using ask_user for shell command confirmation by
+ @Adib234 in [#20504](https://github.com/google-gemini/gemini-cli/pull/20504)
+- fix(core): disable retries for code assist streaming requests by @sehoon38 in
+ [#20561](https://github.com/google-gemini/gemini-cli/pull/20561)
+- feat(billing): implement G1 AI credits overage flow with billing telemetry by
+ @gsquared94 in
+ [#18590](https://github.com/google-gemini/gemini-cli/pull/18590)
+- feat: better error messages by @gsquared94 in
+ [#20577](https://github.com/google-gemini/gemini-cli/pull/20577)
+- fix(ui): persist expansion in AskUser dialog when navigating options by @jerop
+ in [#20559](https://github.com/google-gemini/gemini-cli/pull/20559)
+- fix(cli): prevent sub-agent tool calls from leaking into UI by @abhipatel12 in
+ [#20580](https://github.com/google-gemini/gemini-cli/pull/20580)
+- fix(cli): Shell autocomplete polish by @jacob314 in
+ [#20411](https://github.com/google-gemini/gemini-cli/pull/20411)
+- Changelog for v0.31.0-preview.1 by @gemini-cli-robot in
+ [#20590](https://github.com/google-gemini/gemini-cli/pull/20590)
+- Add slash command for promoting behavioral evals to CI blocking by @gundermanc
+ in [#20575](https://github.com/google-gemini/gemini-cli/pull/20575)
+- Changelog for v0.30.1 by @gemini-cli-robot in
+ [#20589](https://github.com/google-gemini/gemini-cli/pull/20589)
+- Add low/full CLI error verbosity mode for cleaner UI by @LyalinDotCom in
+ [#20399](https://github.com/google-gemini/gemini-cli/pull/20399)
+- Disable Gemini PR reviews on draft PRs. by @gundermanc in
+ [#20362](https://github.com/google-gemini/gemini-cli/pull/20362)
+- Docs: FAQ update by @jkcinouye in
+ [#20585](https://github.com/google-gemini/gemini-cli/pull/20585)
+- fix(core): reduce intrusive MCP errors and deduplicate diagnostics by
@spencer426 in
- [#19478](https://github.com/google-gemini/gemini-cli/pull/19478)
-- Remove unused files and update index and sidebar. by @g-samroberts in
- [#19479](https://github.com/google-gemini/gemini-cli/pull/19479)
-- Migrate core render util to use xterm.js as part of the rendering loop. by
- @jacob314 in [#19044](https://github.com/google-gemini/gemini-cli/pull/19044)
-- Changelog for v0.30.0-preview.1 by @gemini-cli-robot in
- [#19496](https://github.com/google-gemini/gemini-cli/pull/19496)
-- build: replace deprecated built-in punycode with userland package by @jacob314
- in [#19502](https://github.com/google-gemini/gemini-cli/pull/19502)
-- Speculative fixes to try to fix react error. by @jacob314 in
- [#19508](https://github.com/google-gemini/gemini-cli/pull/19508)
-- fix spacing by @jacob314 in
- [#19494](https://github.com/google-gemini/gemini-cli/pull/19494)
-- fix(core): ensure user rejections update tool outcome for telemetry by
- @abhiasap in [#18982](https://github.com/google-gemini/gemini-cli/pull/18982)
-- fix(acp): Initialize config (#18897) by @Mervap in
- [#18898](https://github.com/google-gemini/gemini-cli/pull/18898)
-- fix(core): add error logging for IDE fetch failures by @yuvrajangadsingh in
- [#17981](https://github.com/google-gemini/gemini-cli/pull/17981)
-- feat(acp): support set_mode interface (#18890) by @Mervap in
- [#18891](https://github.com/google-gemini/gemini-cli/pull/18891)
-- fix(core): robust workspace-based IDE connection discovery by @ehedlund in
- [#18443](https://github.com/google-gemini/gemini-cli/pull/18443)
-- Deflake windows tests. by @jacob314 in
- [#19511](https://github.com/google-gemini/gemini-cli/pull/19511)
-- Fix: Avoid tool confirmation timeout when no UI listeners are present by
- @pdHaku0 in [#17955](https://github.com/google-gemini/gemini-cli/pull/17955)
-- format md file by @scidomino in
- [#19474](https://github.com/google-gemini/gemini-cli/pull/19474)
-- feat(cli): add experimental.useOSC52Copy setting by @scidomino in
- [#19488](https://github.com/google-gemini/gemini-cli/pull/19488)
-- feat(cli): replace loading phrases boolean with enum setting by @LyalinDotCom
- in [#19347](https://github.com/google-gemini/gemini-cli/pull/19347)
-- Update skill to adjust for generated results. by @g-samroberts in
- [#19500](https://github.com/google-gemini/gemini-cli/pull/19500)
-- Fix message too large issue. by @gundermanc in
- [#19499](https://github.com/google-gemini/gemini-cli/pull/19499)
-- fix(core): prevent duplicate tool approval entries in auto-saved.toml by
- @Abhijit-2592 in
- [#19487](https://github.com/google-gemini/gemini-cli/pull/19487)
-- fix(core): resolve crash in ClearcutLogger when os.cpus() is empty by @Adib234
- in [#19555](https://github.com/google-gemini/gemini-cli/pull/19555)
-- chore(core): improve encapsulation and remove unused exports by @adamfweidman
- in [#19556](https://github.com/google-gemini/gemini-cli/pull/19556)
-- Revert "Add generic searchable list to back settings and extensions (… by
- @chrstnb in [#19434](https://github.com/google-gemini/gemini-cli/pull/19434)
-- fix(core): improve error type extraction for telemetry by @yunaseoul in
- [#19565](https://github.com/google-gemini/gemini-cli/pull/19565)
-- fix: remove extra padding in Composer by @jackwotherspoon in
- [#19529](https://github.com/google-gemini/gemini-cli/pull/19529)
-- feat(plan): support configuring custom plans storage directory by @jerop in
- [#19577](https://github.com/google-gemini/gemini-cli/pull/19577)
-- Migrate files to resource or references folder. by @g-samroberts in
- [#19503](https://github.com/google-gemini/gemini-cli/pull/19503)
-- feat(policy): implement project-level policy support by @Abhijit-2592 in
- [#18682](https://github.com/google-gemini/gemini-cli/pull/18682)
-- feat(core): Implement parallel FC for read only tools. by @joshualitt in
- [#18791](https://github.com/google-gemini/gemini-cli/pull/18791)
-- chore(skills): adds pr-address-comments skill to work on PR feedback by
- @mbleigh in [#19576](https://github.com/google-gemini/gemini-cli/pull/19576)
-- refactor(sdk): introduce session-based architecture by @mbleigh in
- [#19180](https://github.com/google-gemini/gemini-cli/pull/19180)
-- fix(ci): add fallback JSON extraction to issue triage workflow by @bdmorgan in
- [#19593](https://github.com/google-gemini/gemini-cli/pull/19593)
-- feat(core): refine Edit and WriteFile tool schemas for Gemini 3 by
- @SandyTao520 in
- [#19476](https://github.com/google-gemini/gemini-cli/pull/19476)
-- Changelog for v0.30.0-preview.3 by @gemini-cli-robot in
- [#19585](https://github.com/google-gemini/gemini-cli/pull/19585)
-- fix(plan): exclude EnterPlanMode tool from YOLO mode by @Adib234 in
- [#19570](https://github.com/google-gemini/gemini-cli/pull/19570)
-- chore: resolve build warnings and update dependencies by @mattKorwel in
- [#18880](https://github.com/google-gemini/gemini-cli/pull/18880)
-- feat(ui): add source indicators to slash commands by @ehedlund in
- [#18839](https://github.com/google-gemini/gemini-cli/pull/18839)
-- docs: refine Plan Mode documentation structure and workflow by @jerop in
- [#19644](https://github.com/google-gemini/gemini-cli/pull/19644)
-- Docs: Update release information regarding Gemini 3.1 by @jkcinouye in
- [#19568](https://github.com/google-gemini/gemini-cli/pull/19568)
-- fix(security): rate limit web_fetch tool to mitigate DDoS via prompt injection
- by @mattKorwel in
- [#19567](https://github.com/google-gemini/gemini-cli/pull/19567)
-- Add initial implementation of /extensions explore command by @chrstnb in
- [#19029](https://github.com/google-gemini/gemini-cli/pull/19029)
-- fix: use discoverOAuthFromWWWAuthenticate for reactive OAuth flow (#18760) by
- @maximus12793 in
- [#19038](https://github.com/google-gemini/gemini-cli/pull/19038)
-- Search updates by @alisa-alisa in
- [#19482](https://github.com/google-gemini/gemini-cli/pull/19482)
-- feat(cli): add support for numpad SS3 sequences by @scidomino in
- [#19659](https://github.com/google-gemini/gemini-cli/pull/19659)
-- feat(cli): enhance folder trust with configuration discovery and security
- warnings by @galz10 in
- [#19492](https://github.com/google-gemini/gemini-cli/pull/19492)
-- feat(ui): improve startup warnings UX with dismissal and show-count limits by
- @spencer426 in
- [#19584](https://github.com/google-gemini/gemini-cli/pull/19584)
-- feat(a2a): Add API key authentication provider by @adamfweidman in
- [#19548](https://github.com/google-gemini/gemini-cli/pull/19548)
-- Send accepted/removed lines with ACCEPT_FILE telemetry. by @gundermanc in
- [#19670](https://github.com/google-gemini/gemini-cli/pull/19670)
-- feat(models): support Gemini 3.1 Pro Preview and fixes by @sehoon38 in
- [#19676](https://github.com/google-gemini/gemini-cli/pull/19676)
-- feat(plan): enforce read-only constraints in Plan Mode by @mattKorwel in
- [#19433](https://github.com/google-gemini/gemini-cli/pull/19433)
-- fix(cli): allow perfect match @scripts/test-windows-paths.js completions to
- submit on Enter by @spencer426 in
- [#19562](https://github.com/google-gemini/gemini-cli/pull/19562)
-- fix(core): treat 503 Service Unavailable as retryable quota error by @sehoon38
- in [#19642](https://github.com/google-gemini/gemini-cli/pull/19642)
-- Update sidebar.json for to allow top nav tabs. by @g-samroberts in
- [#19595](https://github.com/google-gemini/gemini-cli/pull/19595)
-- security: strip deceptive Unicode characters from terminal output by @ehedlund
- in [#19026](https://github.com/google-gemini/gemini-cli/pull/19026)
-- Fixes 'input.on' is not a function error in Gemini CLI by @gundermanc in
- [#19691](https://github.com/google-gemini/gemini-cli/pull/19691)
-- Revert "feat(ui): add source indicators to slash commands" by @ehedlund in
- [#19695](https://github.com/google-gemini/gemini-cli/pull/19695)
-- security: implement deceptive URL detection and disclosure in tool
- confirmations by @ehedlund in
- [#19288](https://github.com/google-gemini/gemini-cli/pull/19288)
-- fix(core): restore auth consent in headless mode and add unit tests by
- @ehedlund in [#19689](https://github.com/google-gemini/gemini-cli/pull/19689)
-- Fix unsafe assertions in code_assist folder. by @gundermanc in
- [#19706](https://github.com/google-gemini/gemini-cli/pull/19706)
-- feat(cli): make JetBrains warning more specific by @jacob314 in
- [#19687](https://github.com/google-gemini/gemini-cli/pull/19687)
-- fix(cli): extensions dialog UX polish by @jacob314 in
- [#19685](https://github.com/google-gemini/gemini-cli/pull/19685)
-- fix(cli): use getDisplayString for manual model selection in dialog by
- @sehoon38 in [#19726](https://github.com/google-gemini/gemini-cli/pull/19726)
-- feat(policy): repurpose "Always Allow" persistence to workspace level by
- @Abhijit-2592 in
- [#19707](https://github.com/google-gemini/gemini-cli/pull/19707)
-- fix(cli): re-enable CLI banner by @sehoon38 in
- [#19741](https://github.com/google-gemini/gemini-cli/pull/19741)
-- Disallow and suppress unsafe assignment by @gundermanc in
- [#19736](https://github.com/google-gemini/gemini-cli/pull/19736)
-- feat(core): migrate read_file to 1-based start_line/end_line parameters by
- @adamfweidman in
- [#19526](https://github.com/google-gemini/gemini-cli/pull/19526)
-- feat(cli): improve CTRL+O experience for both standard and alternate screen
- buffer (ASB) modes by @jwhelangoog in
- [#19010](https://github.com/google-gemini/gemini-cli/pull/19010)
-- Utilize pipelining of grep_search -> read_file to eliminate turns by
- @gundermanc in
- [#19574](https://github.com/google-gemini/gemini-cli/pull/19574)
-- refactor(core): remove unsafe type assertions in error utils (Phase 1.1) by
- @mattKorwel in
- [#19750](https://github.com/google-gemini/gemini-cli/pull/19750)
-- Disallow unsafe returns. by @gundermanc in
- [#19767](https://github.com/google-gemini/gemini-cli/pull/19767)
-- fix(cli): filter subagent sessions from resume history by @abhipatel12 in
- [#19698](https://github.com/google-gemini/gemini-cli/pull/19698)
-- chore(lint): fix lint errors seen when running npm run lint by @abhipatel12 in
- [#19844](https://github.com/google-gemini/gemini-cli/pull/19844)
-- feat(core): remove unnecessary login verbiage from Code Assist auth by
- @NTaylorMullen in
- [#19861](https://github.com/google-gemini/gemini-cli/pull/19861)
-- fix(plan): time share by approval mode dashboard reporting negative time
- shares by @Adib234 in
- [#19847](https://github.com/google-gemini/gemini-cli/pull/19847)
-- fix(core): allow any preview model in quota access check by @bdmorgan in
- [#19867](https://github.com/google-gemini/gemini-cli/pull/19867)
-- fix(core): prevent omission placeholder deletions in replace/write_file by
- @nsalerni in [#19870](https://github.com/google-gemini/gemini-cli/pull/19870)
-- fix(core): add uniqueness guard to edit tool by @Shivangisharma4 in
- [#19890](https://github.com/google-gemini/gemini-cli/pull/19890)
-- refactor(config): remove enablePromptCompletion from settings by @sehoon38 in
- [#19974](https://github.com/google-gemini/gemini-cli/pull/19974)
-- refactor(core): move session conversion logic to core by @abhipatel12 in
- [#19972](https://github.com/google-gemini/gemini-cli/pull/19972)
-- Fix: Persist manual model selection on restart #19864 by @Nixxx19 in
- [#19891](https://github.com/google-gemini/gemini-cli/pull/19891)
-- fix(core): increase default retry attempts and add quota error backoff by
- @sehoon38 in [#19949](https://github.com/google-gemini/gemini-cli/pull/19949)
-- feat(core): add policy chain support for Gemini 3.1 by @sehoon38 in
- [#19991](https://github.com/google-gemini/gemini-cli/pull/19991)
-- Updates command reference and /stats command. by @g-samroberts in
- [#19794](https://github.com/google-gemini/gemini-cli/pull/19794)
-- Fix for silent failures in non-interactive mode by @owenofbrien in
- [#19905](https://github.com/google-gemini/gemini-cli/pull/19905)
-- fix(plan): allow plan mode writes on Windows and fix prompt paths by @Adib234
- in [#19658](https://github.com/google-gemini/gemini-cli/pull/19658)
-- fix(core): prevent OAuth server crash on unexpected requests by @reyyanxahmed
- in [#19668](https://github.com/google-gemini/gemini-cli/pull/19668)
-- feat: Map tool kinds to explicit ACP.ToolKind values and update test … by
- @sripasg in [#19547](https://github.com/google-gemini/gemini-cli/pull/19547)
-- chore: restrict gemini-automted-issue-triage to only allow echo by @galz10 in
- [#20047](https://github.com/google-gemini/gemini-cli/pull/20047)
-- Allow ask headers longer than 16 chars by @scidomino in
- [#20041](https://github.com/google-gemini/gemini-cli/pull/20041)
-- fix(core): prevent state corruption in McpClientManager during collis by @h30s
- in [#19782](https://github.com/google-gemini/gemini-cli/pull/19782)
-- fix(bundling): copy devtools package to bundle for runtime resolution by
- @SandyTao520 in
- [#19766](https://github.com/google-gemini/gemini-cli/pull/19766)
-- feat(policy): Support MCP Server Wildcards in Policy Engine by @jerop in
- [#20024](https://github.com/google-gemini/gemini-cli/pull/20024)
-- docs(CONTRIBUTING): update React DevTools version to 6 by @mmgok in
- [#20014](https://github.com/google-gemini/gemini-cli/pull/20014)
-- feat(core): optimize tool descriptions and schemas for Gemini 3 by
- @aishaneeshah in
- [#19643](https://github.com/google-gemini/gemini-cli/pull/19643)
-- feat(core): implement experimental direct web fetch by @mbleigh in
- [#19557](https://github.com/google-gemini/gemini-cli/pull/19557)
-- feat(core): replace expected_replacements with allow_multiple in replace tool
- by @SandyTao520 in
- [#20033](https://github.com/google-gemini/gemini-cli/pull/20033)
-- fix(sandbox): harden image packaging integrity checks by @aviralgarg05 in
- [#19552](https://github.com/google-gemini/gemini-cli/pull/19552)
-- fix(core): allow environment variable expansion and explicit overrides for MCP
- servers by @galz10 in
- [#18837](https://github.com/google-gemini/gemini-cli/pull/18837)
-- feat(policy): Implement Tool Annotation Matching in Policy Engine by @jerop in
- [#20029](https://github.com/google-gemini/gemini-cli/pull/20029)
-- fix(core): prevent utility calls from changing session active model by
- @adamfweidman in
- [#20035](https://github.com/google-gemini/gemini-cli/pull/20035)
-- fix(cli): skip workspace policy loading when in home directory by
- @Abhijit-2592 in
- [#20054](https://github.com/google-gemini/gemini-cli/pull/20054)
-- fix(scripts): Add Windows (win32/x64) support to lint.js by @ZafeerMahmood in
- [#16193](https://github.com/google-gemini/gemini-cli/pull/16193)
-- fix(a2a-server): Remove unsafe type assertions in agent by @Nixxx19 in
- [#19723](https://github.com/google-gemini/gemini-cli/pull/19723)
-- Fix: Handle corrupted token file gracefully when switching auth types (#19845)
- by @Nixxx19 in
- [#19850](https://github.com/google-gemini/gemini-cli/pull/19850)
-- fix critical dep vulnerability by @scidomino in
- [#20087](https://github.com/google-gemini/gemini-cli/pull/20087)
-- Add new setting to configure maxRetries by @kevinjwang1 in
- [#20064](https://github.com/google-gemini/gemini-cli/pull/20064)
-- Stabilize tests. by @gundermanc in
- [#20095](https://github.com/google-gemini/gemini-cli/pull/20095)
-- make windows tests mandatory by @scidomino in
- [#20096](https://github.com/google-gemini/gemini-cli/pull/20096)
-- Add 3.1 pro preview to behavioral evals. by @gundermanc in
- [#20088](https://github.com/google-gemini/gemini-cli/pull/20088)
-- feat:PR-rate-limit by @JagjeevanAK in
- [#19804](https://github.com/google-gemini/gemini-cli/pull/19804)
-- feat(cli): allow expanding full details of MCP tool on approval by @y-okt in
- [#19916](https://github.com/google-gemini/gemini-cli/pull/19916)
-- feat(security): Introduce Conseca framework by @shrishabh in
- [#13193](https://github.com/google-gemini/gemini-cli/pull/13193)
-- fix(cli): Remove unsafe type assertions in activityLogger #19713 by @Nixxx19
- in [#19745](https://github.com/google-gemini/gemini-cli/pull/19745)
-- feat: implement AfterTool tail tool calls by @googlestrobe in
- [#18486](https://github.com/google-gemini/gemini-cli/pull/18486)
-- ci(actions): fix PR rate limiter excluding maintainers by @scidomino in
- [#20117](https://github.com/google-gemini/gemini-cli/pull/20117)
-- Shortcuts: Move SectionHeader title below top line and refine styling by
- @keithguerin in
- [#18721](https://github.com/google-gemini/gemini-cli/pull/18721)
-- refactor(ui): Update and simplify use of gray colors in themes by @keithguerin
- in [#20141](https://github.com/google-gemini/gemini-cli/pull/20141)
-- fix punycode2 by @jacob314 in
- [#20154](https://github.com/google-gemini/gemini-cli/pull/20154)
-- feat(ide): add GEMINI_CLI_IDE_PID env var to override IDE process detection by
- @kiryltech in [#15842](https://github.com/google-gemini/gemini-cli/pull/15842)
-- feat(policy): Propagate Tool Annotations for MCP Servers by @jerop in
- [#20083](https://github.com/google-gemini/gemini-cli/pull/20083)
-- fix(a2a-server): pass allowedTools settings to core Config by @reyyanxahmed in
- [#19680](https://github.com/google-gemini/gemini-cli/pull/19680)
-- feat(mcp): add progress bar, throttling, and input validation for MCP tool
- progress by @jasmeetsb in
- [#19772](https://github.com/google-gemini/gemini-cli/pull/19772)
-- feat(policy): centralize plan mode tool visibility in policy engine by @jerop
- in [#20178](https://github.com/google-gemini/gemini-cli/pull/20178)
-- feat(browser): implement experimental browser agent by @gsquared94 in
- [#19284](https://github.com/google-gemini/gemini-cli/pull/19284)
-- feat(plan): summarize work after executing a plan by @jerop in
- [#19432](https://github.com/google-gemini/gemini-cli/pull/19432)
-- fix(core): create new McpClient on restart to apply updated config by @h30s in
- [#20126](https://github.com/google-gemini/gemini-cli/pull/20126)
-- Changelog for v0.30.0-preview.5 by @gemini-cli-robot in
- [#20107](https://github.com/google-gemini/gemini-cli/pull/20107)
-- Update packages. by @jacob314 in
- [#20152](https://github.com/google-gemini/gemini-cli/pull/20152)
-- Fix extension env dir loading issue by @chrstnb in
- [#20198](https://github.com/google-gemini/gemini-cli/pull/20198)
-- restrict /assign to help-wanted issues by @scidomino in
- [#20207](https://github.com/google-gemini/gemini-cli/pull/20207)
-- feat(plan): inject message when user manually exits Plan mode by @jerop in
- [#20203](https://github.com/google-gemini/gemini-cli/pull/20203)
-- feat(extensions): enforce folder trust for local extension install by @galz10
- in [#19703](https://github.com/google-gemini/gemini-cli/pull/19703)
-- feat(hooks): adds support for RuntimeHook functions. by @mbleigh in
- [#19598](https://github.com/google-gemini/gemini-cli/pull/19598)
-- Docs: Update UI links. by @jkcinouye in
- [#20224](https://github.com/google-gemini/gemini-cli/pull/20224)
-- feat: prompt users to run /terminal-setup with yes/no by @ishaanxgupta in
- [#16235](https://github.com/google-gemini/gemini-cli/pull/16235)
-- fix: additional high vulnerabilities (minimatch, cross-spawn) by @adamfweidman
- in [#20221](https://github.com/google-gemini/gemini-cli/pull/20221)
-- feat(telemetry): Add context breakdown to API response event by @SandyTao520
- in [#19699](https://github.com/google-gemini/gemini-cli/pull/19699)
-- Docs: Add nested sub-folders for related topics by @g-samroberts in
- [#20235](https://github.com/google-gemini/gemini-cli/pull/20235)
-- feat(plan): support automatic model switching for Plan Mode by @jerop in
- [#20240](https://github.com/google-gemini/gemini-cli/pull/20240)
-- fix(patch): cherry-pick 58df1c6 to release/v0.31.0-preview.0-pr-20374 to patch
- version v0.31.0-preview.0 and create version 0.31.0-preview.1 by
- @gemini-cli-robot in
- [#20568](https://github.com/google-gemini/gemini-cli/pull/20568)
-- fix(patch): cherry-pick ea48bd9 to release/v0.31.0-preview.1-pr-20577
- [CONFLICTS] by @gemini-cli-robot in
- [#20592](https://github.com/google-gemini/gemini-cli/pull/20592)
-- fix(patch): cherry-pick 32e777f to release/v0.31.0-preview.2-pr-20531 to patch
- version v0.31.0-preview.2 and create version 0.31.0-preview.3 by
- @gemini-cli-robot in
- [#20607](https://github.com/google-gemini/gemini-cli/pull/20607)
+ [#20232](https://github.com/google-gemini/gemini-cli/pull/20232)
+- docs: fix spelling typos in installation guide by @campox747 in
+ [#20579](https://github.com/google-gemini/gemini-cli/pull/20579)
+- Promote stable tests to CI blocking. by @gundermanc in
+ [#20581](https://github.com/google-gemini/gemini-cli/pull/20581)
+- feat(core): enable contiguous parallel admission for Kind.Agent tools by
+ @abhipatel12 in
+ [#20583](https://github.com/google-gemini/gemini-cli/pull/20583)
+- Enforce import/no-duplicates as error by @Nixxx19 in
+ [#19797](https://github.com/google-gemini/gemini-cli/pull/19797)
+- fix: merge duplicate imports in sdk and test-utils packages (1/4) by @Nixxx19
+ in [#19777](https://github.com/google-gemini/gemini-cli/pull/19777)
+- fix: merge duplicate imports in a2a-server package (2/4) by @Nixxx19 in
+ [#19781](https://github.com/google-gemini/gemini-cli/pull/19781)
**Full Changelog**:
-https://github.com/google-gemini/gemini-cli/compare/v0.30.1...v0.31.0
+https://github.com/google-gemini/gemini-cli/compare/v0.31.0...v0.32.1
diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md
index b08f4fa1b0..3b4e10bae8 100644
--- a/docs/changelogs/preview.md
+++ b/docs/changelogs/preview.md
@@ -1,6 +1,6 @@
-# Preview release: v0.32.0-preview.0
+# Preview release: v0.33.0-preview.1
-Released: February 27, 2026
+Released: March 04, 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,196 +13,175 @@ npm install -g @google/gemini-cli@preview
## Highlights
-- **Plan Mode Enhancements**: Significant updates to Plan Mode, including
- support for modifying plans in external editors, adaptive workflows based on
- task complexity, and new integration tests.
-- **Agent and Core Engine Updates**: Enabled the generalist agent, introduced
- `Kind.Agent` for sub-agent classification, implemented task tracking
- foundation, and improved Agent-to-Agent (A2A) streaming and content
- extraction.
-- **CLI & User Experience**: Introduced interactive shell autocompletion, added
- a new verbosity mode for cleaner error reporting, enabled parallel loading of
- extensions, and improved UI hints and shortcut handling.
-- **Billing and Security**: Implemented G1 AI credits overage flow with enhanced
- billing telemetry, updated the authentication handshake to specification, and
- added support for a policy engine in extensions.
-- **Stability and Bug Fixes**: Addressed numerous issues including 100% CPU
- consumption by orphaned processes, enhanced retry logic for Code Assist,
- reduced intrusive MCP errors, and merged duplicate imports across packages.
+- **Plan Mode Enhancements**: Added support for annotating plans with feedback
+ for iteration, enabling built-in research subagents in plan mode, and a new
+ `copy` subcommand.
+- **Agent and Skill Improvements**: Introduced the new `github-issue-creator`
+ skill, implemented HTTP authentication support for A2A remote agents, and
+ added support for authenticated A2A agent card discovery.
+- **CLI UX/UI Updates**: Redesigned the header to be compact with an ASCII icon,
+ inverted the context window display to show usage, and directly indicate auth
+ required state for agents.
+- **Core and ACP Enhancements**: Implemented slash command handling in ACP (for
+ `/memory`, `/init`, `/extensions`, and `/restore`), added a set models
+ interface to ACP, and centralized `read_file` limits while truncating large
+ MCP tool output.
## What's Changed
-- feat(plan): add integration tests for plan mode by @Adib234 in
- [#20214](https://github.com/google-gemini/gemini-cli/pull/20214)
-- fix(acp): update auth handshake to spec by @skeshive in
- [#19725](https://github.com/google-gemini/gemini-cli/pull/19725)
-- feat(core): implement robust A2A streaming reassembly and fix task continuity
- by @adamfweidman in
- [#20091](https://github.com/google-gemini/gemini-cli/pull/20091)
-- feat(cli): load extensions in parallel by @scidomino in
- [#20229](https://github.com/google-gemini/gemini-cli/pull/20229)
-- Plumb the maxAttempts setting through Config args by @kevinjwang1 in
- [#20239](https://github.com/google-gemini/gemini-cli/pull/20239)
-- fix(cli): skip 404 errors in setup-github file downloads by @h30s in
- [#20287](https://github.com/google-gemini/gemini-cli/pull/20287)
-- fix(cli): expose model.name setting in settings dialog for persistence by
- @achaljhawar in
- [#19605](https://github.com/google-gemini/gemini-cli/pull/19605)
-- docs: remove legacy cmd examples in favor of powershell by @scidomino in
- [#20323](https://github.com/google-gemini/gemini-cli/pull/20323)
-- feat(core): Enable model steering in workspace. by @joshualitt in
- [#20343](https://github.com/google-gemini/gemini-cli/pull/20343)
-- fix: remove trailing comma in issue triage workflow settings json by @Nixxx19
- in [#20265](https://github.com/google-gemini/gemini-cli/pull/20265)
-- feat(core): implement task tracker foundation and service by @anj-s in
- [#19464](https://github.com/google-gemini/gemini-cli/pull/19464)
-- test: support tests that include color information by @jacob314 in
- [#20220](https://github.com/google-gemini/gemini-cli/pull/20220)
-- feat(core): introduce Kind.Agent for sub-agent classification by @abhipatel12
- in [#20369](https://github.com/google-gemini/gemini-cli/pull/20369)
-- Changelog for v0.30.0 by @gemini-cli-robot in
- [#20252](https://github.com/google-gemini/gemini-cli/pull/20252)
-- Update changelog workflow to reject nightly builds by @g-samroberts in
- [#20248](https://github.com/google-gemini/gemini-cli/pull/20248)
-- Changelog for v0.31.0-preview.0 by @gemini-cli-robot in
- [#20249](https://github.com/google-gemini/gemini-cli/pull/20249)
-- feat(cli): hide workspace policy update dialog and auto-accept by default by
- @Abhijit-2592 in
- [#20351](https://github.com/google-gemini/gemini-cli/pull/20351)
-- feat(core): rename grep_search include parameter to include_pattern by
+- fix(patch): cherry-pick 0659ad1 to release/v0.33.0-preview.0-pr-21042 to patch
+ version v0.33.0-preview.0 and create version 0.33.0-preview.1 by
+ @gemini-cli-robot in
+ [#21047](https://github.com/google-gemini/gemini-cli/pull/21047)
+
+* Docs: Update model docs to remove Preview Features. by @jkcinouye in
+ [#20084](https://github.com/google-gemini/gemini-cli/pull/20084)
+* docs: fix typo in installation documentation by @AdityaSharma-Git3207 in
+ [#20153](https://github.com/google-gemini/gemini-cli/pull/20153)
+* docs: add Windows PowerShell equivalents for environments and scripting by
+ @scidomino in [#20333](https://github.com/google-gemini/gemini-cli/pull/20333)
+* fix(core): parse raw ASCII buffer strings in Gaxios errors by @sehoon38 in
+ [#20626](https://github.com/google-gemini/gemini-cli/pull/20626)
+* chore(release): bump version to 0.33.0-nightly.20260227.ba149afa0 by @galz10
+ in [#20637](https://github.com/google-gemini/gemini-cli/pull/20637)
+* fix(github): use robot PAT for automated PRs to pass CLA check by @galz10 in
+ [#20641](https://github.com/google-gemini/gemini-cli/pull/20641)
+* chore/release: bump version to 0.33.0-nightly.20260228.1ca5c05d0 by
+ @gemini-cli-robot in
+ [#20644](https://github.com/google-gemini/gemini-cli/pull/20644)
+* Changelog for v0.31.0 by @gemini-cli-robot in
+ [#20634](https://github.com/google-gemini/gemini-cli/pull/20634)
+* fix: use full paths for ACP diff payloads by @JagjeevanAK in
+ [#19539](https://github.com/google-gemini/gemini-cli/pull/19539)
+* Changelog for v0.32.0-preview.0 by @gemini-cli-robot in
+ [#20627](https://github.com/google-gemini/gemini-cli/pull/20627)
+* fix: acp/zed race condition between MCP initialisation and prompt by
+ @kartikangiras in
+ [#20205](https://github.com/google-gemini/gemini-cli/pull/20205)
+* fix(cli): reset themeManager between tests to ensure isolation by
+ @NTaylorMullen in
+ [#20598](https://github.com/google-gemini/gemini-cli/pull/20598)
+* refactor(core): Extract tool parameter names as constants by @SandyTao520 in
+ [#20460](https://github.com/google-gemini/gemini-cli/pull/20460)
+* fix(cli): resolve autoThemeSwitching when background hasn't changed but theme
+ mismatches by @sehoon38 in
+ [#20706](https://github.com/google-gemini/gemini-cli/pull/20706)
+* feat(skills): add github-issue-creator skill by @sehoon38 in
+ [#20709](https://github.com/google-gemini/gemini-cli/pull/20709)
+* fix(cli): allow sub-agent confirmation requests in UI while preventing
+ background flicker by @abhipatel12 in
+ [#20722](https://github.com/google-gemini/gemini-cli/pull/20722)
+* Merge User and Agent Card Descriptions #20849 by @adamfweidman in
+ [#20850](https://github.com/google-gemini/gemini-cli/pull/20850)
+* fix(core): reduce LLM-based loop detection false positives by @SandyTao520 in
+ [#20701](https://github.com/google-gemini/gemini-cli/pull/20701)
+* fix(plan): deflake plan mode integration tests by @Adib234 in
+ [#20477](https://github.com/google-gemini/gemini-cli/pull/20477)
+* Add /unassign support by @scidomino in
+ [#20864](https://github.com/google-gemini/gemini-cli/pull/20864)
+* feat(core): implement HTTP authentication support for A2A remote agents by
@SandyTao520 in
- [#20328](https://github.com/google-gemini/gemini-cli/pull/20328)
-- feat(plan): support opening and modifying plan in external editor by @Adib234
- in [#20348](https://github.com/google-gemini/gemini-cli/pull/20348)
-- feat(cli): implement interactive shell autocompletion by @mrpmohiburrahman in
- [#20082](https://github.com/google-gemini/gemini-cli/pull/20082)
-- fix(core): allow /memory add to work in plan mode by @Jefftree in
- [#20353](https://github.com/google-gemini/gemini-cli/pull/20353)
-- feat(core): add HTTP 499 to retryable errors and map to RetryableQuotaError by
- @bdmorgan in [#20432](https://github.com/google-gemini/gemini-cli/pull/20432)
-- feat(core): Enable generalist agent by @joshualitt in
- [#19665](https://github.com/google-gemini/gemini-cli/pull/19665)
-- Updated tests in TableRenderer.test.tsx to use SVG snapshots by @devr0306 in
- [#20450](https://github.com/google-gemini/gemini-cli/pull/20450)
-- Refactor Github Action per b/485167538 by @google-admin in
- [#19443](https://github.com/google-gemini/gemini-cli/pull/19443)
-- fix(github): resolve actionlint and yamllint regressions from #19443 by @jerop
- in [#20467](https://github.com/google-gemini/gemini-cli/pull/20467)
-- fix: action var usage by @galz10 in
- [#20492](https://github.com/google-gemini/gemini-cli/pull/20492)
-- feat(core): improve A2A content extraction by @adamfweidman in
- [#20487](https://github.com/google-gemini/gemini-cli/pull/20487)
-- fix(cli): support quota error fallbacks for all authentication types by
- @sehoon38 in [#20475](https://github.com/google-gemini/gemini-cli/pull/20475)
-- fix(core): flush transcript for pure tool-call responses to ensure BeforeTool
- hooks see complete state by @krishdef7 in
- [#20419](https://github.com/google-gemini/gemini-cli/pull/20419)
-- feat(plan): adapt planning workflow based on complexity of task by @jerop in
- [#20465](https://github.com/google-gemini/gemini-cli/pull/20465)
-- fix: prevent orphaned processes from consuming 100% CPU when terminal closes
- by @yuvrajangadsingh in
- [#16965](https://github.com/google-gemini/gemini-cli/pull/16965)
-- feat(core): increase fetch timeout and fix [object Object] error
- stringification by @bdmorgan in
- [#20441](https://github.com/google-gemini/gemini-cli/pull/20441)
-- [Gemma x Gemini CLI] Add an Experimental Gemma Router that uses a LiteRT-LM
- shim into the Composite Model Classifier Strategy by @sidwan02 in
- [#17231](https://github.com/google-gemini/gemini-cli/pull/17231)
-- docs(plan): update documentation regarding supporting editing of plan files
- during plan approval by @Adib234 in
- [#20452](https://github.com/google-gemini/gemini-cli/pull/20452)
-- test(cli): fix flaky ToolResultDisplay overflow test by @jwhelangoog in
- [#20518](https://github.com/google-gemini/gemini-cli/pull/20518)
-- ui(cli): reduce length of Ctrl+O hint by @jwhelangoog in
- [#20490](https://github.com/google-gemini/gemini-cli/pull/20490)
-- fix(ui): correct styled table width calculations by @devr0306 in
- [#20042](https://github.com/google-gemini/gemini-cli/pull/20042)
-- Avoid overaggressive unescaping by @scidomino in
- [#20520](https://github.com/google-gemini/gemini-cli/pull/20520)
-- feat(telemetry) Instrument traces with more attributes and make them available
- to OTEL users by @heaventourist in
- [#20237](https://github.com/google-gemini/gemini-cli/pull/20237)
-- Add support for policy engine in extensions by @chrstnb in
- [#20049](https://github.com/google-gemini/gemini-cli/pull/20049)
-- Docs: Update to Terms of Service & FAQ by @jkcinouye in
- [#20488](https://github.com/google-gemini/gemini-cli/pull/20488)
-- Fix bottom border rendering for search and add a regression test. by @jacob314
- in [#20517](https://github.com/google-gemini/gemini-cli/pull/20517)
-- fix(core): apply retry logic to CodeAssistServer for all users by @bdmorgan in
- [#20507](https://github.com/google-gemini/gemini-cli/pull/20507)
-- Fix extension MCP server env var loading by @chrstnb in
- [#20374](https://github.com/google-gemini/gemini-cli/pull/20374)
-- feat(ui): add 'ctrl+o' hint to truncated content message by @jerop in
- [#20529](https://github.com/google-gemini/gemini-cli/pull/20529)
-- Fix flicker showing message to press ctrl-O again to collapse. by @jacob314 in
- [#20414](https://github.com/google-gemini/gemini-cli/pull/20414)
-- fix(cli): hide shortcuts hint while model is thinking or the user has typed a
- prompt + add debounce to avoid flicker by @jacob314 in
- [#19389](https://github.com/google-gemini/gemini-cli/pull/19389)
-- feat(plan): update planning workflow to encourage multi-select with
- descriptions of options by @Adib234 in
- [#20491](https://github.com/google-gemini/gemini-cli/pull/20491)
-- refactor(core,cli): useAlternateBuffer read from config by @psinha40898 in
- [#20346](https://github.com/google-gemini/gemini-cli/pull/20346)
-- fix(cli): ensure dialogs stay scrolled to bottom in alternate buffer mode by
- @jacob314 in [#20527](https://github.com/google-gemini/gemini-cli/pull/20527)
-- fix(core): revert auto-save of policies to user space by @Abhijit-2592 in
- [#20531](https://github.com/google-gemini/gemini-cli/pull/20531)
-- Demote unreliable test. by @gundermanc in
- [#20571](https://github.com/google-gemini/gemini-cli/pull/20571)
-- fix(core): handle optional response fields from code assist API by @sehoon38
- in [#20345](https://github.com/google-gemini/gemini-cli/pull/20345)
-- fix(cli): keep thought summary when loading phrases are off by @LyalinDotCom
- in [#20497](https://github.com/google-gemini/gemini-cli/pull/20497)
-- feat(cli): add temporary flag to disable workspace policies by @Abhijit-2592
- in [#20523](https://github.com/google-gemini/gemini-cli/pull/20523)
-- Disable expensive and scheduled workflows on personal forks by @dewitt in
- [#20449](https://github.com/google-gemini/gemini-cli/pull/20449)
-- Moved markdown parsing logic to a separate util file by @devr0306 in
- [#20526](https://github.com/google-gemini/gemini-cli/pull/20526)
-- fix(plan): prevent agent from using ask_user for shell command confirmation by
- @Adib234 in [#20504](https://github.com/google-gemini/gemini-cli/pull/20504)
-- fix(core): disable retries for code assist streaming requests by @sehoon38 in
- [#20561](https://github.com/google-gemini/gemini-cli/pull/20561)
-- feat(billing): implement G1 AI credits overage flow with billing telemetry by
- @gsquared94 in
- [#18590](https://github.com/google-gemini/gemini-cli/pull/18590)
-- feat: better error messages by @gsquared94 in
- [#20577](https://github.com/google-gemini/gemini-cli/pull/20577)
-- fix(ui): persist expansion in AskUser dialog when navigating options by @jerop
- in [#20559](https://github.com/google-gemini/gemini-cli/pull/20559)
-- fix(cli): prevent sub-agent tool calls from leaking into UI by @abhipatel12 in
- [#20580](https://github.com/google-gemini/gemini-cli/pull/20580)
-- fix(cli): Shell autocomplete polish by @jacob314 in
- [#20411](https://github.com/google-gemini/gemini-cli/pull/20411)
-- Changelog for v0.31.0-preview.1 by @gemini-cli-robot in
- [#20590](https://github.com/google-gemini/gemini-cli/pull/20590)
-- Add slash command for promoting behavioral evals to CI blocking by @gundermanc
- in [#20575](https://github.com/google-gemini/gemini-cli/pull/20575)
-- Changelog for v0.30.1 by @gemini-cli-robot in
- [#20589](https://github.com/google-gemini/gemini-cli/pull/20589)
-- Add low/full CLI error verbosity mode for cleaner UI by @LyalinDotCom in
- [#20399](https://github.com/google-gemini/gemini-cli/pull/20399)
-- Disable Gemini PR reviews on draft PRs. by @gundermanc in
- [#20362](https://github.com/google-gemini/gemini-cli/pull/20362)
-- Docs: FAQ update by @jkcinouye in
- [#20585](https://github.com/google-gemini/gemini-cli/pull/20585)
-- fix(core): reduce intrusive MCP errors and deduplicate diagnostics by
- @spencer426 in
- [#20232](https://github.com/google-gemini/gemini-cli/pull/20232)
-- docs: fix spelling typos in installation guide by @campox747 in
- [#20579](https://github.com/google-gemini/gemini-cli/pull/20579)
-- Promote stable tests to CI blocking. by @gundermanc in
- [#20581](https://github.com/google-gemini/gemini-cli/pull/20581)
-- feat(core): enable contiguous parallel admission for Kind.Agent tools by
- @abhipatel12 in
- [#20583](https://github.com/google-gemini/gemini-cli/pull/20583)
-- Enforce import/no-duplicates as error by @Nixxx19 in
- [#19797](https://github.com/google-gemini/gemini-cli/pull/19797)
-- fix: merge duplicate imports in sdk and test-utils packages (1/4) by @Nixxx19
- in [#19777](https://github.com/google-gemini/gemini-cli/pull/19777)
-- fix: merge duplicate imports in a2a-server package (2/4) by @Nixxx19 in
- [#19781](https://github.com/google-gemini/gemini-cli/pull/19781)
+ [#20510](https://github.com/google-gemini/gemini-cli/pull/20510)
+* feat(core): centralize read_file limits and update gemini-3 description by
+ @aishaneeshah in
+ [#20619](https://github.com/google-gemini/gemini-cli/pull/20619)
+* Do not block CI on evals by @gundermanc in
+ [#20870](https://github.com/google-gemini/gemini-cli/pull/20870)
+* document node limitation for shift+tab by @scidomino in
+ [#20877](https://github.com/google-gemini/gemini-cli/pull/20877)
+* Add install as an option when extension is selected. by @DavidAPierce in
+ [#20358](https://github.com/google-gemini/gemini-cli/pull/20358)
+* Update CODEOWNERS for README.md reviewers by @g-samroberts in
+ [#20860](https://github.com/google-gemini/gemini-cli/pull/20860)
+* feat(core): truncate large MCP tool output by @SandyTao520 in
+ [#19365](https://github.com/google-gemini/gemini-cli/pull/19365)
+* Subagent activity UX. by @gundermanc in
+ [#17570](https://github.com/google-gemini/gemini-cli/pull/17570)
+* style(cli) : Dialog pattern for /hooks Command by @AbdulTawabJuly in
+ [#17930](https://github.com/google-gemini/gemini-cli/pull/17930)
+* feat: redesign header to be compact with ASCII icon by @keithguerin in
+ [#18713](https://github.com/google-gemini/gemini-cli/pull/18713)
+* fix(core): ensure subagents use qualified MCP tool names by @abhipatel12 in
+ [#20801](https://github.com/google-gemini/gemini-cli/pull/20801)
+* feat(core): support authenticated A2A agent card discovery by @SandyTao520 in
+ [#20622](https://github.com/google-gemini/gemini-cli/pull/20622)
+* refactor(cli): fully remove React anti patterns, improve type safety and fix
+ UX oversights in SettingsDialog.tsx by @psinha40898 in
+ [#18963](https://github.com/google-gemini/gemini-cli/pull/18963)
+* Adding MCPOAuthProvider implementing the MCPSDK OAuthClientProvider by
+ @Nayana-Parameswarappa in
+ [#20121](https://github.com/google-gemini/gemini-cli/pull/20121)
+* feat(core): add tool name validation in TOML policy files by @allenhutchison
+ in [#19281](https://github.com/google-gemini/gemini-cli/pull/19281)
+* docs: fix broken markdown links in main README.md by @Hamdanbinhashim in
+ [#20300](https://github.com/google-gemini/gemini-cli/pull/20300)
+* refactor(core): replace manual syncPlanModeTools with declarative policy rules
+ by @jerop in [#20596](https://github.com/google-gemini/gemini-cli/pull/20596)
+* fix(core): increase default headers timeout to 5 minutes by @gundermanc in
+ [#20890](https://github.com/google-gemini/gemini-cli/pull/20890)
+* feat(admin): enable 30 day default retention for chat history & remove warning
+ by @skeshive in
+ [#20853](https://github.com/google-gemini/gemini-cli/pull/20853)
+* feat(plan): support annotating plans with feedback for iteration by @Adib234
+ in [#20876](https://github.com/google-gemini/gemini-cli/pull/20876)
+* Add some dos and don'ts to behavioral evals README. by @gundermanc in
+ [#20629](https://github.com/google-gemini/gemini-cli/pull/20629)
+* fix(core): skip telemetry logging for AbortError exceptions by @yunaseoul in
+ [#19477](https://github.com/google-gemini/gemini-cli/pull/19477)
+* fix(core): restrict "System: Please continue" invalid stream retry to Gemini 2
+ models by @SandyTao520 in
+ [#20897](https://github.com/google-gemini/gemini-cli/pull/20897)
+* ci(evals): only run evals in CI if prompts or tools changed by @gundermanc in
+ [#20898](https://github.com/google-gemini/gemini-cli/pull/20898)
+* Build binary by @aswinashok44 in
+ [#18933](https://github.com/google-gemini/gemini-cli/pull/18933)
+* Code review fixes as a pr by @jacob314 in
+ [#20612](https://github.com/google-gemini/gemini-cli/pull/20612)
+* fix(ci): handle empty APP_ID in stale PR closer by @bdmorgan in
+ [#20919](https://github.com/google-gemini/gemini-cli/pull/20919)
+* feat(cli): invert context window display to show usage by @keithguerin in
+ [#20071](https://github.com/google-gemini/gemini-cli/pull/20071)
+* fix(plan): clean up session directories and plans on deletion by @jerop in
+ [#20914](https://github.com/google-gemini/gemini-cli/pull/20914)
+* fix(core): enforce optionality for API response fields in code_assist by
+ @sehoon38 in [#20714](https://github.com/google-gemini/gemini-cli/pull/20714)
+* feat(extensions): add support for plan directory in extension manifest by
+ @mahimashanware in
+ [#20354](https://github.com/google-gemini/gemini-cli/pull/20354)
+* feat(plan): enable built-in research subagents in plan mode by @Adib234 in
+ [#20972](https://github.com/google-gemini/gemini-cli/pull/20972)
+* feat(agents): directly indicate auth required state by @adamfweidman in
+ [#20986](https://github.com/google-gemini/gemini-cli/pull/20986)
+* fix(cli): wait for background auto-update before relaunching by @scidomino in
+ [#20904](https://github.com/google-gemini/gemini-cli/pull/20904)
+* fix: pre-load @scripts/copy_files.js references from external editor prompts
+ by @kartikangiras in
+ [#20963](https://github.com/google-gemini/gemini-cli/pull/20963)
+* feat(evals): add behavioral evals for ask_user tool by @Adib234 in
+ [#20620](https://github.com/google-gemini/gemini-cli/pull/20620)
+* refactor common settings logic for skills,agents by @ishaanxgupta in
+ [#17490](https://github.com/google-gemini/gemini-cli/pull/17490)
+* Update docs-writer skill with new resource by @g-samroberts in
+ [#20917](https://github.com/google-gemini/gemini-cli/pull/20917)
+* fix(cli): pin clipboardy to ~5.2.x by @scidomino in
+ [#21009](https://github.com/google-gemini/gemini-cli/pull/21009)
+* feat: Implement slash command handling in ACP for
+ `/memory`,`/init`,`/extensions` and `/restore` by @sripasg in
+ [#20528](https://github.com/google-gemini/gemini-cli/pull/20528)
+* Docs/add hooks reference by @AadithyaAle in
+ [#20961](https://github.com/google-gemini/gemini-cli/pull/20961)
+* feat(plan): add copy subcommand to plan (#20491) by @ruomengz in
+ [#20988](https://github.com/google-gemini/gemini-cli/pull/20988)
+* fix(core): sanitize and length-check MCP tool qualified names by @abhipatel12
+ in [#20987](https://github.com/google-gemini/gemini-cli/pull/20987)
+* Format the quota/limit style guide. by @g-samroberts in
+ [#21017](https://github.com/google-gemini/gemini-cli/pull/21017)
+* fix(core): send shell output to model on cancel by @devr0306 in
+ [#20501](https://github.com/google-gemini/gemini-cli/pull/20501)
+* remove hardcoded tiername when missing tier by @sehoon38 in
+ [#21022](https://github.com/google-gemini/gemini-cli/pull/21022)
+* feat(acp): add set models interface by @skeshive in
+ [#20991](https://github.com/google-gemini/gemini-cli/pull/20991)
**Full Changelog**:
-https://github.com/google-gemini/gemini-cli/compare/v0.31.0-preview.3...v0.32.0-preview.0
+https://github.com/google-gemini/gemini-cli/compare/v0.32.0-preview.0...v0.33.0-preview.1
diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md
index 44d8ba9467..39c0f7c5c1 100644
--- a/docs/cli/enterprise.md
+++ b/docs/cli/enterprise.md
@@ -244,7 +244,7 @@ gemini
You can significantly enhance security by controlling which tools the Gemini
model can use. This is achieved through the `tools.core` setting and the
[Policy Engine](../reference/policy-engine.md). For a list of available tools,
-see the [Tools documentation](../tools/index.md).
+see the [Tools reference](../reference/tools.md).
### Allowlisting with `coreTools`
@@ -308,8 +308,8 @@ unintended tool execution.
## Managing custom tools (MCP servers)
If your organization uses custom tools via
-[Model-Context Protocol (MCP) servers](../reference/tools-api.md), it is crucial
-to understand how server configurations are managed to apply security policies
+[Model-Context Protocol (MCP) servers](../tools/mcp-server.md), it is crucial to
+understand how server configurations are managed to apply security policies
effectively.
### How MCP server configurations are merged
diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md
index a8511d9c42..41f8ededcd 100644
--- a/docs/cli/plan-mode.md
+++ b/docs/cli/plan-mode.md
@@ -1,7 +1,7 @@
# Plan Mode (experimental)
Plan Mode is a read-only environment for architecting robust solutions before
-implementation. It allows you to:
+implementation. With Plan Mode, you can:
- **Research:** Explore the project in a read-only state to prevent accidental
changes.
@@ -12,62 +12,48 @@ implementation. It allows you to:
> feedback is invaluable as we refine this feature. If you have ideas,
> suggestions, or encounter issues:
>
-> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on
-> GitHub.
+> - [Open an issue] on GitHub.
> - Use the **/bug** command within Gemini CLI to file an issue.
-- [Enabling Plan Mode](#enabling-plan-mode)
-- [How to use Plan Mode](#how-to-use-plan-mode)
- - [Entering Plan Mode](#entering-plan-mode)
- - [Planning Workflow](#planning-workflow)
- - [Exiting Plan Mode](#exiting-plan-mode)
-- [Tool Restrictions](#tool-restrictions)
- - [Customizing Planning with Skills](#customizing-planning-with-skills)
- - [Customizing Policies](#customizing-policies)
- - [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode)
- - [Example: Enable custom subagents in Plan Mode](#example-enable-custom-subagents-in-plan-mode)
- - [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
-- [Automatic Model Routing](#automatic-model-routing)
-- [Cleanup](#cleanup)
+## How to enable Plan Mode
-## Enabling Plan Mode
+Enable Plan Mode in **Settings** or by editing your configuration file.
-To use Plan Mode, enable it via **/settings** (search for **Plan**) or add the
-following to your `settings.json`:
-
-```json
-{
- "experimental": {
- "plan": true
- }
-}
-```
-
-## How to use Plan Mode
-
-### Entering Plan Mode
-
-You can configure Gemini CLI to start in Plan Mode by default or enter it
-manually during a session.
-
-- **Configuration:** Configure Gemini CLI to start directly in Plan Mode by
- default:
- 1. Type `/settings` in the CLI.
- 2. Search for **Default Approval Mode**.
- 3. Set the value to **Plan**.
-
- Alternatively, use the `gemini --approval-mode=plan` CLI flag or manually
- update:
+- **Settings:** Use the `/settings` command and set **Plan** to `true`.
+- **Configuration:** Add the following to your `settings.json`:
```json
{
- "general": {
- "defaultApprovalMode": "plan"
+ "experimental": {
+ "plan": true
}
}
```
-- **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
+## How to enter Plan Mode
+
+Plan Mode integrates seamlessly into your workflow, letting you switch between
+planning and execution as needed.
+
+You can either configure Gemini CLI to start in Plan Mode by default or enter
+Plan Mode manually during a session.
+
+### Launch in Plan Mode
+
+To start Gemini CLI directly in Plan Mode by default:
+
+1. Use the `/settings` command.
+2. Set **Default Approval Mode** to `Plan`.
+
+To launch Gemini CLI in Plan Mode once:
+
+1. Use `gemini --approval-mode=plan` when launching Gemini CLI.
+
+### Enter Plan Mode manually
+
+To start Plan Mode while using Gemini CLI:
+
+- **Keyboard shortcut:** Press `Shift+Tab` to cycle through approval modes
(`Default` -> `Auto-Edit` -> `Plan`).
> **Note:** Plan Mode is automatically removed from the rotation when Gemini
@@ -75,56 +61,54 @@ manually during a session.
- **Command:** Type `/plan` in the input box.
-- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI then
+- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
calls the [`enter_plan_mode`] tool to switch modes.
> **Note:** This tool is not available when Gemini CLI is in [YOLO mode].
-### Planning Workflow
+## How to use Plan Mode
-Plan Mode uses an adaptive planning workflow where the research depth, plan
-structure, and consultation level are proportional to the task's complexity:
+Plan Mode lets you collaborate with Gemini CLI to design a solution before
+Gemini CLI takes action.
-1. **Explore & Analyze:** Analyze requirements and use read-only tools to map
- affected modules and identify dependencies.
-2. **Consult:** The depth of consultation is proportional to the task's
- complexity:
- - **Simple Tasks:** Proceed directly to drafting.
- - **Standard Tasks:** Present a summary of viable approaches via
- [`ask_user`] for selection.
- - **Complex Tasks:** Present detailed trade-offs for at least two viable
- approaches via [`ask_user`] and obtain approval before drafting.
-3. **Draft:** Write a detailed implementation plan to the
- [plans directory](#custom-plan-directory-and-policies). The plan's structure
- adapts to the task:
- - **Simple Tasks:** Focused on specific **Changes** and **Verification**
- steps.
- - **Standard Tasks:** Includes an **Objective**, **Key Files & Context**,
- **Implementation Steps**, and **Verification & Testing**.
- - **Complex Tasks:** Comprehensive plans including **Background &
- Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives
- Considered**, a phased **Implementation Plan**, **Verification**, and
- **Migration & Rollback** strategies.
-4. **Review & Approval:** Use the [`exit_plan_mode`] tool to present the plan
- and formally request approval.
- - **Approve:** Exit Plan Mode and start implementation.
- - **Iterate:** Provide feedback to refine the plan.
- - **Refine manually:** Press **Ctrl + X** to open the plan file in your
- [preferred external editor]. This allows you to manually refine the plan
- steps before approval. If you make any changes and save the file, the CLI
- will automatically send the updated plan back to the agent for review and
- iteration.
+1. **Provide a goal:** Start by describing what you want to achieve. Gemini CLI
+ will then enter Plan Mode (if it's not already) to research the task.
+2. **Review research and provide input:** As Gemini CLI analyzes your codebase,
+ it may ask you questions or present different implementation options using
+ [`ask_user`]. Provide your preferences to help guide the design.
+3. **Review the plan:** Once Gemini CLI has a proposed strategy, it creates a
+ detailed implementation plan as a Markdown file in your plans directory. You
+ can open and read this file to understand the proposed changes.
+4. **Approve or iterate:** Gemini CLI will present the finalized plan for your
+ approval.
+ - **Approve:** If you're satisfied with the plan, approve it to start the
+ implementation immediately: **Yes, automatically accept edits** or **Yes,
+ manually accept edits**.
+ - **Iterate:** If the plan needs adjustments, provide feedback. Gemini CLI
+ will refine the strategy and update the plan.
+ - **Cancel:** You can cancel your plan with `Esc`.
For more complex or specialized planning tasks, you can
-[customize the planning workflow with skills](#customizing-planning-with-skills).
+[customize the planning workflow with skills](#custom-planning-with-skills).
-### Exiting Plan Mode
+## How to exit Plan Mode
-To exit Plan Mode, you can:
+You can exit Plan Mode at any time, whether you have finalized a plan or want to
+switch back to another mode.
-- **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
+- **Approve a plan:** When Gemini CLI presents a finalized plan, approving it
+ automatically exits Plan Mode and starts the implementation.
+- **Keyboard shortcut:** Press `Shift+Tab` to cycle to the desired mode.
+- **Natural language:** Ask Gemini CLI to "exit plan mode" or "stop planning."
-- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the
- finalized plan for your approval.
+## Customization and best practices
+
+Plan Mode is secure by default, but you can adapt it to fit your specific
+workflows. You can customize how Gemini CLI plans by using skills, adjusting
+safety policies, or changing where plans are stored.
+
+## Commands
+
+- **`/plan copy`**: Copy the currently approved plan to your clipboard.
## Tool Restrictions
@@ -136,7 +120,7 @@ These are the only allowed tools:
- **Search:** [`grep_search`], [`google_web_search`]
- **Research Subagents:** [`codebase_investigator`], [`cli_help`]
- **Interaction:** [`ask_user`]
-- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
+- **MCP tools (Read):** Read-only [MCP tools] (for example, `github_read_issue`,
`postgres_read_schema`) are allowed.
- **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md`
files in the `~/.gemini/tmp///plans/` directory or your
@@ -145,12 +129,12 @@ These are the only allowed tools:
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
resources in a read-only manner)
-### Customizing Planning with Skills
+### Custom planning with skills
-You can use [Agent Skills](./skills.md) to customize how Gemini CLI approaches
-planning for specific types of tasks. When a skill is activated during Plan
-Mode, its specialized instructions and procedural workflows will guide the
-research, design and planning phases.
+You can use [Agent Skills] to customize how Gemini CLI approaches planning for
+specific types of tasks. When a skill is activated during Plan Mode, its
+specialized instructions and procedural workflows will guide the research,
+design, and planning phases.
For example:
@@ -165,7 +149,7 @@ To use a skill in Plan Mode, you can explicitly ask Gemini CLI to "use the
`` skill to plan..." or Gemini CLI may autonomously activate it
based on the task description.
-### Customizing Policies
+### Custom policies
Plan Mode's default tool restrictions are managed by the [policy engine] and
defined in the built-in [`plan.toml`] file. The built-in policy (Tier 1)
@@ -189,10 +173,13 @@ priority = 100
modes = ["plan"]
```
+For more information on how the policy engine works, see the [policy engine]
+docs.
+
#### Example: Allow git commands in Plan Mode
-This rule allows you to check the repository status and see changes while in
-Plan Mode.
+This rule lets you check the repository status and see changes while in Plan
+Mode.
`~/.gemini/policies/git-research.toml`
@@ -224,10 +211,7 @@ modes = ["plan"]
Tell Gemini CLI it can use these tools in your prompt, for example: _"You can
check ongoing changes in git."_
-For more information on how the policy engine works, see the [policy engine]
-docs.
-
-### Custom Plan Directory and Policies
+### Custom plan directory and policies
By default, planning artifacts are stored in a managed temporary directory
outside your project: `~/.gemini/tmp///plans/`.
@@ -267,10 +251,59 @@ modes = ["plan"]
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
```
+## Planning workflows
+
+Plan Mode provides building blocks for structured research and design. These are
+implemented as [extensions] using core planning tools like [`enter_plan_mode`],
+[`exit_plan_mode`], and [`ask_user`].
+
+### Built-in planning workflow
+
+The built-in planner uses an adaptive workflow to analyze your project, consult
+you on trade-offs via [`ask_user`], and draft a plan for your approval.
+
+### Custom planning workflows
+
+You can install or create specialized planners to suit your workflow.
+
+#### Conductor
+
+[Conductor] is designed for spec-driven development. It organizes work into
+"tracks" and stores persistent artifacts in your project's `conductor/`
+directory:
+
+- **Automate transitions:** Switches to read-only mode via [`enter_plan_mode`].
+- **Streamline decisions:** Uses [`ask_user`] for architectural choices.
+- **Maintain project context:** Stores artifacts in the project directory using
+ [custom plan directory and policies](#custom-plan-directory-and-policies).
+- **Handoff execution:** Transitions to implementation via [`exit_plan_mode`].
+
+#### Build your own
+
+Since Plan Mode is built on modular building blocks, you can develop your own
+custom planning workflow as an [extensions]. By leveraging core tools and
+[custom policies](#custom-policies), you can define how Gemini CLI researches
+and stores plans for your specific domain.
+
+To build a custom planning workflow, you can use:
+
+- **Tool usage:** Use core tools like [`enter_plan_mode`], [`ask_user`], and
+ [`exit_plan_mode`] to manage the research and design process.
+- **Customization:** Set your own storage locations and policy rules using
+ [custom plan directories](#custom-plan-directory-and-policies) and
+ [custom policies](#custom-policies).
+
+> **Note:** Use [Conductor] as a reference when building your own custom
+> planning workflow.
+
+By using Plan Mode as its execution environment, your custom methodology can
+enforce read-only safety during the design phase while benefiting from
+high-reasoning model routing.
+
## Automatic Model Routing
-When using an [**auto model**], Gemini CLI automatically optimizes [**model
-routing**] based on the current phase of your task:
+When using an [auto model], Gemini CLI automatically optimizes [model routing]
+based on the current phase of your task:
1. **Planning Phase:** While in Plan Mode, the CLI routes requests to a
high-reasoning **Pro** model to ensure robust architectural decisions and
@@ -321,8 +354,8 @@ those files are not automatically deleted and must be managed manually.
[MCP tools]: /docs/tools/mcp-server.md
[`save_memory`]: /docs/tools/memory.md
[`activate_skill`]: /docs/cli/skills.md
-[`codebase_investigator`]: /docs/core/subagents.md#codebase_investigator
-[`cli_help`]: /docs/core/subagents.md#cli_help
+[`codebase_investigator`]: /docs/core/subagents.md#codebase-investigator
+[`cli_help`]: /docs/core/subagents.md#cli-help-agent
[subagents]: /docs/core/subagents.md
[custom subagents]: /docs/core/subagents.md#creating-custom-subagents
[policy engine]: /docs/reference/policy-engine.md
@@ -332,7 +365,11 @@ those files are not automatically deleted and must be managed manually.
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
[`plan.toml`]:
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
-[auto model]: /docs/reference/configuration.md#model-settings
+[auto model]: /docs/reference/configuration.md#model
[model routing]: /docs/cli/telemetry.md#model-routing
[preferred external editor]: /docs/reference/configuration.md#general
[session retention]: /docs/cli/session-management.md#session-retention
+[extensions]: /docs/extensions/
+[Conductor]: https://github.com/gemini-cli-extensions/conductor
+[open an issue]: https://github.com/google-gemini/gemini-cli/issues
+[Agent Skills]: /docs/cli/skills.md
diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md
index 1d075989af..ec7e88f624 100644
--- a/docs/cli/sandbox.md
+++ b/docs/cli/sandbox.md
@@ -50,6 +50,74 @@ Cross-platform sandboxing with complete process isolation.
**Note**: Requires building the sandbox image locally or using a published image
from your organization's registry.
+### 3. gVisor / runsc (Linux only)
+
+Strongest isolation available: runs containers inside a user-space kernel via
+[gVisor](https://github.com/google/gvisor). gVisor intercepts all container
+system calls and handles them in a sandboxed kernel written in Go, providing a
+strong security barrier between AI operations and the host OS.
+
+**Prerequisites:**
+
+- Linux (gVisor supports Linux only)
+- Docker installed and running
+- gVisor/runsc runtime configured
+
+When you set `sandbox: "runsc"`, Gemini CLI runs
+`docker run --runtime=runsc ...` to execute containers with gVisor isolation.
+runsc is not auto-detected; you must specify it explicitly (e.g.
+`GEMINI_SANDBOX=runsc` or `sandbox: "runsc"`).
+
+To set up runsc:
+
+1. Install the runsc binary.
+2. Configure the Docker daemon to use the runsc runtime.
+3. Verify the installation.
+
+### 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
+services. This is ideal for tools that don't work in standard Docker containers,
+such as Snapcraft and Rockcraft.
+
+**Prerequisites**:
+
+- Linux only.
+- LXC/LXD must be installed (`snap install lxd` or `apt install lxd`).
+- A container must be created and running before starting Gemini CLI. Gemini
+ does **not** create the container automatically.
+
+**Quick setup**:
+
+```bash
+# Initialize LXD (first time only)
+lxd init --auto
+
+# Create and start an Ubuntu container
+lxc launch ubuntu:24.04 gemini-sandbox
+
+# Enable LXC sandboxing
+export GEMINI_SANDBOX=lxc
+gemini -p "build the project"
+```
+
+**Custom container name**:
+
+```bash
+export GEMINI_SANDBOX=lxc
+export GEMINI_SANDBOX_IMAGE=my-snapcraft-container
+gemini -p "build the snap"
+```
+
+**Limitations**:
+
+- Linux only (LXC is not available on macOS or Windows).
+- The container must already exist and be running.
+- The workspace directory is bind-mounted into the container at the same
+ absolute path — the path must be writable inside the container.
+- Used with tools like Snapcraft or Rockcraft that require a full system.
+
## Quickstart
```bash
@@ -88,7 +156,8 @@ gemini -p "run the test suite"
### Enable sandboxing (in order of precedence)
1. **Command flag**: `-s` or `--sandbox`
-2. **Environment variable**: `GEMINI_SANDBOX=true|docker|podman|sandbox-exec`
+2. **Environment variable**:
+ `GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc`
3. **Settings file**: `"sandbox": true` in the `tools` object of your
`settings.json` file (e.g., `{"tools": {"sandbox": true}}`).
diff --git a/docs/cli/settings.md b/docs/cli/settings.md
index 37508fc04e..d2680d65ad 100644
--- a/docs/cli/settings.md
+++ b/docs/cli/settings.md
@@ -57,7 +57,7 @@ they appear in the UI.
| 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 path in the footer. | `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` |
diff --git a/docs/core/index.md b/docs/core/index.md
index 53aa647dc2..adf186116f 100644
--- a/docs/core/index.md
+++ b/docs/core/index.md
@@ -9,8 +9,8 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
- **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use
specialized sub-agents for complex tasks.
-- **[Core tools API](../reference/tools-api.md):** Information on how tools are
- defined, registered, and used by the core.
+- **[Core tools reference](../reference/tools.md):** Information on how tools
+ are defined, registered, and used by the core.
- **[Memory Import Processor](../reference/memport.md):** Documentation for the
modular GEMINI.md import feature using @file.md syntax.
- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for
diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md
index 61d4a5c040..8b8f592335 100644
--- a/docs/get-started/authentication.md
+++ b/docs/get-started/authentication.md
@@ -4,6 +4,10 @@ To use Gemini CLI, you'll need to authenticate with Google. This guide helps you
quickly find the best way to sign in based on your account type and how you're
using the CLI.
+> **Note:** Looking for a high-level comparison of all available subscriptions?
+> To compare features and find the right quota for your needs, see our
+> [Plans page](/plans/).
+
For most users, we recommend starting Gemini CLI and logging in with your
personal Google account.
diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md
index a5eed9ab1d..bc83d990d5 100644
--- a/docs/get-started/gemini-3.md
+++ b/docs/get-started/gemini-3.md
@@ -39,6 +39,10 @@ When you encounter that limit, you’ll be given the option to switch to Gemini
2.5 Pro, upgrade for higher limits, or stop. You’ll also be told when your usage
limit resets and Gemini 3 Pro can be used again.
+> **Note:** Looking to upgrade for higher limits? To compare subscription
+> options and find the right quota for your needs, see our
+> [Plans page](/plans/).
+
Similarly, when you reach your daily usage limit for Gemini 2.5 Pro, you’ll see
a message prompting fallback to Gemini 2.5 Flash.
diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md
index 9b7226ac05..445035b1aa 100644
--- a/docs/hooks/reference.md
+++ b/docs/hooks/reference.md
@@ -82,8 +82,8 @@ For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is
compared against the name of the tool being executed.
- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`,
- `run_shell_command`). See the [Tools Reference](/docs/tools) for a full list
- of available tool names.
+ `run_shell_command`). See the [Tools Reference](/docs/reference/tools) for a
+ full list of available tool names.
- **MCP Tools**: Tools from MCP servers follow the naming pattern
`mcp____`.
- **Regex Support**: Matchers support regular expressions (e.g.,
diff --git a/docs/index.md b/docs/index.md
index 3ccaf3b797..af1915bb8f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -108,8 +108,8 @@ Deep technical documentation and API specifications.
processes memory from various sources.
- **[Policy engine](./reference/policy-engine.md):** Fine-grained execution
control.
-- **[Tools API](./reference/tools-api.md):** The API for defining and using
- tools.
+- **[Tools reference](./reference/tools.md):** Information on how tools are
+ defined, registered, and used.
## Resources
diff --git a/docs/issue-and-pr-automation.md b/docs/issue-and-pr-automation.md
index 27185de11c..6c023b651b 100644
--- a/docs/issue-and-pr-automation.md
+++ b/docs/issue-and-pr-automation.md
@@ -113,7 +113,45 @@ process.
ensure every issue is eventually categorized, even if the initial triage
fails.
-### 5. Release automation
+### 5. Automatic unassignment of inactive contributors: `Unassign Inactive Issue Assignees`
+
+To keep the list of open `help wanted` issues accessible to all contributors,
+this workflow automatically removes **external contributors** who have not
+opened a linked pull request within **7 days** of being assigned. Maintainers,
+org members, and repo collaborators with write access or above are always exempt
+and will never be auto-unassigned.
+
+- **Workflow File**: `.github/workflows/unassign-inactive-assignees.yml`
+- **When it runs**: Every day at 09:00 UTC, and can be triggered manually with
+ an optional `dry_run` mode.
+- **What it does**:
+ 1. Finds every open issue labeled `help wanted` that has at least one
+ assignee.
+ 2. Identifies privileged users (team members, repo collaborators with write+
+ access, maintainers) and skips them entirely.
+ 3. For each remaining (external) assignee it reads the issue's timeline to
+ determine:
+ - The exact date they were assigned (using `assigned` timeline events).
+ - Whether they have opened a PR that is already linked/cross-referenced to
+ the issue.
+ 4. Each cross-referenced PR is fetched to verify it is **ready for review**:
+ open and non-draft, or already merged. Draft PRs do not count.
+ 5. If an assignee has been assigned for **more than 7 days** and no qualifying
+ PR is found, they are automatically unassigned and a comment is posted
+ explaining the reason and how to re-claim the issue.
+ 6. Assignees who have a non-draft, open or merged PR linked to the issue are
+ **never** unassigned by this workflow.
+- **What you should do**:
+ - **Open a real PR, not a draft**: Within 7 days of being assigned, open a PR
+ that is ready for review and include `Fixes #` in the
+ description. Draft PRs do not satisfy the requirement and will not prevent
+ auto-unassignment.
+ - **Re-assign if unassigned by mistake**: Comment `/assign` on the issue to
+ assign yourself again.
+ - **Unassign yourself** if you can no longer work on the issue by commenting
+ `/unassign`, so other contributors can pick it up right away.
+
+### 6. Release automation
This workflow handles the process of packaging and publishing new versions of
the Gemini CLI.
diff --git a/docs/redirects.json b/docs/redirects.json
index 5183d0d476..598f42cccf 100644
--- a/docs/redirects.json
+++ b/docs/redirects.json
@@ -8,7 +8,8 @@
"/docs/core/concepts": "/docs",
"/docs/core/memport": "/docs/reference/memport",
"/docs/core/policy-engine": "/docs/reference/policy-engine",
- "/docs/core/tools-api": "/docs/reference/tools-api",
+ "/docs/core/tools-api": "/docs/reference/tools",
+ "/docs/reference/tools-api": "/docs/reference/tools",
"/docs/faq": "/docs/resources/faq",
"/docs/get-started/configuration": "/docs/reference/configuration",
"/docs/get-started/configuration-v1": "/docs/reference/configuration",
diff --git a/docs/reference/commands.md b/docs/reference/commands.md
index ceb064a9bf..bb251bea09 100644
--- a/docs/reference/commands.md
+++ b/docs/reference/commands.md
@@ -270,6 +270,9 @@ Slash commands provide meta-level control over the CLI itself.
one has been generated.
- **Note:** This feature requires the `experimental.plan` setting to be
enabled in your configuration.
+- **Sub-commands:**
+ - **`copy`**:
+ - **Description:** Copy the currently approved plan to your clipboard.
### `/policies`
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 49954da8c6..1f1299072b 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -250,8 +250,18 @@ their corresponding top-level category object in your `settings.json` file.
input.
- **Default:** `false`
+- **`ui.footer.items`** (array):
+ - **Description:** List of item IDs to display in the footer. Rendered in
+ order
+ - **Default:** `undefined`
+
+- **`ui.footer.showLabels`** (boolean):
+ - **Description:** Display a second line above the footer items with
+ descriptive headers (e.g., /model).
+ - **Default:** `true`
+
- **`ui.footer.hideCWD`** (boolean):
- - **Description:** Hide the current working directory path in the footer.
+ - **Description:** Hide the current working directory in the footer.
- **Default:** `false`
- **`ui.footer.hideSandboxStatus`** (boolean):
@@ -747,7 +757,8 @@ their corresponding top-level category object in your `settings.json` file.
- **`tools.sandbox`** (boolean | string):
- **Description:** Sandbox execution environment. Set to a boolean to enable
- or disable the sandbox, or provide a string path to a sandbox profile.
+ or disable the sandbox, provide a string path to a sandbox profile, or
+ specify an explicit sandbox command (e.g., "docker", "podman", "lxc").
- **Default:** `undefined`
- **Requires restart:** Yes
@@ -1014,6 +1025,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
+- **`experimental.taskTracker`** (boolean):
+ - **Description:** Enable task tracker tools.
+ - **Default:** `false`
+ - **Requires restart:** Yes
+
- **`experimental.modelSteering`** (boolean):
- **Description:** Enable model steering (user hints) to guide the model
during tool execution.
diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md
index e5691c43ee..5ad55a2c74 100644
--- a/docs/reference/keyboard-shortcuts.md
+++ b/docs/reference/keyboard-shortcuts.md
@@ -19,12 +19,12 @@ available combinations.
| Action | Keys |
| ------------------------------------------- | ------------------------------------------------------------ |
-| Move the cursor to the start of the line. | `Ctrl + A` `Home (no Shift, Ctrl)` |
-| Move the cursor to the end of the line. | `Ctrl + E` `End (no Shift, Ctrl)` |
-| Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` |
-| Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` |
-| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)` |
-| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)` `Ctrl + F` |
+| Move the cursor to the start of the line. | `Ctrl + A` `Home` |
+| Move the cursor to the end of the line. | `Ctrl + E` `End` |
+| Move the cursor up one line. | `Up Arrow` |
+| Move the cursor down one line. | `Down Arrow` |
+| Move the cursor one character to the left. | `Left Arrow` |
+| Move the cursor one character to the right. | `Right Arrow` `Ctrl + F` |
| Move the cursor one word to the left. | `Ctrl + Left Arrow` `Alt + Left Arrow` `Alt + B` |
| Move the cursor one word to the right. | `Ctrl + Right Arrow` `Alt + Right Arrow` `Alt + F` |
@@ -39,7 +39,7 @@ available combinations.
| Delete the next word. | `Ctrl + Delete` `Alt + Delete` `Alt + D` |
| Delete the character to the left. | `Backspace` `Ctrl + H` |
| Delete the character to the right. | `Delete` `Ctrl + D` |
-| Undo the most recent text edit. | `Cmd + Z (no Shift)` `Alt + Z (no Shift)` |
+| Undo the most recent text edit. | `Cmd + Z` `Alt + Z` |
| Redo the most recent undone text edit. | `Shift + Ctrl + Z` `Shift + Cmd + Z` `Shift + Alt + Z` |
#### Scrolling
@@ -55,72 +55,72 @@ available combinations.
#### History & Search
-| Action | Keys |
-| -------------------------------------------- | --------------------- |
-| Show the previous entry in history. | `Ctrl + P (no Shift)` |
-| Show the next entry in history. | `Ctrl + N (no Shift)` |
-| Start reverse search through history. | `Ctrl + R` |
-| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
-| Accept a suggestion while reverse searching. | `Tab (no Shift)` |
-| Browse and rewind previous interactions. | `Double Esc` |
+| Action | Keys |
+| -------------------------------------------- | ------------ |
+| Show the previous entry in history. | `Ctrl + P` |
+| Show the next entry in history. | `Ctrl + N` |
+| Start reverse search through history. | `Ctrl + R` |
+| Submit the selected reverse-search match. | `Enter` |
+| Accept a suggestion while reverse searching. | `Tab` |
+| Browse and rewind previous interactions. | `Double Esc` |
#### Navigation
-| Action | Keys |
-| -------------------------------------------------- | ------------------------------------------- |
-| Move selection up in lists. | `Up Arrow (no Shift)` |
-| Move selection down in lists. | `Down Arrow (no Shift)` |
-| Move up within dialog options. | `Up Arrow (no Shift)` `K (no Shift)` |
-| Move down within dialog options. | `Down Arrow (no Shift)` `J (no Shift)` |
-| Move to the next item or question in a dialog. | `Tab (no Shift)` |
-| Move to the previous item or question in a dialog. | `Shift + Tab` |
+| Action | Keys |
+| -------------------------------------------------- | --------------------- |
+| Move selection up in lists. | `Up Arrow` |
+| Move selection down in lists. | `Down Arrow` |
+| Move up within dialog options. | `Up Arrow` `K` |
+| Move down within dialog options. | `Down Arrow` `J` |
+| Move to the next item or question in a dialog. | `Tab` |
+| Move to the previous item or question in a dialog. | `Shift + Tab` |
#### Suggestions & Completions
-| Action | Keys |
-| --------------------------------------- | -------------------------------------------------- |
-| Accept the inline suggestion. | `Tab (no Shift)` `Enter (no Ctrl)` |
-| Move to the previous completion option. | `Up Arrow (no Shift)` `Ctrl + P (no Shift)` |
-| Move to the next completion option. | `Down Arrow (no Shift)` `Ctrl + N (no Shift)` |
-| Expand an inline suggestion. | `Right Arrow` |
-| Collapse an inline suggestion. | `Left Arrow` |
+| Action | Keys |
+| --------------------------------------- | ---------------------------- |
+| Accept the inline suggestion. | `Tab` `Enter` |
+| Move to the previous completion option. | `Up Arrow` `Ctrl + P` |
+| Move to the next completion option. | `Down Arrow` `Ctrl + N` |
+| Expand an inline suggestion. | `Right Arrow` |
+| Collapse an inline suggestion. | `Left Arrow` |
#### Text Input
| Action | Keys |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
-| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` |
+| Submit the current prompt. | `Enter` |
| Insert a newline without submitting. | `Ctrl + Enter` `Cmd + Enter` `Alt + Enter` `Shift + Enter` `Ctrl + J` |
| Open the current prompt or the plan in an external editor. | `Ctrl + X` |
| Paste from the clipboard. | `Ctrl + V` `Cmd + V` `Alt + V` |
#### App Controls
-| Action | Keys |
-| -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
-| Toggle detailed error information. | `F12` |
-| Toggle the full TODO list. | `Ctrl + T` |
-| Show IDE context details. | `Ctrl + G` |
-| Toggle Markdown rendering. | `Alt + M` |
-| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
-| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
-| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift + Tab` |
-| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl + O` |
-| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` |
-| Toggle current background shell visibility. | `Ctrl + B` |
-| Toggle background shell list. | `Ctrl + L` |
-| Kill the active background shell. | `Ctrl + K` |
-| Confirm selection in background shell list. | `Enter` |
-| Dismiss background shell list. | `Esc` |
-| Move focus from background shell to Gemini. | `Shift + Tab` |
-| Move focus from background shell list to Gemini. | `Tab (no Shift)` |
-| Show warning when trying to move focus away from background shell. | `Tab (no Shift)` |
-| Show warning when trying to move focus away from shell input. | `Tab (no Shift)` |
-| Move focus from Gemini to the active shell. | `Tab (no Shift)` |
-| Move focus from the shell back to Gemini. | `Shift + Tab` |
-| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
-| Restart the application. | `R` |
-| Suspend the CLI and move it to the background. | `Ctrl + Z` |
+| Action | Keys |
+| -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
+| Toggle detailed error information. | `F12` |
+| Toggle the full TODO list. | `Ctrl + T` |
+| Show IDE context details. | `Ctrl + G` |
+| Toggle Markdown rendering. | `Alt + M` |
+| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
+| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
+| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift + Tab` |
+| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl + O` |
+| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` |
+| Toggle current background shell visibility. | `Ctrl + B` |
+| Toggle background shell list. | `Ctrl + L` |
+| Kill the active background shell. | `Ctrl + K` |
+| Confirm selection in background shell list. | `Enter` |
+| Dismiss background shell list. | `Esc` |
+| Move focus from background shell to Gemini. | `Shift + Tab` |
+| Move focus from background shell list to Gemini. | `Tab` |
+| Show warning when trying to move focus away from background shell. | `Tab` |
+| Show warning when trying to move focus away from shell input. | `Tab` |
+| Move focus from Gemini to the active shell. | `Tab` |
+| Move focus from the shell back to Gemini. | `Shift + Tab` |
+| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
+| Restart the application. | `R` `Shift + R` |
+| Suspend the CLI and move it to the background. | `Ctrl + Z` |
@@ -156,7 +156,7 @@ available combinations.
## Limitations
- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal):
- - `shift+enter` is not supported.
+ - `shift+enter` is only supported in version 1.25 and higher.
- `shift+tab`
[is not supported](https://github.com/google-gemini/gemini-cli/issues/20314)
on Node 20 and earlier versions of Node 22.
diff --git a/docs/reference/tools-api.md b/docs/reference/tools-api.md
deleted file mode 100644
index 91fae3f720..0000000000
--- a/docs/reference/tools-api.md
+++ /dev/null
@@ -1,131 +0,0 @@
-# Gemini CLI core: Tools API
-
-The Gemini CLI core (`packages/core`) features a robust system for defining,
-registering, and executing tools. These tools extend the capabilities of the
-Gemini model, allowing it to interact with the local environment, fetch web
-content, and perform various actions beyond simple text generation.
-
-## Core concepts
-
-- **Tool (`tools.ts`):** An interface and base class (`BaseTool`) that defines
- the contract for all tools. Each tool must have:
- - `name`: A unique internal name (used in API calls to Gemini).
- - `displayName`: A user-friendly name.
- - `description`: A clear explanation of what the tool does, which is provided
- to the Gemini model.
- - `parameterSchema`: A JSON schema defining the parameters that the tool
- accepts. This is crucial for the Gemini model to understand how to call the
- tool correctly.
- - `validateToolParams()`: A method to validate incoming parameters.
- - `getDescription()`: A method to provide a human-readable description of what
- the tool will do with specific parameters before execution.
- - `shouldConfirmExecute()`: A method to determine if user confirmation is
- required before execution (e.g., for potentially destructive operations).
- - `execute()`: The core method that performs the tool's action and returns a
- `ToolResult`.
-
-- **`ToolResult` (`tools.ts`):** An interface defining the structure of a tool's
- execution outcome:
- - `llmContent`: The factual content to be included in the history sent back to
- the LLM for context. This can be a simple string or a `PartListUnion` (an
- array of `Part` objects and strings) for rich content.
- - `returnDisplay`: A user-friendly string (often Markdown) or a special object
- (like `FileDiff`) for display in the CLI.
-
-- **Returning rich content:** Tools are not limited to returning simple text.
- The `llmContent` can be a `PartListUnion`, which is an array that can contain
- a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a
- single tool execution to return multiple pieces of rich content.
-
-- **Tool registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible
- for:
- - **Registering tools:** Holding a collection of all available built-in tools
- (e.g., `ReadFileTool`, `ShellTool`).
- - **Discovering tools:** It can also discover tools dynamically:
- - **Command-based discovery:** If `tools.discoveryCommand` is configured in
- settings, this command is executed. It's expected to output JSON
- describing custom tools, which are then registered as `DiscoveredTool`
- instances.
- - **MCP-based discovery:** If `mcp.serverCommand` is configured, the
- registry can connect to a Model Context Protocol (MCP) server to list and
- register tools (`DiscoveredMCPTool`).
- - **Providing schemas:** Exposing the `FunctionDeclaration` schemas of all
- registered tools to the Gemini model, so it knows what tools are available
- and how to use them.
- - **Retrieving tools:** Allowing the core to get a specific tool by name for
- execution.
-
-## Built-in tools
-
-The core comes with a suite of pre-defined tools, typically found in
-`packages/core/src/tools/`. These include:
-
-- **File system tools:**
- - `LSTool` (`ls.ts`): Lists directory contents.
- - `ReadFileTool` (`read-file.ts`): Reads the content of a single file.
- - `WriteFileTool` (`write-file.ts`): Writes content to a file.
- - `GrepTool` (`grep.ts`): Searches for patterns in files.
- - `GlobTool` (`glob.ts`): Finds files matching glob patterns.
- - `EditTool` (`edit.ts`): Performs in-place modifications to files (often
- requiring confirmation).
- - `ReadManyFilesTool` (`read-many-files.ts`): Reads and concatenates content
- from multiple files or glob patterns (used by the `@` command in CLI).
-- **Execution tools:**
- - `ShellTool` (`shell.ts`): Executes arbitrary shell commands (requires
- careful sandboxing and user confirmation).
-- **Web tools:**
- - `WebFetchTool` (`web-fetch.ts`): Fetches content from a URL.
- - `WebSearchTool` (`web-search.ts`): Performs a web search.
-- **Memory tools:**
- - `MemoryTool` (`memoryTool.ts`): Interacts with the AI's memory.
-
-Each of these tools extends `BaseTool` and implements the required methods for
-its specific functionality.
-
-## Tool execution flow
-
-1. **Model request:** The Gemini model, based on the user's prompt and the
- provided tool schemas, decides to use a tool and returns a `FunctionCall`
- part in its response, specifying the tool name and arguments.
-2. **Core receives request:** The core parses this `FunctionCall`.
-3. **Tool retrieval:** It looks up the requested tool in the `ToolRegistry`.
-4. **Parameter validation:** The tool's `validateToolParams()` method is
- called.
-5. **Confirmation (if needed):**
- - The tool's `shouldConfirmExecute()` method is called.
- - If it returns details for confirmation, the core communicates this back to
- the CLI, which prompts the user.
- - The user's decision (e.g., proceed, cancel) is sent back to the core.
-6. **Execution:** If validated and confirmed (or if no confirmation is needed),
- the core calls the tool's `execute()` method with the provided arguments and
- an `AbortSignal` (for potential cancellation).
-7. **Result processing:** The `ToolResult` from `execute()` is received by the
- core.
-8. **Response to model:** The `llmContent` from the `ToolResult` is packaged as
- a `FunctionResponse` and sent back to the Gemini model so it can continue
- generating a user-facing response.
-9. **Display to user:** The `returnDisplay` from the `ToolResult` is sent to
- the CLI to show the user what the tool did.
-
-## Extending with custom tools
-
-While direct programmatic registration of new tools by users isn't explicitly
-detailed as a primary workflow in the provided files for typical end-users, the
-architecture supports extension through:
-
-- **Command-based discovery:** Advanced users or project administrators can
- define a `tools.discoveryCommand` in `settings.json`. This command, when run
- by the Gemini CLI core, should output a JSON array of `FunctionDeclaration`
- objects. The core will then make these available as `DiscoveredTool`
- instances. The corresponding `tools.callCommand` would then be responsible for
- actually executing these custom tools.
-- **MCP server(s):** For more complex scenarios, one or more MCP servers can be
- set up and configured via the `mcpServers` setting in `settings.json`. The
- Gemini CLI core can then discover and use tools exposed by these servers. As
- mentioned, if you have multiple MCP servers, the tool names will be prefixed
- with the server name from your configuration (e.g.,
- `serverAlias__actualToolName`).
-
-This tool system provides a flexible and powerful way to augment the Gemini
-model's capabilities, making the Gemini CLI a versatile assistant for a wide
-range of tasks.
diff --git a/docs/reference/tools.md b/docs/reference/tools.md
new file mode 100644
index 0000000000..e1a0958866
--- /dev/null
+++ b/docs/reference/tools.md
@@ -0,0 +1,106 @@
+# Tools reference
+
+Gemini CLI uses tools to interact with your local environment, access
+information, and perform actions on your behalf. These tools extend the model's
+capabilities beyond text generation, letting it read files, execute commands,
+and search the web.
+
+## How to use Gemini CLI's tools
+
+Tools are generally invoked automatically by Gemini CLI when it needs to perform
+an action. However, you can also trigger specific tools manually using shorthand
+syntax.
+
+### Automatic execution and security
+
+When the model wants to use a tool, Gemini CLI evaluates the request against its
+security policies.
+
+- **User confirmation:** You must manually approve tools that modify files or
+ execute shell commands (mutators). The CLI shows you a diff or the exact
+ command before you confirm.
+- **Sandboxing:** You can run tool executions in secure, containerized
+ environments to isolate changes from your host system. For more details, see
+ the [Sandboxing](../cli/sandbox.md) guide.
+- **Trusted folders:** You can configure which directories allow the model to
+ use system tools. For more details, see the
+ [Trusted folders](../cli/trusted-folders.md) guide.
+
+Review confirmation prompts carefully before allowing a tool to execute.
+
+### How to use manually-triggered tools
+
+You can directly trigger key tools using special syntax in your prompt:
+
+- **[File access](../tools/file-system.md#read_many_files) (`@`):** Use the `@`
+ symbol followed by a file or directory path to include its content in your
+ prompt. This triggers the `read_many_files` tool.
+- **[Shell commands](../tools/shell.md) (`!`):** Use the `!` symbol followed by
+ a system command to execute it directly. This triggers the `run_shell_command`
+ tool.
+
+## How to manage tools
+
+Using built-in commands, you can inspect available tools and configure how they
+behave.
+
+### Tool discovery
+
+Use the `/tools` command to see what tools are currently active in your session.
+
+- **`/tools`**: Lists all registered tools with their display names.
+- **`/tools desc`**: Lists all tools with their full descriptions.
+
+This is especially useful for verifying that
+[MCP servers](../tools/mcp-server.md) or custom tools are loaded correctly.
+
+### Tool configuration
+
+You can enable, disable, or configure specific tools in your settings. For
+example, you can set a specific pager for shell commands or configure the
+browser used for web searches. See the [Settings](../cli/settings.md) guide for
+details.
+
+## Available tools
+
+The following table lists all available tools, categorized by their primary
+function.
+
+| 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.
**Parameters:** `command`, `description`, `dir_path`, `is_background` |
+| File System | [`glob`](../tools/file-system.md) | `Search` | Finds files matching specific glob patterns across the workspace.
**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`.
**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.
**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.
**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.
**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.
**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.
**Parameters:** `file_path`, `content` |
+| Interaction | [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog.
**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.
**Parameters:** `todos` |
+| Memory | [`activate_skill`](../tools/activate-skill.md) | `Other` | Loads specialized procedural expertise for specific tasks from the `.gemini/skills` directory.
**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.
**Parameters:** `path` |
+| Memory | [`save_memory`](../tools/memory.md) | `Think` | Persists specific facts and project details to your `GEMINI.md` file to retain context.
**Parameters:** `fact` |
+| Planning | [`enter_plan_mode`](../tools/planning.md) | `Plan` | Switches the CLI to a safe, read-only "Plan Mode" for researching complex changes.
**Parameters:** `reason` |
+| Planning | [`exit_plan_mode`](../tools/planning.md) | `Plan` | Finalizes a plan, presents it for review, and requests approval to start implementation.
**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.
**Parameters:** `result` |
+| Web | [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information.
**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.
**Parameters:** `prompt` |
+
+## Under the hood
+
+For developers, the tool system is designed to be extensible and robust. The
+`ToolRegistry` class manages all available tools.
+
+You can extend Gemini CLI with custom tools by configuring
+`tools.discoveryCommand` in your settings or by connecting to MCP servers.
+
+> **Note:** For a deep dive into the internal Tool API and how to implement your
+> own tools in the codebase, see the `packages/core/src/tools/` directory in
+> GitHub.
+
+## Next steps
+
+- Learn how to [Set up an MCP server](../tools/mcp-server.md).
+- Explore [Agent Skills](../cli/skills.md) for specialized expertise.
+- See the [Command reference](./commands.md) for slash commands.
diff --git a/docs/resources/quota-and-pricing.md b/docs/resources/quota-and-pricing.md
index d4ed22a1cb..62258bae09 100644
--- a/docs/resources/quota-and-pricing.md
+++ b/docs/resources/quota-and-pricing.md
@@ -1,14 +1,13 @@
# Gemini CLI: Quotas and pricing
Gemini CLI offers a generous free tier that covers many individual developers'
-use cases. For enterprise or professional usage, or if you need higher limits,
+use cases. For enterprise or professional usage, or if you need increased quota,
several options are available depending on your authentication account type.
-See [privacy and terms](./tos-privacy.md) for details on the Privacy Policy and
-Terms of Service.
+For a high-level comparison of available subscriptions and to select the right
+quota for your needs, see the [Plans page](/plans/).
-> **Note:** Published prices are list price; additional negotiated commercial
-> discounting may apply.
+## Overview
This article outlines the specific quotas and pricing applicable to Gemini CLI
when using different authentication methods.
@@ -23,10 +22,11 @@ Generally, there are three categories to choose from:
## Free usage
-Your journey begins with a generous free tier, perfect for experimentation and
-light use.
+Access to Gemini CLI begins with a generous free tier, perfect for
+experimentation and light use.
-Your free usage limits depend on your authorization type.
+Your free usage is governed by the following limits, which depend on your
+authorization type.
### Log in with Google (Gemini Code Assist for individuals)
@@ -78,14 +78,12 @@ Gemini CLI by upgrading to one of the following subscriptions:
Learn more at
[Gemini Code Assist Quotas and Limits](https://developers.google.com/gemini-code-assist/resources/quotas)
-- [Purchase a Gemini Code Assist Subscription through Google Cloud ](https://cloud.google.com/gemini/docs/codeassist/overview)
- by signing up in the Google Cloud console. Learn more at
- [Set up Gemini Code Assist](https://cloud.google.com/gemini/docs/discover/set-up-gemini).
+- [Purchase a Gemini Code Assist Subscription through Google Cloud](https://cloud.google.com/gemini/docs/codeassist/overview).
Quotas and pricing are based on a fixed price subscription with assigned
license seats. For predictable costs, you can sign in with Google.
- This includes:
+ This includes the following request limits:
- Gemini Code Assist Standard edition:
- 1500 model requests / user / day
- 120 model requests / user / minute
@@ -106,18 +104,27 @@ recommended path for uninterrupted access.
To do this, log in using a Gemini API key or Vertex AI.
-- Vertex AI (Regular Mode):
- - Quota: Governed by a dynamic shared quota system or pre-purchased
- provisioned throughput.
- - Cost: Based on model and token usage.
+### Vertex AI (regular mode)
+
+An enterprise-grade platform for building, deploying, and managing AI models,
+including Gemini. It offers enhanced security, data governance, and integration
+with other Google Cloud services.
+
+- Quota: Governed by a dynamic shared quota system or pre-purchased provisioned
+ throughput.
+- Cost: Based on model and token usage.
Learn more at
[Vertex AI Dynamic Shared Quota](https://cloud.google.com/vertex-ai/generative-ai/docs/resources/dynamic-shared-quota)
and [Vertex AI Pricing](https://cloud.google.com/vertex-ai/pricing).
-- Gemini API key:
- - Quota: Varies by pricing tier.
- - Cost: Varies by pricing tier and model/token usage.
+### Gemini API key
+
+Ideal for developers who want to quickly build applications with the Gemini
+models. This is the most direct way to use the models.
+
+- Quota: Varies by pricing tier.
+- Cost: Varies by pricing tier and model/token usage.
Learn more at
[Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rate-limits),
@@ -125,7 +132,8 @@ Learn more at
It’s important to highlight that when using an API key, you pay per token/call.
This can be more expensive for many small calls with few tokens, but it's the
-only way to ensure your workflow isn't interrupted by quota limits.
+only way to ensure your workflow isn't interrupted by reaching a limit on your
+quota.
## Gemini for workspace plans
@@ -135,12 +143,12 @@ Flow video editor). These plans do not apply to the API usage which powers the
Gemini CLI. Supporting these plans is under active consideration for future
support.
-## Check usage and quota
+## Check usage and limits
-You can check your current token usage and quota information using the
+You can check your current token usage and applicable limits using the
`/stats model` command. This command provides a snapshot of your current
-session's token usage, as well as your overall quota and usage for the supported
-models.
+session's token usage, as well as information about the limits associated with
+your current quota.
For more information on the `/stats` command and its subcommands, see the
[Command Reference](../../reference/commands.md#stats).
@@ -149,17 +157,16 @@ A summary of model usage is also presented on exit at the end of a session.
## Tips to avoid high costs
-When using a Pay as you Go API key, be mindful of your usage to avoid unexpected
+When using a pay-as-you-go plan, be mindful of your usage to avoid unexpected
costs.
-- Don't blindly accept every suggestion, especially for computationally
- intensive tasks like refactoring large codebases.
-- Be intentional with your prompts and commands. You are paying per call, so
- think about the most efficient way to get the job done.
-
-## Gemini API vs. Vertex
-
-- Gemini API (gemini developer api): This is the fastest way to use the Gemini
- models directly.
-- Vertex AI: This is the enterprise-grade platform for building, deploying, and
- managing Gemini models with specific security and control requirements.
+- **Be selective with suggestions**: Before accepting a suggestion, especially
+ for a computationally intensive task like refactoring a large codebase,
+ consider if it's the most cost-effective approach.
+- **Use precise prompts**: You are paying per call, so think about the most
+ efficient way to get your desired result. A well-crafted prompt can often get
+ you the answer you need in a single call, rather than multiple back-and-forth
+ interactions.
+- **Monitor your usage**: Use the `/stats model` command to track your token
+ usage during a session. This can help you stay aware of your spending in real
+ time.
diff --git a/docs/sidebar.json b/docs/sidebar.json
index c2c6295bfa..000f571077 100644
--- a/docs/sidebar.json
+++ b/docs/sidebar.json
@@ -94,7 +94,14 @@
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
- { "label": "Hooks", "slug": "docs/hooks" },
+ {
+ "label": "Hooks",
+ "collapsed": true,
+ "items": [
+ { "label": "Overview", "slug": "docs/hooks" },
+ { "label": "Reference", "slug": "docs/hooks/reference" }
+ ]
+ },
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
@@ -181,7 +188,7 @@
"slug": "docs/reference/memport"
},
{ "label": "Policy engine", "slug": "docs/reference/policy-engine" },
- { "label": "Tools API", "slug": "docs/reference/tools-api" }
+ { "label": "Tools reference", "slug": "docs/reference/tools" }
]
}
]
diff --git a/docs/tools/index.md b/docs/tools/index.md
deleted file mode 100644
index 6bdf298fea..0000000000
--- a/docs/tools/index.md
+++ /dev/null
@@ -1,105 +0,0 @@
-# Gemini CLI tools
-
-Gemini CLI uses tools to interact with your local environment, access
-information, and perform actions on your behalf. These tools extend the model's
-capabilities beyond text generation, letting it read files, execute commands,
-and search the web.
-
-## User-triggered tools
-
-You can directly trigger these tools using special syntax in your prompts.
-
-- **[File access](./file-system.md#read_many_files) (`@`):** Use the `@` symbol
- followed by a file or directory path to include its content in your prompt.
- This triggers the `read_many_files` tool.
-- **[Shell commands](./shell.md) (`!`):** Use the `!` symbol followed by a
- system command to execute it directly. This triggers the `run_shell_command`
- tool.
-
-## Model-triggered tools
-
-The Gemini model automatically requests these tools when it needs to perform
-specific actions or gather information to fulfill your requests. You do not call
-these tools manually.
-
-### File management
-
-These tools let the model explore and modify your local codebase.
-
-- **[Directory listing](./file-system.md#list_directory) (`list_directory`):**
- Lists files and subdirectories.
-- **[File reading](./file-system.md#read_file) (`read_file`):** Reads the
- content of a specific file.
-- **[File writing](./file-system.md#write_file) (`write_file`):** Creates or
- overwrites a file with new content.
-- **[File search](./file-system.md#glob) (`glob`):** Finds files matching a glob
- pattern.
-- **[Text search](./file-system.md#search_file_content)
- (`search_file_content`):** Searches for text within files using grep or
- ripgrep.
-- **[Text replacement](./file-system.md#replace) (`replace`):** Performs precise
- edits within a file.
-
-### Agent coordination
-
-These tools help the model manage its plan and interact with you.
-
-- **Ask user (`ask_user`):** Requests clarification or missing information from
- you via an interactive dialog.
-- **[Memory](./memory.md) (`save_memory`):** Saves important facts to your
- long-term memory (`GEMINI.md`).
-- **[Todos](./todos.md) (`write_todos`):** Manages a list of subtasks for
- complex plans.
-- **[Agent Skills](../cli/skills.md) (`activate_skill`):** Loads specialized
- procedural expertise when needed.
-- **[Browser agent](../core/subagents.md#browser-agent-experimental)
- (`browser_agent`):** Automates web browser tasks through the accessibility
- tree.
-- **Internal docs (`get_internal_docs`):** Accesses Gemini CLI's own
- documentation to help answer your questions.
-
-### Information gathering
-
-These tools provide the model with access to external data.
-
-- **[Web fetch](./web-fetch.md) (`web_fetch`):** Retrieves and processes content
- from specific URLs.
-- **[Web search](./web-search.md) (`google_web_search`):** Performs a Google
- Search to find up-to-date information.
-
-## How to use tools
-
-You use tools indirectly by providing natural language prompts to Gemini CLI.
-
-1. **Prompt:** You enter a request or use syntax like `@` or `!`.
-2. **Request:** The model analyzes your request and identifies if a tool is
- required.
-3. **Validation:** If a tool is needed, the CLI validates the parameters and
- checks your security settings.
-4. **Confirmation:** For sensitive operations (like writing files), the CLI
- prompts you for approval.
-5. **Execution:** The tool runs, and its output is sent back to the model.
-6. **Response:** The model uses the results to generate a final, grounded
- answer.
-
-## Security and confirmation
-
-Safety is a core part of the tool system. To protect your system, Gemini CLI
-implements several safeguards.
-
-- **User confirmation:** You must manually approve tools that modify files or
- execute shell commands. The CLI shows you a diff or the exact command before
- you confirm.
-- **Sandboxing:** You can run tool executions in secure, containerized
- environments to isolate changes from your host system. For more details, see
- the [Sandboxing](../cli/sandbox.md) guide.
-- **Trusted folders:** You can configure which directories allow the model to
- use system tools.
-
-Always review confirmation prompts carefully before allowing a tool to execute.
-
-## Next steps
-
-- Learn how to [Provide context](../cli/gemini-md.md) to guide tool use.
-- Explore the [Command reference](../reference/commands.md) for tool-related
- slash commands.
diff --git a/docs/tools/planning.md b/docs/tools/planning.md
index 458b172510..9e9ab3d044 100644
--- a/docs/tools/planning.md
+++ b/docs/tools/planning.md
@@ -1,8 +1,8 @@
# Gemini CLI planning tools
-Planning tools allow the Gemini model to switch into a safe, read-only "Plan
-Mode" for researching and planning complex changes, and to signal the
-finalization of a plan to the user.
+Planning tools let Gemini CLI switch into a safe, read-only "Plan Mode" for
+researching and planning complex changes, and to signal the finalization of a
+plan to the user.
## 1. `enter_plan_mode` (EnterPlanMode)
@@ -18,11 +18,12 @@ and planning.
- **File:** `enter-plan-mode.ts`
- **Parameters:**
- `reason` (string, optional): A short reason explaining why the agent is
- entering plan mode (e.g., "Starting a complex feature implementation").
+ entering plan mode (for example, "Starting a complex feature
+ implementation").
- **Behavior:**
- Switches the CLI's approval mode to `PLAN`.
- Notifies the user that the agent has entered Plan Mode.
-- **Output (`llmContent`):** A message indicating the switch, e.g.,
+- **Output (`llmContent`):** A message indicating the switch, for example,
`Switching to Plan mode.`
- **Confirmation:** Yes. The user is prompted to confirm entering Plan Mode.
@@ -37,7 +38,7 @@ finalized plan to the user and requests approval to start the implementation.
- **Parameters:**
- `plan_path` (string, required): The path to the finalized Markdown plan
file. This file MUST be located within the project's temporary plans
- directory (e.g., `~/.gemini/tmp//plans/`).
+ directory (for example, `~/.gemini/tmp//plans/`).
- **Behavior:**
- Validates that the `plan_path` is within the allowed directory and that the
file exists and has content.
diff --git a/evals/ask_user.eval.ts b/evals/ask_user.eval.ts
new file mode 100644
index 0000000000..c67f995168
--- /dev/null
+++ b/evals/ask_user.eval.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect } from 'vitest';
+import { evalTest } from './test-helper.js';
+
+describe('ask_user', () => {
+ evalTest('USUALLY_PASSES', {
+ name: 'Agent uses AskUser tool to present multiple choice options',
+ prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`,
+ assert: async (rig) => {
+ const wasToolCalled = await rig.waitForToolCall('ask_user');
+ expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
+ },
+ });
+
+ evalTest('USUALLY_PASSES', {
+ name: 'Agent uses AskUser tool to clarify ambiguous requirements',
+ files: {
+ 'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }),
+ },
+ prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`,
+ assert: async (rig) => {
+ const wasToolCalled = await rig.waitForToolCall('ask_user');
+ expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
+ },
+ });
+
+ evalTest('USUALLY_PASSES', {
+ name: 'Agent uses AskUser tool before performing significant ambiguous rework',
+ files: {
+ 'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";',
+ 'packages/core/src/util.ts': '// util\nexport function help() {}',
+ 'packages/core/package.json': JSON.stringify({
+ name: '@google/gemini-cli-core',
+ }),
+ 'README.md': '# Gemini CLI',
+ },
+ prompt: `Refactor the entire core package to be better.`,
+ assert: async (rig) => {
+ const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode');
+ expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe(
+ true,
+ );
+
+ const wasAskUserCalled = await rig.waitForToolCall('ask_user');
+ expect(
+ wasAskUserCalled,
+ 'Expected ask_user tool to be called to clarify the significant rework',
+ ).toBe(true);
+ },
+ });
+
+ // --- Regression Tests for Recent Fixes ---
+
+ // Regression test for issue #20177: Ensure the agent does not use `ask_user` to
+ // confirm shell commands. Fixed via prompt refinements and tool definition
+ // updates to clarify that shell command confirmation is handled by the UI.
+ // See fix: https://github.com/google-gemini/gemini-cli/pull/20504
+ evalTest('USUALLY_PASSES', {
+ name: 'Agent does NOT use AskUser to confirm shell commands',
+ files: {
+ 'package.json': JSON.stringify({
+ scripts: { build: 'echo building' },
+ }),
+ },
+ prompt: `Run 'npm run build' in the current directory.`,
+ assert: async (rig) => {
+ await rig.waitForTelemetryReady();
+
+ const toolLogs = rig.readToolLogs();
+ const wasShellCalled = toolLogs.some(
+ (log) => log.toolRequest.name === 'run_shell_command',
+ );
+ const wasAskUserCalled = toolLogs.some(
+ (log) => log.toolRequest.name === 'ask_user',
+ );
+
+ expect(
+ wasShellCalled,
+ 'Expected run_shell_command tool to be called',
+ ).toBe(true);
+ expect(
+ wasAskUserCalled,
+ 'ask_user should not be called to confirm shell commands',
+ ).toBe(false);
+ },
+ });
+});
diff --git a/integration-tests/acp-env-auth.test.ts b/integration-tests/acp-env-auth.test.ts
index c83dbafce5..65f8adbf22 100644
--- a/integration-tests/acp-env-auth.test.ts
+++ b/integration-tests/acp-env-auth.test.ts
@@ -55,7 +55,7 @@ describe.skip('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
- child = spawn('node', [bundlePath, '--experimental-acp'], {
+ child = spawn('node', [bundlePath, '--acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
@@ -120,7 +120,7 @@ describe.skip('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
- child = spawn('node', [bundlePath, '--experimental-acp'], {
+ child = spawn('node', [bundlePath, '--acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
diff --git a/integration-tests/acp-telemetry.test.ts b/integration-tests/acp-telemetry.test.ts
index 393156df3e..f883b977bf 100644
--- a/integration-tests/acp-telemetry.test.ts
+++ b/integration-tests/acp-telemetry.test.ts
@@ -58,7 +58,7 @@ describe('ACP telemetry', () => {
'node',
[
bundlePath,
- '--experimental-acp',
+ '--acp',
'--fake-responses',
join(rig.testDir!, 'fake-responses.json'),
],
diff --git a/integration-tests/api-resilience.responses b/integration-tests/api-resilience.responses
new file mode 100644
index 0000000000..d30d29906e
--- /dev/null
+++ b/integration-tests/api-resilience.responses
@@ -0,0 +1 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0}],"finishReason":"STOP"}]}
diff --git a/integration-tests/api-resilience.test.ts b/integration-tests/api-resilience.test.ts
new file mode 100644
index 0000000000..870adf701a
--- /dev/null
+++ b/integration-tests/api-resilience.test.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { TestRig } from './test-helper.js';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+describe('API Resilience E2E', () => {
+ let rig: TestRig;
+
+ beforeEach(() => {
+ rig = new TestRig();
+ });
+
+ afterEach(async () => {
+ await rig.cleanup();
+ });
+
+ it('should not crash when receiving metadata-only chunks in a stream', async () => {
+ await rig.setup('api-resilience-metadata-only', {
+ fakeResponsesPath: join(
+ dirname(fileURLToPath(import.meta.url)),
+ 'api-resilience.responses',
+ ),
+ settings: {
+ planSettings: { modelRouting: false },
+ },
+ });
+
+ // Run the CLI with a simple prompt.
+ // The fake responses will provide a stream with a metadata-only chunk in the middle.
+ // We use gemini-3-pro-preview to minimize internal service calls.
+ const result = await rig.run({
+ args: ['hi', '--model', 'gemini-3-pro-preview'],
+ });
+
+ // Verify the output contains text from the normal chunks.
+ // If the CLI crashed on the metadata chunk, rig.run would throw.
+ expect(result).toContain('Part 1.');
+ expect(result).toContain('Part 2.');
+
+ // Verify telemetry event for the prompt was still generated
+ const hasUserPromptEvent = await rig.waitForTelemetryEvent('user_prompt');
+ expect(hasUserPromptEvent).toBe(true);
+ });
+});
diff --git a/integration-tests/browser-agent.cleanup.responses b/integration-tests/browser-agent.cleanup.responses
new file mode 100644
index 0000000000..988f2fa456
--- /dev/null
+++ b/integration-tests/browser-agent.cleanup.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title of https://example.com is \"Example Domain\". The browser session has been completed and cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
diff --git a/integration-tests/browser-agent.interaction.responses b/integration-tests/browser-agent.interaction.responses
new file mode 100644
index 0000000000..98474d6b59
--- /dev/null
+++ b/integration-tests/browser-agent.interaction.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and analyze the links on the page."},{"functionCall":{"name":"browser_agent","args":{"task":"Go to https://example.com and find all links on the page, then describe them"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"After analyzing https://example.com, I found the following links:\n\n1. **\"More information...\"** - This is the main link on the page that points to the IANA (Internet Assigned Numbers Authority) website for more details about reserved domains.\n\nThe page is quite minimal with just this single informational link, which is typical for example domains used in documentation."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":70,"totalTokenCount":270}}]}
diff --git a/integration-tests/browser-agent.navigate-snapshot.responses b/integration-tests/browser-agent.navigate-snapshot.responses
new file mode 100644
index 0000000000..481520234d
--- /dev/null
+++ b/integration-tests/browser-agent.navigate-snapshot.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you open https://example.com and analyze the page. Let me use the browser agent to navigate and capture the page information."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and capture the accessibility tree to get the page title and main content"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Based on the browser analysis of https://example.com:\n\n**Page Title**: Example Domain\n\n**Main Content**: The page contains a simple heading \"Example Domain\" and explanatory text stating \"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.\"\n\nThe page has a clean, minimal layout typical of placeholder domains used for documentation and examples."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]}
diff --git a/integration-tests/browser-agent.screenshot.responses b/integration-tests/browser-agent.screenshot.responses
new file mode 100644
index 0000000000..c9abaa4268
--- /dev/null
+++ b/integration-tests/browser-agent.screenshot.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and take a screenshot for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and take a screenshot of the page"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully navigated to https://example.com and captured a screenshot. The page shows the familiar \"Example Domain\" header with explanatory text below it. The screenshot captures the clean, minimal layout of this demonstration website."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]}
diff --git a/integration-tests/browser-agent.sequential.responses b/integration-tests/browser-agent.sequential.responses
new file mode 100644
index 0000000000..74732998aa
--- /dev/null
+++ b/integration-tests/browser-agent.sequential.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you navigate to https://example.com and perform both operations - capturing the accessibility tree and taking a screenshot."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":45,"totalTokenCount":145}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully completed both operations on https://example.com:\n\n**Accessibility Tree Snapshot**: The page has a clear structure with the main heading \"Example Domain\" and descriptive text about the domain's purpose for documentation examples.\n\n**Screenshot**: Captured a visual representation of the page showing the clean, minimal layout with the heading and explanatory text.\n\nBoth the accessibility data and visual screenshot confirm this is the standard example domain page used for documentation purposes."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]}
diff --git a/integration-tests/browser-agent.test.ts b/integration-tests/browser-agent.test.ts
new file mode 100644
index 0000000000..0fdb3e717b
--- /dev/null
+++ b/integration-tests/browser-agent.test.ts
@@ -0,0 +1,206 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Integration tests for the browser agent.
+ *
+ * These tests verify the complete end-to-end flow from CLI prompt through
+ * browser_agent delegation to MCP/Chrome DevTools and back. Unlike the unit
+ * tests in packages/core/src/agents/browser/ which mock all MCP components,
+ * these tests launch real Chrome instances in headless mode.
+ *
+ * Tests are skipped on systems without Chrome/Chromium installed.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { TestRig, assertModelHasOutput } from './test-helper.js';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execSync } from 'node:child_process';
+import { existsSync } from 'node:fs';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const chromeAvailable = (() => {
+ try {
+ if (process.platform === 'darwin') {
+ execSync(
+ 'test -d "/Applications/Google Chrome.app" || test -d "/Applications/Chromium.app"',
+ {
+ stdio: 'ignore',
+ },
+ );
+ } else if (process.platform === 'linux') {
+ execSync(
+ 'which google-chrome || which chromium-browser || which chromium',
+ { stdio: 'ignore' },
+ );
+ } else if (process.platform === 'win32') {
+ // Check standard Windows installation paths using Node.js fs
+ const chromePaths = [
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
+ `${process.env['LOCALAPPDATA'] ?? ''}\\Google\\Chrome\\Application\\chrome.exe`,
+ ];
+ const found = chromePaths.some((p) => existsSync(p));
+ if (!found) {
+ // Fall back to PATH check
+ execSync('where chrome || where chromium', { stdio: 'ignore' });
+ }
+ } else {
+ return false;
+ }
+ return true;
+ } catch {
+ return false;
+ }
+})();
+
+describe.skipIf(!chromeAvailable)('browser-agent', () => {
+ let rig: TestRig;
+
+ beforeEach(() => {
+ rig = new TestRig();
+ });
+
+ afterEach(async () => await rig.cleanup());
+
+ it('should navigate to a page and capture accessibility tree', async () => {
+ rig.setup('browser-navigate-and-snapshot', {
+ fakeResponsesPath: join(
+ __dirname,
+ 'browser-agent.navigate-snapshot.responses',
+ ),
+ settings: {
+ agents: {
+ browser_agent: {
+ headless: true,
+ sessionMode: 'isolated',
+ },
+ },
+ },
+ });
+
+ const result = await rig.run({
+ args: 'Open https://example.com in the browser and tell me the page title and main content.',
+ });
+
+ assertModelHasOutput(result);
+
+ const toolLogs = rig.readToolLogs();
+ const browserAgentCall = toolLogs.find(
+ (t) => t.toolRequest.name === 'browser_agent',
+ );
+ expect(
+ browserAgentCall,
+ 'Expected browser_agent to be called',
+ ).toBeDefined();
+ });
+
+ it('should take screenshots of web pages', async () => {
+ rig.setup('browser-screenshot', {
+ fakeResponsesPath: join(__dirname, 'browser-agent.screenshot.responses'),
+ settings: {
+ agents: {
+ browser_agent: {
+ headless: true,
+ sessionMode: 'isolated',
+ },
+ },
+ },
+ });
+
+ const result = await rig.run({
+ args: 'Navigate to https://example.com and take a screenshot.',
+ });
+
+ const toolLogs = rig.readToolLogs();
+ const browserCalls = toolLogs.filter(
+ (t) => t.toolRequest.name === 'browser_agent',
+ );
+ expect(browserCalls.length).toBeGreaterThan(0);
+
+ assertModelHasOutput(result);
+ });
+
+ it('should interact with page elements', async () => {
+ rig.setup('browser-interaction', {
+ fakeResponsesPath: join(__dirname, 'browser-agent.interaction.responses'),
+ settings: {
+ agents: {
+ browser_agent: {
+ headless: true,
+ sessionMode: 'isolated',
+ },
+ },
+ },
+ });
+
+ const result = await rig.run({
+ args: 'Go to https://example.com, find any links on the page, and describe them.',
+ });
+
+ const toolLogs = rig.readToolLogs();
+ const browserAgentCall = toolLogs.find(
+ (t) => t.toolRequest.name === 'browser_agent',
+ );
+ expect(
+ browserAgentCall,
+ 'Expected browser_agent to be called',
+ ).toBeDefined();
+
+ assertModelHasOutput(result);
+ });
+
+ it('should clean up browser processes after completion', async () => {
+ rig.setup('browser-cleanup', {
+ fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
+ settings: {
+ agents: {
+ browser_agent: {
+ headless: true,
+ sessionMode: 'isolated',
+ },
+ },
+ },
+ });
+
+ await rig.run({
+ args: 'Open https://example.com in the browser and check the page title.',
+ });
+
+ // Test passes if we reach here, relying on Vitest's timeout mechanism
+ // to detect hanging browser processes.
+ });
+
+ it('should handle multiple browser operations in sequence', async () => {
+ rig.setup('browser-sequential', {
+ fakeResponsesPath: join(__dirname, 'browser-agent.sequential.responses'),
+ settings: {
+ agents: {
+ browser_agent: {
+ headless: true,
+ sessionMode: 'isolated',
+ },
+ },
+ },
+ });
+
+ const result = await rig.run({
+ args: 'Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot.',
+ });
+
+ const toolLogs = rig.readToolLogs();
+ const browserCalls = toolLogs.filter(
+ (t) => t.toolRequest.name === 'browser_agent',
+ );
+ expect(browserCalls.length).toBeGreaterThan(0);
+
+ // Should successfully complete all operations
+ assertModelHasOutput(result);
+ });
+});
diff --git a/integration-tests/policy-headless-readonly.responses b/integration-tests/policy-headless-readonly.responses
new file mode 100644
index 0000000000..35ba546bae
--- /dev/null
+++ b/integration-tests/policy-headless-readonly.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I will read the content of the file to identify its"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":11,"totalTokenCount":8061,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"text":" language.\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":14,"totalTokenCount":8064,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"test.txt"}},"thoughtSignature":"EvkCCvYCAb4+9vt8mJ/o45uuuAJtfjaZ3YzkJzqXHZBttRE+Om0ahcr1S5RDFp50KpgHtJtbAH1pwEXampOnDV3WKiWwA+e3Jnyk4CNQegz7ZMKsl55Nem2XDViP8BZKnJVqGmSFuMoKJLFmbVIxKejtWcblfn3httbGsrUUNbHwdPjPHo1qY043lF63g0kWx4v68gPSsJpNhxLrSugKKjiyRFN+J0rOIBHI2S9MdZoHEKhJxvGMtXiJquxmhPmKcNEsn+hMdXAZB39hmrRrGRHDQPVYVPhfJthVc73ufzbn+5KGJpaMQyKY5hqrc2ea8MHz+z6BSx+tFz4NZBff1tJQOiUp09/QndxQRZHSQZr1ALGy0O1Qw4JqsX94x81IxtXqYkSRo3zgm2vl/xPMC5lKlnK5xoKJmoWaHkUNeXs/sopu3/Waf1a5Csoh9ImnKQsW0rJ6GRyDQvky1FwR6Aa98bgfNdcXOPHml/BtghaqRMXTiG6vaPJ8UFs="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":64,"totalTokenCount":8114,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":64,"totalTokenCount":8114,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":81}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The language of the file is Latin."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8054,"candidatesTokenCount":8,"totalTokenCount":8078,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8054}],"thoughtsTokenCount":16}},{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"EnIKcAG+Pvb7vnRBJVz3khx1oArQQqTNvXOXkliNQS7NvYw94dq5m+wGKRmSj3egO3GVp7pacnAtLn9NT1ABKBGpa7MpRhiAe3bbPZfkqOuveeyC19LKQ9fzasCywiYqg5k5qSxfjs5okk+O0NLOvTjN/tg="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8135,"candidatesTokenCount":8,"totalTokenCount":8159,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8135}],"thoughtsTokenCount":16}}]}
diff --git a/integration-tests/policy-headless-shell-allowed.responses b/integration-tests/policy-headless-shell-allowed.responses
new file mode 100644
index 0000000000..7c98e60db0
--- /dev/null
+++ b/integration-tests/policy-headless-shell-allowed.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I will run the requested"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":5,"totalTokenCount":8092,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"text":" shell command to verify the policy configuration.\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":14,"totalTokenCount":8101,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"command":"echo POLICY_TEST_ECHO_COMMAND","description":"Echo the test string to verify policy settings."}},"thoughtSignature":"EpwFCpkFAb4+9vulXgVj96CAm2eMFbDEGHz9B37GwI8N1KOvu9AHwdYWiita7yS4RKAdeBui22B5320XBaxOtZGnMo2E9pG0Pcus2WsBiecRaHUTxTmhx1BvURevrs+5m4UJeLRGMfP94+ncha4DeIQod3PKBnK8xeIJTyZBFB7+hmHbHvem2VwZh/v14e4fXlpEkkdntJbzrA1nUdctIGdEmdm0sL8PaFnMqWLUnkZvGdfq7ctFt9EYk2HW2SrHVhk3HdsyWhoxNz2MU0sRWzAgiSQY/heSSAbU7Jdgg0RjwB9o3SkCIHxqnVpkH8PQsARwnah5I5s7pW6EHr3D4f1/UVl0n26hyI2xBqF/n4aZKhtX55U4h/DIhxooZa2znstt6BS8vRcdzflFrX7OV86WQxHE4JHjQecP2ciBRimm8pL3Od3pXnRcx32L8JbrWm6dPyWlo5h5uCRy0qXye2+3SuHs5wtxOjD9NETR4TwzqFe+m0zThpxsR1ZKQeKlO7lN/s3pWih/TjbZQEQs9xr72UnlE8ZtJ4bOKj8GNbemvsrbYAO98NzJwvdil0FhblaXmReP1uYjucmLC0jCJHShqNz2KzAkDTvKs4tmio13IuCRjTZ3E5owqCUn7djDqOSDwrg235RIVJkiDIaPlHemOR15lbVQD1VOzytzT8TZLEzTV750oyHq/IhLMQHYixO8jJ2GkVvUp7bxz9oQ4UeTqT5lTF4s40H2Rlkb6trF4hKXoFhzILy1aOJTC9W3fCoop7VJLIMNulgHLWxiq65Uas6sIep87yiD4xLfbGfMm6HS4JTRhPlfxeckn/SzUfu1afg1nAvW3vBlR/YNREf0N28/PnRC08VYqA3mqCRiyPqPWsf3a0jyio0dD9A="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":54,"totalTokenCount":8141,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":54,"totalTokenCount":8141,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":138}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"POLICY_TEST_"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8042,"candidatesTokenCount":4,"totalTokenCount":8046,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8042}]}},{"candidates":[{"content":{"parts":[{"text":"ECHO_COMMAND"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8042,"candidatesTokenCount":8,"totalTokenCount":8050,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8042}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8180,"candidatesTokenCount":8,"totalTokenCount":8188,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8180}]}}]}
diff --git a/integration-tests/policy-headless-shell-denied.responses b/integration-tests/policy-headless-shell-denied.responses
new file mode 100644
index 0000000000..4278543b7e
--- /dev/null
+++ b/integration-tests/policy-headless-shell-denied.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Assessing Command Execution**\n\nOkay, I'm currently assessing the feasibility of executing `echo POLICY_TEST_ECHO_COMMAND` using the `run_shell_command` function. Restrictions are being evaluated; the prompt is specifically geared towards a successful command output: \"POLICY_TEST_ECHO_COMMAND\".\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"totalTokenCount":7949,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}]}},{"candidates":[{"content":{"parts":[{"text":"I will execute the requested echo"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":6,"totalTokenCount":8161,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"text":" command to verify the policy."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":12,"totalTokenCount":8167,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"description":"Execute the echo command as requested.","command":"echo POLICY_TEST_ECHO_COMMAND"}},"thoughtSignature":"EvkGCvYGAb4+9vucYbmJ8DrNCca9c0C8o4qKQ6V2WnzmT4mbCw8V7s0+2I/PoxrgnsxZJIIRM8y5E4bW7Jbs46GjbJ2cefY9Q3iC45eiGS5Gqvq0eAG04N3GZRwizyDOp+wJlBsaPu1cNB1t6CnMk/ZHDAHEIQUpYfYWmPudbHOQMspGMu3bX23YSI1+Q5vPVdOtM16J3EFbk3dCp+RnPa/8tVC+5AqFlLveuDbJXtrLN9wAyf4SjnPhn9BPfD0bgas3+gF03qRJvWoNcnnJiYxL3DNQtjsAYJ7IWRzciYYZSTm99blD730bn3NzvSObhlHDtb3hFpApYvG396+3prsgJg0Yjef54B4KxHfZaQbE2ndSP5zGrwLtVD5y7XJAYskvhiUqwPFHNVykqroEMzPn8wWQSGvonNR6ezcMIsUV5xwnxZDaPhvrDdIwF4NR1F5DeriJRu27+fwtCApeYkx9mPx4LqnyxOuVsILjzdSPHE6Bqf690VJSXpo67lCN4F3DRRYIuCD4UOlf8V3dvUO6BKjvChDDWnIq7KPoByDQT9VhVlZvS3/nYlkeDuhi0rk2jpByN1NdgD2YSvOlpJcka8JqKQ+lnO/7Swunij2ISUfpL2hkx6TEHjebPU2dBQkub5nSl9J1EhZn4sUGG5r6Zdv1lYcpIcO4ZYeMqZZ4uNvTvSpGdT4Jj1+qS88taKgYq7uN1RgQSTsT5wcpmlubIpgIycNwAIRFvN+DjkQjiUC6hSqdeOx3dc7LWgC/O/+PRog7kuFrD2nzih+oIP0YxXrLA9CMVPlzeAgPUi9b75HAJQ92GRHxfQ163tjZY+4bWmJtcU4NBqGH0x/jLEU9xCojTeh+mZoUDGsb3N+bVcGJftRIet7IBYveD29Z+XHtKhf7s/YIkFW8lgsG8Q0EtNchCxqIQxf9UjYEO52RhCx7i7zScB1knovt2HAotACKqDdPqg18PmpDv8Frw6Y66XeCCJzBCmNcSUTETq3K05gwkU8nyANQtjbJT0wF4LS9h5vPE+Vc7/dGH6pi1TgxWB/n4q1IXfNqilo/h2Pyw01VPsHKthNtKKq1/nSW/WuEU0rimqu7wHplMqU2nwRDCTNE9pPO59RtTHMfUxxd8yEgKBj9L8MiQGM5isIYl/lJtvucee4HD9iLpbYADlrQAlUCd0rg/z+5sQ=="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":50,"totalTokenCount":8205,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":50,"totalTokenCount":8205,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":206}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"AR NAR"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8020,"candidatesTokenCount":2,"totalTokenCount":8049,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8020}],"thoughtsTokenCount":27}},{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"Er8BCrwBAb4+9vv6KGeMf6yopmPBE/az7Kjdp+Pe5a/R6wgXcyCZzGNwkwKFW3i3ro0j26bRrVeHD1zRfWFTIGdOSZKV6OMPWLqFC/RU6CNJ88B1xY7hbCVwA7EchYPzgd3YZRVNwmFu52j86/9qXf/zaqTFN+WQ0mUESJXh2O2YX8E7imAvxhmRdobVkxvEt4ZX3dW5skDhXHMDZOxbLpX0nkK7cWWS7iEc+qBFP0yinlA/eiG2ZdKpuTiDl76a9ik="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8226,"candidatesTokenCount":2,"totalTokenCount":8255,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8226}],"thoughtsTokenCount":27}}]}
diff --git a/integration-tests/policy-headless.test.ts b/integration-tests/policy-headless.test.ts
new file mode 100644
index 0000000000..1e3286e1ae
--- /dev/null
+++ b/integration-tests/policy-headless.test.ts
@@ -0,0 +1,192 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { join } from 'node:path';
+import { TestRig } from './test-helper.js';
+
+interface PromptCommand {
+ prompt: (testFile: string) => string;
+ tool: string;
+ command: string;
+ expectedSuccessResult: string;
+ expectedFailureResult: string;
+}
+
+const ECHO_PROMPT: PromptCommand = {
+ command: 'echo',
+ prompt: () =>
+ `Use the \`echo POLICY_TEST_ECHO_COMMAND\` shell command. On success, ` +
+ `your final response must ONLY be "POLICY_TEST_ECHO_COMMAND". If the ` +
+ `command fails output AR NAR and stop.`,
+ tool: 'run_shell_command',
+ expectedSuccessResult: 'POLICY_TEST_ECHO_COMMAND',
+ expectedFailureResult: 'AR NAR',
+};
+
+const READ_FILE_PROMPT: PromptCommand = {
+ prompt: (testFile: string) =>
+ `Read the file ${testFile} and tell me what language it is, if the ` +
+ `read_file tool fails output AR NAR and stop.`,
+ tool: 'read_file',
+ command: '',
+ expectedSuccessResult: 'Latin',
+ expectedFailureResult: 'AR NAR',
+};
+
+async function waitForToolCallLog(
+ rig: TestRig,
+ tool: string,
+ command: string,
+ timeout: number = 15000,
+) {
+ const foundToolCall = await rig.waitForToolCall(tool, timeout, (args) =>
+ args.toLowerCase().includes(command.toLowerCase()),
+ );
+
+ expect(foundToolCall).toBe(true);
+
+ const toolLogs = rig
+ .readToolLogs()
+ .filter((toolLog) => toolLog.toolRequest.name === tool);
+ const log = toolLogs.find(
+ (toolLog) =>
+ !command ||
+ toolLog.toolRequest.args.toLowerCase().includes(command.toLowerCase()),
+ );
+
+ // The policy engine should have logged the tool call
+ expect(log).toBeTruthy();
+ return log;
+}
+
+async function verifyToolExecution(
+ rig: TestRig,
+ promptCommand: PromptCommand,
+ result: string,
+ expectAllowed: boolean,
+) {
+ const log = await waitForToolCallLog(
+ rig,
+ promptCommand.tool,
+ promptCommand.command,
+ );
+
+ if (expectAllowed) {
+ expect(log!.toolRequest.success).toBe(true);
+ expect(result).not.toContain('Tool execution denied by policy');
+ expect(result).toContain(promptCommand.expectedSuccessResult);
+ } else {
+ expect(log!.toolRequest.success).toBe(false);
+ expect(result).toContain('Tool execution denied by policy');
+ expect(result).toContain(promptCommand.expectedFailureResult);
+ }
+}
+
+interface TestCase {
+ name: string;
+ responsesFile: string;
+ promptCommand: PromptCommand;
+ policyContent?: string;
+ expectAllowed: boolean;
+}
+
+describe('Policy Engine Headless Mode', () => {
+ let rig: TestRig;
+ let testFile: string;
+
+ beforeEach(() => {
+ rig = new TestRig();
+ });
+
+ afterEach(async () => {
+ if (rig) {
+ await rig.cleanup();
+ }
+ });
+
+ const runTestCase = async (tc: TestCase) => {
+ const fakeResponsesPath = join(import.meta.dirname, tc.responsesFile);
+ rig.setup(tc.name, { fakeResponsesPath });
+
+ testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n');
+ const args = ['-p', tc.promptCommand.prompt(testFile)];
+
+ if (tc.policyContent) {
+ const policyPath = rig.createFile('test-policy.toml', tc.policyContent);
+ args.push('--policy', policyPath);
+ }
+
+ const result = await rig.run({
+ args,
+ approvalMode: 'default',
+ });
+
+ await verifyToolExecution(rig, tc.promptCommand, result, tc.expectAllowed);
+ };
+
+ const testCases = [
+ {
+ name: 'should deny ASK_USER tools by default in headless mode',
+ responsesFile: 'policy-headless-shell-denied.responses',
+ promptCommand: ECHO_PROMPT,
+ expectAllowed: false,
+ },
+ {
+ name: 'should allow ASK_USER tools in headless mode if explicitly allowed via policy file',
+ responsesFile: 'policy-headless-shell-allowed.responses',
+ promptCommand: ECHO_PROMPT,
+ policyContent: `
+ [[rule]]
+ toolName = "run_shell_command"
+ decision = "allow"
+ priority = 100
+ `,
+ expectAllowed: true,
+ },
+ {
+ name: 'should allow read-only tools by default in headless mode',
+ responsesFile: 'policy-headless-readonly.responses',
+ promptCommand: READ_FILE_PROMPT,
+ expectAllowed: true,
+ },
+ {
+ name: 'should allow specific shell commands in policy file',
+ responsesFile: 'policy-headless-shell-allowed.responses',
+ promptCommand: ECHO_PROMPT,
+ policyContent: `
+ [[rule]]
+ toolName = "run_shell_command"
+ commandPrefix = "${ECHO_PROMPT.command}"
+ decision = "allow"
+ priority = 100
+ `,
+ expectAllowed: true,
+ },
+ {
+ name: 'should deny other shell commands in policy file',
+ responsesFile: 'policy-headless-shell-denied.responses',
+ promptCommand: ECHO_PROMPT,
+ policyContent: `
+ [[rule]]
+ toolName = "run_shell_command"
+ commandPrefix = "node"
+ decision = "allow"
+ priority = 100
+ `,
+ expectAllowed: false,
+ },
+ ];
+
+ it.each(testCases)(
+ '$name',
+ async (tc) => {
+ await runTestCase(tc);
+ },
+ // Large timeout for regeneration
+ process.env['REGENERATE_MODEL_GOLDENS'] === 'true' ? 120000 : undefined,
+ );
+});
diff --git a/package-lock.json b/package-lock.json
index 8f7ed6be5c..85448711c7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"workspaces": [
"packages/*"
],
@@ -5464,13 +5464,6 @@
"node": ">=8"
}
},
- "node_modules/array-flatten": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
- "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
- "license": "MIT",
- "peer": true
- },
"node_modules/array-includes": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
@@ -6305,16 +6298,36 @@
"node": ">= 12"
}
},
- "node_modules/clipboardy": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.0.0.tgz",
- "integrity": "sha512-MQfKHaD09eP80Pev4qBxZLbxJK/ONnqfSYAPlCmPh+7BDboYtO/3BmB6HGzxDIT0SlTRc2tzS8lQqfcdLtZ0Kg==",
+ "node_modules/clipboard-image": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/clipboard-image/-/clipboard-image-0.1.0.tgz",
+ "integrity": "sha512-SWk7FgaXLNFld19peQ/rTe0n97lwR1WbkqxV6JKCAOh7U52AKV/PeMFCyt/8IhBdqyDA8rdyewQMKZqvWT5Akg==",
"license": "MIT",
"dependencies": {
- "execa": "^9.6.0",
+ "run-jxa": "^3.0.0"
+ },
+ "bin": {
+ "clipboard-image": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/clipboardy": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.2.1.tgz",
+ "integrity": "sha512-RWp4E/ivQAzgF4QSWA9sjeW+Bjo+U2SvebkDhNIfO7y65eGdXPUxMTdIKYsn+bxM3ItPHGm3e68Bv3fgQ3mARw==",
+ "license": "MIT",
+ "dependencies": {
+ "clipboard-image": "^0.1.0",
+ "execa": "^9.6.1",
"is-wayland": "^0.1.0",
"is-wsl": "^3.1.0",
- "is64bit": "^2.0.0"
+ "is64bit": "^2.0.0",
+ "powershell-utils": "^0.2.0"
},
"engines": {
"node": ">=20"
@@ -6570,7 +6583,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
- "peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -6740,6 +6752,33 @@
"node": ">= 8"
}
},
+ "node_modules/crypto-random-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
+ "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==",
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/crypto-random-string/node_modules/type-fest": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
+ "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -8430,9 +8469,9 @@
}
},
"node_modules/execa": {
- "version": "9.6.0",
- "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
- "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
+ "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
@@ -8550,36 +8589,15 @@
"express": ">= 4.11"
}
},
- "node_modules/express/node_modules/cookie": {
- "version": "0.7.1",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
- "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">= 0.6"
- }
- },
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"ms": "2.0.0"
}
},
- "node_modules/express/node_modules/statuses": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
- "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -8839,7 +8857,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -8848,18 +8865,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/finalhandler/node_modules/statuses": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
- "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">= 0.8"
- }
+ "license": "MIT"
},
"node_modules/find-up": {
"version": "5.0.0",
@@ -11735,6 +11741,21 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
+ "node_modules/macos-version": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/macos-version/-/macos-version-6.0.0.tgz",
+ "integrity": "sha512-O2S8voA+pMfCHhBn/TIYDXzJ1qNHpPDU32oFxglKnVdJABiYYITt45oLkV9yhwA3E2FDwn3tQqUFrTsr1p3sBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -11870,6 +11891,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "license": "MIT"
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -13420,6 +13447,18 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/powershell-utils": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz",
+ "integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -14302,6 +14341,107 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/run-jxa": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/run-jxa/-/run-jxa-3.0.0.tgz",
+ "integrity": "sha512-4f2CrY7H+sXkKXJn/cE6qRA3z+NMVO7zvlZ/nUV0e62yWftpiLAfw5eV9ZdomzWd2TXWwEIiGjAT57+lWIzzvA==",
+ "license": "MIT",
+ "dependencies": {
+ "execa": "^5.1.1",
+ "macos-version": "^6.0.0",
+ "subsume": "^4.0.0",
+ "type-fest": "^2.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/run-jxa/node_modules/execa": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^6.0.0",
+ "human-signals": "^2.1.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.1",
+ "onetime": "^5.1.2",
+ "signal-exit": "^3.0.3",
+ "strip-final-newline": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/run-jxa/node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/run-jxa/node_modules/human-signals": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=10.17.0"
+ }
+ },
+ "node_modules/run-jxa/node_modules/npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/run-jxa/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC"
+ },
+ "node_modules/run-jxa/node_modules/strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/run-jxa/node_modules/type-fest": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
+ "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -15238,6 +15378,34 @@
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
"license": "MIT"
},
+ "node_modules/subsume": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/subsume/-/subsume-4.0.0.tgz",
+ "integrity": "sha512-BWnYJElmHbYZ/zKevy+TG+SsyoFCmRPDHJbR1MzLxkPOv1Jp/4hGhVUtP98s+wZBsBsHwCXvPTP0x287/WMjGg==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^5.0.0",
+ "unique-string": "^3.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/subsume/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/superagent": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz",
@@ -16220,6 +16388,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/unique-string": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz",
+ "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "crypto-random-string": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/universal-user-agent": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
@@ -16286,16 +16469,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
- "node_modules/utils-merge": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
- "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">= 0.4.0"
- }
- },
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@@ -17130,7 +17303,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -17188,7 +17361,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -17200,7 +17373,7 @@
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
- "clipboardy": "^5.0.0",
+ "clipboardy": "~5.2.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
@@ -17271,7 +17444,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -17536,7 +17709,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17551,7 +17724,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17568,7 +17741,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17585,7 +17758,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
diff --git a/package.json b/package.json
index b1053f5b8a..8d931c1462 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
- "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0"
+ "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260304.28af4e127"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json
index 0428a84311..b70ea8986a 100644
--- a/packages/a2a-server/package.json
+++ b/packages/a2a-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts
index c969e601c3..fe15aed37b 100644
--- a/packages/a2a-server/src/agent/task.ts
+++ b/packages/a2a-server/src/agent/task.ts
@@ -28,6 +28,9 @@ import {
type Config,
type UserTierId,
type ToolLiveOutput,
+ type AnsiLine,
+ type AnsiOutput,
+ type AnsiToken,
isSubagentProgress,
EDIT_TOOL_NAMES,
processRestorableToolCalls,
@@ -344,10 +347,15 @@ export class Task {
outputAsText = outputChunk;
} else if (isSubagentProgress(outputChunk)) {
outputAsText = JSON.stringify(outputChunk);
- } else {
- outputAsText = outputChunk
- .map((line) => line.map((token) => token.text).join(''))
+ } else if (Array.isArray(outputChunk)) {
+ const ansiOutput: AnsiOutput = outputChunk;
+ outputAsText = ansiOutput
+ .map((line: AnsiLine) =>
+ line.map((token: AnsiToken) => token.text).join(''),
+ )
.join('\n');
+ } else {
+ outputAsText = String(outputChunk);
}
logger.info(
diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts
index c676e46289..ee63df36f7 100644
--- a/packages/a2a-server/src/config/config.test.ts
+++ b/packages/a2a-server/src/config/config.test.ts
@@ -16,6 +16,9 @@ import {
ExperimentFlags,
fetchAdminControlsOnce,
type FetchAdminControlsResponse,
+ AuthType,
+ isHeadlessMode,
+ FatalAuthenticationError,
} from '@google/gemini-cli-core';
// Mock dependencies
@@ -50,6 +53,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
startupProfiler: {
flush: vi.fn(),
},
+ isHeadlessMode: vi.fn().mockReturnValue(false),
FileDiscoveryService: vi.fn(),
getCodeAssistServer: vi.fn(),
fetchAdminControlsOnce: vi.fn(),
@@ -62,6 +66,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
vi.mock('../utils/logger.js', () => ({
logger: {
info: vi.fn(),
+ warn: vi.fn(),
error: vi.fn(),
},
}));
@@ -73,12 +78,11 @@ describe('loadConfig', () => {
beforeEach(() => {
vi.clearAllMocks();
- process.env['GEMINI_API_KEY'] = 'test-key';
+ vi.stubEnv('GEMINI_API_KEY', 'test-key');
});
afterEach(() => {
- delete process.env['CUSTOM_IGNORE_FILE_PATHS'];
- delete process.env['GEMINI_API_KEY'];
+ vi.unstubAllEnvs();
});
describe('admin settings overrides', () => {
@@ -199,7 +203,7 @@ describe('loadConfig', () => {
it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
const testPath = '/tmp/ignore';
- process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
@@ -224,7 +228,7 @@ describe('loadConfig', () => {
it('should merge customIgnoreFilePaths from settings and env var', async () => {
const envPath = '/env/ignore';
const settingsPath = '/settings/ignore';
- process.env['CUSTOM_IGNORE_FILE_PATHS'] = envPath;
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', envPath);
const settings: Settings = {
fileFiltering: {
customIgnoreFilePaths: [settingsPath],
@@ -240,7 +244,7 @@ describe('loadConfig', () => {
it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => {
const paths = ['/path/one', '/path/two'];
- process.env['CUSTOM_IGNORE_FILE_PATHS'] = paths.join(path.delimiter);
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', paths.join(path.delimiter));
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths);
@@ -254,7 +258,7 @@ describe('loadConfig', () => {
it('should initialize FileDiscoveryService with correct options', async () => {
const testPath = '/tmp/ignore';
- process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
const settings: Settings = {
fileFiltering: {
respectGitIgnore: false,
@@ -311,5 +315,219 @@ describe('loadConfig', () => {
}),
);
});
+
+ describe('interactivity', () => {
+ it('should set interactive true when not headless', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ interactive: true,
+ enableInteractiveShell: true,
+ }),
+ );
+ });
+
+ it('should set interactive false when headless', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(true);
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ interactive: false,
+ enableInteractiveShell: false,
+ }),
+ );
+ });
+ });
+
+ describe('authentication fallback', () => {
+ beforeEach(() => {
+ vi.stubEnv('USE_CCPA', 'true');
+ vi.stubEnv('GEMINI_API_KEY', '');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => {
+ vi.stubEnv('CLOUD_SHELL', 'true');
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
+ const refreshAuthMock = vi.fn().mockImplementation((authType) => {
+ if (authType === AuthType.LOGIN_WITH_GOOGLE) {
+ throw new FatalAuthenticationError('Non-interactive session');
+ }
+ return Promise.resolve();
+ });
+
+ // Update the mock implementation for this test
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+
+ expect(refreshAuthMock).toHaveBeenCalledWith(
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
+ });
+
+ it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
+ const refreshAuthMock = vi.fn().mockImplementation((authType) => {
+ if (authType === AuthType.LOGIN_WITH_GOOGLE) {
+ throw new FatalAuthenticationError('Non-interactive session');
+ }
+ return Promise.resolve();
+ });
+
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+
+ await expect(
+ loadConfig(mockSettings, mockExtensionLoader, taskId),
+ ).rejects.toThrow('Non-interactive session');
+
+ expect(refreshAuthMock).toHaveBeenCalledWith(
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
+ });
+
+ it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => {
+ vi.stubEnv('CLOUD_SHELL', 'true');
+ vi.mocked(isHeadlessMode).mockReturnValue(true);
+
+ const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
+
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+
+ expect(refreshAuthMock).not.toHaveBeenCalledWith(
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
+ });
+
+ it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => {
+ vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
+ vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless
+
+ const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
+
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+
+ expect(refreshAuthMock).not.toHaveBeenCalledWith(
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
+ });
+
+ it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(true);
+
+ const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
+
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+
+ await expect(
+ loadConfig(mockSettings, mockExtensionLoader, taskId),
+ ).rejects.toThrow(
+ 'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.',
+ );
+
+ expect(refreshAuthMock).not.toHaveBeenCalled();
+ });
+
+ it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => {
+ vi.stubEnv('CLOUD_SHELL', 'true');
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
+
+ const refreshAuthMock = vi.fn().mockImplementation((authType) => {
+ if (authType === AuthType.LOGIN_WITH_GOOGLE) {
+ throw new FatalAuthenticationError('OAuth failed');
+ }
+ if (authType === AuthType.COMPUTE_ADC) {
+ throw new Error('ADC failed');
+ }
+ return Promise.resolve();
+ });
+
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+
+ await expect(
+ loadConfig(mockSettings, mockExtensionLoader, taskId),
+ ).rejects.toThrow(
+ 'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
+ );
+ });
+ });
});
});
diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts
index f3100bce4d..1b236f9ac7 100644
--- a/packages/a2a-server/src/config/config.ts
+++ b/packages/a2a-server/src/config/config.ts
@@ -23,6 +23,9 @@ import {
fetchAdminControlsOnce,
getCodeAssistServer,
ExperimentFlags,
+ isHeadlessMode,
+ FatalAuthenticationError,
+ isCloudShell,
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
@@ -103,8 +106,8 @@ export async function loadConfig(
trustedFolder: true,
extensionLoader,
checkpointing,
- interactive: true,
- enableInteractiveShell: true,
+ interactive: !isHeadlessMode(),
+ enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
};
@@ -255,7 +258,61 @@ async function refreshAuthentication(
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
);
}
- await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
+
+ const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
+ const isHeadless = isHeadlessMode();
+ const shouldSkipOauth = isHeadless || useComputeAdc;
+
+ if (shouldSkipOauth) {
+ if (isCloudShell() || useComputeAdc) {
+ logger.info(
+ `[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`,
+ );
+ try {
+ await config.refreshAuth(AuthType.COMPUTE_ADC);
+ logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
+ } catch (adcError) {
+ const adcMessage =
+ adcError instanceof Error ? adcError.message : String(adcError);
+ throw new FatalAuthenticationError(
+ `COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`,
+ );
+ }
+ } else {
+ throw new FatalAuthenticationError(
+ `Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`,
+ );
+ }
+ } else {
+ try {
+ await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
+ } catch (e) {
+ if (
+ e instanceof FatalAuthenticationError &&
+ (isCloudShell() || useComputeAdc)
+ ) {
+ logger.warn(
+ `[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`,
+ );
+ try {
+ await config.refreshAuth(AuthType.COMPUTE_ADC);
+ logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`);
+ } catch (adcError) {
+ logger.error(
+ `[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`,
+ );
+ const originalMessage = e instanceof Error ? e.message : String(e);
+ const adcMessage =
+ adcError instanceof Error ? adcError.message : String(adcError);
+ throw new FatalAuthenticationError(
+ `${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`,
+ );
+ }
+ } else {
+ throw e;
+ }
+ }
+ }
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
diff --git a/packages/cli/package.json b/packages/cli/package.json
index f4fd2f7bd1..cc561eeb8c 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
- "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0"
+ "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260304.28af4e127"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -38,7 +38,7 @@
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
- "clipboardy": "^5.0.0",
+ "clipboardy": "~5.2.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
diff --git a/packages/cli/src/zed-integration/zedIntegration.test.ts b/packages/cli/src/acp/acpClient.test.ts
similarity index 78%
rename from packages/cli/src/zed-integration/zedIntegration.test.ts
rename to packages/cli/src/acp/acpClient.test.ts
index e8e5355dc0..0922e3a510 100644
--- a/packages/cli/src/zed-integration/zedIntegration.test.ts
+++ b/packages/cli/src/acp/acpClient.test.ts
@@ -14,7 +14,8 @@ import {
type Mock,
type Mocked,
} from 'vitest';
-import { GeminiAgent, Session } from './zedIntegration.js';
+import { GeminiAgent, Session } from './acpClient.js';
+import type { CommandHandler } from './commandHandler.js';
import * as acp from '@agentclientprotocol/sdk';
import {
AuthType,
@@ -26,6 +27,7 @@ import {
type Config,
type MessageBus,
LlmRole,
+ type GitService,
} from '@google/gemini-cli-core';
import {
SettingScope,
@@ -62,7 +64,33 @@ vi.mock('node:path', async (importOriginal) => {
};
});
-// Mock ReadManyFilesTool
+vi.mock('../ui/commands/memoryCommand.js', () => ({
+ memoryCommand: {
+ name: 'memory',
+ action: vi.fn(),
+ },
+}));
+
+vi.mock('../ui/commands/extensionsCommand.js', () => ({
+ extensionsCommand: vi.fn().mockReturnValue({
+ name: 'extensions',
+ action: vi.fn(),
+ }),
+}));
+
+vi.mock('../ui/commands/restoreCommand.js', () => ({
+ restoreCommand: vi.fn().mockReturnValue({
+ name: 'restore',
+ action: vi.fn(),
+ }),
+}));
+
+vi.mock('../ui/commands/initCommand.js', () => ({
+ initCommand: {
+ name: 'init',
+ action: vi.fn(),
+ },
+}));
vi.mock(
'@google/gemini-cli-core',
async (
@@ -145,6 +173,9 @@ describe('GeminiAgent', () => {
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
+ getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
+ getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
+ getCheckpointingEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked>>;
mockSettings = {
merged: {
@@ -177,7 +208,16 @@ describe('GeminiAgent', () => {
});
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
- expect(response.authMethods).toHaveLength(3);
+ expect(response.authMethods).toHaveLength(4);
+ const gatewayAuth = response.authMethods?.find(
+ (m) => m.id === AuthType.GATEWAY,
+ );
+ expect(gatewayAuth?._meta).toEqual({
+ gateway: {
+ protocol: 'google',
+ restartRequired: 'false',
+ },
+ });
const geminiAuth = response.authMethods?.find(
(m) => m.id === AuthType.USE_GEMINI,
);
@@ -197,6 +237,8 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
undefined,
+ undefined,
+ undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -216,6 +258,8 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
'test-api-key',
+ undefined,
+ undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -224,7 +268,47 @@ describe('GeminiAgent', () => {
);
});
+ it('should authenticate correctly with gateway method', async () => {
+ await agent.authenticate({
+ methodId: AuthType.GATEWAY,
+ _meta: {
+ gateway: {
+ baseUrl: 'https://example.com',
+ headers: { Authorization: 'Bearer token' },
+ },
+ },
+ } as unknown as acp.AuthenticateRequest);
+
+ expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
+ AuthType.GATEWAY,
+ undefined,
+ 'https://example.com',
+ { Authorization: 'Bearer token' },
+ );
+ expect(mockSettings.setValue).toHaveBeenCalledWith(
+ SettingScope.User,
+ 'security.auth.selectedType',
+ AuthType.GATEWAY,
+ );
+ });
+
+ it('should throw acp.RequestError when gateway payload is malformed', async () => {
+ await expect(
+ agent.authenticate({
+ methodId: AuthType.GATEWAY,
+ _meta: {
+ gateway: {
+ // Invalid baseUrl
+ baseUrl: 123,
+ headers: { Authorization: 'Bearer token' },
+ },
+ },
+ } as unknown as acp.AuthenticateRequest),
+ ).rejects.toThrow(/Malformed gateway payload/);
+ });
+
it('should create a new session', async () => {
+ vi.useFakeTimers();
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
@@ -237,6 +321,17 @@ describe('GeminiAgent', () => {
expect(loadCliConfig).toHaveBeenCalled();
expect(mockConfig.initialize).toHaveBeenCalled();
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
+
+ // Verify deferred call
+ await vi.runAllTimersAsync();
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ update: expect.objectContaining({
+ sessionUpdate: 'available_commands_update',
+ }),
+ }),
+ );
+ vi.useRealTimers();
});
it('should return modes without plan mode when plan is disabled', async () => {
@@ -263,6 +358,38 @@ describe('GeminiAgent', () => {
],
currentModeId: 'default',
});
+ expect(response.models).toEqual({
+ availableModels: expect.arrayContaining([
+ expect.objectContaining({
+ modelId: 'auto-gemini-2.5',
+ name: 'Auto (Gemini 2.5)',
+ }),
+ ]),
+ currentModelId: 'gemini-pro',
+ });
+ });
+
+ it('should include preview models when user has access', async () => {
+ mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
+ mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
+
+ const response = await agent.newSession({
+ cwd: '/tmp',
+ mcpServers: [],
+ });
+
+ expect(response.models?.availableModels).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ modelId: 'auto-gemini-3',
+ name: expect.stringContaining('Auto'),
+ }),
+ expect.objectContaining({
+ modelId: 'gemini-3.1-pro-preview',
+ name: 'gemini-3.1-pro-preview',
+ }),
+ ]),
+ );
});
it('should return modes with plan mode when plan is enabled', async () => {
@@ -290,6 +417,15 @@ describe('GeminiAgent', () => {
],
currentModeId: 'plan',
});
+ expect(response.models).toEqual({
+ availableModels: expect.arrayContaining([
+ expect.objectContaining({
+ modelId: 'auto-gemini-2.5',
+ name: 'Auto (Gemini 2.5)',
+ }),
+ ]),
+ currentModelId: 'gemini-pro',
+ });
});
it('should fail session creation if Gemini API key is missing', async () => {
@@ -439,6 +575,32 @@ describe('GeminiAgent', () => {
}),
).rejects.toThrow('Session not found: unknown');
});
+
+ it('should delegate setModel to session (unstable)', async () => {
+ await agent.newSession({ cwd: '/tmp', mcpServers: [] });
+ const session = (
+ agent as unknown as { sessions: Map }
+ ).sessions.get('test-session-id');
+ if (!session) throw new Error('Session not found');
+ session.setModel = vi.fn().mockReturnValue({});
+
+ const result = await agent.unstable_setSessionModel({
+ sessionId: 'test-session-id',
+ modelId: 'gemini-2.0-pro-exp',
+ });
+
+ expect(session.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
+ expect(result).toEqual({});
+ });
+
+ it('should throw error when setting model on non-existent session (unstable)', async () => {
+ await expect(
+ agent.unstable_setSessionModel({
+ sessionId: 'unknown',
+ modelId: 'gemini-2.0-pro-exp',
+ }),
+ ).rejects.toThrow('Session not found: unknown');
+ });
});
describe('Session', () => {
@@ -477,6 +639,7 @@ describe('Session', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
+ getMcpServers: vi.fn(),
getFileService: vi.fn().mockReturnValue({
shouldIgnoreFile: vi.fn().mockReturnValue(false),
}),
@@ -486,7 +649,10 @@ describe('Session', () => {
getDebugMode: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
setApprovalMode: vi.fn(),
+ setModel: vi.fn(),
isPlanEnabled: vi.fn().mockReturnValue(false),
+ getCheckpointingEnabled: vi.fn().mockReturnValue(false),
+ getGitService: vi.fn().mockResolvedValue({} as GitService),
waitForMcpInit: vi.fn(),
} as unknown as Mocked;
mockConnection = {
@@ -495,13 +661,38 @@ describe('Session', () => {
sendNotification: vi.fn(),
} as unknown as Mocked;
- session = new Session('session-1', mockChat, mockConfig, mockConnection);
+ session = new Session('session-1', mockChat, mockConfig, mockConnection, {
+ system: { settings: {} },
+ systemDefaults: { settings: {} },
+ user: { settings: {} },
+ workspace: { settings: {} },
+ merged: { settings: {} },
+ errors: [],
+ } as unknown as LoadedSettings);
});
afterEach(() => {
vi.clearAllMocks();
});
+ it('should send available commands', async () => {
+ await session.sendAvailableCommands();
+
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ update: expect.objectContaining({
+ sessionUpdate: 'available_commands_update',
+ availableCommands: expect.arrayContaining([
+ expect.objectContaining({ name: 'memory' }),
+ expect.objectContaining({ name: 'extensions' }),
+ expect.objectContaining({ name: 'restore' }),
+ expect.objectContaining({ name: 'init' }),
+ ]),
+ }),
+ }),
+ );
+ });
+
it('should await MCP initialization before processing a prompt', async () => {
const stream = createMockStream([
{
@@ -551,6 +742,113 @@ describe('Session', () => {
expect(result).toEqual({ stopReason: 'end_turn' });
});
+ it('should handle /memory command', async () => {
+ const handleCommandSpy = vi
+ .spyOn(
+ (session as unknown as { commandHandler: CommandHandler })
+ .commandHandler,
+ 'handleCommand',
+ )
+ .mockResolvedValue(true);
+
+ const result = await session.prompt({
+ sessionId: 'session-1',
+ prompt: [{ type: 'text', text: '/memory view' }],
+ });
+
+ expect(result).toEqual({ stopReason: 'end_turn' });
+ expect(handleCommandSpy).toHaveBeenCalledWith(
+ '/memory view',
+ expect.any(Object),
+ );
+ expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
+ });
+
+ it('should handle /extensions command', async () => {
+ const handleCommandSpy = vi
+ .spyOn(
+ (session as unknown as { commandHandler: CommandHandler })
+ .commandHandler,
+ 'handleCommand',
+ )
+ .mockResolvedValue(true);
+
+ const result = await session.prompt({
+ sessionId: 'session-1',
+ prompt: [{ type: 'text', text: '/extensions list' }],
+ });
+
+ expect(result).toEqual({ stopReason: 'end_turn' });
+ expect(handleCommandSpy).toHaveBeenCalledWith(
+ '/extensions list',
+ expect.any(Object),
+ );
+ expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
+ });
+
+ it('should handle /extensions explore command', async () => {
+ const handleCommandSpy = vi
+ .spyOn(
+ (session as unknown as { commandHandler: CommandHandler })
+ .commandHandler,
+ 'handleCommand',
+ )
+ .mockResolvedValue(true);
+
+ const result = await session.prompt({
+ sessionId: 'session-1',
+ prompt: [{ type: 'text', text: '/extensions explore' }],
+ });
+
+ expect(result).toEqual({ stopReason: 'end_turn' });
+ expect(handleCommandSpy).toHaveBeenCalledWith(
+ '/extensions explore',
+ expect.any(Object),
+ );
+ expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
+ });
+
+ it('should handle /restore command', async () => {
+ const handleCommandSpy = vi
+ .spyOn(
+ (session as unknown as { commandHandler: CommandHandler })
+ .commandHandler,
+ 'handleCommand',
+ )
+ .mockResolvedValue(true);
+
+ const result = await session.prompt({
+ sessionId: 'session-1',
+ prompt: [{ type: 'text', text: '/restore' }],
+ });
+
+ expect(result).toEqual({ stopReason: 'end_turn' });
+ expect(handleCommandSpy).toHaveBeenCalledWith(
+ '/restore',
+ expect.any(Object),
+ );
+ expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
+ });
+
+ it('should handle /init command', async () => {
+ const handleCommandSpy = vi
+ .spyOn(
+ (session as unknown as { commandHandler: CommandHandler })
+ .commandHandler,
+ 'handleCommand',
+ )
+ .mockResolvedValue(true);
+
+ const result = await session.prompt({
+ sessionId: 'session-1',
+ prompt: [{ type: 'text', text: '/init' }],
+ });
+
+ expect(result).toEqual({ stopReason: 'end_turn' });
+ expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object));
+ expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
+ });
+
it('should handle tool calls', async () => {
const stream1 = createMockStream([
{
@@ -1207,4 +1505,30 @@ describe('Session', () => {
'Invalid or unavailable mode: invalid-mode',
);
});
+
+ it('should set model on config', () => {
+ session.setModel('gemini-2.0-flash-exp');
+ expect(mockConfig.setModel).toHaveBeenCalledWith('gemini-2.0-flash-exp');
+ });
+
+ it('should handle unquoted commands from autocomplete (with empty leading parts)', async () => {
+ // Mock handleCommand to verify it gets called
+ const handleCommandSpy = vi
+ .spyOn(
+ (session as unknown as { commandHandler: CommandHandler })
+ .commandHandler,
+ 'handleCommand',
+ )
+ .mockResolvedValue(true);
+
+ await session.prompt({
+ sessionId: 'session-1',
+ prompt: [
+ { type: 'text', text: '' },
+ { type: 'text', text: '/memory' },
+ ],
+ });
+
+ expect(handleCommandSpy).toHaveBeenCalledWith('/memory', expect.anything());
+ });
});
diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/acp/acpClient.ts
similarity index 83%
rename from packages/cli/src/zed-integration/zedIntegration.ts
rename to packages/cli/src/acp/acpClient.ts
index 98c9efdc75..2a8a524ff8 100644
--- a/packages/cli/src/zed-integration/zedIntegration.ts
+++ b/packages/cli/src/acp/acpClient.ts
@@ -4,15 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type {
- Config,
- GeminiChat,
- ToolResult,
- ToolCallConfirmationDetails,
- FilterFilesOptions,
- ConversationRecord,
-} from '@google/gemini-cli-core';
import {
+ type Config,
+ type GeminiChat,
+ type ToolResult,
+ type ToolCallConfirmationDetails,
+ type FilterFilesOptions,
+ type ConversationRecord,
CoreToolCallStatus,
AuthType,
logToolCall,
@@ -39,6 +37,16 @@ import {
ApprovalMode,
getVersion,
convertSessionToClientHistory,
+ DEFAULT_GEMINI_MODEL,
+ DEFAULT_GEMINI_FLASH_MODEL,
+ DEFAULT_GEMINI_FLASH_LITE_MODEL,
+ PREVIEW_GEMINI_MODEL,
+ PREVIEW_GEMINI_3_1_MODEL,
+ PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
+ PREVIEW_GEMINI_FLASH_MODEL,
+ DEFAULT_GEMINI_MODEL_AUTO,
+ PREVIEW_GEMINI_MODEL_AUTO,
+ getDisplayString,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -61,11 +69,14 @@ import { loadCliConfig } from '../config/config.js';
import { runExitCleanup } from '../utils/cleanup.js';
import { SessionSelector } from '../utils/sessionUtils.js';
-export async function runZedIntegration(
+import { CommandHandler } from './commandHandler.js';
+export async function runAcpClient(
config: Config,
settings: LoadedSettings,
argv: CliArgs,
) {
+ // ... (skip unchanged lines) ...
+
const { stdout: workingStdout } = createWorkingStdio();
const stdout = Writable.toWeb(workingStdout) as WritableStream;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -87,6 +98,8 @@ export class GeminiAgent {
private sessions: Map = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
private apiKey: string | undefined;
+ private baseUrl: string | undefined;
+ private customHeaders: Record | undefined;
constructor(
private config: Config,
@@ -120,6 +133,17 @@ export class GeminiAgent {
name: 'Vertex AI',
description: 'Use an API key with Vertex AI GenAI API',
},
+ {
+ id: AuthType.GATEWAY,
+ name: 'AI API Gateway',
+ description: 'Use a custom AI API Gateway',
+ _meta: {
+ gateway: {
+ protocol: 'google',
+ restartRequired: 'false',
+ },
+ },
+ },
];
await this.config.initialize();
@@ -168,7 +192,38 @@ export class GeminiAgent {
if (apiKey) {
this.apiKey = apiKey;
}
- await this.config.refreshAuth(method, apiKey ?? this.apiKey);
+
+ // Extract gateway details if present
+ const gatewaySchema = z.object({
+ baseUrl: z.string().optional(),
+ headers: z.record(z.string()).optional(),
+ });
+
+ let baseUrl: string | undefined;
+ let headers: Record | undefined;
+
+ if (meta?.['gateway']) {
+ const result = gatewaySchema.safeParse(meta['gateway']);
+ if (result.success) {
+ baseUrl = result.data.baseUrl;
+ headers = result.data.headers;
+ } else {
+ throw new acp.RequestError(
+ -32602,
+ `Malformed gateway payload: ${result.error.message}`,
+ );
+ }
+ }
+
+ this.baseUrl = baseUrl;
+ this.customHeaders = headers;
+
+ await this.config.refreshAuth(
+ method,
+ apiKey ?? this.apiKey,
+ baseUrl,
+ headers,
+ );
} catch (e) {
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
}
@@ -198,7 +253,12 @@ export class GeminiAgent {
let isAuthenticated = false;
let authErrorMessage = '';
try {
- await config.refreshAuth(authType, this.apiKey);
+ await config.refreshAuth(
+ authType,
+ this.apiKey,
+ this.baseUrl,
+ this.customHeaders,
+ );
isAuthenticated = true;
// Extra validation for Gemini API key
@@ -240,16 +300,37 @@ export class GeminiAgent {
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
- const session = new Session(sessionId, chat, config, this.connection);
+ const session = new Session(
+ sessionId,
+ chat,
+ config,
+ this.connection,
+ this.settings,
+ );
this.sessions.set(sessionId, session);
- return {
+ setTimeout(() => {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ session.sendAvailableCommands();
+ }, 0);
+
+ const { availableModels, currentModelId } = buildAvailableModels(
+ config,
+ loadedSettings,
+ );
+
+ const response = {
sessionId,
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
+ models: {
+ availableModels,
+ currentModelId,
+ },
};
+ return response;
}
async loadSession({
@@ -291,6 +372,7 @@ export class GeminiAgent {
geminiClient.getChat(),
config,
this.connection,
+ this.settings,
);
this.sessions.set(sessionId, session);
@@ -298,12 +380,27 @@ export class GeminiAgent {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.streamHistory(sessionData.messages);
- return {
+ setTimeout(() => {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ session.sendAvailableCommands();
+ }, 0);
+
+ const { availableModels, currentModelId } = buildAvailableModels(
+ config,
+ this.settings,
+ );
+
+ const response = {
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
+ models: {
+ availableModels,
+ currentModelId,
+ },
};
+ return response;
}
private async initializeSessionConfig(
@@ -323,7 +420,12 @@ export class GeminiAgent {
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
- await config.refreshAuth(selectedAuthType, this.apiKey);
+ await config.refreshAuth(
+ selectedAuthType,
+ this.apiKey,
+ this.baseUrl,
+ this.customHeaders,
+ );
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
@@ -414,16 +516,28 @@ export class GeminiAgent {
}
return session.setMode(params.modeId);
}
+
+ async unstable_setSessionModel(
+ params: acp.SetSessionModelRequest,
+ ): Promise {
+ const session = this.sessions.get(params.sessionId);
+ if (!session) {
+ throw new Error(`Session not found: ${params.sessionId}`);
+ }
+ return session.setModel(params.modelId);
+ }
}
export class Session {
private pendingPrompt: AbortController | null = null;
+ private commandHandler = new CommandHandler();
constructor(
private readonly id: string,
private readonly chat: GeminiChat,
private readonly config: Config,
private readonly connection: acp.AgentSideConnection,
+ private readonly settings: LoadedSettings,
) {}
async cancelPendingPrompt(): Promise {
@@ -446,6 +560,27 @@ export class Session {
return {};
}
+ private getAvailableCommands() {
+ return this.commandHandler.getAvailableCommands();
+ }
+
+ async sendAvailableCommands(): Promise {
+ const availableCommands = this.getAvailableCommands().map((command) => ({
+ name: command.name,
+ description: command.description,
+ }));
+
+ await this.sendUpdate({
+ sessionUpdate: 'available_commands_update',
+ availableCommands,
+ });
+ }
+
+ setModel(modelId: acp.ModelId): acp.SetSessionModelResponse {
+ this.config.setModel(modelId);
+ return {};
+ }
+
async streamHistory(messages: ConversationRecord['messages']): Promise {
for (const msg of messages) {
const contentString = partListUnionToString(msg.content);
@@ -528,6 +663,41 @@ export class Session {
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
+ // Command interception
+ let commandText = '';
+
+ for (const part of parts) {
+ if (typeof part === 'object' && part !== null) {
+ if ('text' in part) {
+ // It is a text part
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion
+ const text = (part as any).text;
+ if (typeof text === 'string') {
+ commandText += text;
+ }
+ } else {
+ // Non-text part (image, embedded resource)
+ // Stop looking for command
+ break;
+ }
+ }
+ }
+
+ commandText = commandText.trim();
+
+ if (
+ commandText &&
+ (commandText.startsWith('/') || commandText.startsWith('$'))
+ ) {
+ // If we found a command, pass it to handleCommand
+ // Note: handleCommand currently expects `commandText` to be the command string
+ // It uses `parts` argument but effectively ignores it in current implementation
+ const handled = await this.handleCommand(commandText, parts);
+ if (handled) {
+ return { stopReason: 'end_turn' };
+ }
+ }
+
let nextMessage: Content | null = { role: 'user', parts };
while (nextMessage !== null) {
@@ -627,9 +797,28 @@ export class Session {
return { stopReason: 'end_turn' };
}
- private async sendUpdate(
- update: acp.SessionNotification['update'],
- ): Promise {
+ private async handleCommand(
+ commandText: string,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ parts: Part[],
+ ): Promise {
+ const gitService = await this.config.getGitService();
+ const commandContext = {
+ config: this.config,
+ settings: this.settings,
+ git: gitService,
+ sendMessage: async (text: string) => {
+ await this.sendUpdate({
+ sessionUpdate: 'agent_message_chunk',
+ content: { type: 'text', text },
+ });
+ },
+ };
+
+ return this.commandHandler.handleCommand(commandText, commandContext);
+ }
+
+ private async sendUpdate(update: acp.SessionUpdate): Promise {
const params: acp.SessionNotification = {
sessionId: this.id,
update,
@@ -1377,3 +1566,94 @@ function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
return modes;
}
+
+function buildAvailableModels(
+ config: Config,
+ settings: LoadedSettings,
+): {
+ availableModels: Array<{
+ modelId: string;
+ name: string;
+ description?: string;
+ }>;
+ currentModelId: string;
+} {
+ const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
+ const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
+ const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
+ const selectedAuthType = settings.merged.security.auth.selectedType;
+ const useCustomToolModel =
+ useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
+
+ const mainOptions = [
+ {
+ value: DEFAULT_GEMINI_MODEL_AUTO,
+ title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
+ description:
+ 'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
+ },
+ ];
+
+ if (shouldShowPreviewModels) {
+ mainOptions.unshift({
+ value: PREVIEW_GEMINI_MODEL_AUTO,
+ title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
+ description: useGemini31
+ ? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
+ : 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
+ });
+ }
+
+ const manualOptions = [
+ {
+ value: DEFAULT_GEMINI_MODEL,
+ title: getDisplayString(DEFAULT_GEMINI_MODEL),
+ },
+ {
+ value: DEFAULT_GEMINI_FLASH_MODEL,
+ title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
+ },
+ {
+ value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
+ title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
+ },
+ ];
+
+ if (shouldShowPreviewModels) {
+ const previewProModel = useGemini31
+ ? PREVIEW_GEMINI_3_1_MODEL
+ : PREVIEW_GEMINI_MODEL;
+
+ const previewProValue = useCustomToolModel
+ ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
+ : previewProModel;
+
+ manualOptions.unshift(
+ {
+ value: previewProValue,
+ title: getDisplayString(previewProModel),
+ },
+ {
+ value: PREVIEW_GEMINI_FLASH_MODEL,
+ title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
+ },
+ );
+ }
+
+ const scaleOptions = (
+ options: Array<{ value: string; title: string; description?: string }>,
+ ) =>
+ options.map((o) => ({
+ modelId: o.value,
+ name: o.title,
+ description: o.description,
+ }));
+
+ return {
+ availableModels: [
+ ...scaleOptions(mainOptions),
+ ...scaleOptions(manualOptions),
+ ],
+ currentModelId: preferredModel,
+ };
+}
diff --git a/packages/cli/src/zed-integration/acpErrors.test.ts b/packages/cli/src/acp/acpErrors.test.ts
similarity index 100%
rename from packages/cli/src/zed-integration/acpErrors.test.ts
rename to packages/cli/src/acp/acpErrors.test.ts
diff --git a/packages/cli/src/zed-integration/acpErrors.ts b/packages/cli/src/acp/acpErrors.ts
similarity index 100%
rename from packages/cli/src/zed-integration/acpErrors.ts
rename to packages/cli/src/acp/acpErrors.ts
diff --git a/packages/cli/src/zed-integration/acpResume.test.ts b/packages/cli/src/acp/acpResume.test.ts
similarity index 95%
rename from packages/cli/src/zed-integration/acpResume.test.ts
rename to packages/cli/src/acp/acpResume.test.ts
index 54c04a0ff3..37354af5c9 100644
--- a/packages/cli/src/zed-integration/acpResume.test.ts
+++ b/packages/cli/src/acp/acpResume.test.ts
@@ -13,7 +13,7 @@ import {
type Mocked,
type Mock,
} from 'vitest';
-import { GeminiAgent } from './zedIntegration.js';
+import { GeminiAgent } from './acpClient.js';
import * as acp from '@agentclientprotocol/sdk';
import {
ApprovalMode,
@@ -93,6 +93,10 @@ describe('GeminiAgent Session Resume', () => {
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
+ getModel: vi.fn().mockReturnValue('gemini-pro'),
+ getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
+ getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
+ getCheckpointingEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked;
mockSettings = {
merged: {
@@ -203,6 +207,10 @@ describe('GeminiAgent Session Resume', () => {
],
currentModeId: ApprovalMode.DEFAULT,
},
+ models: {
+ availableModels: expect.any(Array) as unknown,
+ currentModelId: 'gemini-pro',
+ },
});
// Verify resumeChat received the correct arguments
diff --git a/packages/cli/src/acp/commandHandler.test.ts b/packages/cli/src/acp/commandHandler.test.ts
new file mode 100644
index 0000000000..8e04f014f3
--- /dev/null
+++ b/packages/cli/src/acp/commandHandler.test.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { CommandHandler } from './commandHandler.js';
+import { describe, it, expect } from 'vitest';
+
+describe('CommandHandler', () => {
+ it('parses commands correctly', () => {
+ const handler = new CommandHandler();
+ // @ts-expect-error - testing private method
+ const parse = (query: string) => handler.parseSlashCommand(query);
+
+ const memShow = parse('/memory show');
+ expect(memShow.commandToExecute?.name).toBe('memory show');
+ expect(memShow.args).toBe('');
+
+ const memAdd = parse('/memory add hello world');
+ expect(memAdd.commandToExecute?.name).toBe('memory add');
+ expect(memAdd.args).toBe('hello world');
+
+ const extList = parse('/extensions list');
+ expect(extList.commandToExecute?.name).toBe('extensions list');
+
+ const init = parse('/init');
+ expect(init.commandToExecute?.name).toBe('init');
+ });
+});
diff --git a/packages/cli/src/acp/commandHandler.ts b/packages/cli/src/acp/commandHandler.ts
new file mode 100644
index 0000000000..836cdf7736
--- /dev/null
+++ b/packages/cli/src/acp/commandHandler.ts
@@ -0,0 +1,134 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Command, CommandContext } from './commands/types.js';
+import { CommandRegistry } from './commands/commandRegistry.js';
+import { MemoryCommand } from './commands/memory.js';
+import { ExtensionsCommand } from './commands/extensions.js';
+import { InitCommand } from './commands/init.js';
+import { RestoreCommand } from './commands/restore.js';
+
+export class CommandHandler {
+ private registry: CommandRegistry;
+
+ constructor() {
+ this.registry = CommandHandler.createRegistry();
+ }
+
+ private static createRegistry(): CommandRegistry {
+ const registry = new CommandRegistry();
+ registry.register(new MemoryCommand());
+ registry.register(new ExtensionsCommand());
+ registry.register(new InitCommand());
+ registry.register(new RestoreCommand());
+ return registry;
+ }
+
+ getAvailableCommands(): Array<{ name: string; description: string }> {
+ return this.registry.getAllCommands().map((cmd) => ({
+ name: cmd.name,
+ description: cmd.description,
+ }));
+ }
+
+ /**
+ * Parses and executes a command string if it matches a registered command.
+ * Returns true if a command was handled, false otherwise.
+ */
+ async handleCommand(
+ commandText: string,
+ context: CommandContext,
+ ): Promise {
+ const { commandToExecute, args } = this.parseSlashCommand(commandText);
+
+ if (commandToExecute) {
+ await this.runCommand(commandToExecute, args, context);
+ return true;
+ }
+
+ return false;
+ }
+
+ private async runCommand(
+ commandToExecute: Command,
+ args: string,
+ context: CommandContext,
+ ): Promise {
+ try {
+ const result = await commandToExecute.execute(
+ context,
+ args ? args.split(/\s+/) : [],
+ );
+
+ let messageContent = '';
+ if (typeof result.data === 'string') {
+ messageContent = result.data;
+ } else if (
+ typeof result.data === 'object' &&
+ result.data !== null &&
+ 'content' in result.data
+ ) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
+ messageContent = (result.data as Record)[
+ 'content'
+ ] as string;
+ } else {
+ messageContent = JSON.stringify(result.data, null, 2);
+ }
+
+ await context.sendMessage(messageContent);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ await context.sendMessage(`Error: ${errorMessage}`);
+ }
+ }
+
+ /**
+ * Parses a raw slash command string into its matching headless command and arguments.
+ * Mirrors `packages/cli/src/utils/commands.ts` logic.
+ */
+ private parseSlashCommand(query: string): {
+ commandToExecute: Command | undefined;
+ args: string;
+ } {
+ const trimmed = query.trim();
+ const parts = trimmed.substring(1).trim().split(/\s+/);
+ const commandPath = parts.filter((p) => p);
+
+ let currentCommands = this.registry.getAllCommands();
+ let commandToExecute: Command | undefined;
+ let pathIndex = 0;
+
+ for (const part of commandPath) {
+ const foundCommand = currentCommands.find((cmd) => {
+ const expectedName = commandPath.slice(0, pathIndex + 1).join(' ');
+ return (
+ cmd.name === part ||
+ cmd.name === expectedName ||
+ cmd.aliases?.includes(part) ||
+ cmd.aliases?.includes(expectedName)
+ );
+ });
+
+ if (foundCommand) {
+ commandToExecute = foundCommand;
+ pathIndex++;
+ if (foundCommand.subCommands) {
+ currentCommands = foundCommand.subCommands;
+ } else {
+ break;
+ }
+ } else {
+ break;
+ }
+ }
+
+ const args = parts.slice(pathIndex).join(' ');
+
+ return { commandToExecute, args };
+ }
+}
diff --git a/packages/cli/src/acp/commands/commandRegistry.ts b/packages/cli/src/acp/commands/commandRegistry.ts
new file mode 100644
index 0000000000..b689d5d602
--- /dev/null
+++ b/packages/cli/src/acp/commands/commandRegistry.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { debugLogger } from '@google/gemini-cli-core';
+import type { Command } from './types.js';
+
+export class CommandRegistry {
+ private readonly commands = new Map();
+
+ register(command: Command) {
+ if (this.commands.has(command.name)) {
+ debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
+ return;
+ }
+
+ this.commands.set(command.name, command);
+
+ for (const subCommand of command.subCommands ?? []) {
+ this.register(subCommand);
+ }
+ }
+
+ get(commandName: string): Command | undefined {
+ return this.commands.get(commandName);
+ }
+
+ getAllCommands(): Command[] {
+ return [...this.commands.values()];
+ }
+}
diff --git a/packages/cli/src/acp/commands/extensions.ts b/packages/cli/src/acp/commands/extensions.ts
new file mode 100644
index 0000000000..b9a3ad81ab
--- /dev/null
+++ b/packages/cli/src/acp/commands/extensions.ts
@@ -0,0 +1,428 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { listExtensions } from '@google/gemini-cli-core';
+import { SettingScope } from '../../config/settings.js';
+import {
+ ExtensionManager,
+ inferInstallMetadata,
+} from '../../config/extension-manager.js';
+import { getErrorMessage } from '../../utils/errors.js';
+import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
+import { stat } from 'node:fs/promises';
+import type {
+ Command,
+ CommandContext,
+ CommandExecutionResponse,
+} from './types.js';
+import type { Config } from '@google/gemini-cli-core';
+
+export class ExtensionsCommand implements Command {
+ readonly name = 'extensions';
+ readonly description = 'Manage extensions.';
+ readonly subCommands = [
+ new ListExtensionsCommand(),
+ new ExploreExtensionsCommand(),
+ new EnableExtensionCommand(),
+ new DisableExtensionCommand(),
+ new InstallExtensionCommand(),
+ new LinkExtensionCommand(),
+ new UninstallExtensionCommand(),
+ new RestartExtensionCommand(),
+ new UpdateExtensionCommand(),
+ ];
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ return new ListExtensionsCommand().execute(context, _);
+ }
+}
+
+export class ListExtensionsCommand implements Command {
+ readonly name = 'extensions list';
+ readonly description = 'Lists all installed extensions.';
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ const extensions = listExtensions(context.config);
+ const data = extensions.length ? extensions : 'No extensions installed.';
+
+ return { name: this.name, data };
+ }
+}
+
+export class ExploreExtensionsCommand implements Command {
+ readonly name = 'extensions explore';
+ readonly description = 'Explore available extensions.';
+
+ async execute(
+ _context: CommandContext,
+ _: string[],
+ ): Promise {
+ const extensionsUrl = 'https://geminicli.com/extensions/';
+ return {
+ name: this.name,
+ data: `View or install available extensions at ${extensionsUrl}`,
+ };
+ }
+}
+
+function getEnableDisableContext(
+ config: Config,
+ args: string[],
+ invocationName: string,
+) {
+ const extensionManager = config.getExtensionLoader();
+ if (!(extensionManager instanceof ExtensionManager)) {
+ return {
+ error: `Cannot ${invocationName} extensions in this environment.`,
+ };
+ }
+
+ if (args.length === 0) {
+ return {
+ error: `Usage: /extensions ${invocationName} [--scope=]`,
+ };
+ }
+
+ let scope = SettingScope.User;
+ if (args.includes('--scope=workspace') || args.includes('workspace')) {
+ scope = SettingScope.Workspace;
+ } else if (args.includes('--scope=session') || args.includes('session')) {
+ scope = SettingScope.Session;
+ }
+
+ const name = args.filter(
+ (a) =>
+ !a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a),
+ )[0];
+
+ let names: string[] = [];
+ if (name === '--all') {
+ let extensions = extensionManager.getExtensions();
+ if (invocationName === 'enable') {
+ extensions = extensions.filter((ext) => !ext.isActive);
+ }
+ if (invocationName === 'disable') {
+ extensions = extensions.filter((ext) => ext.isActive);
+ }
+ names = extensions.map((ext) => ext.name);
+ } else if (name) {
+ names = [name];
+ } else {
+ return { error: 'No extension name provided.' };
+ }
+
+ return { extensionManager, names, scope };
+}
+
+export class EnableExtensionCommand implements Command {
+ readonly name = 'extensions enable';
+ readonly description = 'Enable an extension.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const enableContext = getEnableDisableContext(
+ context.config,
+ args,
+ 'enable',
+ );
+ if ('error' in enableContext) {
+ return { name: this.name, data: enableContext.error };
+ }
+
+ const { names, scope, extensionManager } = enableContext;
+ const output: string[] = [];
+
+ for (const name of names) {
+ try {
+ await extensionManager.enableExtension(name, scope);
+ output.push(`Extension "${name}" enabled for scope "${scope}".`);
+
+ const extension = extensionManager
+ .getExtensions()
+ .find((e) => e.name === name);
+
+ if (extension?.mcpServers) {
+ const mcpEnablementManager = McpServerEnablementManager.getInstance();
+ const mcpClientManager = context.config.getMcpClientManager();
+ const enabledServers = await mcpEnablementManager.autoEnableServers(
+ Object.keys(extension.mcpServers),
+ );
+
+ if (mcpClientManager && enabledServers.length > 0) {
+ const restartPromises = enabledServers.map((serverName) =>
+ mcpClientManager.restartServer(serverName).catch((error) => {
+ output.push(
+ `Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`,
+ );
+ }),
+ );
+ await Promise.all(restartPromises);
+ output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`);
+ }
+ }
+ } catch (e) {
+ output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`);
+ }
+ }
+
+ return { name: this.name, data: output.join('\n') || 'No action taken.' };
+ }
+}
+
+export class DisableExtensionCommand implements Command {
+ readonly name = 'extensions disable';
+ readonly description = 'Disable an extension.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const enableContext = getEnableDisableContext(
+ context.config,
+ args,
+ 'disable',
+ );
+ if ('error' in enableContext) {
+ return { name: this.name, data: enableContext.error };
+ }
+
+ const { names, scope, extensionManager } = enableContext;
+ const output: string[] = [];
+
+ for (const name of names) {
+ try {
+ await extensionManager.disableExtension(name, scope);
+ output.push(`Extension "${name}" disabled for scope "${scope}".`);
+ } catch (e) {
+ output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`);
+ }
+ }
+
+ return { name: this.name, data: output.join('\n') || 'No action taken.' };
+ }
+}
+
+export class InstallExtensionCommand implements Command {
+ readonly name = 'extensions install';
+ readonly description = 'Install an extension from a git repo or local path.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const extensionLoader = context.config.getExtensionLoader();
+ if (!(extensionLoader instanceof ExtensionManager)) {
+ return {
+ name: this.name,
+ data: 'Cannot install extensions in this environment.',
+ };
+ }
+
+ const source = args.join(' ').trim();
+ if (!source) {
+ return { name: this.name, data: `Usage: /extensions install ` };
+ }
+
+ if (/[;&|`'"]/.test(source)) {
+ return {
+ name: this.name,
+ data: `Invalid source: contains disallowed characters.`,
+ };
+ }
+
+ try {
+ const installMetadata = await inferInstallMetadata(source);
+ const extension =
+ await extensionLoader.installOrUpdateExtension(installMetadata);
+ return {
+ name: this.name,
+ data: `Extension "${extension.name}" installed successfully.`,
+ };
+ } catch (error) {
+ return {
+ name: this.name,
+ data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`,
+ };
+ }
+ }
+}
+
+export class LinkExtensionCommand implements Command {
+ readonly name = 'extensions link';
+ readonly description = 'Link an extension from a local path.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const extensionLoader = context.config.getExtensionLoader();
+ if (!(extensionLoader instanceof ExtensionManager)) {
+ return {
+ name: this.name,
+ data: 'Cannot link extensions in this environment.',
+ };
+ }
+
+ const sourceFilepath = args.join(' ').trim();
+ if (!sourceFilepath) {
+ return { name: this.name, data: `Usage: /extensions link ` };
+ }
+
+ try {
+ await stat(sourceFilepath);
+ } catch (_error) {
+ return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
+ }
+
+ try {
+ const extension = await extensionLoader.installOrUpdateExtension({
+ source: sourceFilepath,
+ type: 'link',
+ });
+ return {
+ name: this.name,
+ data: `Extension "${extension.name}" linked successfully.`,
+ };
+ } catch (error) {
+ return {
+ name: this.name,
+ data: `Failed to link extension: ${getErrorMessage(error)}`,
+ };
+ }
+ }
+}
+
+export class UninstallExtensionCommand implements Command {
+ readonly name = 'extensions uninstall';
+ readonly description = 'Uninstall an extension.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const extensionLoader = context.config.getExtensionLoader();
+ if (!(extensionLoader instanceof ExtensionManager)) {
+ return {
+ name: this.name,
+ data: 'Cannot uninstall extensions in this environment.',
+ };
+ }
+
+ const name = args.join(' ').trim();
+ if (!name) {
+ return {
+ name: this.name,
+ data: `Usage: /extensions uninstall `,
+ };
+ }
+
+ try {
+ await extensionLoader.uninstallExtension(name, false);
+ return {
+ name: this.name,
+ data: `Extension "${name}" uninstalled successfully.`,
+ };
+ } catch (error) {
+ return {
+ name: this.name,
+ data: `Failed to uninstall extension "${name}": ${getErrorMessage(error)}`,
+ };
+ }
+ }
+}
+
+export class RestartExtensionCommand implements Command {
+ readonly name = 'extensions restart';
+ readonly description = 'Restart an extension.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const extensionLoader = context.config.getExtensionLoader();
+ if (!(extensionLoader instanceof ExtensionManager)) {
+ return { name: this.name, data: 'Cannot restart extensions.' };
+ }
+
+ const all = args.includes('--all');
+ const names = all ? null : args.filter((a) => !!a);
+
+ if (!all && names?.length === 0) {
+ return {
+ name: this.name,
+ data: 'Usage: /extensions restart |--all',
+ };
+ }
+
+ let extensionsToRestart = extensionLoader
+ .getExtensions()
+ .filter((e) => e.isActive);
+ if (names) {
+ extensionsToRestart = extensionsToRestart.filter((e) =>
+ names.includes(e.name),
+ );
+ }
+
+ if (extensionsToRestart.length === 0) {
+ return {
+ name: this.name,
+ data: 'No active extensions matched the request.',
+ };
+ }
+
+ const output: string[] = [];
+ for (const extension of extensionsToRestart) {
+ try {
+ await extensionLoader.restartExtension(extension);
+ output.push(`Restarted "${extension.name}".`);
+ } catch (e) {
+ output.push(
+ `Failed to restart "${extension.name}": ${getErrorMessage(e)}`,
+ );
+ }
+ }
+
+ return { name: this.name, data: output.join('\n') };
+ }
+}
+
+export class UpdateExtensionCommand implements Command {
+ readonly name = 'extensions update';
+ readonly description = 'Update an extension.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const extensionLoader = context.config.getExtensionLoader();
+ if (!(extensionLoader instanceof ExtensionManager)) {
+ return { name: this.name, data: 'Cannot update extensions.' };
+ }
+
+ const all = args.includes('--all');
+ const names = all ? null : args.filter((a) => !!a);
+
+ if (!all && names?.length === 0) {
+ return {
+ name: this.name,
+ data: 'Usage: /extensions update |--all',
+ };
+ }
+
+ return {
+ name: this.name,
+ data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.',
+ };
+ }
+}
diff --git a/packages/cli/src/acp/commands/init.ts b/packages/cli/src/acp/commands/init.ts
new file mode 100644
index 0000000000..5c4197f84c
--- /dev/null
+++ b/packages/cli/src/acp/commands/init.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { performInit } from '@google/gemini-cli-core';
+import type {
+ Command,
+ CommandContext,
+ CommandExecutionResponse,
+} from './types.js';
+
+export class InitCommand implements Command {
+ name = 'init';
+ description = 'Analyzes the project and creates a tailored GEMINI.md file';
+ requiresWorkspace = true;
+
+ async execute(
+ context: CommandContext,
+ _args: string[] = [],
+ ): Promise {
+ const targetDir = context.config.getTargetDir();
+ if (!targetDir) {
+ throw new Error('Command requires a workspace.');
+ }
+
+ const geminiMdPath = path.join(targetDir, 'GEMINI.md');
+ const result = performInit(fs.existsSync(geminiMdPath));
+
+ switch (result.type) {
+ case 'message':
+ return {
+ name: this.name,
+ data: result,
+ };
+ case 'submit_prompt':
+ fs.writeFileSync(geminiMdPath, '', 'utf8');
+
+ if (typeof result.content !== 'string') {
+ throw new Error('Init command content must be a string.');
+ }
+
+ // Inform the user since we can't trigger the UI-based interactive agent loop here directly.
+ // We output the prompt text they can use to re-trigger the generation manually,
+ // or just seed the GEMINI.md file as we've done above.
+ return {
+ name: this.name,
+ data: {
+ type: 'message',
+ messageType: 'info',
+ content: `A template GEMINI.md has been created at ${geminiMdPath}.\n\nTo populate it with project context, you can run the following prompt in a new chat:\n\n${result.content}`,
+ },
+ };
+
+ default:
+ throw new Error('Unknown result type from performInit');
+ }
+ }
+}
diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts
new file mode 100644
index 0000000000..9460af7ad1
--- /dev/null
+++ b/packages/cli/src/acp/commands/memory.ts
@@ -0,0 +1,121 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ addMemory,
+ listMemoryFiles,
+ refreshMemory,
+ showMemory,
+} from '@google/gemini-cli-core';
+import type {
+ Command,
+ CommandContext,
+ CommandExecutionResponse,
+} from './types.js';
+
+const DEFAULT_SANITIZATION_CONFIG = {
+ allowedEnvironmentVariables: [],
+ blockedEnvironmentVariables: [],
+ enableEnvironmentVariableRedaction: false,
+};
+
+export class MemoryCommand implements Command {
+ readonly name = 'memory';
+ readonly description = 'Manage memory.';
+ readonly subCommands = [
+ new ShowMemoryCommand(),
+ new RefreshMemoryCommand(),
+ new ListMemoryCommand(),
+ new AddMemoryCommand(),
+ ];
+ readonly requiresWorkspace = true;
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ return new ShowMemoryCommand().execute(context, _);
+ }
+}
+
+export class ShowMemoryCommand implements Command {
+ readonly name = 'memory show';
+ readonly description = 'Shows the current memory contents.';
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ const result = showMemory(context.config);
+ return { name: this.name, data: result.content };
+ }
+}
+
+export class RefreshMemoryCommand implements Command {
+ readonly name = 'memory refresh';
+ readonly aliases = ['memory reload'];
+ readonly description = 'Refreshes the memory from the source.';
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ const result = await refreshMemory(context.config);
+ return { name: this.name, data: result.content };
+ }
+}
+
+export class ListMemoryCommand implements Command {
+ readonly name = 'memory list';
+ readonly description = 'Lists the paths of the GEMINI.md files in use.';
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ const result = listMemoryFiles(context.config);
+ return { name: this.name, data: result.content };
+ }
+}
+
+export class AddMemoryCommand implements Command {
+ readonly name = 'memory add';
+ readonly description = 'Add content to the memory.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const textToAdd = args.join(' ').trim();
+ const result = addMemory(textToAdd);
+ if (result.type === 'message') {
+ return { name: this.name, data: result.content };
+ }
+
+ const toolRegistry = context.config.getToolRegistry();
+ const tool = toolRegistry.getTool(result.toolName);
+ if (tool) {
+ const abortController = new AbortController();
+ const signal = abortController.signal;
+
+ await context.sendMessage(`Saving memory via ${result.toolName}...`);
+
+ await tool.buildAndExecute(result.toolArgs, signal, undefined, {
+ sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
+ });
+ await refreshMemory(context.config);
+ return {
+ name: this.name,
+ data: `Added memory: "${textToAdd}"`,
+ };
+ } else {
+ return {
+ name: this.name,
+ data: `Error: Tool ${result.toolName} not found.`,
+ };
+ }
+ }
+}
diff --git a/packages/cli/src/acp/commands/restore.ts b/packages/cli/src/acp/commands/restore.ts
new file mode 100644
index 0000000000..ec9166ed84
--- /dev/null
+++ b/packages/cli/src/acp/commands/restore.ts
@@ -0,0 +1,178 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ getCheckpointInfoList,
+ getToolCallDataSchema,
+ isNodeError,
+ performRestore,
+} from '@google/gemini-cli-core';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import type {
+ Command,
+ CommandContext,
+ CommandExecutionResponse,
+} from './types.js';
+
+export class RestoreCommand implements Command {
+ readonly name = 'restore';
+ readonly description =
+ 'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created';
+ readonly requiresWorkspace = true;
+ readonly subCommands = [new ListCheckpointsCommand()];
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const { config, git: gitService } = context;
+ const argsStr = args.join(' ');
+
+ try {
+ if (!argsStr) {
+ return await new ListCheckpointsCommand().execute(context);
+ }
+
+ if (!config.getCheckpointingEnabled()) {
+ return {
+ name: this.name,
+ data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
+ };
+ }
+
+ const selectedFile = argsStr.endsWith('.json')
+ ? argsStr
+ : `${argsStr}.json`;
+
+ const checkpointDir = config.storage.getProjectTempCheckpointsDir();
+ const filePath = path.join(checkpointDir, selectedFile);
+
+ let data: string;
+ try {
+ data = await fs.readFile(filePath, 'utf-8');
+ } catch (error) {
+ if (isNodeError(error) && error.code === 'ENOENT') {
+ return {
+ name: this.name,
+ data: `File not found: ${selectedFile}`,
+ };
+ }
+ throw error;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const toolCallData = JSON.parse(data);
+ const ToolCallDataSchema = getToolCallDataSchema();
+ const parseResult = ToolCallDataSchema.safeParse(toolCallData);
+
+ if (!parseResult.success) {
+ return {
+ name: this.name,
+ data: 'Checkpoint file is invalid or corrupted.',
+ };
+ }
+
+ const restoreResultGenerator = performRestore(
+ parseResult.data,
+ gitService,
+ );
+
+ const restoreResult = [];
+ for await (const result of restoreResultGenerator) {
+ restoreResult.push(result);
+ }
+
+ // Format the result nicely since Zed just dumps data
+ const formattedResult = restoreResult
+ .map((r) => {
+ if (r.type === 'message') {
+ return `[${r.messageType.toUpperCase()}] ${r.content}`;
+ } else if (r.type === 'load_history') {
+ return `Loaded history with ${r.clientHistory.length} messages.`;
+ }
+ return `Restored: ${JSON.stringify(r)}`;
+ })
+ .join('\n');
+
+ return {
+ name: this.name,
+ data: formattedResult,
+ };
+ } catch (error) {
+ return {
+ name: this.name,
+ data: `An unexpected error occurred during restore: ${error}`,
+ };
+ }
+ }
+}
+
+export class ListCheckpointsCommand implements Command {
+ readonly name = 'restore list';
+ readonly description = 'Lists all available checkpoints.';
+
+ async execute(context: CommandContext): Promise {
+ const { config } = context;
+
+ try {
+ if (!config.getCheckpointingEnabled()) {
+ return {
+ name: this.name,
+ data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
+ };
+ }
+
+ const checkpointDir = config.storage.getProjectTempCheckpointsDir();
+ try {
+ await fs.mkdir(checkpointDir, { recursive: true });
+ } catch (_e) {
+ // Ignore
+ }
+
+ const files = await fs.readdir(checkpointDir);
+ const jsonFiles = files.filter((file) => file.endsWith('.json'));
+
+ if (jsonFiles.length === 0) {
+ return { name: this.name, data: 'No checkpoints found.' };
+ }
+
+ const checkpointFiles = new Map();
+ for (const file of jsonFiles) {
+ const filePath = path.join(checkpointDir, file);
+ const data = await fs.readFile(filePath, 'utf-8');
+ checkpointFiles.set(file, data);
+ }
+
+ const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
+
+ const formatted = checkpointInfoList
+ .map((info) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const i = info as Record;
+ const fileName = String(i['fileName'] || 'Unknown');
+ const toolName = String(i['toolName'] || 'Unknown');
+ const status = String(i['status'] || 'Unknown');
+ const timestamp = new Date(
+ Number(i['timestamp']) || 0,
+ ).toLocaleString();
+
+ return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`;
+ })
+ .join('\n');
+
+ return {
+ name: this.name,
+ data: `Available Checkpoints:\n${formatted}`,
+ };
+ } catch (_error) {
+ return {
+ name: this.name,
+ data: 'An unexpected error occurred while listing checkpoints.',
+ };
+ }
+ }
+}
diff --git a/packages/cli/src/acp/commands/types.ts b/packages/cli/src/acp/commands/types.ts
new file mode 100644
index 0000000000..099f0c923f
--- /dev/null
+++ b/packages/cli/src/acp/commands/types.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Config, GitService } from '@google/gemini-cli-core';
+import type { LoadedSettings } from '../../config/settings.js';
+
+export interface CommandContext {
+ config: Config;
+ settings: LoadedSettings;
+ git?: GitService;
+ sendMessage: (text: string) => Promise;
+}
+
+export interface CommandArgument {
+ readonly name: string;
+ readonly description: string;
+ readonly isRequired?: boolean;
+}
+
+export interface Command {
+ readonly name: string;
+ readonly aliases?: string[];
+ readonly description: string;
+ readonly arguments?: CommandArgument[];
+ readonly subCommands?: Command[];
+ readonly requiresWorkspace?: boolean;
+
+ execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise;
+}
+
+export interface CommandExecutionResponse {
+ readonly name: string;
+ readonly data: unknown;
+}
diff --git a/packages/cli/src/zed-integration/fileSystemService.test.ts b/packages/cli/src/acp/fileSystemService.test.ts
similarity index 100%
rename from packages/cli/src/zed-integration/fileSystemService.test.ts
rename to packages/cli/src/acp/fileSystemService.test.ts
diff --git a/packages/cli/src/zed-integration/fileSystemService.ts b/packages/cli/src/acp/fileSystemService.ts
similarity index 100%
rename from packages/cli/src/zed-integration/fileSystemService.ts
rename to packages/cli/src/acp/fileSystemService.ts
diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts
index 5255dfeb83..1886444b88 100644
--- a/packages/cli/src/commands/extensions/install.ts
+++ b/packages/cli/src/commands/extensions/install.ts
@@ -5,6 +5,7 @@
*/
import type { CommandModule } from 'yargs';
+import * as path from 'node:path';
import chalk from 'chalk';
import {
debugLogger,
@@ -51,12 +52,13 @@ export async function handleInstall(args: InstallArgs) {
const settings = loadSettings(workspaceDir).merged;
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
- const resolvedPath = getRealPath(source);
- installMetadata.source = resolvedPath;
- const trustResult = isWorkspaceTrusted(settings, resolvedPath);
+ const absolutePath = path.resolve(source);
+ const realPath = getRealPath(absolutePath);
+ installMetadata.source = absolutePath;
+ const trustResult = isWorkspaceTrusted(settings, absolutePath);
if (trustResult.isTrusted !== true) {
const discoveryResults =
- await FolderTrustDiscoveryService.discover(resolvedPath);
+ await FolderTrustDiscoveryService.discover(realPath);
const hasDiscovery =
discoveryResults.commands.length > 0 ||
@@ -69,7 +71,7 @@ export async function handleInstall(args: InstallArgs) {
'',
chalk.bold('Do you trust the files in this folder?'),
'',
- `The extension source at "${resolvedPath}" is not trusted.`,
+ `The extension source at "${absolutePath}" is not trusted.`,
'',
'Trusting a folder allows Gemini CLI to load its local configurations,',
'including custom commands, hooks, MCP servers, agent skills, and',
@@ -127,10 +129,10 @@ export async function handleInstall(args: InstallArgs) {
);
if (confirmed) {
const trustedFolders = loadTrustedFolders();
- await trustedFolders.setValue(resolvedPath, TrustLevel.TRUST_FOLDER);
+ await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER);
} else {
throw new Error(
- `Installation aborted: Folder "${resolvedPath}" is not trusted.`,
+ `Installation aborted: Folder "${absolutePath}" is not trusted.`,
);
}
}
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index b22b7412cc..f8c857cee8 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -953,12 +953,6 @@ describe('mergeMcpServers', () => {
});
describe('mergeExcludeTools', () => {
- const defaultExcludes = new Set([
- SHELL_TOOL_NAME,
- EDIT_TOOL_NAME,
- WRITE_FILE_TOOL_NAME,
- WEB_FETCH_TOOL_NAME,
- ]);
const originalIsTTY = process.stdin.isTTY;
beforeEach(() => {
@@ -1080,9 +1074,7 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(settings, 'test-session', argv);
- expect(config.getExcludeTools()).toEqual(
- new Set([...defaultExcludes, ASK_USER_TOOL_NAME]),
- );
+ expect(config.getExcludeTools()).toEqual(new Set([ASK_USER_TOOL_NAME]));
});
it('should handle settings with excludeTools but no extensions', async () => {
@@ -1163,9 +1155,9 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, 'test-session', argv);
const excludedTools = config.getExcludeTools();
- expect(excludedTools).toContain(SHELL_TOOL_NAME);
- expect(excludedTools).toContain(EDIT_TOOL_NAME);
- expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME);
+ expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
+ expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
+ expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
});
@@ -1184,9 +1176,9 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, 'test-session', argv);
const excludedTools = config.getExcludeTools();
- expect(excludedTools).toContain(SHELL_TOOL_NAME);
- expect(excludedTools).toContain(EDIT_TOOL_NAME);
- expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME);
+ expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
+ expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
+ expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
});
@@ -1205,7 +1197,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, 'test-session', argv);
const excludedTools = config.getExcludeTools();
- expect(excludedTools).toContain(SHELL_TOOL_NAME);
+ expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
@@ -1251,9 +1243,9 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, 'test-session', argv);
const excludedTools = config.getExcludeTools();
- expect(excludedTools).toContain(SHELL_TOOL_NAME);
- expect(excludedTools).toContain(EDIT_TOOL_NAME);
- expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME);
+ expect(excludedTools).not.toContain(SHELL_TOOL_NAME);
+ expect(excludedTools).not.toContain(EDIT_TOOL_NAME);
+ expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME);
expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
});
@@ -1315,9 +1307,10 @@ describe('Approval mode tool exclusion logic', () => {
const excludedTools = config.getExcludeTools();
expect(excludedTools).toContain('custom_tool'); // From settings
- expect(excludedTools).toContain(SHELL_TOOL_NAME); // From approval mode
+ expect(excludedTools).not.toContain(SHELL_TOOL_NAME); // No longer from approval mode
expect(excludedTools).not.toContain(EDIT_TOOL_NAME); // Should be allowed in auto_edit
expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); // Should be allowed in auto_edit
+ expect(excludedTools).toContain(ASK_USER_TOOL_NAME);
});
it('should throw an error if YOLO mode is attempted when disableYoloMode is true', async () => {
@@ -2164,9 +2157,9 @@ describe('loadCliConfig tool exclusions', () => {
'test-session',
argv,
);
- expect(config.getExcludeTools()).toContain('run_shell_command');
- expect(config.getExcludeTools()).toContain('replace');
- expect(config.getExcludeTools()).toContain('write_file');
+ expect(config.getExcludeTools()).not.toContain('run_shell_command');
+ expect(config.getExcludeTools()).not.toContain('replace');
+ expect(config.getExcludeTools()).not.toContain('write_file');
expect(config.getExcludeTools()).toContain('ask_user');
});
@@ -2204,7 +2197,7 @@ describe('loadCliConfig tool exclusions', () => {
expect(config.getExcludeTools()).not.toContain(SHELL_TOOL_NAME);
});
- it('should exclude web-fetch in non-interactive mode when not allowed', async () => {
+ it('should not exclude web-fetch in non-interactive mode at config level', async () => {
process.stdin.isTTY = false;
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments(createTestMergedSettings());
@@ -2213,7 +2206,7 @@ describe('loadCliConfig tool exclusions', () => {
'test-session',
argv,
);
- expect(config.getExcludeTools()).toContain(WEB_FETCH_TOOL_NAME);
+ expect(config.getExcludeTools()).not.toContain(WEB_FETCH_TOOL_NAME);
});
it('should not exclude web-fetch in non-interactive mode when allowed', async () => {
@@ -3326,11 +3319,11 @@ describe('Policy Engine Integration in loadCliConfig', () => {
await loadCliConfig(settings, 'test-session', argv);
- // In non-interactive mode, ShellTool, etc. are excluded
+ // In non-interactive mode, only ask_user is excluded by default
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
- exclude: expect.arrayContaining([SHELL_TOOL_NAME]),
+ exclude: expect.arrayContaining([ASK_USER_TOOL_NAME]),
}),
}),
expect.anything(),
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index b478d67478..a1ce5b7d1c 100755
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -19,16 +19,11 @@ import {
DEFAULT_FILE_FILTERING_OPTIONS,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
FileDiscoveryService,
- WRITE_FILE_TOOL_NAME,
- SHELL_TOOL_NAMES,
- SHELL_TOOL_NAME,
resolveTelemetrySettings,
FatalConfigError,
getPty,
- EDIT_TOOL_NAME,
debugLogger,
loadServerHierarchicalMemory,
- WEB_FETCH_TOOL_NAME,
ASK_USER_TOOL_NAME,
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
@@ -81,7 +76,8 @@ export interface CliArgs {
policy: string[] | undefined;
allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined;
- experimentalAcp: boolean | undefined;
+ acp?: boolean;
+ experimentalAcp?: boolean;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined;
@@ -177,10 +173,15 @@ export async function parseArguments(
.filter(Boolean),
),
})
- .option('experimental-acp', {
+ .option('acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
+ .option('experimental-acp', {
+ type: 'boolean',
+ description:
+ 'Starts the agent in ACP mode (deprecated, use --acp instead)',
+ })
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
@@ -395,36 +396,6 @@ export async function parseArguments(
return result as unknown as CliArgs;
}
-/**
- * Creates a filter function to determine if a tool should be excluded.
- *
- * In non-interactive mode, we want to disable tools that require user
- * interaction to prevent the CLI from hanging. This function creates a predicate
- * that returns `true` if a tool should be excluded.
- *
- * A tool is excluded if it's not in the `allowedToolsSet`. The shell tool
- * has a special case: it's not excluded if any of its subcommands
- * are in the `allowedTools` list.
- *
- * @param allowedTools A list of explicitly allowed tool names.
- * @param allowedToolsSet A set of explicitly allowed tool names for quick lookups.
- * @returns A function that takes a tool name and returns `true` if it should be excluded.
- */
-function createToolExclusionFilter(
- allowedTools: string[],
- allowedToolsSet: Set,
-) {
- return (tool: string): boolean => {
- if (tool === SHELL_TOOL_NAME) {
- // If any of the allowed tools is ShellTool (even with subcommands), don't exclude it.
- return !allowedTools.some((allowed) =>
- SHELL_TOOL_NAMES.some((shellName) => allowed.startsWith(shellName)),
- );
- }
- return !allowedToolsSet.has(tool);
- };
-}
-
export function isDebugMode(argv: CliArgs): boolean {
return (
argv.debug ||
@@ -632,54 +603,20 @@ export async function loadCliConfig(
// -i/--prompt-interactive forces interactive mode with an initial prompt
const interactive =
!!argv.promptInteractive ||
+ !!argv.acp ||
!!argv.experimentalAcp ||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
!argv.isCommand);
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
- const allowedToolsSet = new Set(allowedTools);
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive) {
- // ask_user requires user interaction and must be excluded in all
- // non-interactive modes, regardless of the approval mode.
+ // The Policy Engine natively handles headless safety by translating ASK_USER
+ // decisions to DENY. However, we explicitly block ask_user here to guarantee
+ // it can never be allowed via a high-priority policy rule when no human is present.
extraExcludes.push(ASK_USER_TOOL_NAME);
-
- const defaultExcludes = [
- SHELL_TOOL_NAME,
- EDIT_TOOL_NAME,
- WRITE_FILE_TOOL_NAME,
- WEB_FETCH_TOOL_NAME,
- ];
- const autoEditExcludes = [SHELL_TOOL_NAME];
-
- const toolExclusionFilter = createToolExclusionFilter(
- allowedTools,
- allowedToolsSet,
- );
-
- switch (approvalMode) {
- case ApprovalMode.PLAN:
- // In plan non-interactive mode, all tools that require approval are excluded.
- // TODO(#16625): Replace this default exclusion logic with specific rules for plan mode.
- extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter));
- break;
- case ApprovalMode.DEFAULT:
- // In default non-interactive mode, all tools that require approval are excluded.
- extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter));
- break;
- case ApprovalMode.AUTO_EDIT:
- // In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
- extraExcludes.push(...autoEditExcludes.filter(toolExclusionFilter));
- break;
- case ApprovalMode.YOLO:
- // No extra excludes for YOLO mode.
- break;
- default:
- // This should never happen due to validation earlier, but satisfies the linter
- break;
- }
}
const excludeTools = mergeExcludeTools(settings, extraExcludes);
@@ -758,6 +695,7 @@ export async function loadCliConfig(
}
return new Config({
+ acpMode: !!argv.acp || !!argv.experimentalAcp,
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -821,7 +759,7 @@ export async function loadCliConfig(
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
- experimentalZedIntegration: argv.experimentalAcp || false,
+
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession,
@@ -830,6 +768,7 @@ export async function loadCliConfig(
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
+ tracker: settings.experimental?.taskTracker,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan?.directory
? settings.general.plan
diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts
index 4ab52e24b5..a5fb822cdb 100644
--- a/packages/cli/src/config/extension-manager.test.ts
+++ b/packages/cli/src/config/extension-manager.test.ts
@@ -12,6 +12,13 @@ import { ExtensionManager } from './extension-manager.js';
import { createTestMergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
+import {
+ TrustLevel,
+ loadTrustedFolders,
+ isWorkspaceTrusted,
+} from './trustedFolders.js';
+import { getRealPath } from '@google/gemini-cli-core';
+import type { MergedSettings } from './settings.js';
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
@@ -185,4 +192,157 @@ describe('ExtensionManager', () => {
fs.rmSync(externalDir, { recursive: true, force: true });
});
});
+
+ describe('symlink handling', () => {
+ let extensionDir: string;
+ let symlinkDir: string;
+
+ beforeEach(() => {
+ extensionDir = path.join(tempHomeDir, 'extension');
+ symlinkDir = path.join(tempHomeDir, 'symlink-ext');
+
+ fs.mkdirSync(extensionDir, { recursive: true });
+
+ fs.writeFileSync(
+ path.join(extensionDir, 'gemini-extension.json'),
+ JSON.stringify({ name: 'test-ext', version: '1.0.0' }),
+ );
+
+ fs.symlinkSync(extensionDir, symlinkDir, 'dir');
+ });
+
+ it('preserves symlinks in installMetadata.source when linking', async () => {
+ const manager = new ExtensionManager({
+ workspaceDir: tempWorkspaceDir,
+ settings: {
+ security: {
+ folderTrust: { enabled: false }, // Disable trust for simplicity in this test
+ },
+ experimental: { extensionConfig: false },
+ admin: { extensions: { enabled: true }, mcp: { enabled: true } },
+ hooksConfig: { enabled: true },
+ } as unknown as MergedSettings,
+ requestConsent: () => Promise.resolve(true),
+ requestSetting: null,
+ });
+
+ // Trust the workspace to allow installation
+ const trustedFolders = loadTrustedFolders();
+ await trustedFolders.setValue(tempWorkspaceDir, TrustLevel.TRUST_FOLDER);
+
+ const installMetadata = {
+ source: symlinkDir,
+ type: 'link' as const,
+ };
+
+ await manager.loadExtensions();
+ const extension = await manager.installOrUpdateExtension(installMetadata);
+
+ // Desired behavior: it preserves symlinks (if they were absolute or relative as provided)
+ expect(extension.installMetadata?.source).toBe(symlinkDir);
+ });
+
+ it('works with the new install command logic (preserves symlink but trusts real path)', async () => {
+ // This simulates the logic in packages/cli/src/commands/extensions/install.ts
+ const absolutePath = path.resolve(symlinkDir);
+ const realPath = getRealPath(absolutePath);
+
+ const settings = {
+ security: {
+ folderTrust: { enabled: true },
+ },
+ experimental: { extensionConfig: false },
+ admin: { extensions: { enabled: true }, mcp: { enabled: true } },
+ hooksConfig: { enabled: true },
+ } as unknown as MergedSettings;
+
+ // Trust the REAL path
+ const trustedFolders = loadTrustedFolders();
+ await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER);
+
+ // Check trust of the symlink path
+ const trustResult = isWorkspaceTrusted(settings, absolutePath);
+ expect(trustResult.isTrusted).toBe(true);
+
+ const manager = new ExtensionManager({
+ workspaceDir: tempWorkspaceDir,
+ settings,
+ requestConsent: () => Promise.resolve(true),
+ requestSetting: null,
+ });
+
+ const installMetadata = {
+ source: absolutePath,
+ type: 'link' as const,
+ };
+
+ await manager.loadExtensions();
+ const extension = await manager.installOrUpdateExtension(installMetadata);
+
+ expect(extension.installMetadata?.source).toBe(absolutePath);
+ expect(extension.installMetadata?.source).not.toBe(realPath);
+ });
+
+ it('enforces allowedExtensions using the real path', async () => {
+ const absolutePath = path.resolve(symlinkDir);
+ const realPath = getRealPath(absolutePath);
+
+ const settings = {
+ security: {
+ folderTrust: { enabled: false },
+ // Only allow the real path, not the symlink path
+ allowedExtensions: [realPath.replace(/\\/g, '\\\\')],
+ },
+ experimental: { extensionConfig: false },
+ admin: { extensions: { enabled: true }, mcp: { enabled: true } },
+ hooksConfig: { enabled: true },
+ } as unknown as MergedSettings;
+
+ const manager = new ExtensionManager({
+ workspaceDir: tempWorkspaceDir,
+ settings,
+ requestConsent: () => Promise.resolve(true),
+ requestSetting: null,
+ });
+
+ const installMetadata = {
+ source: absolutePath,
+ type: 'link' as const,
+ };
+
+ await manager.loadExtensions();
+ // This should pass because realPath is allowed
+ const extension = await manager.installOrUpdateExtension(installMetadata);
+ expect(extension.name).toBe('test-ext');
+
+ // Now try with a settings that only allows the symlink path string
+ const settingsOnlySymlink = {
+ security: {
+ folderTrust: { enabled: false },
+ // Only allow the symlink path string explicitly
+ allowedExtensions: [absolutePath.replace(/\\/g, '\\\\')],
+ },
+ experimental: { extensionConfig: false },
+ admin: { extensions: { enabled: true }, mcp: { enabled: true } },
+ hooksConfig: { enabled: true },
+ } as unknown as MergedSettings;
+
+ const manager2 = new ExtensionManager({
+ workspaceDir: tempWorkspaceDir,
+ settings: settingsOnlySymlink,
+ requestConsent: () => Promise.resolve(true),
+ requestSetting: null,
+ });
+
+ // This should FAIL because it checks the real path against the pattern
+ // (Unless symlinkDir === extensionDir, which shouldn't happen in this test setup)
+ if (absolutePath !== realPath) {
+ await expect(
+ manager2.installOrUpdateExtension(installMetadata),
+ ).rejects.toThrow(
+ /is not allowed by the "allowedExtensions" security setting/,
+ );
+ }
+ });
+ });
});
diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts
index a9fce44635..678350ba49 100644
--- a/packages/cli/src/config/extension-manager.ts
+++ b/packages/cli/src/config/extension-manager.ts
@@ -161,7 +161,9 @@ export class ExtensionManager extends ExtensionLoader {
const extensionAllowed = this.settings.security?.allowedExtensions.some(
(pattern) => {
try {
- return new RegExp(pattern).test(installMetadata.source);
+ return new RegExp(pattern).test(
+ getRealPath(installMetadata.source),
+ );
} catch (e) {
throw new Error(
`Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`,
@@ -210,11 +212,9 @@ export class ExtensionManager extends ExtensionLoader {
await fs.promises.mkdir(extensionsDir, { recursive: true });
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
- installMetadata.source = getRealPath(
- path.isAbsolute(installMetadata.source)
- ? installMetadata.source
- : path.resolve(this.workspaceDir, installMetadata.source),
- );
+ installMetadata.source = path.isAbsolute(installMetadata.source)
+ ? installMetadata.source
+ : path.resolve(this.workspaceDir, installMetadata.source);
}
let tempDir: string | undefined;
@@ -262,7 +262,7 @@ Would you like to attempt to install via "git clone" instead?`,
installMetadata.type === 'local' ||
installMetadata.type === 'link'
) {
- localSourcePath = installMetadata.source;
+ localSourcePath = getRealPath(installMetadata.source);
} else {
throw new Error(`Unsupported install type: ${installMetadata.type}`);
}
@@ -638,7 +638,9 @@ Would you like to attempt to install via "git clone" instead?`,
const extensionAllowed = this.settings.security?.allowedExtensions.some(
(pattern) => {
try {
- return new RegExp(pattern).test(installMetadata?.source);
+ return new RegExp(pattern).test(
+ getRealPath(installMetadata?.source ?? ''),
+ );
} catch (e) {
throw new Error(
`Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`,
diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg
index 6f5879df4c..fbaaa599d4 100644
--- a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg
+++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg
@@ -10,11 +10,15 @@
* server2 (remote): https://remote.com This extension will append info to your gemini.md context using my-context.md This extension will exclude the following core tools: tool1,tool2
- Agent Skills:
+ Agent Skills:This extension will install the following agent skills:
- * skill1: desc1
+ *
+ skill1
+ : desc1 (Source: /mock/temp/dir/skill1/SKILL.md) (2 items in directory)
- * skill2: desc2
+ *
+ skill2
+ : desc2 (Source: /mock/temp/dir/skill2/SKILL.md) (1 items in directory) The extension you are about to install may have been created by a third-party developer and sourcedfrom a public repository. Google does not vet, endorse, or guarantee the functionality or security
diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg
index 3fff32664a..b57af41589 100644
--- a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg
+++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg
@@ -5,9 +5,11 @@
Installing extension "test-ext".
- Agent Skills:
+ Agent Skills:This extension will install the following agent skills:
- * locked-skill: A skill in a locked dir
+ *
+ locked-skill
+ : A skill in a locked dir (Source: /mock/temp/dir/locked/SKILL.md) ⚠️ (Could not count items in directory)The extension you are about to install may have been created by a third-party developer and sourced
diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg
index c52724836e..32b9d8e0a3 100644
--- a/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg
+++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg
@@ -6,7 +6,9 @@
Installing agent skill(s) from "https://example.com/repo.git". The following agent skill(s) will be installing:
- * skill1: desc1
+ *
+ skill1
+ : desc1 (Source: /mock/temp/dir/skill1/SKILL.md) (1 items in directory) Install Destination: /mock/target/dir Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
diff --git a/packages/cli/src/config/footerItems.test.ts b/packages/cli/src/config/footerItems.test.ts
new file mode 100644
index 0000000000..420246811b
--- /dev/null
+++ b/packages/cli/src/config/footerItems.test.ts
@@ -0,0 +1,91 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { deriveItemsFromLegacySettings } from './footerItems.js';
+import { createMockSettings } from '../test-utils/settings.js';
+
+describe('deriveItemsFromLegacySettings', () => {
+ it('returns defaults when no legacy settings are customized', () => {
+ const settings = createMockSettings({
+ ui: { footer: { hideContextPercentage: true } },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).toEqual([
+ 'workspace',
+ 'git-branch',
+ 'sandbox',
+ 'model-name',
+ 'quota',
+ ]);
+ });
+
+ it('removes workspace when hideCWD is true', () => {
+ const settings = createMockSettings({
+ ui: { footer: { hideCWD: true, hideContextPercentage: true } },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).not.toContain('workspace');
+ });
+
+ it('removes sandbox when hideSandboxStatus is true', () => {
+ const settings = createMockSettings({
+ ui: { footer: { hideSandboxStatus: true, hideContextPercentage: true } },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).not.toContain('sandbox');
+ });
+
+ it('removes model-name, context-used, and quota when hideModelInfo is true', () => {
+ const settings = createMockSettings({
+ ui: { footer: { hideModelInfo: true, hideContextPercentage: true } },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).not.toContain('model-name');
+ expect(items).not.toContain('context-used');
+ expect(items).not.toContain('quota');
+ });
+
+ it('includes context-used when hideContextPercentage is false', () => {
+ const settings = createMockSettings({
+ ui: { footer: { hideContextPercentage: false } },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).toContain('context-used');
+ // Should be after model-name
+ const modelIdx = items.indexOf('model-name');
+ const contextIdx = items.indexOf('context-used');
+ expect(contextIdx).toBe(modelIdx + 1);
+ });
+
+ it('includes memory-usage when showMemoryUsage is true', () => {
+ const settings = createMockSettings({
+ ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).toContain('memory-usage');
+ });
+
+ it('handles combination of settings', () => {
+ const settings = createMockSettings({
+ ui: {
+ showMemoryUsage: true,
+ footer: {
+ hideCWD: true,
+ hideModelInfo: true,
+ hideContextPercentage: false,
+ },
+ },
+ }).merged;
+ const items = deriveItemsFromLegacySettings(settings);
+ expect(items).toEqual([
+ 'git-branch',
+ 'sandbox',
+ 'context-used',
+ 'memory-usage',
+ ]);
+ });
+});
diff --git a/packages/cli/src/config/footerItems.ts b/packages/cli/src/config/footerItems.ts
new file mode 100644
index 0000000000..8410d0b5ec
--- /dev/null
+++ b/packages/cli/src/config/footerItems.ts
@@ -0,0 +1,132 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { MergedSettings } from './settings.js';
+
+export const ALL_ITEMS = [
+ {
+ id: 'workspace',
+ header: 'workspace (/directory)',
+ description: 'Current working directory',
+ },
+ {
+ id: 'git-branch',
+ header: 'branch',
+ description: 'Current git branch name (not shown when unavailable)',
+ },
+ {
+ id: 'sandbox',
+ header: 'sandbox',
+ description: 'Sandbox type and trust indicator',
+ },
+ {
+ id: 'model-name',
+ header: '/model',
+ description: 'Current model identifier',
+ },
+ {
+ id: 'context-used',
+ header: 'context',
+ description: 'Percentage of context window used',
+ },
+ {
+ id: 'quota',
+ header: '/stats',
+ description: 'Remaining usage on daily limit (not shown when unavailable)',
+ },
+ {
+ id: 'memory-usage',
+ header: 'memory',
+ description: 'Memory used by the application',
+ },
+ {
+ id: 'session-id',
+ header: 'session',
+ description: 'Unique identifier for the current session',
+ },
+ {
+ id: 'code-changes',
+ header: 'diff',
+ description: 'Lines added/removed in the session (not shown when zero)',
+ },
+ {
+ id: 'token-count',
+ header: 'tokens',
+ description: 'Total tokens used in the session (not shown when zero)',
+ },
+] as const;
+
+export type FooterItemId = (typeof ALL_ITEMS)[number]['id'];
+
+export const DEFAULT_ORDER = [
+ 'workspace',
+ 'git-branch',
+ 'sandbox',
+ 'model-name',
+ 'context-used',
+ 'quota',
+ 'memory-usage',
+ 'session-id',
+ 'code-changes',
+ 'token-count',
+];
+
+export function deriveItemsFromLegacySettings(
+ settings: MergedSettings,
+): string[] {
+ const defaults = [
+ 'workspace',
+ 'git-branch',
+ 'sandbox',
+ 'model-name',
+ 'quota',
+ ];
+ const items = [...defaults];
+
+ const remove = (arr: string[], id: string) => {
+ const idx = arr.indexOf(id);
+ if (idx !== -1) arr.splice(idx, 1);
+ };
+
+ if (settings.ui.footer.hideCWD) remove(items, 'workspace');
+ if (settings.ui.footer.hideSandboxStatus) remove(items, 'sandbox');
+ if (settings.ui.footer.hideModelInfo) {
+ remove(items, 'model-name');
+ remove(items, 'context-used');
+ remove(items, 'quota');
+ }
+ if (
+ !settings.ui.footer.hideContextPercentage &&
+ !items.includes('context-used')
+ ) {
+ const modelIdx = items.indexOf('model-name');
+ if (modelIdx !== -1) items.splice(modelIdx + 1, 0, 'context-used');
+ else items.push('context-used');
+ }
+ if (settings.ui.showMemoryUsage) items.push('memory-usage');
+
+ return items;
+}
+
+const VALID_IDS: Set = new Set(ALL_ITEMS.map((i) => i.id));
+
+/**
+ * Resolves the ordered list and selected set of footer items from settings.
+ * Used by FooterConfigDialog to initialize and reset state.
+ */
+export function resolveFooterState(settings: MergedSettings): {
+ orderedIds: string[];
+ selectedIds: Set;
+} {
+ const source = (
+ settings.ui?.footer?.items ?? deriveItemsFromLegacySettings(settings)
+ ).filter((id: string) => VALID_IDS.has(id));
+ const others = DEFAULT_ORDER.filter((id) => !source.includes(id));
+ return {
+ orderedIds: [...source, ...others],
+ selectedIds: new Set(source),
+ };
+}
diff --git a/packages/cli/src/config/keyBindings.test.ts b/packages/cli/src/config/keyBindings.test.ts
index c2abc32d27..e450e68b71 100644
--- a/packages/cli/src/config/keyBindings.test.ts
+++ b/packages/cli/src/config/keyBindings.test.ts
@@ -58,46 +58,6 @@ describe('keyBindings config', () => {
const config: KeyBindingConfig = defaultKeyBindings;
expect(config[Command.HOME]).toBeDefined();
});
-
- it('should have correct specific bindings', () => {
- // Verify navigation ignores shift
- const navUp = defaultKeyBindings[Command.NAVIGATION_UP];
- expect(navUp).toContainEqual({ key: 'up', shift: false });
-
- const navDown = defaultKeyBindings[Command.NAVIGATION_DOWN];
- expect(navDown).toContainEqual({ key: 'down', shift: false });
-
- // Verify dialog navigation
- const dialogNavUp = defaultKeyBindings[Command.DIALOG_NAVIGATION_UP];
- expect(dialogNavUp).toContainEqual({ key: 'up', shift: false });
- expect(dialogNavUp).toContainEqual({ key: 'k', shift: false });
-
- const dialogNavDown = defaultKeyBindings[Command.DIALOG_NAVIGATION_DOWN];
- expect(dialogNavDown).toContainEqual({ key: 'down', shift: false });
- expect(dialogNavDown).toContainEqual({ key: 'j', shift: false });
-
- // Verify physical home/end keys for cursor movement
- expect(defaultKeyBindings[Command.HOME]).toContainEqual({
- key: 'home',
- ctrl: false,
- shift: false,
- });
- expect(defaultKeyBindings[Command.END]).toContainEqual({
- key: 'end',
- ctrl: false,
- shift: false,
- });
-
- // Verify physical home/end keys for scrolling
- expect(defaultKeyBindings[Command.SCROLL_HOME]).toContainEqual({
- key: 'home',
- ctrl: true,
- });
- expect(defaultKeyBindings[Command.SCROLL_END]).toContainEqual({
- key: 'end',
- ctrl: true,
- });
- });
});
describe('command metadata', () => {
diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts
index 3122acef1d..e2260d99d8 100644
--- a/packages/cli/src/config/keyBindings.ts
+++ b/packages/cli/src/config/keyBindings.ts
@@ -134,27 +134,12 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.EXIT]: [{ key: 'd', ctrl: true }],
// Cursor Movement
- [Command.HOME]: [
- { key: 'a', ctrl: true },
- { key: 'home', shift: false, ctrl: false },
- ],
- [Command.END]: [
- { key: 'e', ctrl: true },
- { key: 'end', shift: false, ctrl: false },
- ],
- [Command.MOVE_UP]: [
- { key: 'up', shift: false, alt: false, ctrl: false, cmd: false },
- ],
- [Command.MOVE_DOWN]: [
- { key: 'down', shift: false, alt: false, ctrl: false, cmd: false },
- ],
- [Command.MOVE_LEFT]: [
- { key: 'left', shift: false, alt: false, ctrl: false, cmd: false },
- ],
- [Command.MOVE_RIGHT]: [
- { key: 'right', shift: false, alt: false, ctrl: false, cmd: false },
- { key: 'f', ctrl: true },
- ],
+ [Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }],
+ [Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }],
+ [Command.MOVE_UP]: [{ key: 'up' }],
+ [Command.MOVE_DOWN]: [{ key: 'down' }],
+ [Command.MOVE_LEFT]: [{ key: 'left' }],
+ [Command.MOVE_RIGHT]: [{ key: 'right' }, { key: 'f', ctrl: true }],
[Command.MOVE_WORD_LEFT]: [
{ key: 'left', ctrl: true },
{ key: 'left', alt: true },
@@ -183,8 +168,8 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }],
[Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }],
[Command.UNDO]: [
- { key: 'z', cmd: true, shift: false },
- { key: 'z', alt: true, shift: false },
+ { key: 'z', cmd: true },
+ { key: 'z', alt: true },
],
[Command.REDO]: [
{ key: 'z', ctrl: true, shift: true },
@@ -207,56 +192,33 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
// History & Search
- [Command.HISTORY_UP]: [{ key: 'p', shift: false, ctrl: true }],
- [Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }],
+ [Command.HISTORY_UP]: [{ key: 'p', ctrl: true }],
+ [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }],
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
- [Command.REWIND]: [{ key: 'double escape' }],
- [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
- [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab', shift: false }],
+ [Command.REWIND]: [{ key: 'double escape' }], // for documentation only
+ [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return' }],
+ [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
// Navigation
- [Command.NAVIGATION_UP]: [{ key: 'up', shift: false }],
- [Command.NAVIGATION_DOWN]: [{ key: 'down', shift: false }],
+ [Command.NAVIGATION_UP]: [{ key: 'up' }],
+ [Command.NAVIGATION_DOWN]: [{ key: 'down' }],
// Navigation shortcuts appropriate for dialogs where we do not need to accept
// text input.
- [Command.DIALOG_NAVIGATION_UP]: [
- { key: 'up', shift: false },
- { key: 'k', shift: false },
- ],
- [Command.DIALOG_NAVIGATION_DOWN]: [
- { key: 'down', shift: false },
- { key: 'j', shift: false },
- ],
- [Command.DIALOG_NEXT]: [{ key: 'tab', shift: false }],
+ [Command.DIALOG_NAVIGATION_UP]: [{ key: 'up' }, { key: 'k' }],
+ [Command.DIALOG_NAVIGATION_DOWN]: [{ key: 'down' }, { key: 'j' }],
+ [Command.DIALOG_NEXT]: [{ key: 'tab' }],
[Command.DIALOG_PREV]: [{ key: 'tab', shift: true }],
// Suggestions & Completions
- [Command.ACCEPT_SUGGESTION]: [
- { key: 'tab', shift: false },
- { key: 'return', ctrl: false },
- ],
- [Command.COMPLETION_UP]: [
- { key: 'up', shift: false },
- { key: 'p', shift: false, ctrl: true },
- ],
- [Command.COMPLETION_DOWN]: [
- { key: 'down', shift: false },
- { key: 'n', shift: false, ctrl: true },
- ],
+ [Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return' }],
+ [Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
+ [Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
// Text Input
// Must also exclude shift to allow shift+enter for newline
- [Command.SUBMIT]: [
- {
- key: 'return',
- shift: false,
- alt: false,
- ctrl: false,
- cmd: false,
- },
- ],
+ [Command.SUBMIT]: [{ key: 'return' }],
[Command.NEWLINE]: [
{ key: 'return', ctrl: true },
{ key: 'return', cmd: true },
@@ -283,19 +245,17 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: [{ key: 'l', ctrl: true }],
[Command.KILL_BACKGROUND_SHELL]: [{ key: 'k', ctrl: true }],
[Command.UNFOCUS_BACKGROUND_SHELL]: [{ key: 'tab', shift: true }],
- [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [{ key: 'tab', shift: false }],
- [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
- { key: 'tab', shift: false },
- ],
- [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab', shift: false }],
+ [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [{ key: 'tab' }],
+ [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [{ key: 'tab' }],
+ [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab' }],
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
[Command.SHOW_MORE_LINES]: [{ key: 'o', ctrl: true }],
[Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }],
- [Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
+ [Command.FOCUS_SHELL_INPUT]: [{ key: 'tab' }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
- [Command.RESTART_APP]: [{ key: 'r' }],
+ [Command.RESTART_APP]: [{ key: 'r' }, { key: 'r', shift: true }],
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
};
diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts
index 02515815d0..0c90ad2b0f 100644
--- a/packages/cli/src/config/policy-engine.integration.test.ts
+++ b/packages/cli/src/config/policy-engine.integration.test.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect, vi } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
ApprovalMode,
PolicyDecision,
@@ -29,6 +29,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
});
describe('Policy Engine Integration Tests', () => {
+ beforeEach(() => vi.stubEnv('GEMINI_SYSTEM_MD', ''));
+
+ afterEach(() => vi.unstubAllEnvs());
+
describe('Policy configuration produces valid PolicyEngine config', () => {
it('should create a working PolicyEngine from basic settings', async () => {
const settings: Settings = {
diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts
index 14080dc30b..51c4f7d83c 100644
--- a/packages/cli/src/config/sandboxConfig.test.ts
+++ b/packages/cli/src/config/sandboxConfig.test.ts
@@ -97,7 +97,7 @@ describe('loadSandboxConfig', () => {
it('should throw if GEMINI_SANDBOX is an invalid command', async () => {
process.env['GEMINI_SANDBOX'] = 'invalid-command';
await expect(loadSandboxConfig({}, {})).rejects.toThrow(
- "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec",
+ "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, runsc, lxc",
);
});
@@ -108,6 +108,22 @@ describe('loadSandboxConfig', () => {
"Missing sandbox command 'docker' (from GEMINI_SANDBOX)",
);
});
+
+ it('should use lxc if GEMINI_SANDBOX=lxc and it exists', async () => {
+ process.env['GEMINI_SANDBOX'] = 'lxc';
+ mockedCommandExistsSync.mockReturnValue(true);
+ const config = await loadSandboxConfig({}, {});
+ expect(config).toEqual({ command: 'lxc', image: 'default/image' });
+ expect(mockedCommandExistsSync).toHaveBeenCalledWith('lxc');
+ });
+
+ it('should throw if GEMINI_SANDBOX=lxc but lxc command does not exist', async () => {
+ process.env['GEMINI_SANDBOX'] = 'lxc';
+ mockedCommandExistsSync.mockReturnValue(false);
+ await expect(loadSandboxConfig({}, {})).rejects.toThrow(
+ "Missing sandbox command 'lxc' (from GEMINI_SANDBOX)",
+ );
+ });
});
describe('with sandbox: true', () => {
@@ -178,7 +194,7 @@ describe('loadSandboxConfig', () => {
await expect(
loadSandboxConfig({}, { sandbox: 'invalid-command' }),
).rejects.toThrow(
- "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec",
+ "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, runsc, lxc",
);
});
});
@@ -231,4 +247,92 @@ describe('loadSandboxConfig', () => {
},
);
});
+
+ describe('with sandbox: runsc (gVisor)', () => {
+ beforeEach(() => {
+ mockedOsPlatform.mockReturnValue('linux');
+ mockedCommandExistsSync.mockReturnValue(true);
+ });
+
+ it('should use runsc via CLI argument on Linux', async () => {
+ const config = await loadSandboxConfig({}, { sandbox: 'runsc' });
+
+ expect(config).toEqual({ command: 'runsc', image: 'default/image' });
+ expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc');
+ expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
+ });
+
+ it('should use runsc via GEMINI_SANDBOX environment variable', async () => {
+ process.env['GEMINI_SANDBOX'] = 'runsc';
+ const config = await loadSandboxConfig({}, {});
+
+ expect(config).toEqual({ command: 'runsc', image: 'default/image' });
+ expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc');
+ expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
+ });
+
+ it('should use runsc via settings file', async () => {
+ const config = await loadSandboxConfig(
+ { tools: { sandbox: 'runsc' } },
+ {},
+ );
+
+ expect(config).toEqual({ command: 'runsc', image: 'default/image' });
+ expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc');
+ expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
+ });
+
+ it('should prioritize GEMINI_SANDBOX over CLI and settings', async () => {
+ process.env['GEMINI_SANDBOX'] = 'runsc';
+ const config = await loadSandboxConfig(
+ { tools: { sandbox: 'docker' } },
+ { sandbox: 'podman' },
+ );
+
+ expect(config).toEqual({ command: 'runsc', image: 'default/image' });
+ });
+
+ it('should reject runsc on macOS (Linux-only)', async () => {
+ mockedOsPlatform.mockReturnValue('darwin');
+
+ await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow(
+ 'gVisor (runsc) sandboxing is only supported on Linux',
+ );
+ });
+
+ it('should reject runsc on Windows (Linux-only)', async () => {
+ mockedOsPlatform.mockReturnValue('win32');
+
+ await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow(
+ 'gVisor (runsc) sandboxing is only supported on Linux',
+ );
+ });
+
+ it('should throw if runsc binary not found', async () => {
+ mockedCommandExistsSync.mockReturnValue(false);
+
+ await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow(
+ "Missing sandbox command 'runsc' (from GEMINI_SANDBOX)",
+ );
+ });
+
+ it('should throw if Docker not available (runsc requires Docker)', async () => {
+ mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'runsc');
+
+ await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow(
+ "runsc (gVisor) requires Docker. Install Docker, or use sandbox: 'docker'.",
+ );
+ });
+
+ it('should NOT auto-detect runsc when both runsc and docker available', async () => {
+ mockedCommandExistsSync.mockImplementation(
+ (cmd) => cmd === 'runsc' || cmd === 'docker',
+ );
+
+ const config = await loadSandboxConfig({}, { sandbox: true });
+
+ expect(config?.command).toBe('docker');
+ expect(config?.command).not.toBe('runsc');
+ });
+ });
});
diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts
index 57430becae..968d3e427a 100644
--- a/packages/cli/src/config/sandboxConfig.ts
+++ b/packages/cli/src/config/sandboxConfig.ts
@@ -27,6 +27,8 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray = [
'docker',
'podman',
'sandbox-exec',
+ 'runsc',
+ 'lxc',
];
function isSandboxCommand(value: string): value is SandboxConfig['command'] {
@@ -63,17 +65,30 @@ function getSandboxCommand(
)}`,
);
}
- // confirm that specified command exists
- if (commandExists.sync(sandbox)) {
- return sandbox;
+ // runsc (gVisor) is only supported on Linux
+ if (sandbox === 'runsc' && os.platform() !== 'linux') {
+ throw new FatalSandboxError(
+ 'gVisor (runsc) sandboxing is only supported on Linux',
+ );
}
- throw new FatalSandboxError(
- `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
- );
+ // confirm that specified command exists
+ if (!commandExists.sync(sandbox)) {
+ throw new FatalSandboxError(
+ `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
+ );
+ }
+ // runsc uses Docker with --runtime=runsc; both must be available (prioritize runsc when explicitly chosen)
+ if (sandbox === 'runsc' && !commandExists.sync('docker')) {
+ throw new FatalSandboxError(
+ "runsc (gVisor) requires Docker. Install Docker, or use sandbox: 'docker'.",
+ );
+ }
+ return sandbox;
}
// look for seatbelt, docker, or podman, in that order
// for container-based sandboxing, require sandbox to be enabled explicitly
+ // note: runsc is NOT auto-detected, it must be explicitly specified
if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) {
return 'sandbox-exec';
} else if (commandExists.sync('docker') && sandbox === true) {
@@ -91,6 +106,9 @@ function getSandboxCommand(
}
return '';
+ // Note: 'lxc' is intentionally not auto-detected because it requires a
+ // pre-existing, running container managed by the user. Use
+ // GEMINI_SANDBOX=lxc or sandbox: "lxc" in settings to enable it.
}
export async function loadSandboxConfig(
diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts
index 8fd0bd81b0..5589ef11ba 100644
--- a/packages/cli/src/config/settings.test.ts
+++ b/packages/cli/src/config/settings.test.ts
@@ -2162,7 +2162,7 @@ describe('Settings Loading and Merging', () => {
}
});
- it('should prioritize new settings over deprecated ones and respect removeDeprecated flag', () => {
+ it('should remove deprecated settings by default and prioritize new ones', () => {
const userSettingsContent = {
general: {
disableAutoUpdate: true,
@@ -2177,27 +2177,11 @@ describe('Settings Loading and Merging', () => {
};
const loadedSettings = createMockSettings(userSettingsContent);
-
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
- // 1. removeDeprecated = false (default)
+ // Default is now removeDeprecated = true
migrateDeprecatedSettings(loadedSettings);
- // Should still have old settings
- expect(
- loadedSettings.forScope(SettingScope.User).settings.general,
- ).toHaveProperty('disableAutoUpdate');
- expect(
- (
- loadedSettings.forScope(SettingScope.User).settings.context as {
- fileFiltering: { disableFuzzySearch: boolean };
- }
- ).fileFiltering,
- ).toHaveProperty('disableFuzzySearch');
-
- // 2. removeDeprecated = true
- migrateDeprecatedSettings(loadedSettings, true);
-
// Should remove disableAutoUpdate and trust enableAutoUpdate: true
expect(setValueSpy).toHaveBeenCalledWith(SettingScope.User, 'general', {
enableAutoUpdate: true,
@@ -2209,6 +2193,37 @@ describe('Settings Loading and Merging', () => {
});
});
+ it('should preserve deprecated settings when removeDeprecated is explicitly false', () => {
+ const userSettingsContent = {
+ general: {
+ disableAutoUpdate: true,
+ enableAutoUpdate: true,
+ },
+ context: {
+ fileFiltering: {
+ disableFuzzySearch: false,
+ enableFuzzySearch: false,
+ },
+ },
+ };
+
+ const loadedSettings = createMockSettings(userSettingsContent);
+
+ migrateDeprecatedSettings(loadedSettings, false);
+
+ // Should still have old settings since removeDeprecated = false
+ expect(
+ loadedSettings.forScope(SettingScope.User).settings.general,
+ ).toHaveProperty('disableAutoUpdate');
+ expect(
+ (
+ loadedSettings.forScope(SettingScope.User).settings.context as {
+ fileFiltering: { disableFuzzySearch: boolean };
+ }
+ ).fileFiltering,
+ ).toHaveProperty('disableFuzzySearch');
+ });
+
it('should trigger migration automatically during loadSettings', () => {
mockFsExistsSync.mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index 4e9faf5767..422dda6115 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -20,8 +20,8 @@ import {
type AdminControlsSettings,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
-import { DefaultLight } from '../ui/themes/default-light.js';
-import { DefaultDark } from '../ui/themes/default.js';
+import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
+import { DefaultDark } from '../ui/themes/builtin/dark/default-dark.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import {
type Settings,
@@ -796,14 +796,13 @@ export function loadSettings(
/**
* Migrates deprecated settings to their new counterparts.
*
- * TODO: After a couple of weeks (around early Feb 2026), we should start removing
- * the deprecated settings from the settings files by default.
+ * Deprecated settings are removed from settings files by default.
*
* @returns true if any changes were made and need to be saved.
*/
export function migrateDeprecatedSettings(
loadedSettings: LoadedSettings,
- removeDeprecated = false,
+ removeDeprecated = true,
): boolean {
let anyModified = false;
const systemWarnings: Map = new Map();
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 660866c0e3..fbc50e8b39 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -565,14 +565,34 @@ const SETTINGS_SCHEMA = {
description: 'Settings for the footer.',
showInDialog: false,
properties: {
+ items: {
+ type: 'array',
+ label: 'Footer Items',
+ category: 'UI',
+ requiresRestart: false,
+ default: undefined as string[] | undefined,
+ description:
+ 'List of item IDs to display in the footer. Rendered in order',
+ showInDialog: false,
+ items: { type: 'string' },
+ },
+ showLabels: {
+ type: 'boolean',
+ label: 'Show Footer Labels',
+ category: 'UI',
+ requiresRestart: false,
+ default: true,
+ description:
+ 'Display a second line above the footer items with descriptive headers (e.g., /model).',
+ showInDialog: false,
+ },
hideCWD: {
type: 'boolean',
label: 'Hide CWD',
category: 'UI',
requiresRestart: false,
default: false,
- description:
- 'Hide the current working directory path in the footer.',
+ description: 'Hide the current working directory in the footer.',
showInDialog: true,
},
hideSandboxStatus: {
@@ -1236,7 +1256,8 @@ const SETTINGS_SCHEMA = {
ref: 'BooleanOrString',
description: oneLine`
Sandbox execution environment.
- Set to a boolean to enable or disable the sandbox, or provide a string path to a sandbox profile.
+ Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile,
+ or specify an explicit sandbox command (e.g., "docker", "podman", "lxc").
`,
showInDialog: false,
},
@@ -1806,6 +1827,15 @@ const SETTINGS_SCHEMA = {
description: 'Enable planning features (Plan Mode and tools).',
showInDialog: true,
},
+ taskTracker: {
+ type: 'boolean',
+ label: 'Task Tracker',
+ category: 'Experimental',
+ requiresRestart: true,
+ default: false,
+ description: 'Enable task tracker tools.',
+ showInDialog: false,
+ },
modelSteering: {
type: 'boolean',
label: 'Model Steering',
diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts
index 714d703241..cfe0447078 100644
--- a/packages/cli/src/config/trustedFolders.test.ts
+++ b/packages/cli/src/config/trustedFolders.test.ts
@@ -506,7 +506,7 @@ describe('Trusted Folders', () => {
const realDir = path.join(tempDir, 'real');
const symlinkDir = path.join(tempDir, 'symlink');
fs.mkdirSync(realDir);
- fs.symlinkSync(realDir, symlinkDir);
+ fs.symlinkSync(realDir, symlinkDir, 'dir');
// Rule uses realpath
const config = { [realDir]: TrustLevel.TRUST_FOLDER };
diff --git a/packages/cli/src/core/auth.test.ts b/packages/cli/src/core/auth.test.ts
index f28e826f49..5db9cd5449 100644
--- a/packages/cli/src/core/auth.test.ts
+++ b/packages/cli/src/core/auth.test.ts
@@ -9,6 +9,7 @@ import { performInitialAuth } from './auth.js';
import {
type Config,
ValidationRequiredError,
+ ProjectIdRequiredError,
AuthType,
} from '@google/gemini-cli-core';
@@ -116,4 +117,22 @@ describe('auth', () => {
AuthType.LOGIN_WITH_GOOGLE,
);
});
+
+ it('should return ProjectIdRequiredError message without "Failed to login" prefix', async () => {
+ const projectIdError = new ProjectIdRequiredError();
+ vi.mocked(mockConfig.refreshAuth).mockRejectedValue(projectIdError);
+ const result = await performInitialAuth(
+ mockConfig,
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ expect(result).toEqual({
+ authError:
+ 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca',
+ accountSuspensionInfo: null,
+ });
+ expect(result.authError).not.toContain('Failed to login');
+ expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ });
});
diff --git a/packages/cli/src/core/auth.ts b/packages/cli/src/core/auth.ts
index f49fdecf76..f0b8015013 100644
--- a/packages/cli/src/core/auth.ts
+++ b/packages/cli/src/core/auth.ts
@@ -10,6 +10,7 @@ import {
getErrorMessage,
ValidationRequiredError,
isAccountSuspendedError,
+ ProjectIdRequiredError,
} from '@google/gemini-cli-core';
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
@@ -54,6 +55,14 @@ export async function performInitialAuth(
},
};
}
+ if (e instanceof ProjectIdRequiredError) {
+ // OAuth succeeded but account setup requires project ID
+ // Show the error message directly without "Failed to login" prefix
+ return {
+ authError: getErrorMessage(e),
+ accountSuspensionInfo: null,
+ };
+ }
return {
authError: `Failed to login. Message: ${getErrorMessage(e)}`,
accountSuspensionInfo: null,
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 88f9f404cd..6071488542 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -79,7 +79,7 @@ import {
type InitializationResult,
} from './core/initializer.js';
import { validateAuthMethod } from './config/auth.js';
-import { runZedIntegration } from './zed-integration/zedIntegration.js';
+import { runAcpClient } from './acp/acpClient.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
@@ -672,8 +672,8 @@ export async function main() {
await getOauthClient(settings.merged.security.auth.selectedType, config);
}
- if (config.getExperimentalZedIntegration()) {
- return runZedIntegration(config, settings, argv);
+ if (config.getAcpMode()) {
+ return runAcpClient(config, settings, argv);
}
let input = config.getQuestion();
diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx
index fb37bb94ec..536da027d4 100644
--- a/packages/cli/src/gemini_cleanup.test.tsx
+++ b/packages/cli/src/gemini_cleanup.test.tsx
@@ -179,7 +179,7 @@ describe('gemini.tsx main function cleanup', () => {
vi.restoreAllMocks();
});
- it('should log error when cleanupExpiredSessions fails', async () => {
+ it.skip('should log error when cleanupExpiredSessions fails', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
@@ -216,7 +216,7 @@ describe('gemini.tsx main function cleanup', () => {
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: vi.fn(() => false),
- getExperimentalZedIntegration: vi.fn(() => true),
+ getAcpMode: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getProjectRoot: vi.fn(() => '/'),
diff --git a/packages/cli/src/integration-tests/modelSteering.test.tsx b/packages/cli/src/integration-tests/modelSteering.test.tsx
index ca1970cebc..27bcde0dc2 100644
--- a/packages/cli/src/integration-tests/modelSteering.test.tsx
+++ b/packages/cli/src/integration-tests/modelSteering.test.tsx
@@ -65,10 +65,6 @@ describe('Model Steering Integration', () => {
// Resolve list_directory (Proceed)
await rig.resolveTool('ReadFolder');
- // Wait for the model to process the hint and output the next action
- // Based on steering.responses, it should first acknowledge the hint
- await rig.waitForOutput('ACK: I will focus on .txt files now.');
-
// Then it should proceed with the next action
await rig.waitForOutput(
/Since you want me to focus on .txt files,[\s\S]*I will read file1.txt/,
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
index 31673e921a..f867f84c80 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -31,6 +31,7 @@ import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
+import { footerCommand } from '../ui/commands/footerCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
import { rewindCommand } from '../ui/commands/rewindCommand.js';
@@ -119,6 +120,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
]
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
helpCommand,
+ footerCommand,
shortcutsCommand,
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
rewindCommand,
diff --git a/packages/cli/src/test-utils/fixtures/steering.responses b/packages/cli/src/test-utils/fixtures/steering.responses
index 66407f819e..6d843010f1 100644
--- a/packages/cli/src/test-utils/fixtures/steering.responses
+++ b/packages/cli/src/test-utils/fixtures/steering.responses
@@ -1,4 +1,3 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"Starting a long task. First, I'll list the files."},{"functionCall":{"name":"list_directory","args":{"dir_path":"."}}}]},"finishReason":"STOP"}]}]}
-{"method":"generateContent","response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ACK: I will focus on .txt files now."}]},"finishReason":"STOP"}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I see the files. Since you want me to focus on .txt files, I will read file1.txt."},{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}}]},"finishReason":"STOP"}]}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I have read file1.txt. Task complete."}]},"finishReason":"STOP"}]}]}
diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts
index 8b7c7c520d..c8ab45a35d 100644
--- a/packages/cli/src/test-utils/mockConfig.ts
+++ b/packages/cli/src/test-utils/mockConfig.ts
@@ -42,7 +42,7 @@ export const createMockConfig = (overrides: Partial = {}): Config =>
setSessionId: vi.fn(),
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
- getExperimentalZedIntegration: vi.fn(() => false),
+ getAcpMode: vi.fn(() => false),
isBrowserLaunchSuppressed: vi.fn(() => false),
setRemoteAdminSettings: vi.fn(),
isYoloModeDisabled: vi.fn(() => false),
diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index 86c46e79e5..06f99c135c 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -17,6 +17,7 @@ import { vi } from 'vitest';
import stripAnsi from 'strip-ansi';
import { act, useState } from 'react';
import os from 'node:os';
+import path from 'node:path';
import { LoadedSettings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
@@ -49,7 +50,7 @@ import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
import { createMockSettings } from './settings.js';
import { SessionStatsProvider } from '../ui/contexts/SessionContext.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
-import { DefaultLight } from '../ui/themes/default-light.js';
+import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
import { pickDefaultThemeName } from '../ui/themes/theme.js';
import { generateSvgForTerminal } from './svg.js';
@@ -502,7 +503,22 @@ const configProxy = new Proxy({} as Config, {
get(_target, prop) {
if (prop === 'getTargetDir') {
return () =>
- '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long';
+ path.join(
+ path.parse(process.cwd()).root,
+ 'Users',
+ 'test',
+ 'project',
+ 'foo',
+ 'bar',
+ 'and',
+ 'some',
+ 'more',
+ 'directories',
+ 'to',
+ 'make',
+ 'it',
+ 'long',
+ );
}
if (prop === 'getUseBackgroundColor') {
return () => true;
diff --git a/packages/cli/src/test-utils/svg.ts b/packages/cli/src/test-utils/svg.ts
index 10528ca6b7..92d3f53c2f 100644
--- a/packages/cli/src/test-utils/svg.ts
+++ b/packages/cli/src/test-utils/svg.ts
@@ -89,6 +89,7 @@ export const generateSvgForTerminal = (terminal: Terminal): string => {
break;
}
}
+
if (contentRows === 0) contentRows = 1; // Minimum 1 row
const width = terminal.cols * charWidth + padding * 2;
@@ -113,6 +114,9 @@ export const generateSvgForTerminal = (terminal: Terminal): string => {
let currentFgHex: string | null = null;
let currentBgHex: string | null = null;
+ let currentIsBold = false;
+ let currentIsItalic = false;
+ let currentIsUnderline = false;
let currentBlockStartCol = -1;
let currentBlockText = '';
let currentBlockNumCells = 0;
@@ -128,12 +132,20 @@ export const generateSvgForTerminal = (terminal: Terminal): string => {
svg += `
`;
}
- if (currentBlockText.trim().length > 0) {
+ if (currentBlockText.trim().length > 0 || currentIsUnderline) {
const fill = currentFgHex || '#ffffff'; // Default text color
const textWidth = currentBlockNumCells * charWidth;
+
+ let extraAttrs = '';
+ if (currentIsBold) extraAttrs += ' font-weight="bold"';
+ if (currentIsItalic) extraAttrs += ' font-style="italic"';
+ if (currentIsUnderline)
+ extraAttrs += ' text-decoration="underline"';
+
// Use textLength to ensure the block fits exactly into its designated cells
- svg += ` ${escapeXml(currentBlockText)}
-`;
+ const textElement = `${escapeXml(currentBlockText)}`;
+
+ svg += ` ${textElement}\n`;
}
}
}
@@ -164,17 +176,27 @@ export const generateSvgForTerminal = (terminal: Terminal): string => {
bgHex = tempFgHex || '#ffffff';
}
+ const isBold = !!cell.isBold();
+ const isItalic = !!cell.isItalic();
+ const isUnderline = !!cell.isUnderline();
+
let chars = cell.getChars();
if (chars === '') chars = ' '.repeat(cellWidth);
if (
fgHex !== currentFgHex ||
bgHex !== currentBgHex ||
+ isBold !== currentIsBold ||
+ isItalic !== currentIsItalic ||
+ isUnderline !== currentIsUnderline ||
currentBlockStartCol === -1
) {
finalizeBlock(x);
currentFgHex = fgHex;
currentBgHex = bgHex;
+ currentIsBold = isBold;
+ currentIsItalic = isItalic;
+ currentIsUnderline = isUnderline;
currentBlockStartCol = x;
currentBlockText = chars;
currentBlockNumCells = cellWidth;
@@ -185,6 +207,7 @@ export const generateSvgForTerminal = (terminal: Terminal): string => {
}
finalizeBlock(line.length);
}
+
svg += ` \n`;
return svg;
};
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 8505afd3ef..0326aee766 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -2544,136 +2544,6 @@ describe('AppContainer State Management', () => {
});
});
- describe('Expansion Persistence', () => {
- let rerender: () => void;
- let unmount: () => void;
- let stdin: ReturnType['stdin'];
-
- const setupExpansionPersistenceTest = async (
- HighPriorityChild?: React.FC,
- ) => {
- const getTree = () => (
-
-
-
-
- {HighPriorityChild && }
-
-
-
- );
-
- const renderResult = render(getTree());
- stdin = renderResult.stdin;
- await act(async () => {
- vi.advanceTimersByTime(100);
- });
- rerender = () => renderResult.rerender(getTree());
- unmount = () => renderResult.unmount();
- };
-
- const writeStdin = async (sequence: string) => {
- await act(async () => {
- stdin.write(sequence);
- // Advance timers to allow escape sequence parsing and broadcasting
- vi.advanceTimersByTime(100);
- });
- rerender();
- };
-
- beforeEach(() => {
- vi.useFakeTimers();
- });
-
- afterEach(() => {
- vi.useRealTimers();
- vi.restoreAllMocks();
- });
-
- it('should reset expansion when a key is NOT handled by anyone', async () => {
- await setupExpansionPersistenceTest();
-
- // Expand first
- act(() => capturedUIActions.setConstrainHeight(false));
- rerender();
- expect(capturedUIState.constrainHeight).toBe(false);
-
- // Press a random key that no one handles (hits Low priority fallback)
- await writeStdin('x');
-
- // Should be reset to true (collapsed)
- expect(capturedUIState.constrainHeight).toBe(true);
-
- unmount();
- });
-
- it('should toggle expansion when Ctrl+O is pressed', async () => {
- await setupExpansionPersistenceTest();
-
- // Initial state is collapsed
- expect(capturedUIState.constrainHeight).toBe(true);
-
- // Press Ctrl+O to expand (Ctrl+O is sequence \x0f)
- await writeStdin('\x0f');
- expect(capturedUIState.constrainHeight).toBe(false);
-
- // Press Ctrl+O again to collapse
- await writeStdin('\x0f');
- expect(capturedUIState.constrainHeight).toBe(true);
-
- unmount();
- });
-
- it('should NOT collapse when a high-priority component handles the key (e.g., up/down arrows)', async () => {
- const NavigationHandler = () => {
- // use real useKeypress
- useKeypress(
- (key: Key) => {
- if (key.name === 'up' || key.name === 'down') {
- return true; // Handle navigation
- }
- return false;
- },
- { isActive: true, priority: true }, // High priority
- );
- return null;
- };
-
- await setupExpansionPersistenceTest(NavigationHandler);
-
- // Expand first
- act(() => capturedUIActions.setConstrainHeight(false));
- rerender();
- expect(capturedUIState.constrainHeight).toBe(false);
-
- // 1. Simulate Up arrow (handled by high priority child)
- // CSI A is Up arrow
- await writeStdin('\u001b[A');
-
- // Should STILL be expanded
- expect(capturedUIState.constrainHeight).toBe(false);
-
- // 2. Simulate Down arrow (handled by high priority child)
- // CSI B is Down arrow
- await writeStdin('\u001b[B');
-
- // Should STILL be expanded
- expect(capturedUIState.constrainHeight).toBe(false);
-
- // 3. Sanity check: press an unhandled key
- await writeStdin('x');
-
- // Should finally collapse
- expect(capturedUIState.constrainHeight).toBe(true);
-
- unmount();
- });
- });
-
describe('Shortcuts Help Visibility', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let mockedUseKeypress: Mock;
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 4f8d739340..41cc5dec3d 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -80,8 +80,8 @@ import {
type ConsentRequestPayload,
type AgentsDiscoveredPayload,
ChangeAuthRequestedError,
+ ProjectIdRequiredError,
CoreToolCallStatus,
- generateSteeringAckMessage,
buildUserSteeringHintPrompt,
logBillingEvent,
ApiKeyUpdatedEvent,
@@ -129,7 +129,7 @@ import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
-import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
+import { relaunchApp } from '../utils/processUtils.js';
import type { SessionInfo } from '../utils/sessionUtils.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useMcpStatus } from './hooks/useMcpStatus.js';
@@ -771,6 +771,12 @@ export const AppContainer = (props: AppContainerProps) => {
if (e instanceof ChangeAuthRequestedError) {
return;
}
+ if (e instanceof ProjectIdRequiredError) {
+ // OAuth succeeded but account setup requires project ID
+ // Show the error message directly without "Failed to authenticate" prefix
+ onAuthError(getErrorMessage(e));
+ return;
+ }
onAuthError(
`Failed to authenticate: ${e instanceof Error ? e.message : String(e)}`,
);
@@ -781,13 +787,12 @@ export const AppContainer = (props: AppContainerProps) => {
authType === AuthType.LOGIN_WITH_GOOGLE &&
config.isBrowserLaunchSuppressed()
) {
- await runExitCleanup();
writeToStdout(`
----------------------------------------------------------------
Logging in with Google... Restarting Gemini CLI to continue.
----------------------------------------------------------------
`);
- process.exit(RELAUNCH_EXIT_CODE);
+ await relaunchApp();
}
}
setAuthState(AuthState.Authenticated);
@@ -1873,10 +1878,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
- useKeypress(handleGlobalKeypress, {
- isActive: true,
- priority: KeypressPriority.Low,
- });
+ useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useKeypress(
() => {
@@ -2106,15 +2108,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
return;
}
- void generateSteeringAckMessage(
- config.getBaseLlmClient(),
- pendingHint,
- ).then((ackText) => {
- historyManager.addItem({
- type: 'info',
- text: ackText,
- });
- });
void submitQuery([{ text: buildUserSteeringHintPrompt(pendingHint) }]);
}, [
config,
@@ -2500,8 +2493,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
});
}
}
- await runExitCleanup();
- process.exit(RELAUNCH_EXIT_CODE);
+ await relaunchApp();
},
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) {
diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx
index c5ac742955..2caad6fd27 100644
--- a/packages/cli/src/ui/auth/ApiAuthDialog.tsx
+++ b/packages/cli/src/ui/auth/ApiAuthDialog.tsx
@@ -98,7 +98,7 @@ export function ApiAuthDialog({
return (
{
- await runExitCleanup();
- process.exit(RELAUNCH_EXIT_CODE);
- }, 100);
+ setTimeout(relaunchApp, 100);
return;
}
@@ -193,7 +189,7 @@ export function AuthDialog({
return (
{
vi.clearAllMocks();
exitSpy.mockClear();
vi.useRealTimers();
+ _resetRelaunchStateForTesting();
});
it('renders correctly', async () => {
diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx
index 86cd645fee..94ca359b59 100644
--- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx
+++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx
@@ -8,8 +8,7 @@ import { type Config } from '@google/gemini-cli-core';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { runExitCleanup } from '../../utils/cleanup.js';
-import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
+import { relaunchApp } from '../../utils/processUtils.js';
interface LoginWithGoogleRestartDialogProps {
onDismiss: () => void;
@@ -36,8 +35,7 @@ export const LoginWithGoogleRestartDialog = ({
});
}
}
- await runExitCleanup();
- process.exit(RELAUNCH_EXIT_CODE);
+ await relaunchApp();
}, 100);
return true;
}
diff --git a/packages/cli/src/ui/auth/useAuth.test.tsx b/packages/cli/src/ui/auth/useAuth.test.tsx
index 36d9aeec4f..20a02ffb21 100644
--- a/packages/cli/src/ui/auth/useAuth.test.tsx
+++ b/packages/cli/src/ui/auth/useAuth.test.tsx
@@ -15,7 +15,11 @@ import {
} from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js';
-import { AuthType, type Config } from '@google/gemini-cli-core';
+import {
+ AuthType,
+ type Config,
+ ProjectIdRequiredError,
+} from '@google/gemini-cli-core';
import { AuthState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { waitFor } from '../../test-utils/async.js';
@@ -288,5 +292,21 @@ describe('useAuth', () => {
expect(result.current.authState).toBe(AuthState.Updating);
});
});
+
+ it('should handle ProjectIdRequiredError without "Failed to login" prefix', async () => {
+ const projectIdError = new ProjectIdRequiredError();
+ (mockConfig.refreshAuth as Mock).mockRejectedValue(projectIdError);
+ const { result } = renderHook(() =>
+ useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
+ );
+
+ await waitFor(() => {
+ expect(result.current.authError).toBe(
+ 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca',
+ );
+ expect(result.current.authError).not.toContain('Failed to login');
+ expect(result.current.authState).toBe(AuthState.Updating);
+ });
+ });
});
});
diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts
index 3faec2d5a8..afd438bb00 100644
--- a/packages/cli/src/ui/auth/useAuth.ts
+++ b/packages/cli/src/ui/auth/useAuth.ts
@@ -12,6 +12,7 @@ import {
loadApiKey,
debugLogger,
isAccountSuspendedError,
+ ProjectIdRequiredError,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '@google/gemini-cli-core';
import { AuthState } from '../types.js';
@@ -143,6 +144,10 @@ export const useAuthCommand = (
appealUrl: suspendedError.appealUrl,
appealLinkText: suspendedError.appealLinkText,
});
+ } else if (e instanceof ProjectIdRequiredError) {
+ // OAuth succeeded but account setup requires project ID
+ // Show the error message directly without "Failed to login" prefix
+ onAuthError(getErrorMessage(e));
} else {
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
}
diff --git a/packages/cli/src/ui/commands/footerCommand.tsx b/packages/cli/src/ui/commands/footerCommand.tsx
new file mode 100644
index 0000000000..4a6760e229
--- /dev/null
+++ b/packages/cli/src/ui/commands/footerCommand.tsx
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ type SlashCommand,
+ type CommandContext,
+ type OpenCustomDialogActionReturn,
+ CommandKind,
+} from './types.js';
+import { FooterConfigDialog } from '../components/FooterConfigDialog.js';
+
+export const footerCommand: SlashCommand = {
+ name: 'footer',
+ altNames: ['statusline'],
+ description: 'Configure which items appear in the footer (statusline)',
+ kind: CommandKind.BUILT_IN,
+ autoExecute: true,
+ action: (context: CommandContext): OpenCustomDialogActionReturn => ({
+ type: 'custom_dialog',
+ component: ,
+ }),
+};
diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts
index 2608b44ca9..fab1267b17 100644
--- a/packages/cli/src/ui/commands/planCommand.test.ts
+++ b/packages/cli/src/ui/commands/planCommand.test.ts
@@ -14,7 +14,9 @@ import {
coreEvents,
processSingleFileContent,
type ProcessedFileReadResult,
+ readFileWithEncoding,
} from '@google/gemini-cli-core';
+import { copyToClipboard } from '../utils/commandUtils.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -25,6 +27,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
emitFeedback: vi.fn(),
},
processSingleFileContent: vi.fn(),
+ readFileWithEncoding: vi.fn(),
partToString: vi.fn((val) => val),
};
});
@@ -35,9 +38,14 @@ vi.mock('node:path', async (importOriginal) => {
...actual,
default: { ...actual },
join: vi.fn((...args) => args.join('/')),
+ basename: vi.fn((p) => p.split('/').pop()),
};
});
+vi.mock('../utils/commandUtils.js', () => ({
+ copyToClipboard: vi.fn(),
+}));
+
describe('planCommand', () => {
let mockContext: CommandContext;
@@ -115,4 +123,46 @@ describe('planCommand', () => {
text: '# Approved Plan Content',
});
});
+
+ describe('copy subcommand', () => {
+ it('should copy the approved plan to clipboard', async () => {
+ const mockPlanPath = '/mock/plans/dir/approved-plan.md';
+ vi.mocked(
+ mockContext.services.config!.getApprovedPlanPath,
+ ).mockReturnValue(mockPlanPath);
+ vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content');
+
+ const copySubCommand = planCommand.subCommands?.find(
+ (sc) => sc.name === 'copy',
+ );
+ if (!copySubCommand?.action) throw new Error('Copy action missing');
+
+ await copySubCommand.action(mockContext, '');
+
+ expect(readFileWithEncoding).toHaveBeenCalledWith(mockPlanPath);
+ expect(copyToClipboard).toHaveBeenCalledWith('# Plan Content');
+ expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'info',
+ 'Plan copied to clipboard (approved-plan.md).',
+ );
+ });
+
+ it('should warn if no approved plan is found', async () => {
+ vi.mocked(
+ mockContext.services.config!.getApprovedPlanPath,
+ ).mockReturnValue(undefined);
+
+ const copySubCommand = planCommand.subCommands?.find(
+ (sc) => sc.name === 'copy',
+ );
+ if (!copySubCommand?.action) throw new Error('Copy action missing');
+
+ await copySubCommand.action(mockContext, '');
+
+ expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
+ 'warning',
+ 'No approved plan found to copy.',
+ );
+ });
+ });
});
diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts
index d9cc6739da..cfa3f9433e 100644
--- a/packages/cli/src/ui/commands/planCommand.ts
+++ b/packages/cli/src/ui/commands/planCommand.ts
@@ -4,22 +4,54 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { CommandKind, type SlashCommand } from './types.js';
+import {
+ type CommandContext,
+ CommandKind,
+ type SlashCommand,
+} from './types.js';
import {
ApprovalMode,
coreEvents,
debugLogger,
processSingleFileContent,
partToString,
+ readFileWithEncoding,
} from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
import * as path from 'node:path';
+import { copyToClipboard } from '../utils/commandUtils.js';
+
+async function copyAction(context: CommandContext) {
+ const config = context.services.config;
+ if (!config) {
+ debugLogger.debug('Plan copy command: config is not available in context');
+ return;
+ }
+
+ const planPath = config.getApprovedPlanPath();
+
+ if (!planPath) {
+ coreEvents.emitFeedback('warning', 'No approved plan found to copy.');
+ return;
+ }
+
+ try {
+ const content = await readFileWithEncoding(planPath);
+ await copyToClipboard(content);
+ coreEvents.emitFeedback(
+ 'info',
+ `Plan copied to clipboard (${path.basename(planPath)}).`,
+ );
+ } catch (error) {
+ coreEvents.emitFeedback('error', `Failed to copy plan: ${error}`, error);
+ }
+}
export const planCommand: SlashCommand = {
name: 'plan',
description: 'Switch to Plan Mode and view current plan',
kind: CommandKind.BUILT_IN,
- autoExecute: true,
+ autoExecute: false,
action: async (context) => {
const config = context.services.config;
if (!config) {
@@ -62,4 +94,13 @@ export const planCommand: SlashCommand = {
);
}
},
+ subCommands: [
+ {
+ name: 'copy',
+ description: 'Copy the currently approved plan to your clipboard',
+ kind: CommandKind.BUILT_IN,
+ autoExecute: true,
+ action: copyAction,
+ },
+ ],
};
diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx
index 03cd10823d..16093ef0d7 100644
--- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx
+++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx
@@ -427,7 +427,7 @@ export const BackgroundShellDisplay = ({
height="100%"
width="100%"
borderStyle="single"
- borderColor={isFocused ? theme.border.focused : undefined}
+ borderColor={isFocused ? theme.ui.focus : undefined}
>
{renderTabs()}
diff --git a/packages/cli/src/ui/components/ColorsDisplay.test.tsx b/packages/cli/src/ui/components/ColorsDisplay.test.tsx
new file mode 100644
index 0000000000..ec44bd6406
--- /dev/null
+++ b/packages/cli/src/ui/components/ColorsDisplay.test.tsx
@@ -0,0 +1,118 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderWithProviders } from '../../test-utils/render.js';
+import { ColorsDisplay } from './ColorsDisplay.js';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { themeManager } from '../themes/theme-manager.js';
+import type { Theme, ColorsTheme } from '../themes/theme.js';
+import type { SemanticColors } from '../themes/semantic-tokens.js';
+
+describe('ColorsDisplay', () => {
+ beforeEach(() => {
+ vi.spyOn(themeManager, 'getSemanticColors').mockReturnValue({
+ text: {
+ primary: '#ffffff',
+ secondary: '#cccccc',
+ link: '#0000ff',
+ accent: '#ff00ff',
+ response: '#ffffff',
+ },
+ background: {
+ primary: '#000000',
+ message: '#111111',
+ input: '#222222',
+ focus: '#333333',
+ diff: {
+ added: '#003300',
+ removed: '#330000',
+ },
+ },
+ border: {
+ default: '#555555',
+ },
+ ui: {
+ comment: '#666666',
+ symbol: '#cccccc',
+ active: '#0000ff',
+ dark: '#333333',
+ focus: '#0000ff',
+ gradient: undefined,
+ },
+ status: {
+ error: '#ff0000',
+ success: '#00ff00',
+ warning: '#ffff00',
+ },
+ });
+
+ vi.spyOn(themeManager, 'getActiveTheme').mockReturnValue({
+ name: 'Test Theme',
+ type: 'dark',
+ colors: {} as unknown as ColorsTheme,
+ semanticColors: {
+ text: {
+ primary: '#ffffff',
+ secondary: '#cccccc',
+ link: '#0000ff',
+ accent: '#ff00ff',
+ response: '#ffffff',
+ },
+ background: {
+ primary: '#000000',
+ message: '#111111',
+ input: '#222222',
+ diff: {
+ added: '#003300',
+ removed: '#330000',
+ },
+ },
+ border: {
+ default: '#555555',
+ },
+ ui: {
+ comment: '#666666',
+ symbol: '#cccccc',
+ active: '#0000ff',
+ dark: '#333333',
+ focus: '#0000ff',
+ gradient: undefined,
+ },
+ status: {
+ error: '#ff0000',
+ success: '#00ff00',
+ warning: '#ffff00',
+ },
+ } as unknown as SemanticColors,
+ } as unknown as Theme);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders correctly', async () => {
+ const mockTheme = themeManager.getActiveTheme();
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+
+ // Check for title and description
+ expect(output).toContain('How do colors get applied?');
+ expect(output).toContain('Hex:');
+
+ // Check for some color names and values expect(output).toContain('text.primary');
+ expect(output).toContain('#ffffff');
+ expect(output).toContain('background.diff.added');
+ expect(output).toContain('#003300');
+ expect(output).toContain('border.default');
+ expect(output).toContain('#555555');
+
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/ColorsDisplay.tsx b/packages/cli/src/ui/components/ColorsDisplay.tsx
new file mode 100644
index 0000000000..96b98bf540
--- /dev/null
+++ b/packages/cli/src/ui/components/ColorsDisplay.tsx
@@ -0,0 +1,277 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import Gradient from 'ink-gradient';
+import { theme } from '../semantic-colors.js';
+import type { Theme } from '../themes/theme.js';
+
+interface StandardColorRow {
+ type: 'standard';
+ name: string;
+ value: string;
+}
+
+interface GradientColorRow {
+ type: 'gradient';
+ name: string;
+ value: string[];
+}
+
+interface BackgroundColorRow {
+ type: 'background';
+ name: string;
+ value: string;
+}
+
+type ColorRow = StandardColorRow | GradientColorRow | BackgroundColorRow;
+
+const VALUE_COLUMN_WIDTH = 10;
+
+const COLOR_DESCRIPTIONS: Record = {
+ 'text.primary': 'Primary text color (uses terminal default if blank)',
+ 'text.secondary': 'Secondary/dimmed text color',
+ 'text.link': 'Hyperlink and highlighting color',
+ 'text.accent': 'Accent color for emphasis',
+ 'text.response':
+ 'Color for model response text (uses terminal default if blank)',
+ 'background.primary': 'Main terminal background color',
+ 'background.message': 'Subtle background for message blocks',
+ 'background.input': 'Background for the input prompt',
+ 'background.focus': 'Background highlight for selected/focused items',
+ 'background.diff.added': 'Background for added lines in diffs',
+ 'background.diff.removed': 'Background for removed lines in diffs',
+ 'border.default': 'Standard border color',
+ 'ui.comment': 'Color for code comments and metadata',
+ 'ui.symbol': 'Color for technical symbols and UI icons',
+ 'ui.active': 'Border color for active or running elements',
+ 'ui.dark': 'Deeply dimmed color for subtle UI elements',
+ 'ui.focus':
+ 'Color for focused elements (e.g. selected menu items, focused borders)',
+ 'status.error': 'Color for error messages and critical status',
+ 'status.success': 'Color for success messages and positive status',
+ 'status.warning': 'Color for warnings and cautionary status',
+};
+
+interface ColorsDisplayProps {
+ activeTheme: Theme;
+}
+
+/**
+ * Determines a contrasting text color (black or white) based on the background color's luminance.
+ */
+function getContrastingTextColor(hex: string): string {
+ if (!hex || !hex.startsWith('#') || hex.length < 7) {
+ // Fallback for invalid hex codes or named colors
+ return theme.text.primary;
+ }
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ // Using YIQ formula to determine luminance
+ const yiq = (r * 299 + g * 587 + b * 114) / 1000;
+ return yiq >= 128 ? '#000000' : '#FFFFFF';
+}
+
+export const ColorsDisplay: React.FC = ({
+ activeTheme,
+}) => {
+ const semanticColors = activeTheme.semanticColors;
+
+ const backgroundRows: BackgroundColorRow[] = [];
+ const standardRows: StandardColorRow[] = [];
+ let gradientRow: GradientColorRow | null = null;
+
+ if (semanticColors.ui.gradient && semanticColors.ui.gradient.length > 0) {
+ gradientRow = {
+ type: 'gradient',
+ name: 'ui.gradient',
+ value: semanticColors.ui.gradient,
+ };
+ }
+
+ /**
+ * Recursively flattens the semanticColors object.
+ */
+ const flattenColors = (obj: object, path: string = '') => {
+ for (const [key, value] of Object.entries(obj)) {
+ if (value === undefined || value === null) continue;
+ const newPath = path ? `${path}.${key}` : key;
+
+ if (key === 'gradient' && Array.isArray(value)) {
+ // Gradient handled separately
+ continue;
+ }
+
+ if (typeof value === 'object' && !Array.isArray(value)) {
+ flattenColors(value, newPath);
+ } else if (typeof value === 'string') {
+ if (newPath.startsWith('background.')) {
+ backgroundRows.push({
+ type: 'background',
+ name: newPath,
+ value,
+ });
+ } else {
+ standardRows.push({
+ type: 'standard',
+ name: newPath,
+ value,
+ });
+ }
+ }
+ }
+ };
+
+ flattenColors(semanticColors);
+
+ // Final order: Backgrounds first, then Standards, then Gradient
+ const allRows: ColorRow[] = [
+ ...backgroundRows,
+ ...standardRows,
+ ...(gradientRow ? [gradientRow] : []),
+ ];
+
+ return (
+
+
+
+ DEVELOPER TOOLS (Not visible to users)
+
+
+
+ How do colors get applied?
+
+
+
+ • Hex: Rendered exactly by modern terminals. Not
+ overridden by app themes.
+
+
+ • Blank: Uses your terminal's default
+ foreground/background.
+
+
+ • Compatibility: On older terminals, hex is
+ approximated to the nearest ANSI color.
+
+
+ • ANSI Names: 'red',
+ 'green', etc. are mapped to your terminal app's
+ palette.
+
+
+
+
+
+ {/* Header */}
+
+
+
+ Value
+
+
+
+
+ Name
+
+
+
+
+ {/* All Rows */}
+
+ {allRows.map((row) => {
+ if (row.type === 'standard') return renderStandardRow(row);
+ if (row.type === 'gradient') return renderGradientRow(row);
+ if (row.type === 'background') return renderBackgroundRow(row);
+ return null;
+ })}
+
+
+ );
+};
+
+function renderStandardRow({ name, value }: StandardColorRow) {
+ const isHex = value.startsWith('#');
+ const displayColor = isHex ? value : theme.text.primary;
+ const description = COLOR_DESCRIPTIONS[name] || '';
+
+ return (
+
+
+ {value || '(blank)'}
+
+
+
+ {name}
+
+
+ {description}
+
+
+
+ );
+}
+
+function renderGradientRow({ name, value }: GradientColorRow) {
+ const description = COLOR_DESCRIPTIONS[name] || '';
+
+ return (
+
+
+ {value.map((c, i) => (
+
+ {c}
+
+ ))}
+
+
+
+
+ {name}
+
+
+
+ {description}
+
+
+
+ );
+}
+
+function renderBackgroundRow({ name, value }: BackgroundColorRow) {
+ const description = COLOR_DESCRIPTIONS[name] || '';
+
+ return (
+
+
+
+ {value || 'default'}
+
+
+
+
+ {name}
+
+
+ {description}
+
+
+
+ );
+}
diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx
index bcd5fd62b5..dcb2a3eae7 100644
--- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx
+++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx
@@ -28,7 +28,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
- expect(output).toContain('50% context used');
+ expect(output).toContain('50% used');
unmount();
});
@@ -42,7 +42,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
- expect(output).toContain('0% context used');
+ expect(output).toContain('0% used');
unmount();
});
@@ -72,7 +72,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
- expect(output).toContain('80% context used');
+ expect(output).toContain('80% used');
unmount();
});
@@ -86,7 +86,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
- expect(output).toContain('100% context used');
+ expect(output).toContain('100% used');
unmount();
});
});
diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx
index 66cb8ed234..3e82145dca 100644
--- a/packages/cli/src/ui/components/ContextUsageDisplay.tsx
+++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx
@@ -38,7 +38,7 @@ export const ContextUsageDisplay = ({
}
const label =
- terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% context used';
+ terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% used';
return (
diff --git a/packages/cli/src/ui/components/DebugProfiler.tsx b/packages/cli/src/ui/components/DebugProfiler.tsx
index e68b3018dd..b162373473 100644
--- a/packages/cli/src/ui/components/DebugProfiler.tsx
+++ b/packages/cli/src/ui/components/DebugProfiler.tsx
@@ -171,6 +171,16 @@ export const DebugProfiler = () => {
appEvents.on(eventName, handler);
}
+ // Register handlers for extension lifecycle events emitted on coreEvents
+ // but not part of the CoreEvent enum, to prevent false-positive idle warnings.
+ const extensionEvents = [
+ 'extensionsStarting',
+ 'extensionsStopping',
+ ] as const;
+ for (const eventName of extensionEvents) {
+ coreEvents.on(eventName, handler);
+ }
+
return () => {
stdin.off('data', handler);
stdout.off('resize', handler);
@@ -183,6 +193,10 @@ export const DebugProfiler = () => {
appEvents.off(eventName, handler);
}
+ for (const eventName of extensionEvents) {
+ coreEvents.off(eventName, handler);
+ }
+
profiler.profilersActive--;
};
}, []);
diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx
index c86a4ba8d3..5119c1b343 100644
--- a/packages/cli/src/ui/components/DialogManager.tsx
+++ b/packages/cli/src/ui/components/DialogManager.tsx
@@ -21,8 +21,7 @@ import { ProQuotaDialog } from './ProQuotaDialog.js';
import { ValidationDialog } from './ValidationDialog.js';
import { OverageMenuDialog } from './OverageMenuDialog.js';
import { EmptyWalletDialog } from './EmptyWalletDialog.js';
-import { runExitCleanup } from '../../utils/cleanup.js';
-import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
+import { relaunchApp } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
@@ -231,10 +230,7 @@ export const DialogManager = ({
uiActions.closeSettingsDialog()}
- onRestartRequest={async () => {
- await runExitCleanup();
- process.exit(RELAUNCH_EXIT_CODE);
- }}
+ onRestartRequest={relaunchApp}
availableTerminalHeight={terminalHeight - staticExtraHeight}
/>
diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx
index bbda51d8f0..012b2aab2f 100644
--- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx
+++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx
@@ -246,7 +246,9 @@ describe('FolderTrustDialog', () => {
it('should call relaunchApp when isRestarting is true', async () => {
vi.useFakeTimers();
- const relaunchApp = vi.spyOn(processUtils, 'relaunchApp');
+ const relaunchApp = vi
+ .spyOn(processUtils, 'relaunchApp')
+ .mockResolvedValue(undefined);
const { waitUntilReady, unmount } = renderWithProviders(
,
);
@@ -259,7 +261,9 @@ describe('FolderTrustDialog', () => {
it('should not call relaunchApp if unmounted before timeout', async () => {
vi.useFakeTimers();
- const relaunchApp = vi.spyOn(processUtils, 'relaunchApp');
+ const relaunchApp = vi
+ .spyOn(processUtils, 'relaunchApp')
+ .mockResolvedValue(undefined);
const { waitUntilReady, unmount } = renderWithProviders(
,
);
diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx
index 2067a5dc3a..5f154a4d1a 100644
--- a/packages/cli/src/ui/components/FolderTrustDialog.tsx
+++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx
@@ -54,9 +54,7 @@ export const FolderTrustDialog: React.FC = ({
useEffect(() => {
let timer: ReturnType;
if (isRestarting) {
- timer = setTimeout(async () => {
- await relaunchApp();
- }, 250);
+ timer = setTimeout(relaunchApp, 250);
}
return () => {
if (timer) clearTimeout(timer);
diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx
index 7187240249..b79b005d85 100644
--- a/packages/cli/src/ui/components/Footer.test.tsx
+++ b/packages/cli/src/ui/components/Footer.test.tsx
@@ -4,16 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
-import { createMockSettings } from '../../test-utils/settings.js';
import { Footer } from './Footer.js';
-import {
- makeFakeConfig,
- tildeifyPath,
- ToolCallDecision,
-} from '@google/gemini-cli-core';
-import type { SessionStatsState } from '../contexts/SessionContext.js';
+import { createMockSettings } from '../../test-utils/settings.js';
+import path from 'node:path';
+
+// Normalize paths to POSIX slashes for stable cross-platform snapshots.
+const normalizeFrame = (frame: string | undefined) => {
+ if (!frame) return frame;
+ return frame.replace(/\\/g, '/');
+};
let mockIsDevelopment = false;
@@ -49,14 +50,18 @@ const defaultProps = {
branchName: 'main',
};
-const mockSessionStats: SessionStatsState = {
- sessionId: 'test-session',
+const mockSessionStats = {
+ sessionId: 'test-session-id',
sessionStartTime: new Date(),
- lastPromptTokenCount: 0,
promptCount: 0,
+ lastPromptTokenCount: 150000,
metrics: {
- models: {},
+ files: {
+ totalLinesAdded: 12,
+ totalLinesRemoved: 4,
+ },
tools: {
+ count: 0,
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
@@ -65,18 +70,39 @@ const mockSessionStats: SessionStatsState = {
accept: 0,
reject: 0,
modify: 0,
- [ToolCallDecision.AUTO_ACCEPT]: 0,
+ auto_accept: 0,
},
byName: {},
+ latency: { avg: 0, max: 0, min: 0 },
},
- files: {
- totalLinesAdded: 0,
- totalLinesRemoved: 0,
+ models: {
+ 'gemini-pro': {
+ api: {
+ totalRequests: 0,
+ totalErrors: 0,
+ totalLatencyMs: 0,
+ },
+ tokens: {
+ input: 0,
+ prompt: 0,
+ candidates: 0,
+ total: 1500,
+ cached: 0,
+ thoughts: 0,
+ tool: 0,
+ },
+ roles: {},
+ },
},
},
};
describe('', () => {
+ beforeEach(() => {
+ const root = path.parse(process.cwd()).root;
+ vi.stubEnv('GEMINI_CLI_HOME', path.join(root, 'Users', 'test'));
+ });
+
it('renders the component', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
,
@@ -103,11 +129,12 @@ describe('', () => {
},
);
await waitUntilReady();
- const tildePath = tildeifyPath(defaultProps.targetDir);
- const pathLength = Math.max(20, Math.floor(79 * 0.25));
- const expectedPath =
- '...' + tildePath.slice(tildePath.length - pathLength + 3);
- expect(lastFrame()).toContain(expectedPath);
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ // Should contain some part of the path, likely shortened
+ expect(output).toContain(
+ path.join('directories', 'to', 'make', 'it', 'long'),
+ );
unmount();
});
@@ -120,10 +147,11 @@ describe('', () => {
},
);
await waitUntilReady();
- const tildePath = tildeifyPath(defaultProps.targetDir);
- const expectedPath =
- '...' + tildePath.slice(tildePath.length - 80 * 0.25 + 3);
- expect(lastFrame()).toContain(expectedPath);
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ expect(output).toContain(
+ path.join('directories', 'to', 'make', 'it', 'long'),
+ );
unmount();
});
});
@@ -140,7 +168,7 @@ describe('', () => {
},
);
await waitUntilReady();
- expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
+ expect(lastFrame()).toContain(defaultProps.branchName);
unmount();
});
@@ -153,7 +181,7 @@ describe('', () => {
},
);
await waitUntilReady();
- expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
+ expect(lastFrame()).not.toContain('Branch');
unmount();
});
@@ -162,7 +190,13 @@ describe('', () => {
,
{
width: 120,
- uiState: { sessionStats: mockSessionStats },
+ uiState: {
+ currentModel: defaultProps.model,
+ sessionStats: {
+ ...mockSessionStats,
+ lastPromptTokenCount: 1000,
+ },
+ },
settings: createMockSettings({
ui: {
footer: {
@@ -174,7 +208,7 @@ describe('', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
- expect(lastFrame()).toMatch(/\d+% context used/);
+ expect(lastFrame()).toMatch(/\d+% used/);
unmount();
});
@@ -202,7 +236,7 @@ describe('', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain('15%');
- expect(lastFrame()).toMatchSnapshot();
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
@@ -229,8 +263,8 @@ describe('', () => {
},
);
await waitUntilReady();
- expect(lastFrame()).not.toContain('used');
- expect(lastFrame()).toMatchSnapshot();
+ expect(normalizeFrame(lastFrame())).not.toContain('used');
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
@@ -257,8 +291,8 @@ describe('', () => {
},
);
await waitUntilReady();
- expect(lastFrame()).toContain('Limit reached');
- expect(lastFrame()).toMatchSnapshot();
+ expect(lastFrame()?.toLowerCase()).toContain('limit reached');
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
@@ -391,7 +425,9 @@ describe('', () => {
},
);
await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot(
+ 'complete-footer-wide',
+ );
unmount();
});
@@ -413,7 +449,9 @@ describe('', () => {
},
);
await waitUntilReady();
- expect(lastFrame({ allowEmpty: true })).toMatchSnapshot('footer-minimal');
+ expect(normalizeFrame(lastFrame({ allowEmpty: true }))).toMatchSnapshot(
+ 'footer-minimal',
+ );
unmount();
});
@@ -435,7 +473,7 @@ describe('', () => {
},
);
await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot('footer-no-model');
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot('footer-no-model');
unmount();
});
@@ -457,7 +495,9 @@ describe('', () => {
},
);
await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot(
+ 'footer-only-sandbox',
+ );
unmount();
});
@@ -478,7 +518,7 @@ describe('', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
- expect(lastFrame()).not.toMatch(/\d+% context used/);
+ expect(lastFrame()).not.toMatch(/\d+% used/);
unmount();
});
it('shows the context percentage when hideContextPercentage is false', async () => {
@@ -498,7 +538,7 @@ describe('', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
- expect(lastFrame()).toMatch(/\d+% context used/);
+ expect(lastFrame()).toMatch(/\d+% used/);
unmount();
});
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
@@ -517,7 +557,77 @@ describe('', () => {
},
);
await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot(
+ 'complete-footer-narrow',
+ );
+ unmount();
+ });
+ });
+
+ describe('Footer Token Formatting', () => {
+ const renderWithTokens = async (tokens: number) => {
+ const result = renderWithProviders(, {
+ width: 120,
+ uiState: {
+ sessionStats: {
+ ...mockSessionStats,
+ metrics: {
+ ...mockSessionStats.metrics,
+ models: {
+ 'gemini-pro': {
+ api: {
+ totalRequests: 0,
+ totalErrors: 0,
+ totalLatencyMs: 0,
+ },
+ tokens: {
+ input: 0,
+ prompt: 0,
+ candidates: 0,
+ total: tokens,
+ cached: 0,
+ thoughts: 0,
+ tool: 0,
+ },
+ roles: {},
+ },
+ },
+ },
+ },
+ },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ items: ['token-count'],
+ },
+ },
+ }),
+ });
+ await result.waitUntilReady();
+ return result;
+ };
+
+ it('formats thousands with k', async () => {
+ const { lastFrame, unmount } = await renderWithTokens(1500);
+ expect(lastFrame()).toContain('1.5k tokens');
+ unmount();
+ });
+
+ it('formats millions with m', async () => {
+ const { lastFrame, unmount } = await renderWithTokens(1500000);
+ expect(lastFrame()).toContain('1.5m tokens');
+ unmount();
+ });
+
+ it('formats billions with b', async () => {
+ const { lastFrame, unmount } = await renderWithTokens(1500000000);
+ expect(lastFrame()).toContain('1.5b tokens');
+ unmount();
+ });
+
+ it('formats small numbers without suffix', async () => {
+ const { lastFrame, unmount } = await renderWithTokens(500);
+ expect(lastFrame()).toContain('500 tokens');
unmount();
});
});
@@ -548,7 +658,6 @@ describe('', () => {
);
await waitUntilReady();
expect(lastFrame()).not.toContain('F12 for details');
- expect(lastFrame()).not.toContain('2 errors');
unmount();
});
@@ -594,68 +703,159 @@ describe('', () => {
expect(lastFrame()).toContain('2 errors');
unmount();
});
+ });
- it('shows error summary in debug mode even when verbosity is low', async () => {
- const debugConfig = makeFakeConfig();
- vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true);
-
+ describe('Footer Custom Items', () => {
+ it('renders items in the specified order', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
,
{
width: 120,
- config: debugConfig,
uiState: {
+ currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
- errorCount: 1,
- showErrorDetails: false,
},
settings: createMockSettings({
- merged: { ui: { errorVerbosity: 'low' } },
+ ui: {
+ footer: {
+ items: ['model-name', 'workspace'],
+ },
+ },
}),
},
);
await waitUntilReady();
- expect(lastFrame()).toContain('F12 for details');
- expect(lastFrame()).toContain('1 error');
+
+ const output = lastFrame();
+ const modelIdx = output.indexOf('/model');
+ const cwdIdx = output.indexOf('workspace (/directory)');
+ expect(modelIdx).toBeLessThan(cwdIdx);
+ unmount();
+ });
+
+ it('renders multiple items with proper alignment', async () => {
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ branchName: 'main',
+ },
+ settings: createMockSettings({
+ vimMode: {
+ vimMode: true,
+ },
+ ui: {
+ footer: {
+ items: ['workspace', 'git-branch', 'sandbox', 'model-name'],
+ },
+ },
+ }),
+ },
+ );
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ // Headers should be present
+ expect(output).toContain('workspace (/directory)');
+ expect(output).toContain('branch');
+ expect(output).toContain('sandbox');
+ expect(output).toContain('/model');
+ // Data should be present
+ expect(output).toContain('main');
+ expect(output).toContain('gemini-pro');
+ unmount();
+ });
+
+ it('handles empty items array', async () => {
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ width: 120,
+ uiState: { sessionStats: mockSessionStats },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ items: [],
+ },
+ },
+ }),
+ },
+ );
+ await waitUntilReady();
+
+ const output = lastFrame({ allowEmpty: true });
+ expect(output).toBeDefined();
+ expect(output.trim()).toBe('');
+ unmount();
+ });
+
+ it('does not render items that are conditionally hidden', async () => {
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ branchName: undefined, // No branch
+ },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ items: ['workspace', 'git-branch', 'model-name'],
+ },
+ },
+ }),
+ },
+ );
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ expect(output).not.toContain('branch');
+ expect(output).toContain('workspace (/directory)');
+ expect(output).toContain('/model');
+ unmount();
+ });
+ });
+
+ describe('fallback mode display', () => {
+ it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
+ },
+ },
+ );
+ await waitUntilReady();
+
+ // Footer should show the effective model (Flash), not the config model (Pro)
+ expect(lastFrame()).toContain('gemini-2.5-flash');
+ expect(lastFrame()).not.toContain('gemini-2.5-pro');
+ unmount();
+ });
+
+ it('should display Pro model when NOT in fallback mode', async () => {
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
+ },
+ },
+ );
+ await waitUntilReady();
+
+ expect(lastFrame()).toContain('gemini-2.5-pro');
unmount();
});
});
});
-
-describe('fallback mode display', () => {
- it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
- const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
- ,
- {
- width: 120,
- uiState: {
- sessionStats: mockSessionStats,
- currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
- },
- },
- );
- await waitUntilReady();
-
- // Footer should show the effective model (Flash), not the config model (Pro)
- expect(lastFrame()).toContain('gemini-2.5-flash');
- expect(lastFrame()).not.toContain('gemini-2.5-pro');
- unmount();
- });
-
- it('should display Pro model when NOT in fallback mode', async () => {
- const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
- ,
- {
- width: 120,
- uiState: {
- sessionStats: mockSessionStats,
- currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
- },
- },
- );
- await waitUntilReady();
-
- expect(lastFrame()).toContain('gemini-2.5-pro');
- unmount();
- });
-});
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index d9b2a162c5..83a7c059b9 100644
--- a/packages/cli/src/ui/components/Footer.tsx
+++ b/packages/cli/src/ui/components/Footer.tsx
@@ -11,6 +11,7 @@ import {
shortenPath,
tildeifyPath,
getDisplayString,
+ checkExhaustive,
} from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
@@ -18,11 +19,143 @@ import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { QuotaDisplay } from './QuotaDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
-import { isDevelopment } from '../../utils/installationInfo.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
+import {
+ ALL_ITEMS,
+ type FooterItemId,
+ deriveItemsFromLegacySettings,
+} from '../../config/footerItems.js';
+import { isDevelopment } from '../../utils/installationInfo.js';
+
+interface CwdIndicatorProps {
+ targetDir: string;
+ maxWidth: number;
+ debugMode?: boolean;
+ debugMessage?: string;
+ color?: string;
+}
+
+const CwdIndicator: React.FC = ({
+ targetDir,
+ maxWidth,
+ debugMode,
+ debugMessage,
+ color = theme.text.primary,
+}) => {
+ const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
+ const availableForPath = Math.max(10, maxWidth - debugSuffix.length);
+ const displayPath = shortenPath(tildeifyPath(targetDir), availableForPath);
+
+ return (
+
+ {displayPath}
+ {debugMode && {debugSuffix}}
+
+ );
+};
+
+interface SandboxIndicatorProps {
+ isTrustedFolder: boolean | undefined;
+}
+
+const SandboxIndicator: React.FC = ({
+ isTrustedFolder,
+}) => {
+ if (isTrustedFolder === false) {
+ return untrusted;
+ }
+
+ const sandbox = process.env['SANDBOX'];
+ if (sandbox && sandbox !== 'sandbox-exec') {
+ return (
+ {sandbox.replace(/^gemini-(?:cli-)?/, '')}
+ );
+ }
+
+ if (sandbox === 'sandbox-exec') {
+ return (
+
+ macOS Seatbelt{' '}
+
+ ({process.env['SEATBELT_PROFILE']})
+
+
+ );
+ }
+
+ return no sandbox;
+};
+
+const CorgiIndicator: React.FC = () => (
+
+ ▼
+ (´
+ ᴥ
+ `)
+ ▼
+
+);
+
+export interface FooterRowItem {
+ key: string;
+ header: string;
+ element: React.ReactNode;
+}
+
+const COLUMN_GAP = 3;
+
+export const FooterRow: React.FC<{
+ items: FooterRowItem[];
+ showLabels: boolean;
+}> = ({ items, showLabels }) => {
+ const elements: React.ReactNode[] = [];
+
+ items.forEach((item, idx) => {
+ if (idx > 0 && !showLabels) {
+ elements.push(
+
+ ·
+ ,
+ );
+ }
+
+ elements.push(
+
+ {showLabels && (
+
+ {item.header}
+
+ )}
+ {item.element}
+ ,
+ );
+ });
+
+ return (
+
+ {elements}
+
+ );
+};
+
+function isFooterItemId(id: string): id is FooterItemId {
+ return ALL_ITEMS.some((i) => i.id === id);
+}
+
+interface FooterColumn {
+ id: string;
+ header: string;
+ element: (maxWidth: number) => React.ReactNode;
+ width: number;
+ isHighPriority: boolean;
+}
export const Footer: React.FC = () => {
const uiState = useUIState();
@@ -58,142 +191,272 @@ export const Footer: React.FC = () => {
quotaStats: uiState.quota.stats,
};
- const showMemoryUsage =
- config.getDebugMode() || settings.merged.ui.showMemoryUsage;
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary =
!showErrorDetails &&
errorCount > 0 &&
(isFullErrorVerbosity || debugMode || isDevelopment);
- const hideCWD = settings.merged.ui.footer.hideCWD;
- const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
- const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
- const hideContextPercentage = settings.merged.ui.footer.hideContextPercentage;
-
- const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
- const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
-
- const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
const displayVimMode = vimEnabled ? vimMode : undefined;
+ const items =
+ settings.merged.ui.footer.items ??
+ deriveItemsFromLegacySettings(settings.merged);
+ const showLabels = settings.merged.ui.footer.showLabels !== false;
+ const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
- const showDebugProfiler = debugMode || isDevelopment;
+ const potentialColumns: FooterColumn[] = [];
+
+ const addCol = (
+ id: string,
+ header: string,
+ element: (maxWidth: number) => React.ReactNode,
+ dataWidth: number,
+ isHighPriority = false,
+ ) => {
+ potentialColumns.push({
+ id,
+ header: showLabels ? header : '',
+ element,
+ width: Math.max(dataWidth, showLabels ? header.length : 0),
+ isHighPriority,
+ });
+ };
+
+ // 1. System Indicators (Far Left, high priority)
+ if (uiState.showDebugProfiler) {
+ addCol('debug', '', () => , 45, true);
+ }
+ if (displayVimMode) {
+ const vimStr = `[${displayVimMode}]`;
+ addCol(
+ 'vim',
+ '',
+ () => {vimStr},
+ vimStr.length,
+ true,
+ );
+ }
+
+ // 2. Main Configurable Items
+ for (const id of items) {
+ if (!isFooterItemId(id)) continue;
+ const itemConfig = ALL_ITEMS.find((i) => i.id === id);
+ const header = itemConfig?.header ?? id;
+
+ switch (id) {
+ case 'workspace': {
+ const fullPath = tildeifyPath(targetDir);
+ const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
+ addCol(
+ id,
+ header,
+ (maxWidth) => (
+
+ ),
+ fullPath.length + debugSuffix.length,
+ );
+ break;
+ }
+ case 'git-branch': {
+ if (branchName) {
+ addCol(
+ id,
+ header,
+ () => {branchName},
+ branchName.length,
+ );
+ }
+ break;
+ }
+ case 'sandbox': {
+ let str = 'no sandbox';
+ const sandbox = process.env['SANDBOX'];
+ if (isTrustedFolder === false) str = 'untrusted';
+ else if (sandbox === 'sandbox-exec')
+ str = `macOS Seatbelt (${process.env['SEATBELT_PROFILE']})`;
+ else if (sandbox) str = sandbox.replace(/^gemini-(?:cli-)?/, '');
+
+ addCol(
+ id,
+ header,
+ () => ,
+ str.length,
+ );
+ break;
+ }
+ case 'model-name': {
+ const str = getDisplayString(model);
+ addCol(
+ id,
+ header,
+ () => {str},
+ str.length,
+ );
+ break;
+ }
+ case 'context-used': {
+ addCol(
+ id,
+ header,
+ () => (
+
+ ),
+ 10, // "100% used" is 9 chars
+ );
+ break;
+ }
+ case 'quota': {
+ if (quotaStats?.remaining !== undefined && quotaStats.limit) {
+ addCol(
+ id,
+ header,
+ () => (
+
+ ),
+ 10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars)
+ );
+ }
+ break;
+ }
+ case 'memory-usage': {
+ addCol(id, header, () => , 10);
+ break;
+ }
+ case 'session-id': {
+ addCol(
+ id,
+ header,
+ () => (
+
+ {uiState.sessionStats.sessionId.slice(0, 8)}
+
+ ),
+ 8,
+ );
+ break;
+ }
+ case 'code-changes': {
+ const added = uiState.sessionStats.metrics.files.totalLinesAdded;
+ const removed = uiState.sessionStats.metrics.files.totalLinesRemoved;
+ if (added > 0 || removed > 0) {
+ const str = `+${added} -${removed}`;
+ addCol(
+ id,
+ header,
+ () => (
+
+ +{added}{' '}
+ -{removed}
+
+ ),
+ str.length,
+ );
+ }
+ break;
+ }
+ case 'token-count': {
+ let total = 0;
+ for (const m of Object.values(uiState.sessionStats.metrics.models))
+ total += m.tokens.total;
+ if (total > 0) {
+ const formatter = new Intl.NumberFormat('en-US', {
+ notation: 'compact',
+ maximumFractionDigits: 1,
+ });
+ const formatted = formatter.format(total).toLowerCase();
+ addCol(
+ id,
+ header,
+ () => {formatted} tokens,
+ formatted.length + 7,
+ );
+ }
+ break;
+ }
+ default:
+ checkExhaustive(id);
+ break;
+ }
+ }
+
+ // 3. Transients
+ if (corgiMode) addCol('corgi', '', () => , 5);
+ if (showErrorSummary) {
+ addCol(
+ 'error-count',
+ '',
+ () => ,
+ 12,
+ true,
+ );
+ }
+
+ // --- Width Fitting Logic ---
+ let currentWidth = 2; // Initial padding
+ const columnsToRender: FooterColumn[] = [];
+ let droppedAny = false;
+
+ for (let i = 0; i < potentialColumns.length; i++) {
+ const col = potentialColumns[i];
+ const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0; // Use 3 for dot separator width
+ const budgetWidth = col.id === 'workspace' ? 20 : col.width;
+
+ if (
+ col.isHighPriority ||
+ currentWidth + gap + budgetWidth <= terminalWidth - 2
+ ) {
+ columnsToRender.push(col);
+ currentWidth += gap + budgetWidth;
+ } else {
+ droppedAny = true;
+ }
+ }
+
+ const totalBudgeted = columnsToRender.reduce(
+ (sum, c, idx) =>
+ sum +
+ (c.id === 'workspace' ? 20 : c.width) +
+ (idx > 0 ? (showLabels ? COLUMN_GAP : 3) : 0),
+ 2,
+ );
+ const excessSpace = Math.max(0, terminalWidth - totalBudgeted);
+
+ const rowItems: FooterRowItem[] = columnsToRender.map((col) => {
+ const maxWidth = col.id === 'workspace' ? 20 + excessSpace : col.width;
+ return {
+ key: col.id,
+ header: col.header,
+ element: col.element(maxWidth),
+ };
+ });
+
+ if (droppedAny) {
+ rowItems.push({
+ key: 'ellipsis',
+ header: '',
+ element: …,
+ });
+ }
return (
-
- {(showDebugProfiler || displayVimMode || !hideCWD) && (
-
- {showDebugProfiler && }
- {displayVimMode && (
- [{displayVimMode}]
- )}
- {!hideCWD && (
-
- {displayPath}
- {branchName && (
- ({branchName}*)
- )}
-
- )}
- {debugMode && (
-
- {' ' + (debugMessage || '--debug')}
-
- )}
-
- )}
-
- {/* Middle Section: Centered Trust/Sandbox Info */}
- {!hideSandboxStatus && (
-
- {isTrustedFolder === false ? (
- untrusted
- ) : process.env['SANDBOX'] &&
- process.env['SANDBOX'] !== 'sandbox-exec' ? (
-
- {process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
-
- ) : process.env['SANDBOX'] === 'sandbox-exec' ? (
-
- macOS Seatbelt{' '}
-
- ({process.env['SEATBELT_PROFILE']})
-
-
- ) : (
-
- no sandbox
- {terminalWidth >= 100 && (
- (see /docs)
- )}
-
- )}
-
- )}
-
- {/* Right Section: Gemini Label and Console Summary */}
- {!hideModelInfo && (
-
-
-
- /model
- {getDisplayString(model)}
- {!hideContextPercentage && (
- <>
- {' '}
-
- >
- )}
- {quotaStats && (
- <>
- {' '}
-
- >
- )}
-
- {showMemoryUsage && }
-
-
- {corgiMode && (
-
-
- |
- ▼
- (´
- ᴥ
- `)
- ▼
-
-
- )}
- {showErrorSummary && (
-
- |
-
-
- )}
-
-
- )}
+
+
);
};
diff --git a/packages/cli/src/ui/components/FooterConfigDialog.test.tsx b/packages/cli/src/ui/components/FooterConfigDialog.test.tsx
new file mode 100644
index 0000000000..c8b0b93659
--- /dev/null
+++ b/packages/cli/src/ui/components/FooterConfigDialog.test.tsx
@@ -0,0 +1,153 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderWithProviders } from '../../test-utils/render.js';
+import { waitFor } from '../../test-utils/async.js';
+import { FooterConfigDialog } from './FooterConfigDialog.js';
+import { createMockSettings } from '../../test-utils/settings.js';
+import { act } from 'react';
+
+describe('', () => {
+ const mockOnClose = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders correctly with default settings', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, waitUntilReady } = renderWithProviders(
+ ,
+ { settings },
+ );
+
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('toggles an item when enter is pressed', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
+ ,
+ { settings },
+ );
+
+ await waitUntilReady();
+ act(() => {
+ stdin.write('\r'); // Enter to toggle
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('[ ] workspace');
+ });
+
+ act(() => {
+ stdin.write('\r');
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('[✓] workspace');
+ });
+ });
+
+ it('reorders items with arrow keys', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
+ ,
+ { settings },
+ );
+
+ await waitUntilReady();
+ // Initial order: workspace, branch, ...
+ const output = lastFrame();
+ const cwdIdx = output.indexOf('] workspace');
+ const branchIdx = output.indexOf('] git-branch');
+ expect(cwdIdx).toBeGreaterThan(-1);
+ expect(branchIdx).toBeGreaterThan(-1);
+ expect(cwdIdx).toBeLessThan(branchIdx);
+
+ // Move workspace down (right arrow)
+ act(() => {
+ stdin.write('\u001b[C'); // Right arrow
+ });
+
+ await waitFor(() => {
+ const outputAfter = lastFrame();
+ const cwdIdxAfter = outputAfter.indexOf('] workspace');
+ const branchIdxAfter = outputAfter.indexOf('] git-branch');
+ expect(cwdIdxAfter).toBeGreaterThan(-1);
+ expect(branchIdxAfter).toBeGreaterThan(-1);
+ expect(branchIdxAfter).toBeLessThan(cwdIdxAfter);
+ });
+ });
+
+ it('closes on Esc', async () => {
+ const settings = createMockSettings();
+ const { stdin, waitUntilReady } = renderWithProviders(
+ ,
+ { settings },
+ );
+
+ await waitUntilReady();
+ act(() => {
+ stdin.write('\x1b'); // Esc
+ });
+
+ await waitFor(() => {
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+ });
+
+ it('highlights the active item in the preview', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
+ ,
+ { settings },
+ );
+
+ await waitUntilReady();
+ expect(lastFrame()).toContain('~/project/path');
+
+ // Move focus down to 'git-branch'
+ act(() => {
+ stdin.write('\u001b[B'); // Down arrow
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('main');
+ });
+ });
+
+ it('shows an empty preview when all items are deselected', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
+ ,
+ { settings },
+ );
+
+ await waitUntilReady();
+ for (let i = 0; i < 10; i++) {
+ act(() => {
+ stdin.write('\r'); // Toggle (deselect)
+ stdin.write('\u001b[B'); // Down arrow
+ });
+ }
+
+ await waitFor(() => {
+ const output = lastFrame();
+ expect(output).toContain('Preview:');
+ expect(output).not.toContain('~/project/path');
+ expect(output).not.toContain('docker');
+ expect(output).not.toContain('gemini-2.5-pro');
+ expect(output).not.toContain('1.2k left');
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/FooterConfigDialog.tsx b/packages/cli/src/ui/components/FooterConfigDialog.tsx
new file mode 100644
index 0000000000..8c43c0ee1e
--- /dev/null
+++ b/packages/cli/src/ui/components/FooterConfigDialog.tsx
@@ -0,0 +1,406 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useCallback, useMemo, useReducer } from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import { useSettingsStore } from '../contexts/SettingsContext.js';
+import { useKeypress, type Key } from '../hooks/useKeypress.js';
+import { keyMatchers, Command } from '../keyMatchers.js';
+import { FooterRow, type FooterRowItem } from './Footer.js';
+import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
+import { SettingScope } from '../../config/settings.js';
+
+interface FooterConfigDialogProps {
+ onClose?: () => void;
+}
+
+interface FooterConfigState {
+ orderedIds: string[];
+ selectedIds: Set;
+ activeIndex: number;
+ scrollOffset: number;
+}
+
+type FooterConfigAction =
+ | { type: 'MOVE_UP'; itemCount: number; maxToShow: number }
+ | { type: 'MOVE_DOWN'; itemCount: number; maxToShow: number }
+ | {
+ type: 'MOVE_LEFT';
+ items: Array<{ key: string }>;
+ }
+ | {
+ type: 'MOVE_RIGHT';
+ items: Array<{ key: string }>;
+ }
+ | { type: 'TOGGLE_ITEM'; items: Array<{ key: string }> }
+ | { type: 'SET_STATE'; payload: Partial }
+ | { type: 'RESET_INDEX' };
+
+function footerConfigReducer(
+ state: FooterConfigState,
+ action: FooterConfigAction,
+): FooterConfigState {
+ switch (action.type) {
+ case 'MOVE_UP': {
+ const { itemCount, maxToShow } = action;
+ const totalSlots = itemCount + 2; // +1 for showLabels, +1 for reset
+ const newIndex =
+ state.activeIndex > 0 ? state.activeIndex - 1 : totalSlots - 1;
+ let newOffset = state.scrollOffset;
+
+ if (newIndex < itemCount) {
+ if (newIndex === itemCount - 1) {
+ newOffset = Math.max(0, itemCount - maxToShow);
+ } else if (newIndex < state.scrollOffset) {
+ newOffset = newIndex;
+ }
+ }
+ return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
+ }
+ case 'MOVE_DOWN': {
+ const { itemCount, maxToShow } = action;
+ const totalSlots = itemCount + 2;
+ const newIndex =
+ state.activeIndex < totalSlots - 1 ? state.activeIndex + 1 : 0;
+ let newOffset = state.scrollOffset;
+
+ if (newIndex === 0) {
+ newOffset = 0;
+ } else if (
+ newIndex < itemCount &&
+ newIndex >= state.scrollOffset + maxToShow
+ ) {
+ newOffset = newIndex - maxToShow + 1;
+ }
+ return { ...state, activeIndex: newIndex, scrollOffset: newOffset };
+ }
+ case 'MOVE_LEFT':
+ case 'MOVE_RIGHT': {
+ const direction = action.type === 'MOVE_LEFT' ? -1 : 1;
+ const currentItem = action.items[state.activeIndex];
+ if (!currentItem) return state;
+
+ const currentId = currentItem.key;
+ const currentIndex = state.orderedIds.indexOf(currentId);
+ const newIndex = currentIndex + direction;
+
+ if (newIndex < 0 || newIndex >= state.orderedIds.length) return state;
+
+ const newOrderedIds = [...state.orderedIds];
+ [newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
+ newOrderedIds[newIndex],
+ newOrderedIds[currentIndex],
+ ];
+
+ return { ...state, orderedIds: newOrderedIds, activeIndex: newIndex };
+ }
+ case 'TOGGLE_ITEM': {
+ const isSystemFocused = state.activeIndex >= action.items.length;
+ if (isSystemFocused) return state;
+
+ const item = action.items[state.activeIndex];
+ if (!item) return state;
+
+ const nextSelected = new Set(state.selectedIds);
+ if (nextSelected.has(item.key)) {
+ nextSelected.delete(item.key);
+ } else {
+ nextSelected.add(item.key);
+ }
+ return { ...state, selectedIds: nextSelected };
+ }
+ case 'SET_STATE':
+ return { ...state, ...action.payload };
+ case 'RESET_INDEX':
+ return { ...state, activeIndex: 0, scrollOffset: 0 };
+ default:
+ return state;
+ }
+}
+
+export const FooterConfigDialog: React.FC = ({
+ onClose,
+}) => {
+ const { settings, setSetting } = useSettingsStore();
+ const maxItemsToShow = 10;
+
+ const [state, dispatch] = useReducer(footerConfigReducer, undefined, () => ({
+ ...resolveFooterState(settings.merged),
+ activeIndex: 0,
+ scrollOffset: 0,
+ }));
+
+ const { orderedIds, selectedIds, activeIndex, scrollOffset } = state;
+
+ // Prepare items
+ const listItems = useMemo(
+ () =>
+ orderedIds
+ .map((id: string) => {
+ const item = ALL_ITEMS.find((i) => i.id === id);
+ if (!item) return null;
+ return {
+ key: id,
+ label: item.id,
+ description: item.description as string,
+ };
+ })
+ .filter((i): i is NonNullable => i !== null),
+ [orderedIds],
+ );
+
+ const maxLabelWidth = useMemo(
+ () => listItems.reduce((max, item) => Math.max(max, item.label.length), 0),
+ [listItems],
+ );
+
+ const isResetFocused = activeIndex === listItems.length + 1;
+ const isShowLabelsFocused = activeIndex === listItems.length;
+
+ const handleSaveAndClose = useCallback(() => {
+ const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
+ const currentSetting = settings.merged.ui?.footer?.items;
+ if (JSON.stringify(finalItems) !== JSON.stringify(currentSetting)) {
+ setSetting(SettingScope.User, 'ui.footer.items', finalItems);
+ }
+ onClose?.();
+ }, [
+ orderedIds,
+ selectedIds,
+ setSetting,
+ settings.merged.ui?.footer?.items,
+ onClose,
+ ]);
+
+ const handleResetToDefaults = useCallback(() => {
+ setSetting(SettingScope.User, 'ui.footer.items', undefined);
+ dispatch({
+ type: 'SET_STATE',
+ payload: {
+ ...resolveFooterState(settings.merged),
+ activeIndex: 0,
+ scrollOffset: 0,
+ },
+ });
+ }, [setSetting, settings.merged]);
+
+ const handleToggleLabels = useCallback(() => {
+ const current = settings.merged.ui.footer.showLabels !== false;
+ setSetting(SettingScope.User, 'ui.footer.showLabels', !current);
+ }, [setSetting, settings.merged.ui.footer.showLabels]);
+
+ useKeypress(
+ (key: Key) => {
+ if (keyMatchers[Command.ESCAPE](key)) {
+ handleSaveAndClose();
+ return true;
+ }
+
+ if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
+ dispatch({
+ type: 'MOVE_UP',
+ itemCount: listItems.length,
+ maxToShow: maxItemsToShow,
+ });
+ return true;
+ }
+
+ if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
+ dispatch({
+ type: 'MOVE_DOWN',
+ itemCount: listItems.length,
+ maxToShow: maxItemsToShow,
+ });
+ return true;
+ }
+
+ if (keyMatchers[Command.MOVE_LEFT](key)) {
+ dispatch({ type: 'MOVE_LEFT', items: listItems });
+ return true;
+ }
+
+ if (keyMatchers[Command.MOVE_RIGHT](key)) {
+ dispatch({ type: 'MOVE_RIGHT', items: listItems });
+ return true;
+ }
+
+ if (keyMatchers[Command.RETURN](key) || key.name === 'space') {
+ if (isResetFocused) {
+ handleResetToDefaults();
+ } else if (isShowLabelsFocused) {
+ handleToggleLabels();
+ } else {
+ dispatch({ type: 'TOGGLE_ITEM', items: listItems });
+ }
+ return true;
+ }
+
+ return false;
+ },
+ { isActive: true, priority: true },
+ );
+
+ const visibleItems = listItems.slice(
+ scrollOffset,
+ scrollOffset + maxItemsToShow,
+ );
+
+ const activeId = listItems[activeIndex]?.key;
+ const showLabels = settings.merged.ui.footer.showLabels !== false;
+
+ // Preview logic
+ const previewContent = useMemo(() => {
+ if (isResetFocused) {
+ return (
+
+ Default footer (uses legacy settings)
+
+ );
+ }
+
+ const itemsToPreview = orderedIds.filter((id: string) =>
+ selectedIds.has(id),
+ );
+ if (itemsToPreview.length === 0) return null;
+
+ const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
+ const getColor = (id: string, defaultColor?: string) =>
+ id === activeId ? 'white' : defaultColor || itemColor;
+
+ // Mock data for preview (headers come from ALL_ITEMS)
+ const mockData: Record = {
+ workspace: (
+ ~/project/path
+ ),
+ 'git-branch': main,
+ sandbox: docker,
+ 'model-name': (
+ gemini-2.5-pro
+ ),
+ 'context-used': (
+ 85% used
+ ),
+ quota: 97%,
+ 'memory-usage': (
+ 260 MB
+ ),
+ 'session-id': (
+ 769992f9
+ ),
+ 'code-changes': (
+
+
+ +12
+
+
+ -4
+
+ ),
+ 'token-count': (
+ 1.5k tokens
+ ),
+ };
+
+ const rowItems: FooterRowItem[] = itemsToPreview
+ .filter((id: string) => mockData[id])
+ .map((id: string) => ({
+ key: id,
+ header: ALL_ITEMS.find((i) => i.id === id)?.header ?? id,
+ element: mockData[id],
+ }));
+
+ return (
+
+
+
+
+
+ );
+ }, [orderedIds, selectedIds, activeId, isResetFocused, showLabels]);
+
+ return (
+
+ Configure Footer{'\n'}
+
+ Select which items to display in the footer.
+
+
+
+ {visibleItems.length === 0 ? (
+ No items found.
+ ) : (
+ visibleItems.map((item, idx) => {
+ const index = scrollOffset + idx;
+ const isFocused = index === activeIndex;
+ const isChecked = selectedIds.has(item.key);
+
+ return (
+
+
+ {isFocused ? '> ' : ' '}
+
+
+ [{isChecked ? '✓' : ' '}]{' '}
+ {item.label.padEnd(maxLabelWidth + 1)}
+
+ {item.description}
+
+ );
+ })
+ )}
+
+
+
+
+
+ {isShowLabelsFocused ? '> ' : ' '}
+
+
+ [{showLabels ? '✓' : ' '}] Show footer labels
+
+
+
+
+ {isResetFocused ? '> ' : ' '}
+
+
+ Reset to default footer
+
+
+
+
+
+
+ ↑/↓ navigate · ←/→ reorder · enter/space select · esc close
+
+
+
+
+ Preview:
+ {previewContent}
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/GradientRegression.test.tsx b/packages/cli/src/ui/components/GradientRegression.test.tsx
index 91193e8087..bc836a1102 100644
--- a/packages/cli/src/ui/components/GradientRegression.test.tsx
+++ b/packages/cli/src/ui/components/GradientRegression.test.tsx
@@ -22,8 +22,13 @@ vi.mock('../semantic-colors.js', async (importOriginal) => {
...original,
theme: {
...original.theme,
+ background: {
+ ...original.theme.background,
+ focus: '#004000',
+ },
ui: {
...original.theme.ui,
+ focus: '#00ff00',
gradient: [], // Empty array to potentially trigger the crash
},
},
diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx
index 4d59bf14aa..46cdaf5ba0 100644
--- a/packages/cli/src/ui/components/Header.test.tsx
+++ b/packages/cli/src/ui/components/Header.test.tsx
@@ -98,16 +98,18 @@ describe('', () => {
primary: '',
message: '',
input: '',
+ focus: '',
diff: { added: '', removed: '' },
},
border: {
default: '',
- focused: '',
},
ui: {
comment: '',
symbol: '',
+ active: '',
dark: '',
+ focus: '',
gradient: undefined,
},
status: {
diff --git a/packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx b/packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx
index 3202fbb0d1..24a53b82de 100644
--- a/packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx
+++ b/packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx
@@ -62,7 +62,9 @@ describe('IdeTrustChangeDialog', () => {
});
it('calls relaunchApp when "r" is pressed', async () => {
- const relaunchAppSpy = vi.spyOn(processUtils, 'relaunchApp');
+ const relaunchAppSpy = vi
+ .spyOn(processUtils, 'relaunchApp')
+ .mockResolvedValue(undefined);
const { stdin, waitUntilReady, unmount } = renderWithProviders(
,
);
@@ -78,7 +80,9 @@ describe('IdeTrustChangeDialog', () => {
});
it('calls relaunchApp when "R" is pressed', async () => {
- const relaunchAppSpy = vi.spyOn(processUtils, 'relaunchApp');
+ const relaunchAppSpy = vi
+ .spyOn(processUtils, 'relaunchApp')
+ .mockResolvedValue(undefined);
const { stdin, waitUntilReady, unmount } = renderWithProviders(
,
);
@@ -94,7 +98,9 @@ describe('IdeTrustChangeDialog', () => {
});
it('does not call relaunchApp when another key is pressed', async () => {
- const relaunchAppSpy = vi.spyOn(processUtils, 'relaunchApp');
+ const relaunchAppSpy = vi
+ .spyOn(processUtils, 'relaunchApp')
+ .mockResolvedValue(undefined);
const { stdin, waitUntilReady, unmount } = renderWithProviders(
,
);
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 65a4440d77..ac880e4624 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -279,6 +279,9 @@ describe('InputPrompt', () => {
},
getCompletedText: vi.fn().mockReturnValue(null),
completionMode: CompletionMode.IDLE,
+ forceShowShellSuggestions: false,
+ setForceShowShellSuggestions: vi.fn(),
+ isShellSuggestionsVisible: true,
};
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
@@ -2203,7 +2206,8 @@ describe('InputPrompt', () => {
// Check that all lines, including the empty one, are rendered.
// This implicitly tests that the Box wrapper provides height for the empty line.
expect(frame).toContain('hello');
- expect(frame).toContain(`world${chalk.inverse(' ')}`);
+ expect(frame).toContain('world');
+ expect(frame).toContain(chalk.inverse(' '));
const outputLines = frame.trim().split('\n');
// The number of lines should be 2 for the border plus 3 for the content.
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 38b62ad927..6f2cd9ab7a 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -37,6 +37,7 @@ import {
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
+import { formatCommand } from '../utils/keybindingUtils.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
@@ -301,6 +302,27 @@ export const InputPrompt: React.FC = ({
const resetCommandSearchCompletionState =
commandSearchCompletion.resetCompletionState;
+ const getActiveCompletion = useCallback(() => {
+ if (commandSearchActive) return commandSearchCompletion;
+ if (reverseSearchActive) return reverseSearchCompletion;
+ return completion;
+ }, [
+ commandSearchActive,
+ commandSearchCompletion,
+ reverseSearchActive,
+ reverseSearchCompletion,
+ completion,
+ ]);
+
+ const activeCompletion = getActiveCompletion();
+ const shouldShowSuggestions = activeCompletion.showSuggestions;
+
+ const {
+ forceShowShellSuggestions,
+ setForceShowShellSuggestions,
+ isShellSuggestionsVisible,
+ } = completion;
+
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
// Notify parent component about escape prompt state changes
@@ -363,7 +385,8 @@ export const InputPrompt: React.FC = ({
userMessages,
onSubmit: handleSubmitAndClear,
isActive:
- (!completion.showSuggestions || completion.suggestions.length === 1) &&
+ (!(completion.showSuggestions && isShellSuggestionsVisible) ||
+ completion.suggestions.length === 1) &&
!shellModeActive,
currentQuery: buffer.text,
currentCursorOffset: buffer.getOffset(),
@@ -472,7 +495,7 @@ export const InputPrompt: React.FC = ({
buffer.insert(textToInsert, { paste: true });
if (isLargePaste(textToInsert)) {
appEvents.emit(AppEvent.TransientMessage, {
- message: 'Press Ctrl+O to expand pasted text',
+ message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
type: TransientMessageType.Hint,
});
}
@@ -595,9 +618,7 @@ export const InputPrompt: React.FC = ({
keyMatchers[Command.END](key);
const isSuggestionsNav =
- (completion.showSuggestions ||
- reverseSearchCompletion.showSuggestions ||
- commandSearchCompletion.showSuggestions) &&
+ shouldShowSuggestions &&
(keyMatchers[Command.COMPLETION_UP](key) ||
keyMatchers[Command.COMPLETION_DOWN](key) ||
keyMatchers[Command.EXPAND_SUGGESTION](key) ||
@@ -612,6 +633,10 @@ export const InputPrompt: React.FC = ({
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
);
hasUserNavigatedSuggestions.current = false;
+
+ if (key.name !== 'tab') {
+ setForceShowShellSuggestions(false);
+ }
}
// TODO(jacobr): this special case is likely not needed anymore.
@@ -641,15 +666,25 @@ export const InputPrompt: React.FC = ({
const isPlainTab =
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
const hasTabCompletionInteraction =
- completion.showSuggestions ||
+ (completion.showSuggestions && isShellSuggestionsVisible) ||
Boolean(completion.promptCompletion.text) ||
reverseSearchActive ||
commandSearchActive;
if (isPlainTab && shellModeActive) {
resetPlainTabPress();
- if (!completion.showSuggestions) {
+ if (!shouldShowSuggestions) {
setSuppressCompletion(false);
+ if (completion.promptCompletion.text) {
+ completion.promptCompletion.accept();
+ return true;
+ } else if (
+ completion.suggestions.length > 0 &&
+ !forceShowShellSuggestions
+ ) {
+ setForceShowShellSuggestions(true);
+ return true;
+ }
}
} else if (isPlainTab) {
if (!hasTabCompletionInteraction) {
@@ -696,7 +731,7 @@ export const InputPrompt: React.FC = ({
buffer.handleInput(key);
if (key.sequence && isLargePaste(key.sequence)) {
appEvents.emit(AppEvent.TransientMessage, {
- message: 'Press Ctrl+O to expand pasted text',
+ message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
type: TransientMessageType.Hint,
});
}
@@ -752,7 +787,7 @@ export const InputPrompt: React.FC = ({
if (
key.sequence === '!' &&
buffer.text === '' &&
- !completion.showSuggestions
+ !(completion.showSuggestions && isShellSuggestionsVisible)
) {
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
@@ -791,15 +826,15 @@ export const InputPrompt: React.FC = ({
return true;
}
- if (shellModeActive) {
- setShellModeActive(false);
+ if (completion.showSuggestions && isShellSuggestionsVisible) {
+ completion.resetCompletionState();
+ setExpandedSuggestionIndex(-1);
resetEscapeState();
return true;
}
- if (completion.showSuggestions) {
- completion.resetCompletionState();
- setExpandedSuggestionIndex(-1);
+ if (shellModeActive) {
+ setShellModeActive(false);
resetEscapeState();
return true;
}
@@ -895,7 +930,7 @@ export const InputPrompt: React.FC = ({
completion.isPerfectMatch &&
keyMatchers[Command.SUBMIT](key) &&
recentUnsafePasteTime === null &&
- (!completion.showSuggestions ||
+ (!(completion.showSuggestions && isShellSuggestionsVisible) ||
(completion.activeSuggestionIndex <= 0 &&
!hasUserNavigatedSuggestions.current))
) {
@@ -909,7 +944,7 @@ export const InputPrompt: React.FC = ({
return true;
}
- if (completion.showSuggestions) {
+ if (completion.showSuggestions && isShellSuggestionsVisible) {
if (completion.suggestions.length > 1) {
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
@@ -1007,7 +1042,7 @@ export const InputPrompt: React.FC = ({
if (
key.name === 'tab' &&
!key.shift &&
- !completion.showSuggestions &&
+ !(completion.showSuggestions && isShellSuggestionsVisible) &&
completion.promptCompletion.text
) {
completion.promptCompletion.accept();
@@ -1190,6 +1225,7 @@ export const InputPrompt: React.FC = ({
focus,
buffer,
completion,
+ setForceShowShellSuggestions,
shellModeActive,
setShellModeActive,
onClearScreen,
@@ -1221,6 +1257,9 @@ export const InputPrompt: React.FC = ({
registerPlainTabPress,
resetPlainTabPress,
toggleCleanUiDetailsVisible,
+ shouldShowSuggestions,
+ isShellSuggestionsVisible,
+ forceShowShellSuggestions,
],
);
@@ -1346,14 +1385,6 @@ export const InputPrompt: React.FC = ({
]);
const { inlineGhost, additionalLines } = getGhostTextLines();
- const getActiveCompletion = () => {
- if (commandSearchActive) return commandSearchCompletion;
- if (reverseSearchActive) return reverseSearchCompletion;
- return completion;
- };
-
- const activeCompletion = getActiveCompletion();
- const shouldShowSuggestions = activeCompletion.showSuggestions;
const useBackgroundColor = config.getUseBackgroundColor();
const isLowColor = isLowColorDepth();
@@ -1427,7 +1458,7 @@ export const InputPrompt: React.FC = ({
const borderColor =
isShellFocused && !isEmbeddedShellFocused
- ? (statusColor ?? theme.border.focused)
+ ? (statusColor ?? theme.ui.focus)
: theme.border.default;
return (
diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx
index 2d603ebbdd..f9fff9fa9b 100644
--- a/packages/cli/src/ui/components/LoadingIndicator.tsx
+++ b/packages/cli/src/ui/components/LoadingIndicator.tsx
@@ -79,10 +79,18 @@ export const LoadingIndicator: React.FC = ({
/>
{primaryText && (
-
- {thinkingIndicator}
- {primaryText}
-
+
+
+ {thinkingIndicator}
+ {primaryText}
+
+ {primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
+
+ {' '}
+ (press tab to focus)
+
+ )}
+
)}
{cancelAndTimerContent && (
<>
@@ -113,10 +121,18 @@ export const LoadingIndicator: React.FC = ({
/>
{primaryText && (
-
- {thinkingIndicator}
- {primaryText}
-
+
+
+ {thinkingIndicator}
+ {primaryText}
+
+ {primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
+
+ {' '}
+ (press tab to focus)
+
+ )}
+
)}
{!isNarrow && cancelAndTimerContent && (
<>
diff --git a/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx b/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx
index e50d7ef568..3bcb4a9f35 100644
--- a/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx
+++ b/packages/cli/src/ui/components/LogoutConfirmationDialog.tsx
@@ -53,7 +53,7 @@ export const LogoutConfirmationDialog: React.FC<
({
),
}));
-vi.mock('./ShowMoreLines.js', () => ({
- ShowMoreLines: () => ShowMoreLines,
-}));
-
vi.mock('./shared/ScrollableList.js', () => ({
ScrollableList: ({
data,
@@ -203,7 +199,7 @@ describe('getToolGroupBorderAppearance', () => {
});
});
- it('returns symbol border for executing shell commands', () => {
+ it('returns active border for executing shell commands', () => {
const item = {
type: 'tool_group' as const,
tools: [
@@ -219,7 +215,37 @@ describe('getToolGroupBorderAppearance', () => {
],
id: 1,
};
- // While executing shell commands, it's dim false, border symbol
+ // While executing shell commands, it's dim false, border active
+ const result = getToolGroupBorderAppearance(
+ item,
+ activeShellPtyId,
+ false,
+ [],
+ mockBackgroundShells,
+ );
+ expect(result).toEqual({
+ borderColor: theme.ui.active,
+ borderDimColor: true,
+ });
+ });
+
+ it('returns focus border for focused executing shell commands', () => {
+ const item = {
+ type: 'tool_group' as const,
+ tools: [
+ {
+ callId: '1',
+ name: SHELL_COMMAND_NAME,
+ description: '',
+ status: CoreToolCallStatus.Executing,
+ ptyId: activeShellPtyId,
+ resultDisplay: undefined,
+ confirmationDetails: undefined,
+ } as IndividualToolCallDisplay,
+ ],
+ id: 1,
+ };
+ // When focused, it's dim false, border focus
const result = getToolGroupBorderAppearance(
item,
activeShellPtyId,
@@ -228,12 +254,12 @@ describe('getToolGroupBorderAppearance', () => {
mockBackgroundShells,
);
expect(result).toEqual({
- borderColor: theme.ui.symbol,
+ borderColor: theme.ui.focus,
borderDimColor: false,
});
});
- it('returns symbol border and dims color for background executing shell command when another shell is active', () => {
+ it('returns active border and dims color for background executing shell command when another shell is active', () => {
const item = {
type: 'tool_group' as const,
tools: [
@@ -257,7 +283,7 @@ describe('getToolGroupBorderAppearance', () => {
mockBackgroundShells,
);
expect(result).toEqual({
- borderColor: theme.ui.symbol,
+ borderColor: theme.ui.active,
borderDimColor: true,
});
});
@@ -275,7 +301,7 @@ describe('getToolGroupBorderAppearance', () => {
);
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
// so it counts as pending shell.
- expect(result.borderColor).toEqual(theme.ui.symbol);
+ expect(result.borderColor).toEqual(theme.ui.focus);
// It shouldn't be dim because there are no tools to say it isEmbeddedShellFocused = false
expect(result.borderDimColor).toBe(false);
});
@@ -309,6 +335,10 @@ describe('MainContent', () => {
vi.mocked(useAlternateBuffer).mockReturnValue(false);
});
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
it('renders in normal buffer mode', async () => {
const { lastFrame, unmount } = renderWithProviders(, {
uiState: defaultMockUiState as Partial,
@@ -427,6 +457,60 @@ describe('MainContent', () => {
unmount();
});
+ it('renders multiple history items with single line padding between them', async () => {
+ vi.mocked(useAlternateBuffer).mockReturnValue(true);
+ const uiState = {
+ ...defaultMockUiState,
+ history: [
+ { id: 1, type: 'gemini', text: 'Gemini message 1\n'.repeat(10) },
+ { id: 2, type: 'gemini', text: 'Gemini message 2\n'.repeat(10) },
+ ],
+ constrainHeight: true,
+ staticAreaMaxItemHeight: 5,
+ };
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ uiState: uiState as Partial,
+ useAlternateBuffer: true,
+ },
+ );
+
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders mixed history items (user + gemini) with single line padding between them', async () => {
+ vi.mocked(useAlternateBuffer).mockReturnValue(true);
+ const uiState = {
+ ...defaultMockUiState,
+ history: [
+ { id: 1, type: 'user', text: 'User message' },
+ { id: 2, type: 'gemini', text: 'Gemini response\n'.repeat(10) },
+ ],
+ constrainHeight: true,
+ staticAreaMaxItemHeight: 5,
+ };
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ uiState: uiState as unknown as Partial,
+ useAlternateBuffer: true,
+ },
+ );
+
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toMatchSnapshot();
+ unmount();
+ });
+
it('renders a split tool group without a gap between static and pending areas', async () => {
const toolCalls = [
{
diff --git a/packages/cli/src/ui/components/MemoryUsageDisplay.tsx b/packages/cli/src/ui/components/MemoryUsageDisplay.tsx
index 7a413fc227..7941a9cb1d 100644
--- a/packages/cli/src/ui/components/MemoryUsageDisplay.tsx
+++ b/packages/cli/src/ui/components/MemoryUsageDisplay.tsx
@@ -6,35 +6,32 @@
import type React from 'react';
import { useEffect, useState } from 'react';
-import { Box, Text } from 'ink';
+import { Text, Box } from 'ink';
import { theme } from '../semantic-colors.js';
import process from 'node:process';
import { formatBytes } from '../utils/formatters.js';
-export const MemoryUsageDisplay: React.FC = () => {
+export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
+ color = theme.text.primary,
+}) => {
const [memoryUsage, setMemoryUsage] = useState('');
- const [memoryUsageColor, setMemoryUsageColor] = useState(
- theme.text.secondary,
- );
+ const [memoryUsageColor, setMemoryUsageColor] = useState(color);
useEffect(() => {
const updateMemory = () => {
const usage = process.memoryUsage().rss;
setMemoryUsage(formatBytes(usage));
setMemoryUsageColor(
- usage >= 2 * 1024 * 1024 * 1024
- ? theme.status.error
- : theme.text.secondary,
+ usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color,
);
};
const intervalId = setInterval(updateMemory, 2000);
updateMemory(); // Initial update
return () => clearInterval(intervalId);
- }, []);
+ }, [color]);
return (
- | {memoryUsage}
);
diff --git a/packages/cli/src/ui/components/QuotaDisplay.tsx b/packages/cli/src/ui/components/QuotaDisplay.tsx
index d20291580a..881f4c77fd 100644
--- a/packages/cli/src/ui/components/QuotaDisplay.tsx
+++ b/packages/cli/src/ui/components/QuotaDisplay.tsx
@@ -18,6 +18,8 @@ interface QuotaDisplayProps {
limit: number | undefined;
resetTime?: string;
terse?: boolean;
+ forceShow?: boolean;
+ lowercase?: boolean;
}
export const QuotaDisplay: React.FC = ({
@@ -25,6 +27,8 @@ export const QuotaDisplay: React.FC = ({
limit,
resetTime,
terse = false,
+ forceShow = false,
+ lowercase = false,
}) => {
if (remaining === undefined || limit === undefined || limit === 0) {
return null;
@@ -32,7 +36,7 @@ export const QuotaDisplay: React.FC = ({
const percentage = (remaining / limit) * 100;
- if (percentage > QUOTA_THRESHOLD_HIGH) {
+ if (!forceShow && percentage > QUOTA_THRESHOLD_HIGH) {
return null;
}
@@ -45,20 +49,17 @@ export const QuotaDisplay: React.FC = ({
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
if (remaining === 0) {
- return (
-
- {terse
- ? 'Limit reached'
- : `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`}
-
- );
+ let text = terse
+ ? 'Limit reached'
+ : `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`;
+ if (lowercase) text = text.toLowerCase();
+ return {text};
}
- return (
-
- {terse
- ? `${percentage.toFixed(0)}%`
- : `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`}
-
- );
+ let text = terse
+ ? `${percentage.toFixed(0)}%`
+ : `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`;
+ if (lowercase) text = text.toLowerCase();
+
+ return {text};
};
diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx
index 9d1ce57f52..154ad62522 100644
--- a/packages/cli/src/ui/components/SessionBrowser.tsx
+++ b/packages/cli/src/ui/components/SessionBrowser.tsx
@@ -7,6 +7,7 @@
import type React from 'react';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -436,7 +437,7 @@ const SessionItem = ({
if (isDisabled) {
return Colors.Gray;
}
- return isActive ? Colors.AccentPurple : c;
+ return isActive ? theme.ui.focus : c;
};
const prefix = isActive ? '❯ ' : ' ';
@@ -483,7 +484,10 @@ const SessionItem = ({
));
return (
-
+
{prefix}
diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx
index bea3227d78..2ed71762b7 100644
--- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx
+++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx
@@ -5,11 +5,23 @@
*/
import { renderWithProviders } from '../../test-utils/render.js';
-import { describe, it, expect, vi } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
-import { ToolCallDecision } from '@google/gemini-cli-core';
+import {
+ ToolCallDecision,
+ getShellConfiguration,
+} from '@google/gemini-cli-core';
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ getShellConfiguration: vi.fn(),
+ };
+});
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal();
@@ -19,12 +31,16 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
};
});
+const getShellConfigurationMock = vi.mocked(getShellConfiguration);
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
-const renderWithMockedStats = async (metrics: SessionMetrics) => {
+const renderWithMockedStats = async (
+ metrics: SessionMetrics,
+ sessionId = 'test-session',
+) => {
useSessionStatsMock.mockReturnValue({
stats: {
- sessionId: 'test-session',
+ sessionId,
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
@@ -46,8 +62,38 @@ const renderWithMockedStats = async (metrics: SessionMetrics) => {
};
describe('', () => {
+ const emptyMetrics: SessionMetrics = {
+ models: {},
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: {
+ accept: 0,
+ reject: 0,
+ modify: 0,
+ [ToolCallDecision.AUTO_ACCEPT]: 0,
+ },
+ byName: {},
+ },
+ files: {
+ totalLinesAdded: 0,
+ totalLinesRemoved: 0,
+ },
+ };
+
+ beforeEach(() => {
+ getShellConfigurationMock.mockReturnValue({
+ executable: 'bash',
+ argsPrefix: ['-c'],
+ shell: 'bash',
+ });
+ });
+
it('renders the summary display with a title', async () => {
const metrics: SessionMetrics = {
+ ...emptyMetrics,
models: {
'gemini-2.5-pro': {
api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 50234 },
@@ -63,19 +109,6 @@ describe('', () => {
roles: {},
},
},
- tools: {
- totalCalls: 0,
- totalSuccess: 0,
- totalFail: 0,
- totalDurationMs: 0,
- totalDecisions: {
- accept: 0,
- reject: 0,
- modify: 0,
- [ToolCallDecision.AUTO_ACCEPT]: 0,
- },
- byName: {},
- },
files: {
totalLinesAdded: 42,
totalLinesRemoved: 15,
@@ -89,4 +122,70 @@ describe('', () => {
expect(output).toMatchSnapshot();
unmount();
});
+
+ describe('Session ID escaping', () => {
+ it('renders a standard UUID-formatted session ID in the footer (bash)', async () => {
+ const uuidSessionId = '1234-abcd-5678-efgh';
+ const { lastFrame, unmount } = await renderWithMockedStats(
+ emptyMetrics,
+ uuidSessionId,
+ );
+ const output = lastFrame();
+
+ // Standard UUID characters should not be escaped/quoted by default for bash.
+ expect(output).toContain('gemini --resume 1234-abcd-5678-efgh');
+ unmount();
+ });
+
+ it('sanitizes a malicious session ID in the footer (bash)', async () => {
+ const maliciousSessionId = "'; rm -rf / #";
+ const { lastFrame, unmount } = await renderWithMockedStats(
+ emptyMetrics,
+ maliciousSessionId,
+ );
+ const output = lastFrame();
+
+ // escapeShellArg (using shell-quote for bash) will wrap special characters in double quotes.
+ expect(output).toContain('gemini --resume "\'; rm -rf / #"');
+ unmount();
+ });
+
+ it('renders a standard UUID-formatted session ID in the footer (powershell)', async () => {
+ getShellConfigurationMock.mockReturnValue({
+ executable: 'powershell.exe',
+ argsPrefix: ['-NoProfile', '-Command'],
+ shell: 'powershell',
+ });
+
+ const uuidSessionId = '1234-abcd-5678-efgh';
+ const { lastFrame, unmount } = await renderWithMockedStats(
+ emptyMetrics,
+ uuidSessionId,
+ );
+ const output = lastFrame();
+
+ // PowerShell wraps strings in single quotes
+ expect(output).toContain("gemini --resume '1234-abcd-5678-efgh'");
+ unmount();
+ });
+
+ it('sanitizes a malicious session ID in the footer (powershell)', async () => {
+ getShellConfigurationMock.mockReturnValue({
+ executable: 'powershell.exe',
+ argsPrefix: ['-NoProfile', '-Command'],
+ shell: 'powershell',
+ });
+
+ const maliciousSessionId = "'; rm -rf / #";
+ const { lastFrame, unmount } = await renderWithMockedStats(
+ emptyMetrics,
+ maliciousSessionId,
+ );
+ const output = lastFrame();
+
+ // PowerShell wraps in single quotes and escapes internal single quotes by doubling them
+ expect(output).toContain("gemini --resume '''; rm -rf / #'");
+ unmount();
+ });
+ });
});
diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx
index 6975f757aa..5b0a461682 100644
--- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx
+++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx
@@ -6,6 +6,8 @@
import type React from 'react';
import { StatsDisplay } from './StatsDisplay.js';
+import { useSessionStats } from '../contexts/SessionContext.js';
+import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core';
interface SessionSummaryDisplayProps {
duration: string;
@@ -13,10 +15,16 @@ interface SessionSummaryDisplayProps {
export const SessionSummaryDisplay: React.FC = ({
duration,
-}) => (
-
-);
+}) => {
+ const { stats } = useSessionStats();
+ const { shell } = getShellConfiguration();
+ const footer = `To resume this session: gemini --resume ${escapeShellArg(stats.sessionId, shell)}`;
+
+ return (
+
+ );
+};
diff --git a/packages/cli/src/ui/components/ShowMoreLinesLayout.test.tsx b/packages/cli/src/ui/components/ShowMoreLinesLayout.test.tsx
new file mode 100644
index 0000000000..ede092976f
--- /dev/null
+++ b/packages/cli/src/ui/components/ShowMoreLinesLayout.test.tsx
@@ -0,0 +1,67 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { Box, Text } from 'ink';
+import { render } from '../../test-utils/render.js';
+import { ShowMoreLines } from './ShowMoreLines.js';
+import { useOverflowState } from '../contexts/OverflowContext.js';
+import { useStreamingContext } from '../contexts/StreamingContext.js';
+import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
+import { StreamingState } from '../types.js';
+
+vi.mock('../contexts/OverflowContext.js');
+vi.mock('../contexts/StreamingContext.js');
+vi.mock('../hooks/useAlternateBuffer.js');
+
+describe('ShowMoreLines layout and padding', () => {
+ const mockUseOverflowState = vi.mocked(useOverflowState);
+ const mockUseStreamingContext = vi.mocked(useStreamingContext);
+ const mockUseAlternateBuffer = vi.mocked(useAlternateBuffer);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseAlternateBuffer.mockReturnValue(true);
+ mockUseOverflowState.mockReturnValue({
+ overflowingIds: new Set(['1']),
+ } as NonNullable>);
+ mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders with single padding (paddingX=1, marginBottom=1)', async () => {
+ const TestComponent = () => (
+
+ Top
+
+ Bottom
+
+ );
+
+ const { lastFrame, waitUntilReady, unmount } = render();
+ await waitUntilReady();
+
+ // lastFrame() strips some formatting but keeps layout
+ const output = lastFrame({ allowEmpty: true });
+
+ // With paddingX=1, there should be a space before the text
+ // With marginBottom=1, there should be an empty line between the text and "Bottom"
+ // Since "Top" is just above it without margin, it should be on the previous line
+ const lines = output.split('\n');
+
+ expect(lines).toEqual([
+ 'Top',
+ ' Press Ctrl+O to show more lines',
+ '',
+ 'Bottom',
+ '',
+ ]);
+
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx
index d9498e7a6b..7ce950eec9 100644
--- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx
+++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx
@@ -84,7 +84,7 @@ export function SuggestionsDisplay({
const originalIndex = startIndex + index;
const isActive = originalIndex === activeIndex;
const isExpanded = originalIndex === expandedIndex;
- const textColor = isActive ? theme.text.accent : theme.text.secondary;
+ const textColor = isActive ? theme.ui.focus : theme.text.secondary;
const isLong = suggestion.value.length >= MAX_WIDTH;
const labelElement = (
+ ({
+ mockIsDevelopment: { value: false },
+}));
+
+vi.mock('../../utils/installationInfo.js', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ get isDevelopment() {
+ return mockIsDevelopment.value;
+ },
+ };
+});
+
import { createMockSettings } from '../../test-utils/settings.js';
import { DEFAULT_THEME, themeManager } from '../themes/theme-manager.js';
import { act } from 'react';
@@ -30,17 +46,21 @@ describe('ThemeDialog Snapshots', () => {
vi.restoreAllMocks();
});
- it('should render correctly in theme selection mode', async () => {
- const settings = createMockSettings();
- const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
- ,
- { settings },
- );
- await waitUntilReady();
+ it.each([true, false])(
+ 'should render correctly in theme selection mode (isDevelopment: %s)',
+ async (isDev) => {
+ mockIsDevelopment.value = isDev;
+ const settings = createMockSettings();
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ { settings },
+ );
+ await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
- unmount();
- });
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ },
+ );
it('should render correctly in scope selector mode', async () => {
const settings = createMockSettings();
@@ -191,7 +211,7 @@ describe('Hint Visibility', () => {
,
{
settings,
- uiState: { terminalBackgroundColor: '#1E1E2E' },
+ uiState: { terminalBackgroundColor: '#000000' },
},
);
await waitUntilReady();
diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx
index c4bfe66897..4bfb623db7 100644
--- a/packages/cli/src/ui/components/ThemeDialog.tsx
+++ b/packages/cli/src/ui/components/ThemeDialog.tsx
@@ -23,6 +23,8 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { ScopeSelector } from './shared/ScopeSelector.js';
import { useUIState } from '../contexts/UIStateContext.js';
+import { ColorsDisplay } from './ColorsDisplay.js';
+import { isDevelopment } from '../../utils/installationInfo.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
@@ -245,6 +247,11 @@ export function ThemeDialog({
// The code block is slightly longer than the diff, so give it more space.
const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6);
const diffHeight = Math.floor(availableHeightForPanes * 0.4);
+
+ const previewTheme =
+ themeManager.getTheme(highlightedThemeName || DEFAULT_THEME.name) ||
+ DEFAULT_THEME;
+
return (
Preview
- {/* Get the Theme object for the highlighted theme, fall back to default if not found */}
- {(() => {
- const previewTheme =
- themeManager.getTheme(
- highlightedThemeName || DEFAULT_THEME.name,
- ) || DEFAULT_THEME;
-
- return (
-
- {colorizeCode({
- code: `# function
+
+ {colorizeCode({
+ code: `# function
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a`,
- language: 'python',
- availableHeight:
- isAlternateBuffer === false ? codeBlockHeight : undefined,
- maxWidth: colorizeCodeWidth,
- settings,
- })}
-
-
+
-
- );
- })()}
+ availableTerminalHeight={
+ isAlternateBuffer === false ? diffHeight : undefined
+ }
+ terminalWidth={colorizeCodeWidth}
+ theme={previewTheme}
+ />
+
+ {isDevelopment && (
+
+
+
+ )}
) : (
diff --git a/packages/cli/src/ui/components/ThemedGradient.test.tsx b/packages/cli/src/ui/components/ThemedGradient.test.tsx
index 60507015b5..6632a63300 100644
--- a/packages/cli/src/ui/components/ThemedGradient.test.tsx
+++ b/packages/cli/src/ui/components/ThemedGradient.test.tsx
@@ -13,6 +13,10 @@ vi.mock('../semantic-colors.js', () => ({
theme: {
ui: {
gradient: ['red', 'blue'],
+ focus: 'green',
+ },
+ background: {
+ focus: 'darkgreen',
},
text: {
accent: 'cyan',
diff --git a/packages/cli/src/ui/components/UserIdentity.test.tsx b/packages/cli/src/ui/components/UserIdentity.test.tsx
index 8e63415f5c..5391944d26 100644
--- a/packages/cli/src/ui/components/UserIdentity.test.tsx
+++ b/packages/cli/src/ui/components/UserIdentity.test.tsx
@@ -47,6 +47,7 @@ describe('', () => {
const output = lastFrame();
expect(output).toContain('test@example.com');
expect(output).toContain('/auth');
+ expect(output).not.toContain('/upgrade');
unmount();
});
@@ -74,6 +75,7 @@ describe('', () => {
const output = lastFrame();
expect(output).toContain('Logged in with Google');
expect(output).toContain('/auth');
+ expect(output).not.toContain('/upgrade');
unmount();
});
@@ -130,6 +132,26 @@ describe('', () => {
const output = lastFrame();
expect(output).toContain(`Authenticated with ${AuthType.USE_GEMINI}`);
expect(output).toContain('/auth');
+ expect(output).not.toContain('/upgrade');
+ unmount();
+ });
+
+ it('should render specific tier name when provided', async () => {
+ const mockConfig = makeFakeConfig();
+ vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
+ authType: AuthType.LOGIN_WITH_GOOGLE,
+ model: 'gemini-pro',
+ } as unknown as ContentGeneratorConfig);
+ vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue('Enterprise Tier');
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toContain('Enterprise Tier');
+ expect(output).toContain('/upgrade');
unmount();
});
});
diff --git a/packages/cli/src/ui/components/UserIdentity.tsx b/packages/cli/src/ui/components/UserIdentity.tsx
index 08c82573d9..98c62ec68f 100644
--- a/packages/cli/src/ui/components/UserIdentity.tsx
+++ b/packages/cli/src/ui/components/UserIdentity.tsx
@@ -53,12 +53,14 @@ export const UserIdentity: React.FC = ({ config }) => {
{/* Tier Name /upgrade */}
-
-
- {tierName ?? 'Gemini Code Assist for individuals'}
-
- /upgrade
-
+ {tierName && (
+
+
+ {tierName}
+
+ /upgrade
+
+ )}
);
};
diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap
index 29a7683d06..06f509f1f6 100644
--- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap
@@ -5,7 +5,7 @@ exports[`AskUserDialog > Choice question placeholder > uses default placeholder
1. TypeScript
2. JavaScript
-● 3. Enter a custom value
+● 3. Enter a custom value
Enter to submit · Esc to cancel
"
@@ -16,7 +16,7 @@ exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Oth
1. TypeScript
2. JavaScript
-● 3. Type another language...
+● 3. Type another language...
Enter to submit · Esc to cancel
"
@@ -26,8 +26,8 @@ exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scrol
"Choose an option
▲
-● 1. Option 1
- Description 1
+● 1. Option 1
+ Description 1
2. Option 2
Description 2
▼
@@ -39,8 +39,8 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
"Choose an option
-● 1. Option 1
- Description 1
+● 1. Option 1
+ Description 1
2. Option 2
Description 2
3. Option 3
@@ -122,8 +122,8 @@ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
exports[`AskUserDialog > hides progress header for single question 1`] = `
"Which authentication method should we use?
-● 1. OAuth 2.0
- Industry standard, supports SSO
+● 1. OAuth 2.0
+ Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
@@ -135,8 +135,8 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
exports[`AskUserDialog > renders question and options 1`] = `
"Which authentication method should we use?
-● 1. OAuth 2.0
- Industry standard, supports SSO
+● 1. OAuth 2.0
+ Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
@@ -150,8 +150,8 @@ exports[`AskUserDialog > shows Review tab in progress header for multiple questi
Which framework?
-● 1. React
- Component library
+● 1. React
+ Component library
2. Vue
Progressive framework
3. Enter a custom value
@@ -163,8 +163,8 @@ Enter to select · ←/→ to switch questions · Esc to cancel
exports[`AskUserDialog > shows keyboard hints 1`] = `
"Which authentication method should we use?
-● 1. OAuth 2.0
- Industry standard, supports SSO
+● 1. OAuth 2.0
+ Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
@@ -178,8 +178,8 @@ exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
Which database should we use?
-● 1. PostgreSQL
- Relational database
+● 1. PostgreSQL
+ Relational database
2. MongoDB
Document database
3. Enter a custom value
diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap
index db1b6d1ba5..073c106ceb 100644
--- a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap
@@ -19,8 +19,8 @@ Files to Modify
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
-● 2. Yes, manually accept edits
- Approves plan but requires confirmation for each tool
+● 2. Yes, manually accept edits
+ Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
@@ -44,8 +44,8 @@ Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
-● 1. Yes, automatically accept edits
- Approves plan and allows tools to run automatically
+● 1. Yes, automatically accept edits
+ Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
@@ -76,8 +76,8 @@ Implementation Steps
8. Add multi-factor authentication in src/auth/MFAService.ts
... last 22 lines hidden (Ctrl+O to show) ...
-● 1. Yes, automatically accept edits
- Approves plan and allows tools to run automatically
+● 1. Yes, automatically accept edits
+ Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
@@ -103,8 +103,8 @@ Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
-● 1. Yes, automatically accept edits
- Approves plan and allows tools to run automatically
+● 1. Yes, automatically accept edits
+ Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
@@ -132,8 +132,8 @@ Files to Modify
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
-● 2. Yes, manually accept edits
- Approves plan but requires confirmation for each tool
+● 2. Yes, manually accept edits
+ Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
@@ -157,8 +157,8 @@ Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
-● 1. Yes, automatically accept edits
- Approves plan and allows tools to run automatically
+● 1. Yes, automatically accept edits
+ Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
@@ -210,8 +210,8 @@ Testing Strategy
- Security penetration testing
- Load testing for session management
-● 1. Yes, automatically accept edits
- Approves plan and allows tools to run automatically
+● 1. Yes, automatically accept edits
+ Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
@@ -237,8 +237,8 @@ Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
-● 1. Yes, automatically accept edits
- Approves plan and allows tools to run automatically
+● 1. Yes, automatically accept edits
+ Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
diff --git a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
index 2ff7c97df3..2d7b413787 100644
--- a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
@@ -1,38 +1,45 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[` > displays "Limit reached" message when remaining is 0 1`] = `
-" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro Limit reached
+" workspace (/directory) sandbox /model /stats
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
"
`;
exports[` > displays the usage indicator when usage is low 1`] = `
-" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 15%
+" workspace (/directory) sandbox /model /stats
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
"
`;
exports[` > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
-" ...s/to/make/it/long no sandbox /model gemini-pro 0%
+" workspace (/directory) sandbox /model context
+ ...me/more/directories/to/make/it/long no sandbox gemini-pro 14%
"
`;
exports[` > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
-" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 0% context used
+" workspace (/directory) sandbox /model context
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used
"
`;
exports[` > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `
-" no sandbox (see /docs)
+" sandbox
+ no sandbox
"
`;
exports[` > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[` > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `
-" ...directories/to/make/it/long no sandbox (see /docs)
+" workspace (/directory) sandbox
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
"
`;
exports[` > hides the usage indicator when usage is not near limit 1`] = `
-" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro
+" workspace (/directory) sandbox /model /stats
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap
new file mode 100644
index 0000000000..a55f40d1e2
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap
@@ -0,0 +1,34 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > renders correctly with default settings 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Configure Footer │
+│ │
+│ Select which items to display in the footer. │
+│ │
+│ > [✓] workspace Current working directory │
+│ [✓] git-branch Current git branch name (not shown when unavailable) │
+│ [✓] sandbox Sandbox type and trust indicator │
+│ [✓] model-name Current model identifier │
+│ [✓] quota Remaining usage on daily limit (not shown when unavailable) │
+│ [ ] context-used Percentage of context window used │
+│ [ ] memory-usage Memory used by the application │
+│ [ ] session-id Unique identifier for the current session │
+│ [ ] code-changes Lines added/removed in the session (not shown when zero) │
+│ [ ] token-count Total tokens used in the session (not shown when zero) │
+│ │
+│ [✓] Show footer labels │
+│ Reset to default footer │
+│ │
+│ ↑/↓ navigate · ←/→ reorder · enter/space select · esc close │
+│ │
+│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
+│ │ Preview: │ │
+│ │ workspace (/directory) branch sandbox /model /stats │ │
+│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
+│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
index 88a1b0486f..5a2819702e 100644
--- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
@@ -12,8 +12,8 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
(r:) Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
- lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
- ...
+ lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
+ ...
"
`;
@@ -22,8 +22,8 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
(r:) Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
- lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
- llllllllllllllllllllllllllllllllllllllllllllllllll
+ lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
+ llllllllllllllllllllllllllllllllllllllllllllllllll
"
`;
@@ -31,7 +31,7 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
- git commit -m "feat: add search" in src/app
+ git commit -m "feat: add search" in src/app
"
`;
@@ -39,7 +39,7 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
- git commit -m "feat: add search" in src/app
+ git commit -m "feat: add search" in src/app
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap
index d70a278827..666525e720 100644
--- a/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[` > should truncate long primary text instead of wrapping 1`] = `
-"MockRespondin This is an extremely long loading phrase that shoul… (esc to
+"MockRespondin This is an extremely long loading phrase that shoul…(esc to
gSpinner cancel, 5s)
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
index 0599e82f7c..5f0c073d7a 100644
--- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
@@ -4,7 +4,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Focuse
"ScrollableList
AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command Running a long command... │
+│ ⊶ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
@@ -18,7 +18,7 @@ AppHeader(full)
│ Line 19 █ │
│ Line 20 █ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
-ShowMoreLines
+ Press Ctrl+O to show more lines
"
`;
@@ -26,7 +26,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocu
"ScrollableList
AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command Running a long command... │
+│ ⊶ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
@@ -40,14 +40,14 @@ AppHeader(full)
│ Line 19 █ │
│ Line 20 █ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
-ShowMoreLines
+ Press Ctrl+O to show more lines
"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
"AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command Running a long command... │
+│ ⊶ Shell Command Running a long command... │
│ │
│ ... first 11 lines hidden (Ctrl+O to show) ... │
│ Line 12 │
@@ -60,14 +60,13 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
│ Line 19 │
│ Line 20 │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
-ShowMoreLines
"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
"AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command Running a long command... │
+│ ⊶ Shell Command Running a long command... │
│ │
│ Line 1 │
│ Line 2 │
@@ -90,7 +89,6 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
│ Line 19 │
│ Line 20 │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
-ShowMoreLines
"
`;
@@ -105,6 +103,51 @@ exports[`MainContent > renders a split tool group without a gap between static a
│ │
│ Part 2 │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
-ShowMoreLines
+"
+`;
+
+exports[`MainContent > renders mixed history items (user + gemini) with single line padding between them 1`] = `
+"ScrollableList
+AppHeader(full)
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ > User message
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+✦ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+"
+`;
+
+exports[`MainContent > renders multiple history items with single line padding between them 1`] = `
+"ScrollableList
+AppHeader(full)
+✦ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+
+✦ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap
index 583d75d281..15cd8748ae 100644
--- a/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap
@@ -6,7 +6,7 @@ exports[`SessionBrowser component > enters search mode, filters sessions, and re
Search: query (Esc to cancel)
Index │ Msgs │ Age │ Match
- ❯ #1 │ 1 │ 10mo │ You: Query is here a… (+1 more)
+ ❯ #1 │ 1 │ 10mo │ You: Query is here a… (+1 more)
▼
"
`;
@@ -17,7 +17,7 @@ exports[`SessionBrowser component > renders a list of sessions and marks current
Sort: s Reverse: r First/Last: g/G
Index │ Msgs │ Age │ Name
- ❯ #1 │ 5 │ 10mo │ Second conversation about dogs (current)
+ ❯ #1 │ 5 │ 10mo │ Second conversation about dogs (current)
#2 │ 2 │ 10mo │ First conversation about cats
▼
"
diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
index eb0fada885..ab8f60e9f5 100644
--- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
@@ -24,7 +24,7 @@ exports[` > renders the summary display with a title 1`
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
-│ Tip: Resume a previous session using gemini --resume or /resume │
+│ To resume this session: gemini --resume test-session │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg
index b7ad1d10db..96ac9e7621 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg
@@ -4,130 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- false
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update
- true
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ false
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg
index c088c69139..7a35e051b2 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg
@@ -4,130 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- true*
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update
- true
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ true*
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg
index 0b981a31c8..9c01031ebe 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg
@@ -4,128 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- false*
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update true*
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging false*
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ false*
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true*
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false*
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg
index b7ad1d10db..96ac9e7621 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg
@@ -4,130 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- false
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update
- true
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ false
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg
index b7ad1d10db..96ac9e7621 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg
@@ -4,130 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- false
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update
- true
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ false
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg
index 81d4868518..f9cf782f72 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg
@@ -4,128 +4,136 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │ Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- Search to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- Vim Mode
- false
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update
- true
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
- > Apply To
- │
- │
- ●
- 1.
- User Settings
- │
- │
- 2. Workspace Settings
- │
- │
- 3. System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ Search to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+ Vim Mode
+ false
+ │
+ │
+ Enable Vim keybindings
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │
+ > Apply To
+ │
+ │
+
+ ●
+
+
+ 1.
+
+
+ User Settings
+
+ │
+ │
+ 2.
+ Workspace Settings
+ │
+ │
+ 3.
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg
index 324ed5c2cb..1866d1ab67 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg
@@ -4,129 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- false*
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update false*
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ false*
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ false*
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg
index b7ad1d10db..96ac9e7621 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg
@@ -4,130 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- false
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update
- true
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ false
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg
index e99a5b4cdd..739a96cf09 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg
@@ -4,128 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- true*
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update false*
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging true*
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ true*
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ false*
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ true*
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap
index 775233f30e..3c79a534a2 100644
--- a/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap
@@ -7,7 +7,7 @@ exports[`SuggestionsDisplay > handles scrolling 1`] = `
Cmd 7 Description 7
Cmd 8 Description 8
Cmd 9 Description 9
- Cmd 10 Description 10
+ Cmd 10 Description 10
Cmd 11 Description 11
Cmd 12 Description 12
▼
@@ -17,13 +17,13 @@ exports[`SuggestionsDisplay > handles scrolling 1`] = `
exports[`SuggestionsDisplay > highlights active item 1`] = `
" command1 Description 1
- command2 Description 2
+ command2 Description 2
command3 Description 3
"
`;
exports[`SuggestionsDisplay > renders MCP tag for MCP prompts 1`] = `
-" mcp-tool [MCP]
+" mcp-tool [MCP]
"
`;
@@ -33,7 +33,7 @@ exports[`SuggestionsDisplay > renders loading state 1`] = `
`;
exports[`SuggestionsDisplay > renders suggestions list 1`] = `
-" command1 Description 1
+" command1 Description 1
command2 Description 2
command3 Description 3
"
diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg
index 6042642abd..fca715c952 100644
--- a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg
@@ -4,9 +4,12 @@
- ID Name
- ────────────────────────────────────────────────────────────────────────────────────────────────────
- 1 Alice
- 2 Bob
+ ID
+ Name
+ ────────────────────────────────────────────────────────────────────────────────────────────────────
+ 1
+ Alice
+ 2
+ Bob
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg
index 359b4ee76d..870e292d66 100644
--- a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg
@@ -4,8 +4,8 @@
- Value
- ────────────────────────────────────────────────────────────────────────────────────────────────────
+ Value
+ ────────────────────────────────────────────────────────────────────────────────────────────────────20
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg
index 4473a2e810..508eca9a5b 100644
--- a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg
@@ -4,8 +4,8 @@
- Status
- ────────────────────────────────────────────────────────────────────────────────────────────────────
+ Status
+ ────────────────────────────────────────────────────────────────────────────────────────────────────Active
diff --git a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
index 11f2af0a5c..4a5b30fc5c 100644
--- a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
@@ -8,7 +8,7 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal
│ 1. ANSI Dark │ │ │
│ 2. Atom One Dark │ 1 # function │ │
│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
-│ ● 4. Default Dark │ 3 a, b = 0, 1 │ │
+│ ● 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
@@ -58,7 +58,7 @@ exports[`Initial Theme Selection > should use the theme from settings even if te
│ ● 1. ANSI Dark │ │ │
│ 2. Atom One Dark │ 1 # function │ │
│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
-│ 4. Default Dark │ 3 a, b = 0, 1 │ │
+│ 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
@@ -89,7 +89,7 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
"
`;
-exports[`ThemeDialog Snapshots > should render correctly in theme selection mode 1`] = `
+exports[`ThemeDialog Snapshots > should render correctly in theme selection mode (isDevelopment: false) 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Select Theme Preview │
@@ -113,3 +113,90 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
+
+exports[`ThemeDialog Snapshots > should render correctly in theme selection mode (isDevelopment: true) 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Select Theme Preview │
+│ ▲ ┌─────────────────────────────────────────────────┐ │
+│ ● 1. ANSI Dark (Matches terminal) │ │ │
+│ 2. Atom One Dark │ 1 # function │ │
+│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
+│ 4. Default Dark │ 3 a, b = 0, 1 │ │
+│ 5. Dracula Dark │ 4 for _ in range(n): │ │
+│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
+│ 7. Holiday Dark │ 6 return a │ │
+│ 8. Shades Of Purple Dark │ │ │
+│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
+│ 10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │
+│ 11. Ayu Light │ │ │
+│ 12. Default Light └─────────────────────────────────────────────────┘ │
+│ ▼ │
+│ ╭─────────────────────────────────────────────────╮ │
+│ │ DEVELOPER TOOLS (Not visible to users) │ │
+│ │ │ │
+│ │ How do colors get applied? │ │
+│ │ • Hex: Rendered exactly by modern terminals. │ │
+│ │ Not overridden by app themes. │ │
+│ │ • Blank: Uses your terminal's default │ │
+│ │ foreground/background. │ │
+│ │ • Compatibility: On older terminals, hex is │ │
+│ │ approximated to the nearest ANSI color. │ │
+│ │ • ANSI Names: 'red', 'green', etc. are mapped │ │
+│ │ to your terminal app's palette. │ │
+│ │ │ │
+│ │ Value Name │ │
+│ │ #0000… backgroun Main terminal background │ │
+│ │ d.primary color │ │
+│ │ #5F5… backgroun Subtle background for │ │
+│ │ d.message message blocks │ │
+│ │ #5F5… backgroun Background for the input │ │
+│ │ d.input prompt │ │
+│ │ #00… background. Background highlight for │ │
+│ │ focus selected/focused items │ │
+│ │ #005… backgrou Background for added lines │ │
+│ │ nd.diff. in diffs │ │
+│ │ added │ │
+│ │ #5F0… backgroun Background for removed │ │
+│ │ d.diff.re lines in diffs │ │
+│ │ moved │ │
+│ │ #FFFFF text.prim Primary text color (uses │ │
+│ │ F ary terminal default if blank) │ │
+│ │ #AFAFAF text.secon Secondary/dimmed text │ │
+│ │ dary color │ │
+│ │ #87AFFF text.link Hyperlink and highlighting │ │
+│ │ color │ │
+│ │ #D7AFFF text.accen Accent color for │ │
+│ │ t emphasis │ │
+│ │ #FFFFFF text.res Color for model response │ │
+│ │ ponse text (uses terminal default │ │
+│ │ if blank) │ │
+│ │ #878787 border.def Standard border color │ │
+│ │ ault │ │
+│ │ #AFAFAFui.comme Color for code comments and │ │
+│ │ nt metadata │ │
+│ │ #AFAFA ui.symbol Color for technical symbols │ │
+│ │ F and UI icons │ │
+│ │ #87AFF ui.active Border color for active or │ │
+│ │ F running elements │ │
+│ │ #87878 ui.dark Deeply dimmed color for │ │
+│ │ 7 subtle UI elements │ │
+│ │ #D7FFD ui.focus Color for focused elements │ │
+│ │ 7 (e.g. selected menu items, │ │
+│ │ focused borders) │ │
+│ │ #FF87AFstatus.err Color for error messages │ │
+│ │ or and critical status │ │
+│ │ #D7FFD7status.suc Color for success messages │ │
+│ │ cess and positive status │ │
+│ │ #FFFFA status.wa Color for warnings and │ │
+│ │ F rning cautionary status │ │
+│ │ #4796E4 ui.gradien │ │
+│ │ #847ACE t │ │
+│ │ #C3677F │ │
+│ ╰─────────────────────────────────────────────────╯ │
+│ │
+│ (Use Enter to select, Tab to configure scope, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-renders-a-multiline-shell-command-with-syntax-highlighting-and-redirection-warning-SVG-snapshot-.snap.svg b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-renders-a-multiline-shell-command-with-syntax-highlighting-and-redirection-warning-SVG-snapshot-.snap.svg
new file mode 100644
index 0000000000..32ece1f90f
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-renders-a-multiline-shell-command-with-syntax-highlighting-and-redirection-warning-SVG-snapshot-.snap.svg
@@ -0,0 +1,88 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx
index 0bdf9b65e9..481f0a8a0e 100644
--- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx
+++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx
@@ -51,10 +51,7 @@ export const GeminiMessage: React.FC = ({
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
renderMarkdown={renderMarkdown}
/>
-
+
diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx
index 259a0016f3..f3ac6c7749 100644
--- a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx
+++ b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx
@@ -48,10 +48,7 @@ export const GeminiMessageContent: React.FC = ({
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
renderMarkdown={renderMarkdown}
/>
-
+
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
index 72ce8cec5f..76b8f95ce7 100644
--- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
@@ -65,7 +65,7 @@ describe('', () => {
['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
])('clicks inside the shell area sets focus for %s', async (_, name) => {
- const { lastFrame, simulateClick } = renderShell(
+ const { lastFrame, simulateClick, unmount } = renderShell(
{ name },
{ mouseEventsEnabled: true },
);
@@ -79,6 +79,7 @@ describe('', () => {
await waitFor(() => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true);
});
+ unmount();
});
it('resets focus when shell finishes', async () => {
let updateStatus: (s: CoreToolCallStatus) => void = () => {};
@@ -91,7 +92,7 @@ describe('', () => {
return ;
};
- const { lastFrame } = renderWithProviders(, {
+ const { lastFrame, unmount } = renderWithProviders(, {
uiActions,
uiState: {
streamingState: StreamingState.Idle,
@@ -115,6 +116,7 @@ describe('', () => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
expect(lastFrame()).not.toContain('(Shift+Tab to unfocus)');
});
+ unmount();
});
});
@@ -135,6 +137,14 @@ describe('', () => {
{ status: CoreToolCallStatus.Error, resultDisplay: 'Error output' },
undefined,
],
+ [
+ 'renders in Cancelled state with partial output',
+ {
+ status: CoreToolCallStatus.Cancelled,
+ resultDisplay: 'Partial output before cancellation',
+ },
+ undefined,
+ ],
[
'renders in Alternate Buffer mode while focused',
{
@@ -164,9 +174,13 @@ describe('', () => {
},
],
])('%s', async (_, props, options) => {
- const { lastFrame, waitUntilReady } = renderShell(props, options);
+ const { lastFrame, waitUntilReady, unmount } = renderShell(
+ props,
+ options,
+ );
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
+ unmount();
});
});
@@ -197,7 +211,7 @@ describe('', () => {
false,
],
])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => {
- const { lastFrame, waitUntilReady } = renderShell(
+ const { lastFrame, waitUntilReady, unmount } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
@@ -218,10 +232,11 @@ describe('', () => {
const frame = lastFrame();
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
expect(frame).toMatchSnapshot();
+ unmount();
});
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
- const { lastFrame } = renderShell(
+ const { lastFrame, unmount } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
@@ -236,10 +251,11 @@ describe('', () => {
// Should show all 100 lines
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
+ unmount();
});
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
- const { lastFrame, waitUntilReady } = renderShell(
+ const { lastFrame, waitUntilReady, unmount } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
@@ -262,10 +278,11 @@ describe('', () => {
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
expect(lastFrame()).toMatchSnapshot();
+ unmount();
});
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
- const { lastFrame, waitUntilReady } = renderShell(
+ const { lastFrame, waitUntilReady, unmount } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
@@ -288,6 +305,7 @@ describe('', () => {
expect(frame.match(/Line \d+/g)?.length).toBe(15);
});
expect(lastFrame()).toMatchSnapshot();
+ unmount();
});
});
});
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
index 8e760b28e7..3a0cdb702e 100644
--- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
@@ -125,7 +125,11 @@ export const ShellToolMessage: React.FC = ({
borderDimColor={borderDimColor}
containerRef={headerRef}
>
-
+ {
unmount();
});
+ it('should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot)', async () => {
+ const confirmationDetails: SerializableConfirmationDetails = {
+ type: 'exec',
+ title: 'Confirm Multiline Script',
+ command: 'echo "hello"\nfor i in 1 2 3; do\n echo $i\ndone',
+ rootCommand: 'echo',
+ rootCommands: ['echo'],
+ };
+
+ const result = renderWithProviders(
+ ,
+ );
+ await result.waitUntilReady();
+
+ const output = result.lastFrame();
+ expect(output).toContain('echo "hello"');
+ expect(output).toContain('for i in 1 2 3; do');
+ expect(output).toContain('echo $i');
+ expect(output).toContain('done');
+
+ await expect(result).toMatchSvgSnapshot();
+ result.unmount();
+ });
+
describe('with folder trust', () => {
const editConfirmationDetails: SerializableConfirmationDetails = {
type: 'edit',
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index 022a68e953..b60dd4dc8b 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -40,6 +40,7 @@ import {
import { AskUserDialog } from '../AskUserDialog.js';
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
import { WarningMessage } from './WarningMessage.js';
+import { colorizeCode } from '../../utils/CodeColorizer.js';
import {
getDeceptiveUrlDetails,
toUnicodeUrl,
@@ -548,9 +549,19 @@ export const ToolConfirmationMessage: React.FC<
>
{commandsToDisplay.map((cmd, idx) => (
-
- {sanitizeForDisplay(cmd)}
-
+
+ {colorizeCode({
+ code: cmd,
+ language: 'bash',
+ maxWidth: Math.max(terminalWidth, 1),
+ settings,
+ hideLineNumbers: true,
+ })}
+
))}
@@ -634,6 +645,7 @@ export const ToolConfirmationMessage: React.FC<
mcpToolDetailsText,
expandDetailsHintKey,
getPreferredEditor,
+ settings,
]);
const bodyOverflowDirection: 'top' | 'bottom' =
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx
index 8a3e2e2c09..6b9184b0b4 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx
@@ -88,7 +88,11 @@ export const ToolMessage: React.FC = ({
borderColor={borderColor}
borderDimColor={borderDimColor}
>
-
+
-
+
{isThisShellFocused
? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)`
: `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`}
@@ -137,15 +137,21 @@ export type TextEmphasis = 'high' | 'medium' | 'low';
type ToolStatusIndicatorProps = {
status: CoreToolCallStatus;
name: string;
+ isFocused?: boolean;
};
export const ToolStatusIndicator: React.FC = ({
status: coreStatus,
name,
+ isFocused,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
const isShell = isShellTool(name);
- const statusColor = isShell ? theme.ui.symbol : theme.status.warning;
+ const statusColor = isFocused
+ ? theme.ui.focus
+ : isShell
+ ? theme.ui.active
+ : theme.status.warning;
return (
@@ -153,10 +159,9 @@ export const ToolStatusIndicator: React.FC = ({
{TOOL_STATUS.PENDING}
)}
{status === ToolCallStatus.Executing && (
-
+
+
+
)}
{status === ToolCallStatus.Success && (
diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx
index 6453ab94c1..6609a7d1c4 100644
--- a/packages/cli/src/ui/components/messages/UserMessage.tsx
+++ b/packages/cli/src/ui/components/messages/UserMessage.tsx
@@ -29,7 +29,7 @@ export const UserMessage: React.FC = ({ text, width }) => {
const config = useConfig();
const useBackgroundColor = config.getUseBackgroundColor();
- const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary;
+ const textColor = isSlashCommand ? theme.text.accent : theme.text.primary;
const displayText = useMemo(() => {
if (!text) return text;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap
index 4f89811121..f584e7f483 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap
@@ -7,7 +7,7 @@ Note: Command contains redirection which can be undesirable.
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
Allow execution of: 'echo, redirection (>)'?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap
index 0d34c7e49d..3fa8a62bf8 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap
@@ -2,7 +2,7 @@
exports[` > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command │
+│ ⊶ Shell Command A shell command │
│ │
│ Line 86 │
│ Line 87 │
@@ -131,7 +131,7 @@ exports[` > Height Constraints > fully expands in alternate
exports[` > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command │
+│ ⊶ Shell Command A shell command │
│ │
│ Line 93 │
│ Line 94 │
@@ -168,7 +168,7 @@ exports[` > Height Constraints > stays constrained in altern
exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command │
+│ ⊶ Shell Command A shell command │
│ │
│ Line 86 │
│ Line 87 │
@@ -190,7 +190,7 @@ exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
exports[` > Height Constraints > uses full availableTerminalHeight when focused in alternate buffer mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
+│ ⊶ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Line 3 │
│ Line 4 │
@@ -295,7 +295,7 @@ exports[` > Height Constraints > uses full availableTerminal
exports[` > Snapshots > renders in Alternate Buffer mode while focused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
+│ ⊶ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Test result │
"
@@ -303,12 +303,20 @@ exports[` > Snapshots > renders in Alternate Buffer mode whi
exports[` > Snapshots > renders in Alternate Buffer mode while unfocused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command │
+│ ⊶ Shell Command A shell command │
│ │
│ Test result │
"
`;
+exports[` > Snapshots > renders in Cancelled state with partial output 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ - Shell Command A shell command │
+│ │
+│ Partial output before cancellation │
+"
+`;
+
exports[` > Snapshots > renders in Error state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ x Shell Command A shell command │
@@ -319,7 +327,7 @@ exports[` > Snapshots > renders in Error state 1`] = `
exports[` > Snapshots > renders in Executing state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command │
+│ ⊶ Shell Command A shell command │
│ │
│ Test result │
"
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg
new file mode 100644
index 0000000000..d1396e2335
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg
@@ -0,0 +1,32 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap
index 72eda055d5..3f207df881 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap
@@ -2,11 +2,13 @@
exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = `
"echo "hello"
+
ls -la
+
whoami
Allow execution of 3 commands?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -19,7 +21,7 @@ URLs to fetch:
- https://raw.githubusercontent.com/google/gemini-react/main/README.md
Do you want to proceed?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -29,7 +31,20 @@ exports[`ToolConfirmationMessage > should not display urls if prompt and url are
"https://example.com
Do you want to proceed?
-● 1. Allow once
+● 1. Allow once
+ 2. Allow for this session
+ 3. No, suggest changes (esc)
+"
+`;
+
+exports[`ToolConfirmationMessage > should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot) 1`] = `
+"echo "hello"
+for i in 1 2 3; do
+ echo $i
+done
+Allow execution of: 'echo'?
+
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -40,7 +55,7 @@ exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool an
Tool: testtool
Allow execution of MCP tool "testtool" from server "testserver"?
-● 1. Allow once
+● 1. Allow once
2. Allow tool for this session
3. Allow all server tools for this session
4. No, suggest changes (esc)
@@ -55,7 +70,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
-● 1. Allow once
+● 1. Allow once
2. Modify with external editor
3. No, suggest changes (esc)
"
@@ -69,7 +84,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. Modify with external editor
4. No, suggest changes (esc)
@@ -80,7 +95,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
"echo "hello"
Allow execution of: 'echo'?
-● 1. Allow once
+● 1. Allow once
2. No, suggest changes (esc)
"
`;
@@ -89,7 +104,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
"echo "hello"
Allow execution of: 'echo'?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -99,7 +114,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
"https://example.com
Do you want to proceed?
-● 1. Allow once
+● 1. Allow once
2. No, suggest changes (esc)
"
`;
@@ -108,7 +123,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
"https://example.com
Do you want to proceed?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -119,7 +134,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' >
Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
-● 1. Allow once
+● 1. Allow once
2. No, suggest changes (esc)
"
`;
@@ -129,7 +144,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' >
Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
-● 1. Allow once
+● 1. Allow once
2. Allow tool for this session
3. Allow all server tools for this session
4. No, suggest changes (esc)
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap
index 6adcb80a5c..29da4d5860 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap
@@ -71,7 +71,7 @@ exports[` > Golden Snapshots > renders mixed tool calls incl
│ │
│ Test result │
│ │
-│ ⊷ run_shell_command Run command │
+│ ⊶ run_shell_command Run command │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap
index f31865874d..ec5643e773 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap
@@ -29,7 +29,7 @@ exports[` > ToolStatusIndicator rendering > shows - for Canceled
exports[` > ToolStatusIndicator rendering > shows MockRespondingSpinner for Executing status when streamingState is Responding 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ MockRespondingSpinnertest-tool A tool for testing │
+│ ⊶ test-tool A tool for testing │
│ │
│ Test result │
"
@@ -45,7 +45,7 @@ exports[` > ToolStatusIndicator rendering > shows o for Pending s
exports[` > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is Idle 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ MockRespondingSpinnertest-tool A tool for testing │
+│ ⊶ test-tool A tool for testing │
│ │
│ Test result │
"
@@ -53,7 +53,7 @@ exports[` > ToolStatusIndicator rendering > shows paused spinner
exports[` > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is WaitingForConfirmation 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ MockRespondingSpinnertest-tool A tool for testing │
+│ ⊶ test-tool A tool for testing │
│ │
│ Test result │
"
@@ -94,7 +94,7 @@ exports[` > renders DiffRenderer for diff results 1`] = `
exports[` > renders McpProgressIndicator with percentage and message for executing tools 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ MockRespondingSpinnertest-tool A tool for testing │
+│ ⊶ test-tool A tool for testing │
│ │
│ ████████░░░░░░░░░░░░ 42% │
│ Working on it... │
@@ -128,7 +128,7 @@ exports[` > renders emphasis correctly 2`] = `
exports[` > renders indeterminate progress when total is missing 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ MockRespondingSpinnertest-tool A tool for testing │
+│ ⊶ test-tool A tool for testing │
│ │
│ ███████░░░░░░░░░░░░░ 7 │
│ Test result │
@@ -137,7 +137,7 @@ exports[` > renders indeterminate progress when total is missing
exports[` > renders only percentage when progressMessage is missing 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ MockRespondingSpinnertest-tool A tool for testing │
+│ ⊶ test-tool A tool for testing │
│ │
│ ███████████████░░░░░ 75% │
│ Test result │
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap
index fb4f1ec722..8da15d7fdb 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap
@@ -2,63 +2,63 @@
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing (Tab to focus) │
+│ ⊶ Shell Command A tool for testing (Tab to focus) │
│ │
"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > initial-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing │
+│ ⊶ Shell Command A tool for testing │
│ │
"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing (Tab to focus) │
+│ ⊶ Shell Command A tool for testing (Tab to focus) │
│ │
"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > initial-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing │
+│ ⊶ Shell Command A tool for testing │
│ │
"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing (Tab to focus) │
+│ ⊶ Shell Command A tool for testing (Tab to focus) │
│ │
"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > initial-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing │
+│ ⊶ Shell Command A tool for testing │
│ │
"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing (Tab to focus) │
+│ ⊶ Shell Command A tool for testing (Tab to focus) │
│ │
"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > initial-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing │
+│ ⊶ Shell Command A tool for testing │
│ │
"
`;
exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (Tab to focus) │
+│ ⊶ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (Tab to focus) │
│ │
"
`;
diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx
index 2444374c3e..8fffd4c5fc 100644
--- a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx
@@ -19,13 +19,15 @@ vi.mock('../../hooks/useSelectionList.js');
const mockTheme = {
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
- status: { success: 'COLOR_SUCCESS' },
+ ui: { focus: 'COLOR_FOCUS' },
+ background: { focus: 'COLOR_FOCUS_BG' },
} as typeof theme;
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
- status: { success: 'COLOR_SUCCESS' },
+ ui: { focus: 'COLOR_FOCUS' },
+ background: { focus: 'COLOR_FOCUS_BG' },
},
}));
@@ -161,8 +163,8 @@ describe('BaseSelectionList', () => {
expect(mockRenderItem).toHaveBeenCalledWith(
items[0],
expect.objectContaining({
- titleColor: mockTheme.status.success,
- numberColor: mockTheme.status.success,
+ titleColor: mockTheme.ui.focus,
+ numberColor: mockTheme.ui.focus,
isSelected: true,
}),
);
@@ -207,8 +209,8 @@ describe('BaseSelectionList', () => {
expect(mockRenderItem).toHaveBeenCalledWith(
items[1],
expect.objectContaining({
- titleColor: mockTheme.status.success,
- numberColor: mockTheme.status.success,
+ titleColor: mockTheme.ui.focus,
+ numberColor: mockTheme.ui.focus,
isSelected: true,
}),
);
@@ -267,7 +269,7 @@ describe('BaseSelectionList', () => {
items[0],
expect.objectContaining({
isSelected: true,
- titleColor: mockTheme.status.success,
+ titleColor: mockTheme.ui.focus,
numberColor: mockTheme.text.secondary,
}),
);
diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
index db0d624a74..1467bb357e 100644
--- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
@@ -117,8 +117,8 @@ export function BaseSelectionList<
let numberColor = theme.text.primary;
if (isSelected) {
- titleColor = theme.status.success;
- numberColor = theme.status.success;
+ titleColor = theme.ui.focus;
+ numberColor = theme.ui.focus;
} else if (item.disabled) {
titleColor = theme.text.secondary;
numberColor = theme.text.secondary;
@@ -137,11 +137,15 @@ export function BaseSelectionList<
)}.`;
return (
-
+
{/* Radio button indicator */}
{isSelected ? '●' : ' '}
diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
index 58f15aa85a..05cef4fcf2 100644
--- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
@@ -25,6 +25,7 @@ import {
} from '../../utils/textUtils.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
+import { formatCommand } from '../../utils/keybindingUtils.js';
/**
* Represents a single item in the settings dialog.
@@ -459,7 +460,7 @@ export function BaseSettingsDialog({
editingKey
? theme.border.default
: focusSection === 'settings'
- ? theme.border.focused
+ ? theme.ui.focus
: theme.border.default
}
paddingX={1}
@@ -522,12 +523,17 @@ export function BaseSettingsDialog({
return (
-
+
{isActive ? '●' : ''}
@@ -544,9 +550,7 @@ export function BaseSettingsDialog({
minWidth={0}
>
{item.label}
{item.scopeMessage && (
@@ -565,7 +569,7 @@ export function BaseSettingsDialog({
- (Use Enter to select, Ctrl+L to reset
+ (Use Enter to select, {formatCommand(Command.CLEAR_SCREEN)} to reset
{showScopeSelector ? ', Tab to change focus' : ''}, Esc to close)
diff --git a/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.test.tsx b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.test.tsx
index 14af016b38..8fe9f66bee 100644
--- a/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.test.tsx
+++ b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.test.tsx
@@ -29,6 +29,12 @@ vi.mock('../../semantic-colors.js', () => ({
primary: 'COLOR_PRIMARY',
secondary: 'COLOR_SECONDARY',
},
+ ui: {
+ focus: 'COLOR_FOCUS',
+ },
+ background: {
+ focus: 'COLOR_FOCUS_BG',
+ },
status: {
success: 'COLOR_SUCCESS',
},
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
index c5122770c0..d21cebe971 100644
--- a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
@@ -9,9 +9,19 @@ import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { MaxSizedBox } from './MaxSizedBox.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { Box, Text } from 'ink';
-import { describe, it, expect } from 'vitest';
+import { act } from 'react';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
it('renders children without truncation when they fit', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
@@ -22,6 +32,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('Hello, World!');
expect(lastFrame()).toMatchSnapshot();
@@ -40,6 +53,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 2 lines hidden (Ctrl+O to show) ...',
@@ -60,6 +76,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... last 2 lines hidden (Ctrl+O to show) ...',
@@ -80,6 +99,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 2 lines hidden (Ctrl+O to show) ...',
@@ -98,6 +120,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 1 line hidden (Ctrl+O to show) ...',
@@ -118,6 +143,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 7 lines hidden (Ctrl+O to show) ...',
@@ -137,6 +165,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('This is a');
expect(lastFrame()).toMatchSnapshot();
@@ -154,6 +185,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('Line 1');
expect(lastFrame()).toMatchSnapshot();
@@ -166,6 +200,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).equals('');
unmount();
@@ -185,6 +222,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('Line 1 from Fragment');
expect(lastFrame()).toMatchSnapshot();
@@ -206,6 +246,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 21 lines hidden (Ctrl+O to show) ...',
@@ -229,6 +272,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... last 21 lines hidden (Ctrl+O to show) ...',
@@ -253,6 +299,9 @@ describe('', () => {
{ width: 80 },
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('... last');
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
index 0c2922ddfb..ee91d34f57 100644
--- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
@@ -96,12 +96,15 @@ export const MaxSizedBox: React.FC = ({
} else {
removeOverflowingId?.(id);
}
-
- return () => {
- removeOverflowingId?.(id);
- };
}, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]);
+ useEffect(
+ () => () => {
+ removeOverflowingId?.(id);
+ },
+ [id, removeOverflowingId],
+ );
+
if (effectiveMaxHeight === undefined) {
return (
diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
index 33c77f1a25..00607e522a 100644
--- a/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
+++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
@@ -27,6 +27,8 @@ vi.mock('./BaseSelectionList.js', () => ({
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: { secondary: 'COLOR_SECONDARY' },
+ ui: { focus: 'COLOR_FOCUS' },
+ background: { focus: 'COLOR_FOCUS_BG' },
},
}));
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap
index 803ec8dd98..9f256d4cb6 100644
--- a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap
+++ b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap
@@ -10,7 +10,7 @@ exports[`SearchableList > should match snapshot 1`] = `
● Item One
Description for item one
- Item Two
+ Item Two
Description for item two
Item Three
@@ -25,7 +25,7 @@ exports[`SearchableList > should reset selection to top when items change if res
│ Search... │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
- Item One
+ Item One
Description for item one
● Item Two
@@ -58,7 +58,7 @@ exports[`SearchableList > should reset selection to top when items change if res
● Item One
Description for item one
- Item Two
+ Item Two
Description for item two
Item Three
diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts
index 7a59645cef..448dc37523 100644
--- a/packages/cli/src/ui/constants.ts
+++ b/packages/cli/src/ui/constants.ts
@@ -37,6 +37,7 @@ export const EXPAND_HINT_DURATION_MS = 5000;
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
+export const DEFAULT_SELECTION_OPACITY = 0.2;
export const DEFAULT_BORDER_OPACITY = 0.4;
export const KEYBOARD_SHORTCUTS_URL =
diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
index e25ff57642..bc8e198168 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
@@ -288,7 +288,7 @@ describe('KeypressContext', () => {
expect.objectContaining({
name: 'escape',
shift: false,
- alt: true,
+ alt: false,
cmd: false,
}),
);
@@ -297,7 +297,7 @@ describe('KeypressContext', () => {
expect.objectContaining({
name: 'escape',
shift: false,
- alt: true,
+ alt: false,
cmd: false,
}),
);
@@ -326,7 +326,7 @@ describe('KeypressContext', () => {
expect.objectContaining({
name: 'escape',
shift: false,
- alt: true,
+ alt: false,
cmd: false,
}),
);
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
index 7d1881644d..d3f9031ffe 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -178,8 +178,7 @@ function nonKeyboardEventFilter(
}
/**
- * Converts return keys pressed quickly after other keys into plain
- * insertable return characters.
+ * Converts return keys pressed quickly after insertable keys into a shift+return
*
* This is to accommodate older terminals that paste text without bracketing.
*/
@@ -201,7 +200,7 @@ function bufferFastReturn(keypressHandler: KeypressHandler): KeypressHandler {
} else {
keypressHandler(key);
}
- lastKeyTime = now;
+ lastKeyTime = key.insertable ? now : 0;
};
}
@@ -630,7 +629,7 @@ function* emitKeys(
} else if (sequence === `${ESC}${ESC}`) {
// Double escape
name = 'escape';
- alt = true;
+ alt = false;
// Emit first escape key here, then continue processing
keypressHandler({
@@ -645,7 +644,7 @@ function* emitKeys(
} else if (escaped) {
// Escape sequence timeout
name = ch.length ? undefined : 'escape';
- alt = true;
+ alt = ch.length > 0;
} else {
// Any other character is considered printable.
insertable = true;
@@ -786,6 +785,8 @@ export function KeypressProvider({
);
useEffect(() => {
+ terminalCapabilityManager.enableSupportedModes();
+
const wasRaw = stdin.isRaw;
if (wasRaw === false) {
setRawMode(true);
diff --git a/packages/cli/src/ui/contexts/OverflowContext.tsx b/packages/cli/src/ui/contexts/OverflowContext.tsx
index cee02090b6..f27108367a 100644
--- a/packages/cli/src/ui/contexts/OverflowContext.tsx
+++ b/packages/cli/src/ui/contexts/OverflowContext.tsx
@@ -11,6 +11,8 @@ import {
useState,
useCallback,
useMemo,
+ useRef,
+ useEffect,
} from 'react';
export interface OverflowState {
@@ -42,31 +44,70 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({
}) => {
const [overflowingIds, setOverflowingIds] = useState(new Set());
- const addOverflowingId = useCallback((id: string) => {
- setOverflowingIds((prevIds) => {
- if (prevIds.has(id)) {
- return prevIds;
- }
- const newIds = new Set(prevIds);
- newIds.add(id);
- return newIds;
- });
+ /**
+ * We use a ref to track the current set of overflowing IDs and a timeout to
+ * batch updates to the next tick. This prevents infinite render loops (layout
+ * oscillation) where showing an overflow hint causes a layout shift that
+ * hides the hint, which then restores the layout and shows the hint again.
+ */
+ const idsRef = useRef(new Set());
+ const timeoutRef = useRef(null);
+
+ const syncState = useCallback(() => {
+ if (timeoutRef.current) return;
+
+ // Use a microtask to batch updates and break synchronous recursive loops.
+ // This prevents "Maximum update depth exceeded" errors during layout shifts.
+ timeoutRef.current = setTimeout(() => {
+ timeoutRef.current = null;
+ setOverflowingIds((prevIds) => {
+ // Optimization: only update state if the set has actually changed
+ if (
+ prevIds.size === idsRef.current.size &&
+ [...prevIds].every((id) => idsRef.current.has(id))
+ ) {
+ return prevIds;
+ }
+ return new Set(idsRef.current);
+ });
+ }, 0);
}, []);
- const removeOverflowingId = useCallback((id: string) => {
- setOverflowingIds((prevIds) => {
- if (!prevIds.has(id)) {
- return prevIds;
+ useEffect(
+ () => () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
}
- const newIds = new Set(prevIds);
- newIds.delete(id);
- return newIds;
- });
- }, []);
+ },
+ [],
+ );
+
+ const addOverflowingId = useCallback(
+ (id: string) => {
+ if (!idsRef.current.has(id)) {
+ idsRef.current.add(id);
+ syncState();
+ }
+ },
+ [syncState],
+ );
+
+ const removeOverflowingId = useCallback(
+ (id: string) => {
+ if (idsRef.current.has(id)) {
+ idsRef.current.delete(id);
+ syncState();
+ }
+ },
+ [syncState],
+ );
const reset = useCallback(() => {
- setOverflowingIds(new Set());
- }, []);
+ if (idsRef.current.size > 0) {
+ idsRef.current.clear();
+ syncState();
+ }
+ }, [syncState]);
const stateValue = useMemo(
() => ({
diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts
index 02eb4c47f8..03e9383833 100644
--- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts
@@ -120,8 +120,8 @@ describe('useAtCompletion', () => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'src/',
- 'src/components/',
'src/index.js',
+ 'src/components/',
'src/components/Button.tsx',
]);
});
diff --git a/packages/cli/src/ui/hooks/useBanner.test.ts b/packages/cli/src/ui/hooks/useBanner.test.ts
index 1d876c078c..cb5712bec4 100644
--- a/packages/cli/src/ui/hooks/useBanner.test.ts
+++ b/packages/cli/src/ui/hooks/useBanner.test.ts
@@ -29,6 +29,9 @@ vi.mock('../semantic-colors.js', () => ({
status: {
warning: 'mock-warning-color',
},
+ ui: {
+ focus: 'mock-focus-color',
+ },
},
}));
diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx
index 8f91013070..bbcddb7d9d 100644
--- a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx
+++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx
@@ -46,6 +46,7 @@ vi.mock('./useShellCompletion', () => ({
completionStart: 0,
completionEnd: 0,
query: '',
+ activeStart: 0,
})),
}));
@@ -57,7 +58,12 @@ const setupMocks = ({
isLoading = false,
isPerfectMatch = false,
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
- shellCompletionRange = { completionStart: 0, completionEnd: 0, query: '' },
+ shellCompletionRange = {
+ completionStart: 0,
+ completionEnd: 0,
+ query: '',
+ activeStart: 0,
+ },
}: {
atSuggestions?: Suggestion[];
slashSuggestions?: Suggestion[];
@@ -69,6 +75,7 @@ const setupMocks = ({
completionStart: number;
completionEnd: number;
query: string;
+ activeStart?: number;
};
}) => {
// Mock for @-completions
@@ -116,7 +123,10 @@ const setupMocks = ({
setSuggestions(shellSuggestions);
}
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
- return shellCompletionRange;
+ return {
+ ...shellCompletionRange,
+ activeStart: shellCompletionRange.activeStart ?? 0,
+ };
},
);
};
@@ -139,38 +149,57 @@ describe('useCommandCompletion', () => {
});
}
+ let hookResult: ReturnType & {
+ textBuffer: ReturnType;
+ };
+
+ function TestComponent({
+ initialText,
+ cursorOffset,
+ shellModeActive,
+ active,
+ }: {
+ initialText: string;
+ cursorOffset?: number;
+ shellModeActive: boolean;
+ active: boolean;
+ }) {
+ const textBuffer = useTextBufferForTest(initialText, cursorOffset);
+ const completion = useCommandCompletion({
+ buffer: textBuffer,
+ cwd: testRootDir,
+ slashCommands: [],
+ commandContext: mockCommandContext,
+ reverseSearchActive: false,
+ shellModeActive,
+ config: mockConfig,
+ active,
+ });
+ hookResult = { ...completion, textBuffer };
+ return null;
+ }
+
const renderCommandCompletionHook = (
initialText: string,
cursorOffset?: number,
shellModeActive = false,
active = true,
) => {
- let hookResult: ReturnType & {
- textBuffer: ReturnType;
- };
-
- function TestComponent() {
- const textBuffer = useTextBufferForTest(initialText, cursorOffset);
- const completion = useCommandCompletion({
- buffer: textBuffer,
- cwd: testRootDir,
- slashCommands: [],
- commandContext: mockCommandContext,
- reverseSearchActive: false,
- shellModeActive,
- config: mockConfig,
- active,
- });
- hookResult = { ...completion, textBuffer };
- return null;
- }
- renderWithProviders();
+ const renderResult = renderWithProviders(
+ ,
+ );
return {
result: {
get current() {
return hookResult;
},
},
+ ...renderResult,
};
};
@@ -524,6 +553,129 @@ describe('useCommandCompletion', () => {
expect(result.current.textBuffer.text).toBe('@src\\components\\');
});
+
+ it('should show ghost text for a single shell completion', async () => {
+ const text = 'l';
+ setupMocks({
+ shellSuggestions: [{ label: 'ls', value: 'ls' }],
+ shellCompletionRange: {
+ completionStart: 0,
+ completionEnd: 1,
+ query: 'l',
+ activeStart: 0,
+ },
+ });
+
+ const { result } = renderCommandCompletionHook(
+ text,
+ text.length,
+ true, // shellModeActive
+ );
+
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ // Should show "ls " as ghost text (including trailing space)
+ expect(result.current.promptCompletion.text).toBe('ls ');
+ });
+
+ it('should not show ghost text if there are multiple completions', async () => {
+ const text = 'l';
+ setupMocks({
+ shellSuggestions: [
+ { label: 'ls', value: 'ls' },
+ { label: 'ln', value: 'ln' },
+ ],
+ shellCompletionRange: {
+ completionStart: 0,
+ completionEnd: 1,
+ query: 'l',
+ activeStart: 0,
+ },
+ });
+
+ const { result } = renderCommandCompletionHook(
+ text,
+ text.length,
+ true, // shellModeActive
+ );
+
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ expect(result.current.promptCompletion.text).toBe('');
+ });
+
+ it('should not show ghost text if the typed text extends past the completion', async () => {
+ // "ls " is already typed.
+ const text = 'ls ';
+ const cursorOffset = text.length;
+
+ const { result } = renderCommandCompletionHook(
+ text,
+ cursorOffset,
+ true, // shellModeActive
+ );
+
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ expect(result.current.promptCompletion.text).toBe('');
+ });
+
+ it('should clear ghost text after user types a space when exact match ghost text was showing', async () => {
+ const textWithoutSpace = 'ls';
+
+ setupMocks({
+ shellSuggestions: [{ label: 'ls', value: 'ls' }],
+ shellCompletionRange: {
+ completionStart: 0,
+ completionEnd: 2,
+ query: 'ls',
+ activeStart: 0,
+ },
+ });
+
+ const { result } = renderCommandCompletionHook(
+ textWithoutSpace,
+ textWithoutSpace.length,
+ true, // shellModeActive
+ );
+
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ // Initially no ghost text because "ls" perfectly matches "ls"
+ expect(result.current.promptCompletion.text).toBe('');
+
+ // Now simulate typing a space.
+ // In the real app, shellCompletionRange.completionStart would change immediately to 3,
+ // but suggestions (and activeStart) would still be from the previous token for a few ms.
+ setupMocks({
+ shellSuggestions: [{ label: 'ls', value: 'ls' }], // Stale suggestions
+ shellCompletionRange: {
+ completionStart: 3, // New token position
+ completionEnd: 3,
+ query: '',
+ activeStart: 0, // Stale active start
+ },
+ });
+
+ act(() => {
+ result.current.textBuffer.setText('ls ', 'end');
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ // Should STILL be empty because completionStart (3) !== activeStart (0)
+ expect(result.current.promptCompletion.text).toBe('');
+ });
});
describe('prompt completion filtering', () => {
diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx
index f9b772bc93..480ca2c28e 100644
--- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx
+++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useCallback, useMemo, useEffect } 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';
@@ -37,6 +37,9 @@ export interface UseCommandCompletionReturn {
showSuggestions: boolean;
isLoadingSuggestions: boolean;
isPerfectMatch: boolean;
+ forceShowShellSuggestions: boolean;
+ setForceShowShellSuggestions: (value: boolean) => void;
+ isShellSuggestionsVisible: boolean;
setActiveSuggestionIndex: React.Dispatch>;
resetCompletionState: () => void;
navigateUp: () => void;
@@ -80,6 +83,9 @@ export function useCommandCompletion({
config,
active,
}: UseCommandCompletionOptions): UseCommandCompletionReturn {
+ const [forceShowShellSuggestions, setForceShowShellSuggestions] =
+ useState(false);
+
const {
suggestions,
activeSuggestionIndex,
@@ -93,11 +99,16 @@ export function useCommandCompletion({
setIsPerfectMatch,
setVisibleStartIndex,
- resetCompletionState,
+ resetCompletionState: baseResetCompletionState,
navigateUp,
navigateDown,
} = useCompletion();
+ const resetCompletionState = useCallback(() => {
+ baseResetCompletionState();
+ setForceShowShellSuggestions(false);
+ }, [baseResetCompletionState]);
+
const cursorRow = buffer.cursor[0];
const cursorCol = buffer.cursor[1];
@@ -231,10 +242,73 @@ export function useCommandCompletion({
? shellCompletionRange.query
: memoQuery;
- const promptCompletion = usePromptCompletion({
+ const basePromptCompletion = usePromptCompletion({
buffer,
});
+ const isShellSuggestionsVisible =
+ completionMode !== CompletionMode.SHELL || forceShowShellSuggestions;
+
+ const promptCompletion = useMemo(() => {
+ if (
+ completionMode === CompletionMode.SHELL &&
+ suggestions.length === 1 &&
+ query != null &&
+ shellCompletionRange.completionStart === shellCompletionRange.activeStart
+ ) {
+ const suggestion = suggestions[0];
+ const textToInsertBase = suggestion.value;
+
+ if (
+ textToInsertBase.startsWith(query) &&
+ textToInsertBase.length > query.length
+ ) {
+ const currentLine = buffer.lines[cursorRow] || '';
+ const start = shellCompletionRange.completionStart;
+ const end = shellCompletionRange.completionEnd;
+
+ let textToInsert = textToInsertBase;
+ const charAfterCompletion = currentLine[end];
+ if (
+ charAfterCompletion !== ' ' &&
+ !textToInsert.endsWith('/') &&
+ !textToInsert.endsWith('\\')
+ ) {
+ textToInsert += ' ';
+ }
+
+ const newText =
+ currentLine.substring(0, start) +
+ textToInsert +
+ currentLine.substring(end);
+
+ return {
+ text: newText,
+ isActive: true,
+ isLoading: false,
+ accept: () => {
+ buffer.replaceRangeByOffset(
+ logicalPosToOffset(buffer.lines, cursorRow, start),
+ logicalPosToOffset(buffer.lines, cursorRow, end),
+ textToInsert,
+ );
+ },
+ clear: () => {},
+ markSelected: () => {},
+ };
+ }
+ }
+ return basePromptCompletion;
+ }, [
+ completionMode,
+ suggestions,
+ query,
+ basePromptCompletion,
+ buffer,
+ cursorRow,
+ shellCompletionRange,
+ ]);
+
useEffect(() => {
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
setVisibleStartIndex(0);
@@ -271,6 +345,7 @@ export function useCommandCompletion({
active &&
completionMode !== CompletionMode.IDLE &&
!reverseSearchActive &&
+ isShellSuggestionsVisible &&
(isLoadingSuggestions || suggestions.length > 0);
/**
@@ -395,6 +470,9 @@ export function useCommandCompletion({
showSuggestions,
isLoadingSuggestions,
isPerfectMatch,
+ forceShowShellSuggestions,
+ setForceShowShellSuggestions,
+ isShellSuggestionsVisible,
setActiveSuggestionIndex,
resetCompletionState,
navigateUp,
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index b5da495b35..ec8ea0751a 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -807,14 +807,6 @@ describe('useGeminiStream', () => {
expect(injectedHintPart.text).toContain(
'Do not cancel/skip tasks unless the user explicitly cancels them.',
);
- expect(
- mockAddItem.mock.calls.some(
- ([item]) =>
- item?.type === 'info' &&
- typeof item.text === 'string' &&
- item.text.includes('Got it. Focusing on tests only.'),
- ),
- ).toBe(true);
expect(mockRunInDevTraceSpan).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1058,9 +1050,9 @@ describe('useGeminiStream', () => {
);
expect(noteIndex).toBeGreaterThanOrEqual(0);
expect(stopIndex).toBeGreaterThanOrEqual(0);
- expect(failureHintIndex).toBeGreaterThanOrEqual(0);
+ // The failure hint should NOT be present if the suppressed error note was shown
+ expect(failureHintIndex).toBe(-1);
expect(noteIndex).toBeLessThan(stopIndex);
- expect(stopIndex).toBeLessThan(failureHintIndex);
});
it('should group multiple cancelled tool call responses into a single history entry', async () => {
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 2a25359614..630566090b 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -35,7 +35,6 @@ import {
CoreEvent,
CoreToolCallStatus,
buildUserSteeringHintPrompt,
- generateSteeringAckMessage,
GeminiCliOperation,
getPlanModeExitMessage,
} from '@google/gemini-cli-core';
@@ -597,7 +596,10 @@ export const useGeminiStream = (
if (!isLowErrorVerbosity || config.getDebugMode()) {
return;
}
- if (lowVerbosityFailureNoteShownRef.current) {
+ if (
+ lowVerbosityFailureNoteShownRef.current ||
+ suppressedToolErrorNoteShownRef.current
+ ) {
return;
}
@@ -1761,18 +1763,6 @@ export const useGeminiStream = (
responsesToSend.unshift({
text: buildUserSteeringHintPrompt(hintText),
});
- void generateSteeringAckMessage(
- config.getBaseLlmClient(),
- hintText,
- ).then((ackText) => {
- addItem({
- type: 'info',
- icon: '· ',
- color: theme.text.secondary,
- marginBottom: 1,
- text: ackText,
- } as HistoryItemInfo);
- });
}
}
@@ -1809,7 +1799,6 @@ export const useGeminiStream = (
addItem,
registerBackgroundShell,
consumeUserHint,
- config,
isLowErrorVerbosity,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
diff --git a/packages/cli/src/ui/hooks/useShellCompletion.ts b/packages/cli/src/ui/hooks/useShellCompletion.ts
index cc73128344..ec50c98ac9 100644
--- a/packages/cli/src/ui/hooks/useShellCompletion.ts
+++ b/packages/cli/src/ui/hooks/useShellCompletion.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useEffect, useRef, useCallback, useMemo } from 'react';
+import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
@@ -435,6 +435,7 @@ export interface UseShellCompletionReturn {
completionStart: number;
completionEnd: number;
query: string;
+ activeStart: number;
}
const EMPTY_TOKENS: string[] = [];
@@ -451,6 +452,7 @@ export function useShellCompletion({
const pathEnvRef = useRef(process.env['PATH'] ?? '');
const abortRef = useRef(null);
const debounceRef = useRef(null);
+ const [activeStart, setActiveStart] = useState(-1);
const tokenInfo = useMemo(
() => (enabled ? getTokenAtCursor(line, cursorCol) : null),
@@ -467,6 +469,14 @@ export function useShellCompletion({
commandToken = '',
} = tokenInfo || {};
+ // Immediately clear suggestions if the token range has changed.
+ // This avoids a frame of flickering with stale suggestions (e.g. "ls ls")
+ // when moving to a new token.
+ if (enabled && activeStart !== -1 && completionStart !== activeStart) {
+ setSuggestions([]);
+ setActiveStart(-1);
+ }
+
// Invalidate PATH cache when $PATH changes
useEffect(() => {
const currentPath = process.env['PATH'] ?? '';
@@ -558,6 +568,7 @@ export function useShellCompletion({
if (signal.aborted) return;
setSuggestions(results);
+ setActiveStart(completionStart);
} catch (error) {
if (
!(
@@ -571,6 +582,7 @@ export function useShellCompletion({
}
if (!signal.aborted) {
setSuggestions([]);
+ setActiveStart(completionStart);
}
} finally {
if (!signal.aborted) {
@@ -586,6 +598,7 @@ export function useShellCompletion({
cursorIndex,
commandToken,
cwd,
+ completionStart,
setSuggestions,
setIsLoadingSuggestions,
]);
@@ -594,6 +607,7 @@ export function useShellCompletion({
if (!enabled) {
abortRef.current?.abort();
setSuggestions([]);
+ setActiveStart(-1);
setIsLoadingSuggestions(false);
}
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
@@ -633,5 +647,6 @@ export function useShellCompletion({
completionStart,
completionEnd,
query,
+ activeStart,
};
}
diff --git a/packages/cli/src/ui/hooks/useSnowfall.test.tsx b/packages/cli/src/ui/hooks/useSnowfall.test.tsx
index 321da83090..e3e6df9100 100644
--- a/packages/cli/src/ui/hooks/useSnowfall.test.tsx
+++ b/packages/cli/src/ui/hooks/useSnowfall.test.tsx
@@ -23,7 +23,7 @@ vi.mock('../themes/theme-manager.js', () => ({
DEFAULT_THEME: { name: 'Default' },
}));
-vi.mock('../themes/holiday.js', () => ({
+vi.mock('../themes/builtin/dark/holiday-dark.js', () => ({
Holiday: { name: 'Holiday' },
}));
diff --git a/packages/cli/src/ui/hooks/useSnowfall.ts b/packages/cli/src/ui/hooks/useSnowfall.ts
index 6edb2e4b92..60c6d6d78f 100644
--- a/packages/cli/src/ui/hooks/useSnowfall.ts
+++ b/packages/cli/src/ui/hooks/useSnowfall.ts
@@ -8,7 +8,7 @@ import { useState, useEffect, useMemo } from 'react';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { debugState } from '../debug.js';
import { themeManager } from '../themes/theme-manager.js';
-import { Holiday } from '../themes/holiday.js';
+import { Holiday } from '../themes/builtin/dark/holiday-dark.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useTerminalSize } from './useTerminalSize.js';
import { shortAsciiLogo } from '../components/AsciiArt.js';
diff --git a/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx b/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
index d20c6149b0..31df95495c 100644
--- a/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
+++ b/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
@@ -65,7 +65,7 @@ vi.mock('../themes/theme-manager.js', async (importOriginal) => {
};
});
-vi.mock('../themes/default-light.js', () => ({
+vi.mock('../themes/builtin/light/default-light.js', () => ({
DefaultLight: { name: 'default-light' },
}));
diff --git a/packages/cli/src/ui/hooks/useTerminalTheme.ts b/packages/cli/src/ui/hooks/useTerminalTheme.ts
index 5590c2a97c..29168b281a 100644
--- a/packages/cli/src/ui/hooks/useTerminalTheme.ts
+++ b/packages/cli/src/ui/hooks/useTerminalTheme.ts
@@ -11,7 +11,7 @@ import {
shouldSwitchTheme,
} from '../themes/color-utils.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
-import { DefaultLight } from '../themes/default-light.js';
+import { DefaultLight } from '../themes/builtin/light/default-light.js';
import { useSettings } from '../contexts/SettingsContext.js';
import type { Config } from '@google/gemini-cli-core';
import { useTerminalContext } from '../contexts/TerminalContext.js';
diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts
index 763754ec95..888393be83 100644
--- a/packages/cli/src/ui/keyMatchers.test.ts
+++ b/packages/cli/src/ui/keyMatchers.test.ts
@@ -32,8 +32,12 @@ describe('keyMatchers', () => {
},
{
command: Command.ESCAPE,
- positive: [createKey('escape'), createKey('escape', { ctrl: true })],
- negative: [createKey('e'), createKey('esc')],
+ positive: [createKey('escape')],
+ negative: [
+ createKey('e'),
+ createKey('esc'),
+ createKey('escape', { ctrl: true }),
+ ],
},
// Cursor movement
@@ -192,13 +196,21 @@ describe('keyMatchers', () => {
},
{
command: Command.PAGE_UP,
- positive: [createKey('pageup'), createKey('pageup', { shift: true })],
- negative: [createKey('pagedown'), createKey('up')],
+ positive: [createKey('pageup')],
+ negative: [
+ createKey('pagedown'),
+ createKey('up'),
+ createKey('pageup', { shift: true }),
+ ],
},
{
command: Command.PAGE_DOWN,
- positive: [createKey('pagedown'), createKey('pagedown', { ctrl: true })],
- negative: [createKey('pageup'), createKey('down')],
+ positive: [createKey('pagedown')],
+ negative: [
+ createKey('pageup'),
+ createKey('down'),
+ createKey('pagedown', { ctrl: true }),
+ ],
},
// History navigation
@@ -214,13 +226,21 @@ describe('keyMatchers', () => {
},
{
command: Command.NAVIGATION_UP,
- positive: [createKey('up'), createKey('up', { ctrl: true })],
- negative: [createKey('p'), createKey('u')],
+ positive: [createKey('up')],
+ negative: [
+ createKey('p'),
+ createKey('u'),
+ createKey('up', { ctrl: true }),
+ ],
},
{
command: Command.NAVIGATION_DOWN,
- positive: [createKey('down'), createKey('down', { ctrl: true })],
- negative: [createKey('n'), createKey('d')],
+ positive: [createKey('down')],
+ negative: [
+ createKey('n'),
+ createKey('d'),
+ createKey('down', { ctrl: true }),
+ ],
},
// Dialog navigation
@@ -333,14 +353,12 @@ describe('keyMatchers', () => {
},
{
command: Command.SUSPEND_APP,
- positive: [
- createKey('z', { ctrl: true }),
- createKey('z', { ctrl: true, shift: true }),
- ],
+ positive: [createKey('z', { ctrl: true })],
negative: [
createKey('z'),
createKey('y', { ctrl: true }),
createKey('z', { alt: true }),
+ createKey('z', { ctrl: true, shift: true }),
],
},
{
@@ -365,8 +383,12 @@ describe('keyMatchers', () => {
},
{
command: Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
- positive: [createKey('tab'), createKey('tab', { ctrl: true })],
- negative: [createKey('return'), createKey('space')],
+ positive: [createKey('tab')],
+ negative: [
+ createKey('return'),
+ createKey('space'),
+ createKey('tab', { ctrl: true }),
+ ],
},
{
command: Command.FOCUS_SHELL_INPUT,
@@ -413,22 +435,6 @@ describe('keyMatchers', () => {
});
});
});
-
- it('should properly handle ACCEPT_SUGGESTION_REVERSE_SEARCH cases', () => {
- expect(
- keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](
- createKey('return', { ctrl: true }),
- ),
- ).toBe(false); // ctrl must be false
- expect(
- keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](createKey('tab')),
- ).toBe(true);
- expect(
- keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](
- createKey('tab', { ctrl: true }),
- ),
- ).toBe(true); // modifiers ignored
- });
});
describe('Custom key bindings', () => {
diff --git a/packages/cli/src/ui/keyMatchers.ts b/packages/cli/src/ui/keyMatchers.ts
index 7c61db1016..f833e5ee09 100644
--- a/packages/cli/src/ui/keyMatchers.ts
+++ b/packages/cli/src/ui/keyMatchers.ts
@@ -13,16 +13,15 @@ import { Command, defaultKeyBindings } from '../config/keyBindings.js';
* Pure data-driven matching logic
*/
function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean {
- // Check modifiers - follow original logic:
- // undefined = ignore this modifier (original behavior)
+ // Check modifiers:
// true = modifier must be pressed
- // false = modifier must NOT be pressed
+ // false or undefined = modifier must NOT be pressed
return (
keyBinding.key === key.name &&
- (keyBinding.shift === undefined || key.shift === keyBinding.shift) &&
- (keyBinding.alt === undefined || key.alt === keyBinding.alt) &&
- (keyBinding.ctrl === undefined || key.ctrl === keyBinding.ctrl) &&
- (keyBinding.cmd === undefined || key.cmd === keyBinding.cmd)
+ !!key.shift === !!keyBinding.shift &&
+ !!key.alt === !!keyBinding.alt &&
+ !!key.ctrl === !!keyBinding.ctrl &&
+ !!key.cmd === !!keyBinding.cmd
);
}
diff --git a/packages/cli/src/ui/themes/ansi.ts b/packages/cli/src/ui/themes/builtin/dark/ansi-dark.ts
similarity index 95%
rename from packages/cli/src/ui/themes/ansi.ts
rename to packages/cli/src/ui/themes/builtin/dark/ansi-dark.ts
index 08c0a2c968..79db07f3b2 100644
--- a/packages/cli/src/ui/themes/ansi.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/ansi-dark.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { darkSemanticColors } from './semantic-tokens.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { darkSemanticColors } from '../../semantic-tokens.js';
const ansiColors: ColorsTheme = {
type: 'dark',
@@ -23,6 +23,7 @@ const ansiColors: ColorsTheme = {
Comment: 'gray',
Gray: 'gray',
DarkGray: 'gray',
+ FocusBackground: 'black',
GradientColors: ['cyan', 'green'],
};
diff --git a/packages/cli/src/ui/themes/atom-one-dark.ts b/packages/cli/src/ui/themes/builtin/dark/atom-one-dark.ts
similarity index 95%
rename from packages/cli/src/ui/themes/atom-one-dark.ts
rename to packages/cli/src/ui/themes/builtin/dark/atom-one-dark.ts
index 5217a8bf30..2abb98cb54 100644
--- a/packages/cli/src/ui/themes/atom-one-dark.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/atom-one-dark.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const atomOneDarkColors: ColorsTheme = {
type: 'dark',
diff --git a/packages/cli/src/ui/themes/ayu.ts b/packages/cli/src/ui/themes/builtin/dark/ayu-dark.ts
similarity index 94%
rename from packages/cli/src/ui/themes/ayu.ts
rename to packages/cli/src/ui/themes/builtin/dark/ayu-dark.ts
index 71798aacf2..d4084569e4 100644
--- a/packages/cli/src/ui/themes/ayu.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/ayu-dark.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const ayuDarkColors: ColorsTheme = {
type: 'dark',
diff --git a/packages/cli/src/ui/themes/default.ts b/packages/cli/src/ui/themes/builtin/dark/default-dark.ts
similarity index 97%
rename from packages/cli/src/ui/themes/default.ts
rename to packages/cli/src/ui/themes/builtin/dark/default-dark.ts
index e1d0247c01..817686395d 100644
--- a/packages/cli/src/ui/themes/default.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/default-dark.ts
@@ -1,10 +1,10 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { darkTheme, Theme } from './theme.js';
+import { darkTheme, Theme } from '../../theme.js';
export const DefaultDark: Theme = new Theme(
'Default',
diff --git a/packages/cli/src/ui/themes/dracula.ts b/packages/cli/src/ui/themes/builtin/dark/dracula-dark.ts
similarity index 94%
rename from packages/cli/src/ui/themes/dracula.ts
rename to packages/cli/src/ui/themes/builtin/dark/dracula-dark.ts
index 2cd2802c45..3a9afcea75 100644
--- a/packages/cli/src/ui/themes/dracula.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/dracula-dark.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const draculaColors: ColorsTheme = {
type: 'dark',
diff --git a/packages/cli/src/ui/themes/github-dark.ts b/packages/cli/src/ui/themes/builtin/dark/github-dark.ts
similarity index 95%
rename from packages/cli/src/ui/themes/github-dark.ts
rename to packages/cli/src/ui/themes/builtin/dark/github-dark.ts
index 28c14f598d..27b804857d 100644
--- a/packages/cli/src/ui/themes/github-dark.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/github-dark.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const githubDarkColors: ColorsTheme = {
type: 'dark',
diff --git a/packages/cli/src/ui/themes/holiday.ts b/packages/cli/src/ui/themes/builtin/dark/holiday-dark.ts
similarity index 95%
rename from packages/cli/src/ui/themes/holiday.ts
rename to packages/cli/src/ui/themes/builtin/dark/holiday-dark.ts
index b3e72b1cc1..e49ae046d0 100644
--- a/packages/cli/src/ui/themes/holiday.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/holiday-dark.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const holidayColors: ColorsTheme = {
type: 'dark',
@@ -23,6 +23,7 @@ const holidayColors: ColorsTheme = {
Comment: '#8FBC8F',
Gray: '#D7F5D3',
DarkGray: interpolateColor('#D7F5D3', '#151B18', 0.5),
+ FocusColor: '#33F9FF', // AccentCyan for neon pop
GradientColors: ['#FF0000', '#FFFFFF', '#008000'],
};
diff --git a/packages/cli/src/ui/themes/shades-of-purple.ts b/packages/cli/src/ui/themes/builtin/dark/shades-of-purple-dark.ts
similarity index 98%
rename from packages/cli/src/ui/themes/shades-of-purple.ts
rename to packages/cli/src/ui/themes/builtin/dark/shades-of-purple-dark.ts
index 6e11aaec8b..b9e45fd924 100644
--- a/packages/cli/src/ui/themes/shades-of-purple.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/shades-of-purple-dark.ts
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -8,8 +8,8 @@
* Shades of Purple Theme — for Highlight.js.
* @author Ahmad Awais
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const shadesOfPurpleColors: ColorsTheme = {
type: 'dark',
diff --git a/packages/cli/src/ui/themes/solarized-dark.ts b/packages/cli/src/ui/themes/builtin/dark/solarized-dark.ts
similarity index 91%
rename from packages/cli/src/ui/themes/solarized-dark.ts
rename to packages/cli/src/ui/themes/builtin/dark/solarized-dark.ts
index c2bf3db34d..44168138f7 100644
--- a/packages/cli/src/ui/themes/solarized-dark.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/solarized-dark.ts
@@ -1,11 +1,12 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { type SemanticColors } from './semantic-tokens.js';
+import { type ColorsTheme, Theme, interpolateColor } from '../../theme.js';
+import { type SemanticColors } from '../../semantic-tokens.js';
+import { DEFAULT_SELECTION_OPACITY } from '../../../constants.js';
const solarizedDarkColors: ColorsTheme = {
type: 'dark',
@@ -38,6 +39,7 @@ const semanticColors: SemanticColors = {
primary: '#002b36',
message: '#073642',
input: '#073642',
+ focus: interpolateColor('#002b36', '#859900', DEFAULT_SELECTION_OPACITY),
diff: {
added: '#00382f',
removed: '#3d0115',
@@ -45,13 +47,14 @@ const semanticColors: SemanticColors = {
},
border: {
default: '#073642',
- focused: '#586e75',
},
ui: {
comment: '#586e75',
symbol: '#93a1a1',
+ active: '#268bd2',
dark: '#073642',
- gradient: ['#268bd2', '#2aa198'],
+ focus: '#859900',
+ gradient: ['#268bd2', '#2aa198', '#859900'],
},
status: {
success: '#859900',
diff --git a/packages/cli/src/ui/themes/ansi-light.ts b/packages/cli/src/ui/themes/builtin/light/ansi-light.ts
similarity index 94%
rename from packages/cli/src/ui/themes/ansi-light.ts
rename to packages/cli/src/ui/themes/builtin/light/ansi-light.ts
index 201cc500e5..0d3b2003f8 100644
--- a/packages/cli/src/ui/themes/ansi-light.ts
+++ b/packages/cli/src/ui/themes/builtin/light/ansi-light.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { lightSemanticColors } from './semantic-tokens.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { lightSemanticColors } from '../../semantic-tokens.js';
const ansiLightColors: ColorsTheme = {
type: 'light',
diff --git a/packages/cli/src/ui/themes/ayu-light.ts b/packages/cli/src/ui/themes/builtin/light/ayu-light.ts
similarity index 95%
rename from packages/cli/src/ui/themes/ayu-light.ts
rename to packages/cli/src/ui/themes/builtin/light/ayu-light.ts
index 393ed44ba6..6c5a7616e1 100644
--- a/packages/cli/src/ui/themes/ayu-light.ts
+++ b/packages/cli/src/ui/themes/builtin/light/ayu-light.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const ayuLightColors: ColorsTheme = {
type: 'light',
diff --git a/packages/cli/src/ui/themes/default-light.ts b/packages/cli/src/ui/themes/builtin/light/default-light.ts
similarity index 96%
rename from packages/cli/src/ui/themes/default-light.ts
rename to packages/cli/src/ui/themes/builtin/light/default-light.ts
index 1803e7fae0..2d60f6d2bb 100644
--- a/packages/cli/src/ui/themes/default-light.ts
+++ b/packages/cli/src/ui/themes/builtin/light/default-light.ts
@@ -1,10 +1,10 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { lightTheme, Theme } from './theme.js';
+import { lightTheme, Theme } from '../../theme.js';
export const DefaultLight: Theme = new Theme(
'Default Light',
diff --git a/packages/cli/src/ui/themes/github-light.ts b/packages/cli/src/ui/themes/builtin/light/github-light.ts
similarity index 94%
rename from packages/cli/src/ui/themes/github-light.ts
rename to packages/cli/src/ui/themes/builtin/light/github-light.ts
index 264a9d7a88..a794a9312e 100644
--- a/packages/cli/src/ui/themes/github-light.ts
+++ b/packages/cli/src/ui/themes/builtin/light/github-light.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const githubLightColors: ColorsTheme = {
type: 'light',
@@ -23,6 +23,7 @@ const githubLightColors: ColorsTheme = {
Comment: '#998',
Gray: '#999',
DarkGray: interpolateColor('#999', '#f8f8f8', 0.5),
+ FocusColor: '#458', // AccentBlue for GitHub branding
GradientColors: ['#458', '#008080'],
};
diff --git a/packages/cli/src/ui/themes/googlecode.ts b/packages/cli/src/ui/themes/builtin/light/googlecode-light.ts
similarity index 95%
rename from packages/cli/src/ui/themes/googlecode.ts
rename to packages/cli/src/ui/themes/builtin/light/googlecode-light.ts
index 1795451c91..67f5618d60 100644
--- a/packages/cli/src/ui/themes/googlecode.ts
+++ b/packages/cli/src/ui/themes/builtin/light/googlecode-light.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme, lightTheme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme, lightTheme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const googleCodeColors: ColorsTheme = {
type: 'light',
diff --git a/packages/cli/src/ui/themes/solarized-light.ts b/packages/cli/src/ui/themes/builtin/light/solarized-light.ts
similarity index 91%
rename from packages/cli/src/ui/themes/solarized-light.ts
rename to packages/cli/src/ui/themes/builtin/light/solarized-light.ts
index 297238866d..b30dbb7b7f 100644
--- a/packages/cli/src/ui/themes/solarized-light.ts
+++ b/packages/cli/src/ui/themes/builtin/light/solarized-light.ts
@@ -1,11 +1,12 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { type SemanticColors } from './semantic-tokens.js';
+import { type ColorsTheme, Theme, interpolateColor } from '../../theme.js';
+import { type SemanticColors } from '../../semantic-tokens.js';
+import { DEFAULT_SELECTION_OPACITY } from '../../../constants.js';
const solarizedLightColors: ColorsTheme = {
type: 'light',
@@ -38,6 +39,7 @@ const semanticColors: SemanticColors = {
primary: '#fdf6e3',
message: '#eee8d5',
input: '#eee8d5',
+ focus: interpolateColor('#fdf6e3', '#859900', DEFAULT_SELECTION_OPACITY),
diff: {
added: '#d7f2d7',
removed: '#f2d7d7',
@@ -45,13 +47,14 @@ const semanticColors: SemanticColors = {
},
border: {
default: '#eee8d5',
- focused: '#93a1a1',
},
ui: {
comment: '#93a1a1',
symbol: '#586e75',
+ active: '#268bd2',
dark: '#eee8d5',
- gradient: ['#268bd2', '#2aa198'],
+ focus: '#859900',
+ gradient: ['#268bd2', '#2aa198', '#859900'],
},
status: {
success: '#859900',
diff --git a/packages/cli/src/ui/themes/xcode.ts b/packages/cli/src/ui/themes/builtin/light/xcode-light.ts
similarity index 94%
rename from packages/cli/src/ui/themes/xcode.ts
rename to packages/cli/src/ui/themes/builtin/light/xcode-light.ts
index 5d20f35c36..71c9442f7f 100644
--- a/packages/cli/src/ui/themes/xcode.ts
+++ b/packages/cli/src/ui/themes/builtin/light/xcode-light.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const xcodeColors: ColorsTheme = {
type: 'light',
@@ -23,6 +23,7 @@ const xcodeColors: ColorsTheme = {
Comment: '#007400',
Gray: '#c0c0c0',
DarkGray: interpolateColor('#c0c0c0', '#fff', 0.5),
+ FocusColor: '#1c00cf', // AccentBlue for more vibrance
GradientColors: ['#1c00cf', '#007400'],
};
diff --git a/packages/cli/src/ui/themes/no-color.ts b/packages/cli/src/ui/themes/builtin/no-color.ts
similarity index 90%
rename from packages/cli/src/ui/themes/no-color.ts
rename to packages/cli/src/ui/themes/builtin/no-color.ts
index 30e34c2c12..6f1a099454 100644
--- a/packages/cli/src/ui/themes/no-color.ts
+++ b/packages/cli/src/ui/themes/builtin/no-color.ts
@@ -1,12 +1,12 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import type { ColorsTheme } from './theme.js';
-import { Theme } from './theme.js';
-import type { SemanticColors } from './semantic-tokens.js';
+import type { ColorsTheme } from '../theme.js';
+import { Theme } from '../theme.js';
+import type { SemanticColors } from '../semantic-tokens.js';
const noColorColorsTheme: ColorsTheme = {
type: 'ansi',
@@ -26,6 +26,7 @@ const noColorColorsTheme: ColorsTheme = {
DarkGray: '',
InputBackground: '',
MessageBackground: '',
+ FocusBackground: '',
};
const noColorSemanticColors: SemanticColors = {
@@ -40,6 +41,7 @@ const noColorSemanticColors: SemanticColors = {
primary: '',
message: '',
input: '',
+ focus: '',
diff: {
added: '',
removed: '',
@@ -47,12 +49,13 @@ const noColorSemanticColors: SemanticColors = {
},
border: {
default: '',
- focused: '',
},
ui: {
comment: '',
symbol: '',
+ active: '',
dark: '',
+ focus: '',
gradient: [],
},
status: {
diff --git a/packages/cli/src/ui/themes/color-utils.ts b/packages/cli/src/ui/themes/color-utils.ts
index 476703a7fc..2901bd6b2e 100644
--- a/packages/cli/src/ui/themes/color-utils.ts
+++ b/packages/cli/src/ui/themes/color-utils.ts
@@ -4,38 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { debugLogger } from '@google/gemini-cli-core';
-import tinygradient from 'tinygradient';
-import tinycolor from 'tinycolor2';
+import {
+ resolveColor,
+ interpolateColor,
+ getThemeTypeFromBackgroundColor,
+ INK_SUPPORTED_NAMES,
+ INK_NAME_TO_HEX_MAP,
+ getLuminance,
+ CSS_NAME_TO_HEX_MAP,
+} from './theme.js';
-// Define the set of Ink's named colors for quick lookup
-export const INK_SUPPORTED_NAMES = new Set([
- 'black',
- 'red',
- 'green',
- 'yellow',
- 'blue',
- 'cyan',
- 'magenta',
- 'white',
- 'gray',
- 'grey',
- 'blackbright',
- 'redbright',
- 'greenbright',
- 'yellowbright',
- 'bluebright',
- 'cyanbright',
- 'magentabright',
- 'whitebright',
-]);
-
-// Use tinycolor's built-in names map for CSS colors, excluding ones Ink supports
-export const CSS_NAME_TO_HEX_MAP = Object.fromEntries(
- Object.entries(tinycolor.names)
- .filter(([name]) => !INK_SUPPORTED_NAMES.has(name))
- .map(([name, hex]) => [name, `#${hex}`]),
-);
+export {
+ resolveColor,
+ interpolateColor,
+ getThemeTypeFromBackgroundColor,
+ INK_SUPPORTED_NAMES,
+ INK_NAME_TO_HEX_MAP,
+ getLuminance,
+ CSS_NAME_TO_HEX_MAP,
+};
/**
* Checks if a color string is valid (hex, Ink-supported color name, or CSS color name).
@@ -66,45 +53,6 @@ export function isValidColor(color: string): boolean {
return false;
}
-/**
- * Resolves a CSS color value (name or hex) into an Ink-compatible color string.
- * @param colorValue The raw color string (e.g., 'blue', '#ff0000', 'darkkhaki').
- * @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
- */
-export function resolveColor(colorValue: string): string | undefined {
- const lowerColor = colorValue.toLowerCase();
-
- // 1. Check if it's already a hex code and valid
- if (lowerColor.startsWith('#')) {
- if (/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
- return lowerColor;
- } else {
- return undefined;
- }
- }
-
- // Handle hex codes without #
- if (/^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
- return `#${lowerColor}`;
- }
-
- // 2. Check if it's an Ink supported name (lowercase)
- if (INK_SUPPORTED_NAMES.has(lowerColor)) {
- return lowerColor; // Use Ink name directly
- }
-
- // 3. Check if it's a known CSS name we can map to hex
- if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
- return CSS_NAME_TO_HEX_MAP[lowerColor]; // Use mapped hex
- }
-
- // 4. Could not resolve
- debugLogger.warn(
- `[ColorUtils] Could not resolve color "${colorValue}" to an Ink-compatible format.`,
- );
- return undefined;
-}
-
/**
* Returns a "safe" background color to use in low-color terminals if the
* terminal background is a standard black or white.
@@ -132,73 +80,6 @@ export function getSafeLowColorBackground(
return undefined;
}
-export function interpolateColor(
- color1: string,
- color2: string,
- factor: number,
-) {
- if (factor <= 0 && color1) {
- return color1;
- }
- if (factor >= 1 && color2) {
- return color2;
- }
- if (!color1 || !color2) {
- return '';
- }
- const gradient = tinygradient(color1, color2);
- const color = gradient.rgbAt(factor);
- return color.toHexString();
-}
-
-export function getThemeTypeFromBackgroundColor(
- backgroundColor: string | undefined,
-): 'light' | 'dark' | undefined {
- if (!backgroundColor) {
- return undefined;
- }
-
- const resolvedColor = resolveColor(backgroundColor);
- if (!resolvedColor) {
- return undefined;
- }
-
- const luminance = getLuminance(resolvedColor);
- return luminance > 128 ? 'light' : 'dark';
-}
-
-// Mapping for ANSI bright colors that are not in tinycolor's standard CSS names
-export const INK_NAME_TO_HEX_MAP: Readonly> = {
- blackbright: '#555555',
- redbright: '#ff5555',
- greenbright: '#55ff55',
- yellowbright: '#ffff55',
- bluebright: '#5555ff',
- magentabright: '#ff55ff',
- cyanbright: '#55ffff',
- whitebright: '#ffffff',
-};
-
-/**
- * Calculates the relative luminance of a color.
- * See https://www.w3.org/TR/WCAG20/#relativeluminancedef
- *
- * @param color Color string (hex or Ink-supported name)
- * @returns Luminance value (0-255)
- */
-export function getLuminance(color: string): number {
- const resolved = color.toLowerCase();
- const hex = INK_NAME_TO_HEX_MAP[resolved] || resolved;
-
- const colorObj = tinycolor(hex);
- if (!colorObj.isValid()) {
- return 0;
- }
-
- // tinycolor returns 0-1, we need 0-255
- return colorObj.getLuminance() * 255;
-}
-
// Hysteresis thresholds to prevent flickering when the background color
// is ambiguous (near the midpoint).
export const LIGHT_THEME_LUMINANCE_THRESHOLD = 140;
diff --git a/packages/cli/src/ui/themes/semantic-tokens.ts b/packages/cli/src/ui/themes/semantic-tokens.ts
index ca46fadb56..b5e9140156 100644
--- a/packages/cli/src/ui/themes/semantic-tokens.ts
+++ b/packages/cli/src/ui/themes/semantic-tokens.ts
@@ -18,6 +18,7 @@ export interface SemanticColors {
primary: string;
message: string;
input: string;
+ focus: string;
diff: {
added: string;
removed: string;
@@ -25,12 +26,13 @@ export interface SemanticColors {
};
border: {
default: string;
- focused: string;
};
ui: {
comment: string;
symbol: string;
+ active: string;
dark: string;
+ focus: string;
gradient: string[] | undefined;
};
status: {
@@ -52,6 +54,7 @@ export const lightSemanticColors: SemanticColors = {
primary: lightTheme.Background,
message: lightTheme.MessageBackground!,
input: lightTheme.InputBackground!,
+ focus: lightTheme.FocusBackground!,
diff: {
added: lightTheme.DiffAdded,
removed: lightTheme.DiffRemoved,
@@ -59,12 +62,13 @@ export const lightSemanticColors: SemanticColors = {
},
border: {
default: lightTheme.DarkGray,
- focused: lightTheme.AccentBlue,
},
ui: {
comment: lightTheme.Comment,
symbol: lightTheme.Gray,
+ active: lightTheme.AccentBlue,
dark: lightTheme.DarkGray,
+ focus: lightTheme.AccentGreen,
gradient: lightTheme.GradientColors,
},
status: {
@@ -86,6 +90,7 @@ export const darkSemanticColors: SemanticColors = {
primary: darkTheme.Background,
message: darkTheme.MessageBackground!,
input: darkTheme.InputBackground!,
+ focus: darkTheme.FocusBackground!,
diff: {
added: darkTheme.DiffAdded,
removed: darkTheme.DiffRemoved,
@@ -93,12 +98,13 @@ export const darkSemanticColors: SemanticColors = {
},
border: {
default: darkTheme.DarkGray,
- focused: darkTheme.AccentBlue,
},
ui: {
comment: darkTheme.Comment,
symbol: darkTheme.Gray,
+ active: darkTheme.AccentBlue,
dark: darkTheme.DarkGray,
+ focus: darkTheme.AccentGreen,
gradient: darkTheme.GradientColors,
},
status: {
diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts
index da54ba5d3e..7456746d95 100644
--- a/packages/cli/src/ui/themes/theme-manager.ts
+++ b/packages/cli/src/ui/themes/theme-manager.ts
@@ -1,42 +1,44 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { AyuDark } from './ayu.js';
-import { AyuLight } from './ayu-light.js';
-import { AtomOneDark } from './atom-one-dark.js';
-import { Dracula } from './dracula.js';
-import { GitHubDark } from './github-dark.js';
-import { GitHubLight } from './github-light.js';
-import { GoogleCode } from './googlecode.js';
-import { Holiday } from './holiday.js';
-import { DefaultLight } from './default-light.js';
-import { DefaultDark } from './default.js';
-import { ShadesOfPurple } from './shades-of-purple.js';
-import { SolarizedDark } from './solarized-dark.js';
-import { SolarizedLight } from './solarized-light.js';
-import { XCode } from './xcode.js';
+import { AyuDark } from './builtin/dark/ayu-dark.js';
+import { AyuLight } from './builtin/light/ayu-light.js';
+import { AtomOneDark } from './builtin/dark/atom-one-dark.js';
+import { Dracula } from './builtin/dark/dracula-dark.js';
+import { GitHubDark } from './builtin/dark/github-dark.js';
+import { GitHubLight } from './builtin/light/github-light.js';
+import { GoogleCode } from './builtin/light/googlecode-light.js';
+import { Holiday } from './builtin/dark/holiday-dark.js';
+import { DefaultLight } from './builtin/light/default-light.js';
+import { DefaultDark } from './builtin/dark/default-dark.js';
+import { ShadesOfPurple } from './builtin/dark/shades-of-purple-dark.js';
+import { SolarizedDark } from './builtin/dark/solarized-dark.js';
+import { SolarizedLight } from './builtin/light/solarized-light.js';
+import { XCode } from './builtin/light/xcode-light.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Theme, ThemeType, ColorsTheme } from './theme.js';
import type { CustomTheme } from '@google/gemini-cli-core';
-import { createCustomTheme, validateCustomTheme } from './theme.js';
-import type { SemanticColors } from './semantic-tokens.js';
import {
+ createCustomTheme,
+ validateCustomTheme,
interpolateColor,
getThemeTypeFromBackgroundColor,
resolveColor,
-} from './color-utils.js';
+} from './theme.js';
+import type { SemanticColors } from './semantic-tokens.js';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
+ DEFAULT_SELECTION_OPACITY,
DEFAULT_BORDER_OPACITY,
} from '../constants.js';
-import { ANSI } from './ansi.js';
-import { ANSILight } from './ansi-light.js';
-import { NoColorTheme } from './no-color.js';
+import { ANSI } from './builtin/dark/ansi-dark.js';
+import { ANSILight } from './builtin/light/ansi-light.js';
+import { NoColorTheme } from './builtin/no-color.js';
import process from 'node:process';
import { debugLogger, homedir } from '@google/gemini-cli-core';
@@ -369,6 +371,11 @@ class ThemeManager {
colors.Gray,
DEFAULT_BACKGROUND_OPACITY,
),
+ FocusBackground: interpolateColor(
+ this.terminalBackground,
+ activeTheme.colors.FocusColor ?? activeTheme.colors.AccentGreen,
+ DEFAULT_SELECTION_OPACITY,
+ ),
};
} else {
this.cachedColors = colors;
@@ -402,6 +409,7 @@ class ThemeManager {
primary: this.terminalBackground,
message: colors.MessageBackground!,
input: colors.InputBackground!,
+ focus: colors.FocusBackground!,
},
border: {
...semanticColors.border,
@@ -410,6 +418,7 @@ class ThemeManager {
ui: {
...semanticColors.ui,
dark: colors.DarkGray,
+ focus: colors.FocusColor ?? colors.AccentGreen,
},
};
} else {
diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts
index c4277cd834..da7bccf1b2 100644
--- a/packages/cli/src/ui/themes/theme.ts
+++ b/packages/cli/src/ui/themes/theme.ts
@@ -8,18 +8,153 @@ import type { CSSProperties } from 'react';
import type { SemanticColors } from './semantic-tokens.js';
-import {
- resolveColor,
- interpolateColor,
- getThemeTypeFromBackgroundColor,
-} from './color-utils.js';
-
import type { CustomTheme } from '@google/gemini-cli-core';
import {
- DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
+ DEFAULT_SELECTION_OPACITY,
DEFAULT_BORDER_OPACITY,
} from '../constants.js';
+import tinygradient from 'tinygradient';
+import tinycolor from 'tinycolor2';
+
+// Define the set of Ink's named colors for quick lookup
+export const INK_SUPPORTED_NAMES = new Set([
+ 'black',
+ 'red',
+ 'green',
+ 'yellow',
+ 'blue',
+ 'cyan',
+ 'magenta',
+ 'white',
+ 'gray',
+ 'grey',
+ 'blackbright',
+ 'redbright',
+ 'greenbright',
+ 'yellowbright',
+ 'bluebright',
+ 'cyanbright',
+ 'magentabright',
+ 'whitebright',
+]);
+
+// Use tinycolor's built-in names map for CSS colors, excluding ones Ink supports
+export const CSS_NAME_TO_HEX_MAP = Object.fromEntries(
+ Object.entries(tinycolor.names)
+ .filter(([name]) => !INK_SUPPORTED_NAMES.has(name))
+ .map(([name, hex]) => [name, `#${hex}`]),
+);
+
+// Mapping for ANSI bright colors that are not in tinycolor's standard CSS names
+export const INK_NAME_TO_HEX_MAP: Readonly> = {
+ blackbright: '#555555',
+ redbright: '#ff5555',
+ greenbright: '#55ff55',
+ yellowbright: '#ffff55',
+ bluebright: '#5555ff',
+ magentabright: '#ff55ff',
+ cyanbright: '#55ffff',
+ whitebright: '#ffffff',
+};
+
+/**
+ * Calculates the relative luminance of a color.
+ * See https://www.w3.org/TR/WCAG20/#relativeluminancedef
+ *
+ * @param color Color string (hex or Ink-supported name)
+ * @returns Luminance value (0-255)
+ */
+export function getLuminance(color: string): number {
+ const resolved = color.toLowerCase();
+ const hex = INK_NAME_TO_HEX_MAP[resolved] || resolved;
+
+ const colorObj = tinycolor(hex);
+ if (!colorObj.isValid()) {
+ return 0;
+ }
+
+ // tinycolor returns 0-1, we need 0-255
+ return colorObj.getLuminance() * 255;
+}
+
+/**
+ * Resolves a CSS color value (name or hex) into an Ink-compatible color string.
+ * @param colorValue The raw color string (e.g., 'blue', '#ff0000', 'darkkhaki').
+ * @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
+ */
+export function resolveColor(colorValue: string): string | undefined {
+ const lowerColor = colorValue.toLowerCase();
+
+ // 1. Check if it's already a hex code and valid
+ if (lowerColor.startsWith('#')) {
+ if (/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
+ return lowerColor;
+ } else {
+ return undefined;
+ }
+ }
+
+ // Handle hex codes without #
+ if (/^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
+ return `#${lowerColor}`;
+ }
+
+ // 2. Check if it's an Ink supported name (lowercase)
+ if (INK_SUPPORTED_NAMES.has(lowerColor)) {
+ return lowerColor; // Use Ink name directly
+ }
+
+ // 3. Check if it's a known CSS name we can map to hex
+ // We can't import CSS_NAME_TO_HEX_MAP here due to circular deps,
+ // but we can use tinycolor directly for named colors.
+ const colorObj = tinycolor(lowerColor);
+ if (colorObj.isValid()) {
+ return colorObj.toHexString();
+ }
+
+ // 4. Could not resolve
+ return undefined;
+}
+
+export function interpolateColor(
+ color1: string,
+ color2: string,
+ factor: number,
+) {
+ if (factor <= 0 && color1) {
+ return color1;
+ }
+ if (factor >= 1 && color2) {
+ return color2;
+ }
+ if (!color1 || !color2) {
+ return '';
+ }
+ try {
+ const gradient = tinygradient(color1, color2);
+ const color = gradient.rgbAt(factor);
+ return color.toHexString();
+ } catch (_e) {
+ return color1;
+ }
+}
+
+export function getThemeTypeFromBackgroundColor(
+ backgroundColor: string | undefined,
+): 'light' | 'dark' | undefined {
+ if (!backgroundColor) {
+ return undefined;
+ }
+
+ const resolvedColor = resolveColor(backgroundColor);
+ if (!resolvedColor) {
+ return undefined;
+ }
+
+ const luminance = getLuminance(resolvedColor);
+ return luminance > 128 ? 'light' : 'dark';
+}
export type { CustomTheme };
@@ -43,64 +178,52 @@ export interface ColorsTheme {
DarkGray: string;
InputBackground?: string;
MessageBackground?: string;
+ FocusBackground?: string;
+ FocusColor?: string;
GradientColors?: string[];
}
export const lightTheme: ColorsTheme = {
type: 'light',
- Background: '#FAFAFA',
- Foreground: '',
- LightBlue: '#89BDCD',
- AccentBlue: '#3B82F6',
- AccentPurple: '#8B5CF6',
- AccentCyan: '#06B6D4',
- AccentGreen: '#3CA84B',
- AccentYellow: '#D5A40A',
- AccentRed: '#DD4C4C',
- DiffAdded: '#C6EAD8',
- DiffRemoved: '#FFCCCC',
- Comment: '#008000',
- Gray: '#97a0b0',
- DarkGray: interpolateColor('#FAFAFA', '#97a0b0', DEFAULT_BORDER_OPACITY),
- InputBackground: interpolateColor(
- '#FAFAFA',
- '#97a0b0',
- DEFAULT_INPUT_BACKGROUND_OPACITY,
- ),
- MessageBackground: interpolateColor(
- '#FAFAFA',
- '#97a0b0',
- DEFAULT_BACKGROUND_OPACITY,
- ),
+ Background: '#FFFFFF',
+ Foreground: '#000000',
+ LightBlue: '#005FAF',
+ AccentBlue: '#005FAF',
+ AccentPurple: '#5F00FF',
+ AccentCyan: '#005F87',
+ AccentGreen: '#005F00',
+ AccentYellow: '#875F00',
+ AccentRed: '#AF0000',
+ DiffAdded: '#D7FFD7',
+ DiffRemoved: '#FFD7D7',
+ Comment: '#008700',
+ Gray: '#5F5F5F',
+ DarkGray: '#5F5F5F',
+ InputBackground: '#E4E4E4',
+ MessageBackground: '#FAFAFA',
+ FocusBackground: '#D7FFD7',
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
export const darkTheme: ColorsTheme = {
type: 'dark',
- Background: '#1E1E2E',
- Foreground: '',
- LightBlue: '#ADD8E6',
- AccentBlue: '#89B4FA',
- AccentPurple: '#CBA6F7',
- AccentCyan: '#89DCEB',
- AccentGreen: '#A6E3A1',
- AccentYellow: '#F9E2AF',
- AccentRed: '#F38BA8',
- DiffAdded: '#28350B',
- DiffRemoved: '#430000',
- Comment: '#6C7086',
- Gray: '#6C7086',
- DarkGray: interpolateColor('#1E1E2E', '#6C7086', DEFAULT_BORDER_OPACITY),
- InputBackground: interpolateColor(
- '#1E1E2E',
- '#6C7086',
- DEFAULT_INPUT_BACKGROUND_OPACITY,
- ),
- MessageBackground: interpolateColor(
- '#1E1E2E',
- '#6C7086',
- DEFAULT_BACKGROUND_OPACITY,
- ),
+ Background: '#000000',
+ Foreground: '#FFFFFF',
+ LightBlue: '#AFD7D7',
+ AccentBlue: '#87AFFF',
+ AccentPurple: '#D7AFFF',
+ AccentCyan: '#87D7D7',
+ AccentGreen: '#D7FFD7',
+ AccentYellow: '#FFFFAF',
+ AccentRed: '#FF87AF',
+ DiffAdded: '#005F00',
+ DiffRemoved: '#5F0000',
+ Comment: '#AFAFAF',
+ Gray: '#AFAFAF',
+ DarkGray: '#878787',
+ InputBackground: '#5F5F5F',
+ MessageBackground: '#5F5F5F',
+ FocusBackground: '#005F00',
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -122,6 +245,7 @@ export const ansiTheme: ColorsTheme = {
DarkGray: 'gray',
InputBackground: 'black',
MessageBackground: 'black',
+ FocusBackground: 'black',
};
export class Theme {
@@ -164,7 +288,7 @@ export class Theme {
interpolateColor(
this.colors.Background,
this.colors.Gray,
- DEFAULT_BACKGROUND_OPACITY,
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
),
input:
this.colors.InputBackground ??
@@ -173,6 +297,13 @@ export class Theme {
this.colors.Gray,
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
+ focus:
+ this.colors.FocusBackground ??
+ interpolateColor(
+ this.colors.Background,
+ this.colors.FocusColor ?? this.colors.AccentGreen,
+ DEFAULT_SELECTION_OPACITY,
+ ),
diff: {
added: this.colors.DiffAdded,
removed: this.colors.DiffRemoved,
@@ -180,12 +311,13 @@ export class Theme {
},
border: {
default: this.colors.DarkGray,
- focused: this.colors.AccentBlue,
},
ui: {
comment: this.colors.Gray,
symbol: this.colors.AccentCyan,
+ active: this.colors.AccentBlue,
dark: this.colors.DarkGray,
+ focus: this.colors.FocusColor ?? this.colors.AccentGreen,
gradient: this.colors.GradientColors,
},
status: {
@@ -292,8 +424,14 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
MessageBackground: interpolateColor(
customTheme.background?.primary ?? customTheme.Background ?? '',
customTheme.text?.secondary ?? customTheme.Gray ?? '',
- DEFAULT_BACKGROUND_OPACITY,
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
),
+ FocusBackground: interpolateColor(
+ customTheme.background?.primary ?? customTheme.Background ?? '',
+ customTheme.status?.success ?? customTheme.AccentGreen ?? '#3CA84B', // Fallback to a default green if not found
+ DEFAULT_SELECTION_OPACITY,
+ ),
+ FocusColor: customTheme.ui?.focus ?? customTheme.AccentGreen,
GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors,
};
@@ -450,6 +588,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
primary: customTheme.background?.primary ?? colors.Background,
message: colors.MessageBackground!,
input: colors.InputBackground!,
+ focus: colors.FocusBackground!,
diff: {
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,
@@ -457,12 +596,13 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
},
border: {
default: colors.DarkGray,
- focused: customTheme.border?.focused ?? colors.AccentBlue,
},
ui: {
comment: customTheme.ui?.comment ?? colors.Comment,
symbol: customTheme.ui?.symbol ?? colors.Gray,
+ active: customTheme.ui?.active ?? colors.AccentBlue,
dark: colors.DarkGray,
+ focus: colors.FocusColor ?? colors.AccentGreen,
gradient: customTheme.ui?.gradient ?? colors.GradientColors,
},
status: {
diff --git a/packages/cli/src/ui/utils/CodeColorizer.test.tsx b/packages/cli/src/ui/utils/CodeColorizer.test.tsx
index 2f231e1bb3..7fc120b58b 100644
--- a/packages/cli/src/ui/utils/CodeColorizer.test.tsx
+++ b/packages/cli/src/ui/utils/CodeColorizer.test.tsx
@@ -50,4 +50,36 @@ describe('colorizeCode', () => {
expect(lastFrame()).toMatch(/line 1\s*\n\s*\n\s*line 3/);
unmount();
});
+
+ it('does not let colors from ansi escape codes leak into colorized code', async () => {
+ const code = 'line 1\n\x1b[41mline 2 with red background\x1b[0m\nline 3';
+ const settings = new LoadedSettings(
+ { path: '', settings: {}, originalSettings: {} },
+ { path: '', settings: {}, originalSettings: {} },
+ {
+ path: '',
+ settings: { ui: { useAlternateBuffer: true, showLineNumbers: false } },
+ originalSettings: {
+ ui: { useAlternateBuffer: true, showLineNumbers: false },
+ },
+ },
+ { path: '', settings: {}, originalSettings: {} },
+ true,
+ [],
+ );
+
+ const result = colorizeCode({
+ code,
+ language: 'javascript',
+ maxWidth: 80,
+ settings,
+ hideLineNumbers: true,
+ });
+
+ const renderResult = renderWithProviders(<>{result}>);
+ await renderResult.waitUntilReady();
+
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
+ });
});
diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx
index 56e34eefa4..e5ce2562af 100644
--- a/packages/cli/src/ui/utils/CodeColorizer.tsx
+++ b/packages/cli/src/ui/utils/CodeColorizer.tsx
@@ -14,6 +14,7 @@ import type {
ElementContent,
RootContent,
} from 'hast';
+import stripAnsi from 'strip-ansi';
import { themeManager } from '../themes/theme-manager.js';
import type { Theme } from '../themes/theme.js';
import {
@@ -98,16 +99,17 @@ function highlightAndRenderLine(
theme: Theme,
): React.ReactNode {
try {
+ const strippedLine = stripAnsi(line);
const getHighlightedLine = () =>
!language || !lowlight.registered(language)
- ? lowlight.highlightAuto(line)
- : lowlight.highlight(language, line);
+ ? lowlight.highlightAuto(strippedLine)
+ : lowlight.highlight(language, strippedLine);
const renderedNode = renderHastNode(getHighlightedLine(), theme, undefined);
- return renderedNode !== null ? renderedNode : line;
+ return renderedNode !== null ? renderedNode : strippedLine;
} catch (_error) {
- return line;
+ return stripAnsi(line);
}
}
@@ -238,7 +240,7 @@ export function colorizeCode({
{`${index + 1}`}
)}
- {line}
+ {stripAnsi(line)}
));
diff --git a/packages/cli/src/ui/utils/__snapshots__/CodeColorizer-colorizeCode-does-not-let-colors-from-ansi-escape-codes-leak-into-colorized-code.snap.svg b/packages/cli/src/ui/utils/__snapshots__/CodeColorizer-colorizeCode-does-not-let-colors-from-ansi-escape-codes-leak-into-colorized-code.snap.svg
new file mode 100644
index 0000000000..89450d03e0
--- /dev/null
+++ b/packages/cli/src/ui/utils/__snapshots__/CodeColorizer-colorizeCode-does-not-let-colors-from-ansi-escape-codes-leak-into-colorized-code.snap.svg
@@ -0,0 +1,16 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/utils/__snapshots__/CodeColorizer.test.tsx.snap b/packages/cli/src/ui/utils/__snapshots__/CodeColorizer.test.tsx.snap
new file mode 100644
index 0000000000..c348c6ef50
--- /dev/null
+++ b/packages/cli/src/ui/utils/__snapshots__/CodeColorizer.test.tsx.snap
@@ -0,0 +1,7 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`colorizeCode > does not let colors from ansi escape codes leak into colorized code 1`] = `
+"line 1
+line 2 with red background
+line 3"
+`;
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-column-widths-based-on-ren-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-column-widths-based-on-ren-.snap.svg
index e01d29e15d..b2704f56ba 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-column-widths-based-on-ren-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-column-widths-based-on-ren-.snap.svg
@@ -6,33 +6,33 @@
┌────────┬────────┬────────┐│
- Col 1
+ Col 1│
- Col 2
+ Col 2│
- Col 3
+ Col 3│├────────┼────────┼────────┤│
- 123456
+ 123456│
- Normal
+ Normal│
- Short
+ Short││
- Short
+ Short│
- 123456
+ 123456│
- Normal
+ Normal││
- Normal
+ Normal│
- Short
+ Short│
- 123456
+ 123456│└────────┴────────┴────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-width-correctly-for-conten-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-width-correctly-for-conten-.snap.svg
index f6f83c0cb0..f631406225 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-width-correctly-for-conten-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-width-correctly-for-conten-.snap.svg
@@ -6,39 +6,39 @@
┌───────────────────────────────────┬───────────────────────────────┬─────────────────────────────────┐│
- Col 1
+ Col 1│
- Col 2
+ Col 2│
- Col 3
+ Col 3│├───────────────────────────────────┼───────────────────────────────┼─────────────────────────────────┤│
- Visit Google (
- https://google.com
- )
+ Visit Google (
+ https://google.com
+ )│
- Plain Text
+ Plain Text│
- More Info
+ More Info││
- Info Here
+ Info Here│
- Visit Bing (
- https://bing.com
- )
+ Visit Bing (
+ https://bing.com
+ )│
- Links
+ Links││
- Check This
+ Check This│
- Search
+ Search│
- Visit Yahoo (
- https://yahoo.com
- )
+ Visit Yahoo (
+ https://yahoo.com
+ )│└───────────────────────────────────┴───────────────────────────────┴─────────────────────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-does-not-parse-markdown-inside-code-s-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-does-not-parse-markdown-inside-code-s-.snap.svg
index 68069bd0ab..08eab7e946 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-does-not-parse-markdown-inside-code-s-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-does-not-parse-markdown-inside-code-s-.snap.svg
@@ -6,34 +6,34 @@
┌─────────────────┬──────────────────────┬──────────────────┐│
- Col 1
+ Col 1│
- Col 2
+ Col 2│
- Col 3
+ Col 3│├─────────────────┼──────────────────────┼──────────────────┤│
- **not bold**
+ **not bold**│
- _not italic_
+ _not italic_│
- ~~not strike~~
+ ~~not strike~~││
- [not link](url)
+ [not link](url)│
- <u>not underline</u>
+ <u>not underline</u>│
- https://not.link
+ https://not.link││
- Normal Text
+ Normal Text│
- More Code:
- *test*
+ More Code:
+ *test*│
- ***nested***
+ ***nested***│└─────────────────┴──────────────────────┴──────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-nested-markdown-styles-recurs-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-nested-markdown-styles-recurs-.snap.svg
index 3269e29f19..b15120756b 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-nested-markdown-styles-recurs-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-nested-markdown-styles-recurs-.snap.svg
@@ -6,33 +6,39 @@
┌─────────────────────────────┬─────────────────────────────┬─────────────────────────────┐│
- Header 1
+ Header 1│
- Header 2
+ Header 2│
- Header 3
+ Header 3│├─────────────────────────────┼─────────────────────────────┼─────────────────────────────┤│
- Bold with Italic and Strike
+ Bold with
+ Italic
+ and Strike│
- Normal
+ Normal│
- Short
+ Short││
- Short
+ Short│
- Bold with Italic and Strike
+ Bold with
+ Italic
+ and Strike│
- Normal
+ Normal││
- Normal
+ Normal│
- Short
+ Short│
- Bold with Italic and Strike
+ Bold with
+ Italic
+ and Strike│└─────────────────────────────┴─────────────────────────────┴─────────────────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg
index 13898e8641..a4410812dd 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg
@@ -6,26 +6,26 @@
┌──────────────┬────────────┬───────────────┐│
- Emoji 😃
+ Emoji 😃│
- Asian 汉字
+ Asian 汉字│
- Mixed 🚀 Text
+ Mixed 🚀 Text│├──────────────┼────────────┼───────────────┤│
- Start 🌟 End
+ Start 🌟 End│
- 你好世界
+ 你好世界│
- Rocket 🚀 Man
+ Rocket 🚀 Man││
- Thumbs 👍 Up
+ Thumbs 👍 Up│
- こんにちは
+ こんにちは│
- Fire 🔥
+ Fire 🔥│└──────────────┴────────────┴───────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg
index 30d847e86c..99ba8aff43 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg
@@ -6,40 +6,40 @@
┌─────────────┬───────┬─────────┐│
- Very Long
+ Very Long│
- Short
+ Short│
- Another
+ Another││
- Bold Header
+ Bold Header││
- Long
+ Long││
- That Will
+ That Will││
- Header
+ Header││
- Wrap
+ Wrap│││├─────────────┼───────┼─────────┤│
- Data 1
+ Data 1│
- Data
+ Data│
- Data 3
+ Data 3│││
- 2
+ 2││└─────────────┴───────┴─────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg
index dea907221c..ef39407726 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg
@@ -6,33 +6,33 @@
┌──────────────┬──────────────┬──────────────┐│
- Header 1
+ Header 1│
- Header 2
+ Header 2│
- Header 3
+ Header 3│├──────────────┼──────────────┼──────────────┤│
- Row 1, Col 1
+ Row 1, Col 1│
- Row 1, Col 2
+ Row 1, Col 2│
- Row 1, Col 3
+ Row 1, Col 3││
- Row 2, Col 1
+ Row 2, Col 1│
- Row 2, Col 2
+ Row 2, Col 2│
- Row 2, Col 3
+ Row 2, Col 3││
- Row 3, Col 1
+ Row 3, Col 1│
- Row 3, Col 2
+ Row 3, Col 2│
- Row 3, Col 3
+ Row 3, Col 3│└──────────────┴──────────────┴──────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg
index f5a00dbe7c..251476d9e1 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg
@@ -6,56 +6,56 @@
┌─────────────────────────────┬──────────────────────────────┬─────────────────────────────┬──────────────────────────────┬─────┬────────┬─────────┬───────┐│
- Comprehensive Architectural
+ Comprehensive Architectural│
- Implementation Details for
+ Implementation Details for│
- Longitudinal Performance
+ Longitudinal Performance│
- Strategic Security Framework
+ Strategic Security Framework│
- Key
+ Key│
- Status
+ Status│
- Version
+ Version│
- Owner
+ Owner││
- Specification for the
+ Specification for the│
- the High-Throughput
+ the High-Throughput│
- Analysis Across
+ Analysis Across│
- for Mitigating Sophisticated
+ for Mitigating Sophisticated││││││
- Distributed Infrastructure
+ Distributed Infrastructure│
- Asynchronous Message
+ Asynchronous Message│
- Multi-Regional Cloud
+ Multi-Regional Cloud│
- Cross-Site Scripting
+ Cross-Site Scripting││││││
- Layer
+ Layer│
- Processing Pipeline with
+ Processing Pipeline with│
- Deployment Clusters
+ Deployment Clusters│
- Vulnerabilities
+ Vulnerabilities│││
@@ -63,7 +63,7 @@
│││
- Extended Scalability
+ Extended Scalability│││
@@ -73,7 +73,7 @@
│││
- Features and Redundancy
+ Features and Redundancy│││
@@ -83,7 +83,7 @@
│││
- Protocols
+ Protocols│││
@@ -93,105 +93,105 @@
│├─────────────────────────────┼──────────────────────────────┼─────────────────────────────┼──────────────────────────────┼─────┼────────┼─────────┼───────┤│
- The primary architecture
+ The primary architecture│
- Each message is processed
+ Each message is processed│
- Historical data indicates a
+ Historical data indicates a│
- A multi-layered defense
+ A multi-layered defense│
- INF
+ INF│
- Active
+ Active│
- v2.4
+ v2.4│
- J.
+ J.││
- utilizes a decoupled
+ utilizes a decoupled│
- through a series of
+ through a series of│
- significant reduction in
+ significant reduction in│
- strategy incorporates
+ strategy incorporates││││
- Doe
+ Doe││
- microservices approach,
+ microservices approach,│
- specialized workers that
+ specialized workers that│
- tail latency when utilizing
+ tail latency when utilizing│
- content security policies,
+ content security policies,││││││
- leveraging container
+ leveraging container│
- handle data transformation,
+ handle data transformation,│
- edge computing nodes closer
+ edge computing nodes closer│
- input sanitization
+ input sanitization││││││
- orchestration for
+ orchestration for│
- validation, and persistent
+ validation, and persistent│
- to the geographic location
+ to the geographic location│
- libraries, and regular
+ libraries, and regular││││││
- scalability and fault
+ scalability and fault│
- storage using a persistent
+ storage using a persistent│
- of the end-user base.
+ of the end-user base.│
- automated penetration
+ automated penetration││││││
- tolerance in high-load
+ tolerance in high-load│
- queue.
+ queue.││
- testing routines.
+ testing routines.││││││
- scenarios.
+ scenarios.││
- Monitoring tools have
+ Monitoring tools have│││
@@ -200,85 +200,85 @@
│││
- The pipeline features
+ The pipeline features│
- captured a steady increase
+ captured a steady increase│
- Developers are required to
+ Developers are required to││││││
- This layer provides the
+ This layer provides the│
- built-in retry mechanisms
+ built-in retry mechanisms│
- in throughput efficiency
+ in throughput efficiency│
- undergo mandatory security
+ undergo mandatory security││││││
- fundamental building blocks
+ fundamental building blocks│
- with exponential backoff to
+ with exponential backoff to│
- since the introduction of
+ since the introduction of│
- training focusing on the
+ training focusing on the││││││
- for service discovery, load
+ for service discovery, load│
- ensure message delivery
+ ensure message delivery│
- the vectorized query engine
+ the vectorized query engine│
- OWASP Top Ten to ensure that
+ OWASP Top Ten to ensure that││││││
- balancing, and
+ balancing, and│
- integrity even during
+ integrity even during│
- in the primary data
+ in the primary data│
- security is integrated into
+ security is integrated into││││││
- inter-service communication
+ inter-service communication│
- transient network or service
+ transient network or service│
- warehouse.
+ warehouse.│
- the initial design phase.
+ the initial design phase.││││││
- via highly efficient
+ via highly efficient│
- failures.
+ failures.│││
@@ -287,12 +287,12 @@
│││
- protocol buffers.
+ protocol buffers.││
- Resource utilization
+ Resource utilization│
- The implementation of a
+ The implementation of a│││
@@ -300,85 +300,85 @@
│││
- Horizontal autoscaling is
+ Horizontal autoscaling is│
- metrics demonstrate that
+ metrics demonstrate that│
- robust Identity and Access
+ robust Identity and Access││││││
- Advanced telemetry and
+ Advanced telemetry and│
- triggered automatically
+ triggered automatically│
- the transition to
+ the transition to│
- Management system ensures
+ Management system ensures││││││
- logging integrations allow
+ logging integrations allow│
- based on the depth of the
+ based on the depth of the│
- serverless compute for
+ serverless compute for│
- that the principle of least
+ that the principle of least││││││
- for real-time monitoring of
+ for real-time monitoring of│
- processing queue, ensuring
+ processing queue, ensuring│
- intermittent tasks has
+ intermittent tasks has│
- privilege is strictly
+ privilege is strictly││││││
- system health and rapid
+ system health and rapid│
- consistent performance
+ consistent performance│
- resulted in a thirty
+ resulted in a thirty│
- enforced across all
+ enforced across all││││││
- identification of
+ identification of│
- during unexpected traffic
+ during unexpected traffic│
- percent cost optimization.
+ percent cost optimization.│
- environments.
+ environments.││││││
- bottlenecks within the
+ bottlenecks within the│
- spikes.
+ spikes.│││
@@ -387,7 +387,7 @@
│││
- service mesh.
+ service mesh.│││
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg
index 8da55efa8b..828c7fd9fa 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg
@@ -6,57 +6,57 @@
┌───────────────┬───────────────┬──────────────────┬──────────────────┐│
- Very Long
+ Very Long│
- Very Long
+ Very Long│
- Very Long Column
+ Very Long Column│
- Very Long Column
+ Very Long Column││
- Column Header
+ Column Header│
- Column Header
+ Column Header│
- Header Three
+ Header Three│
- Header Four
+ Header Four││
- One
+ One│
- Two
+ Two│││├───────────────┼───────────────┼──────────────────┼──────────────────┤│
- Data 1.1
+ Data 1.1│
- Data 1.2
+ Data 1.2│
- Data 1.3
+ Data 1.3│
- Data 1.4
+ Data 1.4││
- Data 2.1
+ Data 2.1│
- Data 2.2
+ Data 2.2│
- Data 2.3
+ Data 2.3│
- Data 2.4
+ Data 2.4││
- Data 3.1
+ Data 3.1│
- Data 3.2
+ Data 3.2│
- Data 3.3
+ Data 3.3│
- Data 3.4
+ Data 3.4│└───────────────┴───────────────┴──────────────────┴──────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg
index 0db46485e0..3e76bc05e3 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg
@@ -6,26 +6,26 @@
┌───────────────┬───────────────────┬────────────────┐│
- Mixed 😃 中文
+ Mixed 😃 中文│
- Complex 🚀 日本語
+ Complex 🚀 日本語│
- Text 📝 한국어
+ Text 📝 한국어│├───────────────┼───────────────────┼────────────────┤│
- 你好 😃
+ 你好 😃│
- こんにちは 🚀
+ こんにちは 🚀│
- 안녕하세요 📝
+ 안녕하세요 📝││
- World 🌍
+ World 🌍│
- Code 💻
+ Code 💻│
- Pizza 🍕
+ Pizza 🍕│└───────────────┴───────────────────┴────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg
index b808d1e335..7f31b51548 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg
@@ -6,26 +6,26 @@
┌──────────────┬─────────────────┬───────────────┐│
- Chinese 中文
+ Chinese 中文│
- Japanese 日本語
+ Japanese 日本語│
- Korean 한국어
+ Korean 한국어│├──────────────┼─────────────────┼───────────────┤│
- 你好
+ 你好│
- こんにちは
+ こんにちは│
- 안녕하세요
+ 안녕하세요││
- 世界
+ 世界│
- 世界
+ 世界│
- 세계
+ 세계│└──────────────┴─────────────────┴───────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg
index 9277078253..a3abd45c53 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg
@@ -6,26 +6,26 @@
┌──────────┬───────────┬──────────┐│
- Happy 😀
+ Happy 😀│
- Rocket 🚀
+ Rocket 🚀│
- Heart ❤️
+ Heart ❤️│├──────────┼───────────┼──────────┤│
- Smile 😃
+ Smile 😃│
- Fire 🔥
+ Fire 🔥│
- Love 💖
+ Love 💖││
- Cool 😎
+ Cool 😎│
- Star ⭐
+ Star ⭐│
- Blue 💙
+ Blue 💙│└──────────┴───────────┴──────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-complex-markdown-in-rows-and-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-complex-markdown-in-rows-and-.snap.svg
index 8b251c3ab2..b48572438b 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-complex-markdown-in-rows-and-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-complex-markdown-in-rows-and-.snap.svg
@@ -6,47 +6,47 @@
┌───────────────┬─────────────────────────────┐│
- Feature
+ Feature│
- Markdown
+ Markdown│├───────────────┼─────────────────────────────┤│
- Bold
+ Bold│
- Bold Text
+ Bold Text││
- Italic
+ Italic│
- Italic Text
+ Italic Text││
- Combined
+ Combined│
- Bold and Italic
+ Bold and Italic││
- Link
+ Link│
- Google (
- https://google.com
- )
+ Google (
+ https://google.com
+ )││
- Code
+ Code│
- const x = 1
+ const x = 1││
- Strikethrough
+ Strikethrough│
- Strike
+ Strike││
- Underline
+ Underline│
- Underline
+ Underline│└───────────────┴─────────────────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg
index b2523badcd..180c7aeb56 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg
@@ -10,9 +10,9 @@
│├────────┼────────┤│
- Data 1
+ Data 1│
- Data 2
+ Data 2│└────────┴────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg
index 89ad1cfb4c..685260b84d 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg
@@ -6,17 +6,17 @@
┌──────────┬──────────┬──────────┐│
- Header 1
+ Header 1│
- Header 2
+ Header 2│
- Header 3
+ Header 3│├──────────┼──────────┼──────────┤│
- Data 1
+ Data 1│
- Data 2
+ Data 2││└──────────┴──────────┴──────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg
index 717a8803f8..bc33d9e78a 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg
@@ -6,19 +6,19 @@
┌─────────────┬───────────────┬──────────────┐│
- Bold Header
+ Bold Header│
- Normal Header
+ Normal Header│
- Another Bold
+ Another Bold│├─────────────┼───────────────┼──────────────┤│
- Data 1
+ Data 1│
- Data 2
+ Data 2│
- Data 3
+ Data 3│└─────────────┴───────────────┴──────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg
index e59cefbc72..d69f29ece4 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg
@@ -6,46 +6,46 @@
┌────────────────┬────────────────┬─────────────────┐│
- Col 1
+ Col 1│
- Col 2
+ Col 2│
- Col 3
+ Col 3│├────────────────┼────────────────┼─────────────────┤│
- This is a very
+ This is a very│
- This is also a
+ This is also a│
- And this is the
+ And this is the││
- long text that
+ long text that│
- very long text
+ very long text│
- third long text
+ third long text││
- needs wrapping
+ needs wrapping│
- that needs
+ that needs│
- that needs
+ that needs││
- in column 1
+ in column 1│
- wrapping in
+ wrapping in│
- wrapping in
+ wrapping in│││
- column 2
+ column 2│
- column 3
+ column 3│└────────────────┴────────────────┴─────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg
index 42f7b188f8..f16cdd29ae 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg
@@ -6,45 +6,45 @@
┌───────────────────┬───────────────┬─────────────────┐│
- Punctuation 1
+ Punctuation 1│
- Punctuation 2
+ Punctuation 2│
- Punctuation 3
+ Punctuation 3│├───────────────────┼───────────────┼─────────────────┤│
- Start. Stop.
+ Start. Stop.│
- Semi; colon:
+ Semi; colon:│
- At@ Hash#
+ At@ Hash#││
- Comma, separated.
+ Comma, separated.│
- Pipe| Slash/
+ Pipe| Slash/│
- Dollar$
+ Dollar$││
- Exclamation!
+ Exclamation!│
- Backslash\
+ Backslash\│
- Percent% Caret^
+ Percent% Caret^││
- Question?
+ Question?││
- Ampersand&
+ Ampersand&││
- hyphen-ated
+ hyphen-ated││
- Asterisk*
+ Asterisk*│└───────────────────┴───────────────┴─────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg
index 2cfd46bc54..f46137df13 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg
@@ -6,28 +6,28 @@
┌───────┬─────────────────────────────┬───────┐│
- Col 1
+ Col 1│
- Col 2
+ Col 2│
- Col 3
+ Col 3│├───────┼─────────────────────────────┼───────┤│
- Short
+ Short│
- This is a very long cell
+ This is a very long cell│
- Short
+ Short│││
- content that should wrap to
+ content that should wrap to││││
- multiple lines
+ multiple lines││└───────┴─────────────────────────────┴───────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg
index 0e5dbcbb30..f517dc3632 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg
@@ -6,29 +6,29 @@
┌───────┬──────────────────────────┬────────┐│
- Short
+ Short│
- Long
+ Long│
- Medium
+ Medium│├───────┼──────────────────────────┼────────┤│
- Tiny
+ Tiny│
- This is a very long text
+ This is a very long text│
- Not so
+ Not so│││
- that definitely needs to
+ that definitely needs to│
- long
+ long│││
- wrap to the next line
+ wrap to the next line││└───────┴──────────────────────────┴────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg
index 280f558d63..6a693d318b 100644
--- a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg
@@ -7,8 +7,8 @@
▝▜▄
- Gemini CLI
- v1.2.3
+ Gemini CLI
+ v1.2.3▝▜▄
@@ -17,15 +17,16 @@
▀▝▀
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- ⊷ google_web_search
- │
- │
- │
- │
- Searching...
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ ⊶
+ google_web_search
+ │
+ │
+ │
+ │
+ Searching...
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg
index 3dddced46d..1c0ff4b121 100644
--- a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg
@@ -7,8 +7,8 @@
▝▜▄
- Gemini CLI
- v1.2.3
+ Gemini CLI
+ v1.2.3▝▜▄
@@ -17,15 +17,16 @@
▀▝▀
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- ⊷ run_shell_command
- │
- │
- │
- │
- Running command...
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ ⊶
+ run_shell_command
+ │
+ │
+ │
+ │
+ Running command...
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg
index 280f558d63..6a693d318b 100644
--- a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg
@@ -7,8 +7,8 @@
▝▜▄
- Gemini CLI
- v1.2.3
+ Gemini CLI
+ v1.2.3▝▜▄
@@ -17,15 +17,16 @@
▀▝▀
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- ⊷ google_web_search
- │
- │
- │
- │
- Searching...
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ ⊶
+ google_web_search
+ │
+ │
+ │
+ │
+ Searching...
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap b/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap
index d34d820236..bdf1e95332 100644
--- a/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap
+++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap
@@ -8,7 +8,7 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ google_web_search │
+│ ⊶ google_web_search │
│ │
│ Searching... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -22,7 +22,7 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ run_shell_command │
+│ ⊶ run_shell_command │
│ │
│ Running command... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -36,7 +36,7 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ google_web_search │
+│ ⊶ google_web_search │
│ │
│ Searching... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
diff --git a/packages/cli/src/ui/utils/borderStyles.test.tsx b/packages/cli/src/ui/utils/borderStyles.test.tsx
index 91b2497f7f..1852a0cb82 100644
--- a/packages/cli/src/ui/utils/borderStyles.test.tsx
+++ b/packages/cli/src/ui/utils/borderStyles.test.tsx
@@ -4,13 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { getToolGroupBorderAppearance } from './borderStyles.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { theme } from '../semantic-colors.js';
import type { IndividualToolCallDisplay } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { MainContent } from '../components/MainContent.js';
+import { Text } from 'ink';
+
+vi.mock('../components/CliSpinner.js', () => ({
+ CliSpinner: () => ⊶,
+}));
describe('getToolGroupBorderAppearance', () => {
it('should use warning color for pending non-shell tools', () => {
@@ -60,7 +65,7 @@ describe('getToolGroupBorderAppearance', () => {
expect(appearance.borderDimColor).toBe(true);
});
- it('should use symbol color for shell tools', () => {
+ it('should use active color for shell tools', () => {
const item = {
type: 'tool_group' as const,
tools: [
@@ -73,9 +78,28 @@ describe('getToolGroupBorderAppearance', () => {
] as IndividualToolCallDisplay[],
};
const appearance = getToolGroupBorderAppearance(item, undefined, false, []);
- expect(appearance.borderColor).toBe(theme.ui.symbol);
+ expect(appearance.borderColor).toBe(theme.ui.active);
expect(appearance.borderDimColor).toBe(true);
});
+
+ it('should use focus color for focused shell tools', () => {
+ const ptyId = 123;
+ const item = {
+ type: 'tool_group' as const,
+ tools: [
+ {
+ name: 'run_shell_command',
+ status: CoreToolCallStatus.Executing,
+ resultDisplay: '',
+ callId: 'call-1',
+ ptyId,
+ },
+ ] as IndividualToolCallDisplay[],
+ };
+ const appearance = getToolGroupBorderAppearance(item, ptyId, true, []);
+ expect(appearance.borderColor).toBe(theme.ui.focus);
+ expect(appearance.borderDimColor).toBe(false);
+ });
});
describe('MainContent tool group border SVG snapshots', () => {
diff --git a/packages/cli/src/ui/utils/borderStyles.ts b/packages/cli/src/ui/utils/borderStyles.ts
index 276d4a2502..7b7b767734 100644
--- a/packages/cli/src/ui/utils/borderStyles.ts
+++ b/packages/cli/src/ui/utils/borderStyles.ts
@@ -113,9 +113,10 @@ export function getToolGroupBorderAppearance(
isCurrentlyInShellTurn &&
!!embeddedShellFocused);
- const borderColor =
- (isShell && isPending) || isEffectivelyFocused
- ? theme.ui.symbol
+ const borderColor = isEffectivelyFocused
+ ? theme.ui.focus
+ : isShell && isPending
+ ? theme.ui.active
: isPending
? theme.status.warning
: theme.border.default;
diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts
index 737948ce98..346eef2fc2 100644
--- a/packages/cli/src/ui/utils/commandUtils.test.ts
+++ b/packages/cli/src/ui/utils/commandUtils.test.ts
@@ -163,7 +163,6 @@ describe('commandUtils', () => {
it('should return true when query starts with @', () => {
expect(isAtCommand('@file')).toBe(true);
expect(isAtCommand('@path/to/file')).toBe(true);
- expect(isAtCommand('@')).toBe(true);
});
it('should return true when query contains @ preceded by whitespace', () => {
@@ -172,17 +171,36 @@ describe('commandUtils', () => {
expect(isAtCommand(' @file')).toBe(true);
});
- it('should return false when query does not start with @ and has no spaced @', () => {
+ it('should return true when @ is preceded by non-whitespace (external editor scenario)', () => {
+ // When a user composes a prompt in an external editor, @-references may
+ // appear after punctuation characters such as ':' or '(' without a space.
+ // The processor must still recognise these as @-commands so that the
+ // referenced files are pre-loaded before the query is sent to the model.
+ expect(isAtCommand('check:@file.py')).toBe(true);
+ expect(isAtCommand('analyze(@file.py)')).toBe(true);
+ expect(isAtCommand('hello@file')).toBe(true);
+ expect(isAtCommand('text@path/to/file')).toBe(true);
+ expect(isAtCommand('user@host')).toBe(true);
+ });
+
+ it('should return false when query does not contain any @ pattern', () => {
expect(isAtCommand('file')).toBe(false);
expect(isAtCommand('hello')).toBe(false);
expect(isAtCommand('')).toBe(false);
- expect(isAtCommand('email@domain.com')).toBe(false);
- expect(isAtCommand('user@host')).toBe(false);
+ // A bare '@' with no following path characters is not an @-command.
+ expect(isAtCommand('@')).toBe(false);
});
- it('should return false when @ is not preceded by whitespace', () => {
- expect(isAtCommand('hello@file')).toBe(false);
- expect(isAtCommand('text@path')).toBe(false);
+ it('should return false when @ is escaped with a backslash', () => {
+ expect(isAtCommand('\\@file')).toBe(false);
+ });
+
+ it('should return true for multi-line external editor prompts with @-references', () => {
+ expect(isAtCommand('Please review:\n@src/main.py\nand fix bugs.')).toBe(
+ true,
+ );
+ // @file after a colon on the same line.
+ expect(isAtCommand('Files:@src/a.py,@src/b.py')).toBe(true);
});
});
diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts
index 0d52c83863..d6fdb99f0f 100644
--- a/packages/cli/src/ui/utils/commandUtils.ts
+++ b/packages/cli/src/ui/utils/commandUtils.ts
@@ -10,18 +10,29 @@ import type { SlashCommand } from '../commands/types.js';
import fs from 'node:fs';
import type { Writable } from 'node:stream';
import type { Settings } from '../../config/settingsSchema.js';
+import { AT_COMMAND_PATH_REGEX_SOURCE } from '../hooks/atCommandProcessor.js';
+
+// Pre-compiled regex for detecting @ patterns consistent with parseAllAtCommands.
+// Uses the same AT_COMMAND_PATH_REGEX_SOURCE so that isAtCommand is true whenever
+// parseAllAtCommands would find at least one atPath part.
+const AT_COMMAND_DETECT_REGEX = new RegExp(
+ `(?' pattern that would be
+ * recognised by the @ command processor, regardless of what character
+ * precedes the '@' sign. This ensures that prompts written in an external
+ * editor (where '@' may follow punctuation like ':' or '(') are correctly
+ * identified and their referenced files pre-loaded before the query is sent
+ * to the model.
*
* @param query The input query string.
* @returns True if the query looks like an '@' command, false otherwise.
*/
export const isAtCommand = (query: string): boolean =>
- // Check if starts with @ OR has a space, then @
- query.startsWith('@') || /\s@/.test(query);
+ AT_COMMAND_DETECT_REGEX.test(query);
/**
* Checks if a query string potentially represents an '/' command.
diff --git a/packages/cli/src/ui/utils/markdownParsingUtils.test.ts b/packages/cli/src/ui/utils/markdownParsingUtils.test.ts
index a9ff96401f..c32bda58fa 100644
--- a/packages/cli/src/ui/utils/markdownParsingUtils.test.ts
+++ b/packages/cli/src/ui/utils/markdownParsingUtils.test.ts
@@ -17,6 +17,9 @@ vi.mock('../semantic-colors.js', () => ({
accent: 'cyan',
link: 'blue',
},
+ ui: {
+ focus: 'green',
+ },
},
}));
diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts
index c5c05db38b..732945ffe8 100644
--- a/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts
+++ b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts
@@ -98,6 +98,7 @@ describe('TerminalCapabilityManager', () => {
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
+ manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
@@ -141,6 +142,8 @@ describe('TerminalCapabilityManager', () => {
// Should resolve without waiting for timeout
await promise;
+ manager.enableSupportedModes();
+
expect(manager.isKittyProtocolEnabled()).toBe(true);
expect(manager.getTerminalBackgroundColor()).toBe('#000000');
});
@@ -156,6 +159,7 @@ describe('TerminalCapabilityManager', () => {
vi.advanceTimersByTime(1000);
await promise;
+ manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
@@ -167,6 +171,7 @@ describe('TerminalCapabilityManager', () => {
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
+ manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(false);
});
@@ -181,6 +186,7 @@ describe('TerminalCapabilityManager', () => {
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
+ manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
@@ -196,6 +202,8 @@ describe('TerminalCapabilityManager', () => {
await promise;
+ manager.enableSupportedModes();
+
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
@@ -210,6 +218,8 @@ describe('TerminalCapabilityManager', () => {
await promise;
+ manager.enableSupportedModes();
+
expect(enableModifyOtherKeys).not.toHaveBeenCalled();
});
@@ -224,6 +234,7 @@ describe('TerminalCapabilityManager', () => {
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
+ manager.enableSupportedModes();
expect(manager.isKittyProtocolEnabled()).toBe(true);
expect(enableKittyKeyboardProtocol).toHaveBeenCalled();
@@ -241,6 +252,8 @@ describe('TerminalCapabilityManager', () => {
await promise;
+ manager.enableSupportedModes();
+
expect(manager.isKittyProtocolEnabled()).toBe(false);
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
@@ -257,6 +270,8 @@ describe('TerminalCapabilityManager', () => {
await promise;
+ manager.enableSupportedModes();
+
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
@@ -272,6 +287,8 @@ describe('TerminalCapabilityManager', () => {
await promise;
+ manager.enableSupportedModes();
+
expect(manager.getTerminalBackgroundColor()).toBe('#1a1a1a');
expect(manager.getTerminalName()).toBe('tmux');
@@ -287,6 +304,8 @@ describe('TerminalCapabilityManager', () => {
await promise;
+ manager.enableSupportedModes();
+
expect(manager.isKittyProtocolEnabled()).toBe(false);
expect(enableModifyOtherKeys).not.toHaveBeenCalled();
});
diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.ts
index a161b2aa1b..7867f48e6f 100644
--- a/packages/cli/src/ui/utils/terminalCapabilityManager.ts
+++ b/packages/cli/src/ui/utils/terminalCapabilityManager.ts
@@ -138,9 +138,6 @@ export class TerminalCapabilityManager {
process.stdin.setRawMode(false);
}
this.detectionComplete = true;
-
- this.enableSupportedModes();
-
resolve();
};
@@ -246,9 +243,11 @@ export class TerminalCapabilityManager {
enableSupportedModes() {
try {
if (this.kittySupported) {
+ debugLogger.log('Enabling Kitty keyboard protocol');
enableKittyKeyboardProtocol();
this.kittyEnabled = true;
} else if (this.modifyOtherKeysSupported) {
+ debugLogger.log('Enabling modifyOtherKeys');
enableModifyOtherKeys();
}
// Always enable bracketed paste since it'll be ignored if unsupported.
diff --git a/packages/cli/src/utils/agentSettings.test.ts b/packages/cli/src/utils/agentSettings.test.ts
new file mode 100644
index 0000000000..ffc113ea73
--- /dev/null
+++ b/packages/cli/src/utils/agentSettings.test.ts
@@ -0,0 +1,147 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import {
+ SettingScope,
+ type LoadedSettings,
+ type LoadableSettingScope,
+} from '../config/settings.js';
+import { enableAgent, disableAgent } from './agentSettings.js';
+
+function createMockLoadedSettings(opts: {
+ userSettings?: Record;
+ workspaceSettings?: Record;
+ userPath?: string;
+ workspacePath?: string;
+}): LoadedSettings {
+ const scopes: Record<
+ string,
+ {
+ settings: Record;
+ originalSettings: Record;
+ path: string;
+ }
+ > = {
+ [SettingScope.User]: {
+ settings: opts.userSettings ?? {},
+ originalSettings: opts.userSettings ?? {},
+ path: opts.userPath ?? '/home/user/.gemini/settings.json',
+ },
+ [SettingScope.Workspace]: {
+ settings: opts.workspaceSettings ?? {},
+ originalSettings: opts.workspaceSettings ?? {},
+ path: opts.workspacePath ?? '/project/.gemini/settings.json',
+ },
+ };
+
+ return {
+ forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]),
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+}
+
+describe('agentSettings', () => {
+ describe('agentStrategy (via enableAgent / disableAgent)', () => {
+ describe('enableAgent', () => {
+ it('should return no-op when the agent is already enabled in both scopes', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: {
+ agents: { overrides: { 'my-agent': { enabled: true } } },
+ },
+ workspaceSettings: {
+ agents: { overrides: { 'my-agent': { enabled: true } } },
+ },
+ });
+
+ const result = enableAgent(settings, 'my-agent');
+
+ expect(result.status).toBe('no-op');
+ expect(result.action).toBe('enable');
+ expect(result.agentName).toBe('my-agent');
+ expect(result.modifiedScopes).toHaveLength(0);
+ expect(settings.setValue).not.toHaveBeenCalled();
+ });
+
+ it('should enable the agent when not present in any scope', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: {},
+ workspaceSettings: {},
+ });
+
+ const result = enableAgent(settings, 'my-agent');
+
+ expect(result.status).toBe('success');
+ expect(result.action).toBe('enable');
+ expect(result.agentName).toBe('my-agent');
+ expect(result.modifiedScopes).toHaveLength(2);
+ expect(settings.setValue).toHaveBeenCalledTimes(2);
+ });
+
+ it('should enable the agent only in the scope where it is not enabled', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: {
+ agents: { overrides: { 'my-agent': { enabled: true } } },
+ },
+ workspaceSettings: {
+ agents: { overrides: { 'my-agent': { enabled: false } } },
+ },
+ });
+
+ const result = enableAgent(settings, 'my-agent');
+
+ expect(result.status).toBe('success');
+ expect(result.modifiedScopes).toHaveLength(1);
+ expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace);
+ expect(result.alreadyInStateScopes).toHaveLength(1);
+ expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User);
+ expect(settings.setValue).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('disableAgent', () => {
+ it('should return no-op when agent is already explicitly disabled', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: {
+ agents: { overrides: { 'my-agent': { enabled: false } } },
+ },
+ });
+
+ const result = disableAgent(settings, 'my-agent', SettingScope.User);
+
+ expect(result.status).toBe('no-op');
+ expect(result.action).toBe('disable');
+ expect(result.agentName).toBe('my-agent');
+ expect(settings.setValue).not.toHaveBeenCalled();
+ });
+
+ it('should disable the agent when it is currently enabled', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: {
+ agents: { overrides: { 'my-agent': { enabled: true } } },
+ },
+ });
+
+ const result = disableAgent(settings, 'my-agent', SettingScope.User);
+
+ expect(result.status).toBe('success');
+ expect(result.action).toBe('disable');
+ expect(result.modifiedScopes).toHaveLength(1);
+ expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
+ expect(settings.setValue).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return error for an invalid scope', () => {
+ const settings = createMockLoadedSettings({});
+
+ const result = disableAgent(settings, 'my-agent', SettingScope.Session);
+
+ expect(result.status).toBe('error');
+ expect(result.error).toContain('Invalid settings scope');
+ });
+ });
+ });
+});
diff --git a/packages/cli/src/utils/agentSettings.ts b/packages/cli/src/utils/agentSettings.ts
index e063e96536..661b065d18 100644
--- a/packages/cli/src/utils/agentSettings.ts
+++ b/packages/cli/src/utils/agentSettings.ts
@@ -4,30 +4,41 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import type { SettingScope, LoadedSettings } from '../config/settings.js';
import {
- SettingScope,
- isLoadableSettingScope,
- type LoadedSettings,
-} from '../config/settings.js';
-import type { ModifiedScope } from './skillSettings.js';
+ type FeatureActionResult,
+ type FeatureToggleStrategy,
+ enableFeature,
+ disableFeature,
+} from './featureToggleUtils.js';
export type AgentActionStatus = 'success' | 'no-op' | 'error';
/**
* Metadata representing the result of an agent settings operation.
*/
-export interface AgentActionResult {
- status: AgentActionStatus;
+export interface AgentActionResult
+ extends Omit {
agentName: string;
- action: 'enable' | 'disable';
- /** Scopes where the agent's state was actually changed. */
- modifiedScopes: ModifiedScope[];
- /** Scopes where the agent was already in the desired state. */
- alreadyInStateScopes: ModifiedScope[];
- /** Error message if status is 'error'. */
- error?: string;
}
+const agentStrategy: FeatureToggleStrategy = {
+ needsEnabling: (settings, scope, agentName) => {
+ const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
+ return agentOverrides?.[agentName]?.enabled !== true;
+ },
+ enable: (settings, scope, agentName) => {
+ settings.setValue(scope, `agents.overrides.${agentName}.enabled`, true);
+ },
+ isExplicitlyDisabled: (settings, scope, agentName) => {
+ const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
+ return agentOverrides?.[agentName]?.enabled === false;
+ },
+ disable: (settings, scope, agentName) => {
+ settings.setValue(scope, `agents.overrides.${agentName}.enabled`, false);
+ },
+};
+
/**
* Enables an agent by ensuring it is enabled in any writable scope (User and Workspace).
* It sets `agents.overrides..enabled` to `true`.
@@ -36,50 +47,14 @@ export function enableAgent(
settings: LoadedSettings,
agentName: string,
): AgentActionResult {
- const writableScopes = [SettingScope.Workspace, SettingScope.User];
- const foundInDisabledScopes: ModifiedScope[] = [];
- const alreadyEnabledScopes: ModifiedScope[] = [];
-
- for (const scope of writableScopes) {
- if (isLoadableSettingScope(scope)) {
- const scopePath = settings.forScope(scope).path;
- const agentOverrides =
- settings.forScope(scope).settings.agents?.overrides;
- const isEnabled = agentOverrides?.[agentName]?.enabled === true;
-
- if (!isEnabled) {
- foundInDisabledScopes.push({ scope, path: scopePath });
- } else {
- alreadyEnabledScopes.push({ scope, path: scopePath });
- }
- }
- }
-
- if (foundInDisabledScopes.length === 0) {
- return {
- status: 'no-op',
- agentName,
- action: 'enable',
- modifiedScopes: [],
- alreadyInStateScopes: alreadyEnabledScopes,
- };
- }
-
- const modifiedScopes: ModifiedScope[] = [];
- for (const { scope, path } of foundInDisabledScopes) {
- if (isLoadableSettingScope(scope)) {
- // Explicitly enable it.
- settings.setValue(scope, `agents.overrides.${agentName}.enabled`, true);
- modifiedScopes.push({ scope, path });
- }
- }
-
- return {
- status: 'success',
+ const { featureName, ...rest } = enableFeature(
+ settings,
agentName,
- action: 'enable',
- modifiedScopes,
- alreadyInStateScopes: alreadyEnabledScopes,
+ agentStrategy,
+ );
+ return {
+ ...rest,
+ agentName: featureName,
};
}
@@ -91,56 +66,14 @@ export function disableAgent(
agentName: string,
scope: SettingScope,
): AgentActionResult {
- if (!isLoadableSettingScope(scope)) {
- return {
- status: 'error',
- agentName,
- action: 'disable',
- modifiedScopes: [],
- alreadyInStateScopes: [],
- error: `Invalid settings scope: ${scope}`,
- };
- }
-
- const scopePath = settings.forScope(scope).path;
- const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
- const isEnabled = agentOverrides?.[agentName]?.enabled !== false;
-
- if (!isEnabled) {
- return {
- status: 'no-op',
- agentName,
- action: 'disable',
- modifiedScopes: [],
- alreadyInStateScopes: [{ scope, path: scopePath }],
- };
- }
-
- // Check if it's already disabled in the other writable scope
- const otherScope =
- scope === SettingScope.Workspace
- ? SettingScope.User
- : SettingScope.Workspace;
- const alreadyDisabledInOther: ModifiedScope[] = [];
-
- if (isLoadableSettingScope(otherScope)) {
- const otherOverrides =
- settings.forScope(otherScope).settings.agents?.overrides;
- if (otherOverrides?.[agentName]?.enabled === false) {
- alreadyDisabledInOther.push({
- scope: otherScope,
- path: settings.forScope(otherScope).path,
- });
- }
- }
-
- settings.setValue(scope, `agents.overrides.${agentName}.enabled`, false);
-
- return {
- status: 'success',
+ const { featureName, ...rest } = disableFeature(
+ settings,
agentName,
- action: 'disable',
- modifiedScopes: [{ scope, path: scopePath }],
- alreadyInStateScopes: alreadyDisabledInOther,
+ scope,
+ agentStrategy,
+ );
+ return {
+ ...rest,
+ agentName: featureName,
};
}
diff --git a/packages/cli/src/utils/deepMerge.test.ts b/packages/cli/src/utils/deepMerge.test.ts
index ee6cc7169c..3310924795 100644
--- a/packages/cli/src/utils/deepMerge.test.ts
+++ b/packages/cli/src/utils/deepMerge.test.ts
@@ -152,13 +152,27 @@ describe('customDeepMerge', () => {
});
it('should not pollute the prototype', () => {
- const maliciousSource = JSON.parse('{"__proto__": {"polluted": "true"}}');
+ const maliciousSource = JSON.parse('{"__proto__": {"polluted1": "true"}}');
const getMergeStrategy = () => undefined;
- const result = customDeepMerge(getMergeStrategy, {}, maliciousSource);
+ let result = customDeepMerge(getMergeStrategy, {}, maliciousSource);
expect(result).toEqual({});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- expect(({} as any).polluted).toBeUndefined();
+ expect(({} as any).polluted1).toBeUndefined();
+
+ const maliciousSource2 = JSON.parse(
+ '{"constructor": {"prototype": {"polluted2": "true"}}}',
+ );
+ result = customDeepMerge(getMergeStrategy, {}, maliciousSource2);
+ expect(result).toEqual({});
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expect(({} as any).polluted2).toBeUndefined();
+
+ const maliciousSource3 = JSON.parse('{"prototype": {"polluted3": "true"}}');
+ result = customDeepMerge(getMergeStrategy, {}, maliciousSource3);
+ expect(result).toEqual({});
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expect(({} as any).polluted3).toBeUndefined();
});
it('should use additionalProperties merge strategy for dynamic properties', () => {
diff --git a/packages/cli/src/utils/deepMerge.ts b/packages/cli/src/utils/deepMerge.ts
index 740021361f..2eef3b4ada 100644
--- a/packages/cli/src/utils/deepMerge.ts
+++ b/packages/cli/src/utils/deepMerge.ts
@@ -30,7 +30,7 @@ function mergeRecursively(
for (const key of Object.keys(source)) {
// JSON.parse can create objects with __proto__ as an own property.
// We must skip it to prevent prototype pollution.
- if (key === '__proto__') {
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
const srcValue = source[key];
diff --git a/packages/cli/src/utils/featureToggleUtils.test.ts b/packages/cli/src/utils/featureToggleUtils.test.ts
new file mode 100644
index 0000000000..345aca68bc
--- /dev/null
+++ b/packages/cli/src/utils/featureToggleUtils.test.ts
@@ -0,0 +1,195 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import {
+ enableFeature,
+ disableFeature,
+ type FeatureToggleStrategy,
+} from './featureToggleUtils.js';
+import {
+ SettingScope,
+ type LoadedSettings,
+ type LoadableSettingScope,
+} from '../config/settings.js';
+
+function createMockLoadedSettings(opts: {
+ userSettings?: Record;
+ workspaceSettings?: Record;
+ userPath?: string;
+ workspacePath?: string;
+}): LoadedSettings {
+ const scopes: Record<
+ string,
+ { settings: Record; path: string }
+ > = {
+ [SettingScope.User]: {
+ settings: opts.userSettings ?? {},
+ path: opts.userPath ?? '/home/user/.gemini/settings.json',
+ },
+ [SettingScope.Workspace]: {
+ settings: opts.workspaceSettings ?? {},
+ path: opts.workspacePath ?? '/project/.gemini/settings.json',
+ },
+ };
+
+ const mockSettings = {
+ forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]),
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+
+ return mockSettings;
+}
+
+function createMockStrategy(overrides?: {
+ needsEnabling?: (
+ settings: LoadedSettings,
+ scope: LoadableSettingScope,
+ featureName: string,
+ ) => boolean;
+ isExplicitlyDisabled?: (
+ settings: LoadedSettings,
+ scope: LoadableSettingScope,
+ featureName: string,
+ ) => boolean;
+}): FeatureToggleStrategy {
+ return {
+ needsEnabling: vi.fn(overrides?.needsEnabling ?? (() => false)),
+ enable: vi.fn(),
+ isExplicitlyDisabled: vi.fn(
+ overrides?.isExplicitlyDisabled ?? (() => false),
+ ),
+ disable: vi.fn(),
+ };
+}
+
+describe('featureToggleUtils', () => {
+ describe('enableFeature', () => {
+ it('should return no-op when the feature is already enabled in all scopes', () => {
+ const settings = createMockLoadedSettings({});
+ const strategy = createMockStrategy({
+ needsEnabling: () => false,
+ });
+
+ const result = enableFeature(settings, 'my-feature', strategy);
+
+ expect(result.status).toBe('no-op');
+ expect(result.action).toBe('enable');
+ expect(result.featureName).toBe('my-feature');
+ expect(result.modifiedScopes).toHaveLength(0);
+ expect(result.alreadyInStateScopes).toHaveLength(2);
+ expect(strategy.enable).not.toHaveBeenCalled();
+ });
+
+ it('should enable the feature when disabled in one scope', () => {
+ const settings = createMockLoadedSettings({});
+ const strategy = createMockStrategy({
+ needsEnabling: (_s, scope) => scope === SettingScope.Workspace,
+ });
+
+ const result = enableFeature(settings, 'my-feature', strategy);
+
+ expect(result.status).toBe('success');
+ expect(result.action).toBe('enable');
+ expect(result.modifiedScopes).toHaveLength(1);
+ expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace);
+ expect(result.alreadyInStateScopes).toHaveLength(1);
+ expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User);
+ expect(strategy.enable).toHaveBeenCalledTimes(1);
+ });
+
+ it('should enable the feature when disabled in both scopes', () => {
+ const settings = createMockLoadedSettings({});
+ const strategy = createMockStrategy({
+ needsEnabling: () => true,
+ });
+
+ const result = enableFeature(settings, 'my-feature', strategy);
+
+ expect(result.status).toBe('success');
+ expect(result.action).toBe('enable');
+ expect(result.modifiedScopes).toHaveLength(2);
+ expect(result.alreadyInStateScopes).toHaveLength(0);
+ expect(strategy.enable).toHaveBeenCalledTimes(2);
+ });
+
+ it('should include correct scope paths in the result', () => {
+ const settings = createMockLoadedSettings({
+ userPath: '/custom/user/path',
+ workspacePath: '/custom/workspace/path',
+ });
+ const strategy = createMockStrategy({
+ needsEnabling: () => true,
+ });
+
+ const result = enableFeature(settings, 'my-feature', strategy);
+
+ const paths = result.modifiedScopes.map((s) => s.path);
+ expect(paths).toContain('/custom/workspace/path');
+ expect(paths).toContain('/custom/user/path');
+ });
+ });
+
+ describe('disableFeature', () => {
+ it('should return no-op when the feature is already disabled in the target scope', () => {
+ const settings = createMockLoadedSettings({});
+ const strategy = createMockStrategy({
+ isExplicitlyDisabled: () => true,
+ });
+
+ const result = disableFeature(
+ settings,
+ 'my-feature',
+ SettingScope.User,
+ strategy,
+ );
+
+ expect(result.status).toBe('no-op');
+ expect(result.action).toBe('disable');
+ expect(result.featureName).toBe('my-feature');
+ expect(result.modifiedScopes).toHaveLength(0);
+ expect(result.alreadyInStateScopes).toHaveLength(1);
+ expect(strategy.disable).not.toHaveBeenCalled();
+ });
+
+ it('should disable the feature when it is enabled', () => {
+ const settings = createMockLoadedSettings({});
+ const strategy = createMockStrategy({
+ isExplicitlyDisabled: () => false,
+ });
+
+ const result = disableFeature(
+ settings,
+ 'my-feature',
+ SettingScope.User,
+ strategy,
+ );
+
+ expect(result.status).toBe('success');
+ expect(result.action).toBe('disable');
+ expect(result.modifiedScopes).toHaveLength(1);
+ expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
+ expect(strategy.disable).toHaveBeenCalledOnce();
+ });
+
+ it('should return error for an invalid scope', () => {
+ const settings = createMockLoadedSettings({});
+ const strategy = createMockStrategy();
+
+ const result = disableFeature(
+ settings,
+ 'my-feature',
+ SettingScope.Session,
+ strategy,
+ );
+
+ expect(result.status).toBe('error');
+ expect(result.action).toBe('disable');
+ expect(result.error).toContain('Invalid settings scope');
+ expect(strategy.disable).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/cli/src/utils/featureToggleUtils.ts b/packages/cli/src/utils/featureToggleUtils.ts
new file mode 100644
index 0000000000..9b3df0e5df
--- /dev/null
+++ b/packages/cli/src/utils/featureToggleUtils.ts
@@ -0,0 +1,185 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ SettingScope,
+ isLoadableSettingScope,
+ type LoadableSettingScope,
+ type LoadedSettings,
+} from '../config/settings.js';
+
+export interface ModifiedScope {
+ scope: SettingScope;
+ path: string;
+}
+
+export type FeatureActionStatus = 'success' | 'no-op' | 'error';
+
+export interface FeatureActionResult {
+ status: FeatureActionStatus;
+ featureName: string;
+ action: 'enable' | 'disable';
+ /** Scopes where the feature's state was actually changed. */
+ modifiedScopes: ModifiedScope[];
+ /** Scopes where the feature was already in the desired state. */
+ alreadyInStateScopes: ModifiedScope[];
+ /** Error message if status is 'error'. */
+ error?: string;
+}
+
+/**
+ * Strategy pattern to handle differences between feature types (e.g. skills vs agents).
+ */
+export interface FeatureToggleStrategy {
+ /**
+ * Checks if the feature needs to be enabled in the given scope.
+ * For skills (blacklist): returns true if in disabled list.
+ * For agents (whitelist): returns true if NOT explicitly enabled (false or undefined).
+ */
+ needsEnabling(
+ settings: LoadedSettings,
+ scope: LoadableSettingScope,
+ featureName: string,
+ ): boolean;
+
+ /**
+ * Applies the enable change to the settings object.
+ */
+ enable(
+ settings: LoadedSettings,
+ scope: LoadableSettingScope,
+ featureName: string,
+ ): void;
+
+ /**
+ * Checks if the feature is explicitly disabled in the given scope.
+ * For skills (blacklist): returns true if in disabled list.
+ * For agents (whitelist): returns true if explicitly set to false.
+ */
+ isExplicitlyDisabled(
+ settings: LoadedSettings,
+ scope: LoadableSettingScope,
+ featureName: string,
+ ): boolean;
+
+ /**
+ * Applies the disable change to the settings object.
+ */
+ disable(
+ settings: LoadedSettings,
+ scope: LoadableSettingScope,
+ featureName: string,
+ ): void;
+}
+
+/**
+ * Enables a feature by ensuring it is enabled in all writable scopes.
+ */
+export function enableFeature(
+ settings: LoadedSettings,
+ featureName: string,
+ strategy: FeatureToggleStrategy,
+): FeatureActionResult {
+ const writableScopes = [SettingScope.Workspace, SettingScope.User];
+ const foundInDisabledScopes: ModifiedScope[] = [];
+ const alreadyEnabledScopes: ModifiedScope[] = [];
+
+ for (const scope of writableScopes) {
+ if (isLoadableSettingScope(scope)) {
+ const scopePath = settings.forScope(scope).path;
+ if (strategy.needsEnabling(settings, scope, featureName)) {
+ foundInDisabledScopes.push({ scope, path: scopePath });
+ } else {
+ alreadyEnabledScopes.push({ scope, path: scopePath });
+ }
+ }
+ }
+
+ if (foundInDisabledScopes.length === 0) {
+ return {
+ status: 'no-op',
+ featureName,
+ action: 'enable',
+ modifiedScopes: [],
+ alreadyInStateScopes: alreadyEnabledScopes,
+ };
+ }
+
+ const modifiedScopes: ModifiedScope[] = [];
+ for (const { scope, path } of foundInDisabledScopes) {
+ if (isLoadableSettingScope(scope)) {
+ strategy.enable(settings, scope, featureName);
+ modifiedScopes.push({ scope, path });
+ }
+ }
+
+ return {
+ status: 'success',
+ featureName,
+ action: 'enable',
+ modifiedScopes,
+ alreadyInStateScopes: alreadyEnabledScopes,
+ };
+}
+
+/**
+ * Disables a feature in the specified scope.
+ */
+export function disableFeature(
+ settings: LoadedSettings,
+ featureName: string,
+ scope: SettingScope,
+ strategy: FeatureToggleStrategy,
+): FeatureActionResult {
+ if (!isLoadableSettingScope(scope)) {
+ return {
+ status: 'error',
+ featureName,
+ action: 'disable',
+ modifiedScopes: [],
+ alreadyInStateScopes: [],
+ error: `Invalid settings scope: ${scope}`,
+ };
+ }
+
+ const scopePath = settings.forScope(scope).path;
+
+ if (strategy.isExplicitlyDisabled(settings, scope, featureName)) {
+ return {
+ status: 'no-op',
+ featureName,
+ action: 'disable',
+ modifiedScopes: [],
+ alreadyInStateScopes: [{ scope, path: scopePath }],
+ };
+ }
+
+ // Check if it's already disabled in the other writable scope
+ const otherScope =
+ scope === SettingScope.Workspace
+ ? SettingScope.User
+ : SettingScope.Workspace;
+ const alreadyDisabledInOther: ModifiedScope[] = [];
+
+ if (isLoadableSettingScope(otherScope)) {
+ if (strategy.isExplicitlyDisabled(settings, otherScope, featureName)) {
+ alreadyDisabledInOther.push({
+ scope: otherScope,
+ path: settings.forScope(otherScope).path,
+ });
+ }
+ }
+
+ strategy.disable(settings, scope, featureName);
+
+ return {
+ status: 'success',
+ featureName,
+ action: 'disable',
+ modifiedScopes: [{ scope, path: scopePath }],
+ alreadyInStateScopes: alreadyDisabledInOther,
+ };
+}
diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts
index 0af2de37b1..5317bf00e4 100644
--- a/packages/cli/src/utils/handleAutoUpdate.test.ts
+++ b/packages/cli/src/utils/handleAutoUpdate.test.ts
@@ -12,7 +12,13 @@ import type { UpdateObject } from '../ui/utils/updateCheck.js';
import type { LoadedSettings } from '../config/settings.js';
import EventEmitter from 'node:events';
import type { ChildProcess } from 'node:child_process';
-import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js';
+import {
+ handleAutoUpdate,
+ setUpdateHandler,
+ isUpdateInProgress,
+ waitForUpdateCompletion,
+ _setUpdateStateForTesting,
+} from './handleAutoUpdate.js';
import { MessageType } from '../ui/types.js';
vi.mock('./installationInfo.js', async () => {
@@ -79,6 +85,7 @@ describe('handleAutoUpdate', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
+ _setUpdateStateForTesting(false);
});
it('should do nothing if update info is null', () => {
@@ -88,6 +95,80 @@ describe('handleAutoUpdate', () => {
expect(mockSpawn).not.toHaveBeenCalled();
});
+ it('should track update progress state', async () => {
+ mockGetInstallationInfo.mockReturnValue({
+ updateCommand: 'npm i -g @google/gemini-cli@latest',
+ updateMessage: 'This is an additional message.',
+ isGlobal: false,
+ packageManager: PackageManager.NPM,
+ });
+
+ expect(isUpdateInProgress()).toBe(false);
+
+ handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
+
+ expect(isUpdateInProgress()).toBe(true);
+
+ mockChildProcess.emit('close', 0);
+
+ expect(isUpdateInProgress()).toBe(false);
+ });
+
+ it('should track update progress state on error', async () => {
+ mockGetInstallationInfo.mockReturnValue({
+ updateCommand: 'npm i -g @google/gemini-cli@latest',
+ updateMessage: 'This is an additional message.',
+ isGlobal: false,
+ packageManager: PackageManager.NPM,
+ });
+
+ handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
+
+ expect(isUpdateInProgress()).toBe(true);
+
+ mockChildProcess.emit('error', new Error('fail'));
+
+ expect(isUpdateInProgress()).toBe(false);
+ });
+
+ it('should resolve waitForUpdateCompletion when update succeeds', async () => {
+ _setUpdateStateForTesting(true);
+
+ const waitPromise = waitForUpdateCompletion();
+ updateEventEmitter.emit('update-success', {});
+
+ await expect(waitPromise).resolves.toBeUndefined();
+ });
+
+ it('should resolve waitForUpdateCompletion when update fails', async () => {
+ _setUpdateStateForTesting(true);
+
+ const waitPromise = waitForUpdateCompletion();
+ updateEventEmitter.emit('update-failed', {});
+
+ await expect(waitPromise).resolves.toBeUndefined();
+ });
+
+ it('should resolve waitForUpdateCompletion immediately if not in progress', async () => {
+ _setUpdateStateForTesting(false);
+
+ const waitPromise = waitForUpdateCompletion();
+
+ await expect(waitPromise).resolves.toBeUndefined();
+ });
+
+ it('should timeout waitForUpdateCompletion', async () => {
+ vi.useFakeTimers();
+ _setUpdateStateForTesting(true);
+
+ const waitPromise = waitForUpdateCompletion(1000);
+
+ vi.advanceTimersByTime(1001);
+
+ await expect(waitPromise).resolves.toBeUndefined();
+ vi.useRealTimers();
+ });
+
it('should do nothing if update prompts are disabled', () => {
mockSettings.merged.general.enableAutoUpdateNotification = false;
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts
index a6d0cdc574..8a7b6f3925 100644
--- a/packages/cli/src/utils/handleAutoUpdate.ts
+++ b/packages/cli/src/utils/handleAutoUpdate.ts
@@ -12,6 +12,54 @@ import type { HistoryItem } from '../ui/types.js';
import { MessageType } from '../ui/types.js';
import { spawnWrapper } from './spawnWrapper.js';
import type { spawn } from 'node:child_process';
+import { debugLogger } from '@google/gemini-cli-core';
+
+let _updateInProgress = false;
+
+/** @internal */
+export function _setUpdateStateForTesting(value: boolean) {
+ _updateInProgress = value;
+}
+
+export function isUpdateInProgress() {
+ return _updateInProgress;
+}
+
+/**
+ * Returns a promise that resolves when the update process completes or times out.
+ */
+export async function waitForUpdateCompletion(
+ timeoutMs = 30000,
+): Promise {
+ if (!_updateInProgress) {
+ return;
+ }
+
+ debugLogger.log(
+ '\nGemini CLI is waiting for a background update to complete before restarting...',
+ );
+
+ return new Promise((resolve) => {
+ // Re-check the condition inside the promise executor to avoid a race condition.
+ // If the update finished between the initial check and now, resolve immediately.
+ if (!_updateInProgress) {
+ resolve();
+ return;
+ }
+
+ const timer = setTimeout(cleanup, timeoutMs);
+
+ function cleanup() {
+ clearTimeout(timer);
+ updateEventEmitter.off('update-success', cleanup);
+ updateEventEmitter.off('update-failed', cleanup);
+ resolve();
+ }
+
+ updateEventEmitter.once('update-success', cleanup);
+ updateEventEmitter.once('update-failed', cleanup);
+ });
+}
export function handleAutoUpdate(
info: UpdateObject | null,
@@ -62,6 +110,11 @@ export function handleAutoUpdate(
) {
return;
}
+
+ if (_updateInProgress) {
+ return;
+ }
+
const isNightly = info.update.latest.includes('nightly');
const updateCommand = installationInfo.updateCommand.replace(
@@ -73,10 +126,14 @@ export function handleAutoUpdate(
shell: true,
detached: true,
});
+
+ _updateInProgress = true;
+
// Un-reference the child process to allow the parent to exit independently.
updateProcess.unref();
updateProcess.on('close', (code) => {
+ _updateInProgress = false;
if (code === 0) {
updateEventEmitter.emit('update-success', {
message:
@@ -90,6 +147,7 @@ export function handleAutoUpdate(
});
updateProcess.on('error', (err) => {
+ _updateInProgress = false;
updateEventEmitter.emit('update-failed', {
message: `Automatic update failed. Please try updating manually. (error: ${err.message})`,
});
diff --git a/packages/cli/src/utils/processUtils.test.ts b/packages/cli/src/utils/processUtils.test.ts
index be85a4dbad..3e6b7913e9 100644
--- a/packages/cli/src/utils/processUtils.test.ts
+++ b/packages/cli/src/utils/processUtils.test.ts
@@ -5,8 +5,17 @@
*/
import { vi } from 'vitest';
-import { RELAUNCH_EXIT_CODE, relaunchApp } from './processUtils.js';
+import {
+ RELAUNCH_EXIT_CODE,
+ relaunchApp,
+ _resetRelaunchStateForTesting,
+} from './processUtils.js';
import * as cleanup from './cleanup.js';
+import * as handleAutoUpdate from './handleAutoUpdate.js';
+
+vi.mock('./handleAutoUpdate.js', () => ({
+ waitForUpdateCompletion: vi.fn().mockResolvedValue(undefined),
+}));
describe('processUtils', () => {
const processExit = vi
@@ -14,8 +23,15 @@ describe('processUtils', () => {
.mockReturnValue(undefined as never);
const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup');
- it('should run cleanup and exit with the relaunch code', async () => {
+ beforeEach(() => {
+ _resetRelaunchStateForTesting();
+ });
+
+ afterEach(() => vi.clearAllMocks());
+
+ it('should wait for updates, run cleanup, and exit with the relaunch code', async () => {
await relaunchApp();
+ expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
expect(runExitCleanup).toHaveBeenCalledTimes(1);
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
});
diff --git a/packages/cli/src/utils/processUtils.ts b/packages/cli/src/utils/processUtils.ts
index 1122a2b0dc..c43f5c54fd 100644
--- a/packages/cli/src/utils/processUtils.ts
+++ b/packages/cli/src/utils/processUtils.ts
@@ -5,6 +5,7 @@
*/
import { runExitCleanup } from './cleanup.js';
+import { waitForUpdateCompletion } from './handleAutoUpdate.js';
/**
* Exit code used to signal that the CLI should be relaunched.
@@ -14,7 +15,17 @@ export const RELAUNCH_EXIT_CODE = 199;
/**
* Exits the process with a special code to signal that the parent process should relaunch it.
*/
+let isRelaunching = false;
+
+/** @internal only for testing */
+export function _resetRelaunchStateForTesting(): void {
+ isRelaunching = false;
+}
+
export async function relaunchApp(): Promise {
+ if (isRelaunching) return;
+ isRelaunching = true;
+ await waitForUpdateCompletion();
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
}
diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts
index 50b1699644..fa562f7ad6 100644
--- a/packages/cli/src/utils/sandbox.test.ts
+++ b/packages/cli/src/utils/sandbox.test.ts
@@ -5,7 +5,7 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { spawn, exec, execSync } from 'node:child_process';
+import { spawn, exec, execFile, execSync } from 'node:child_process';
import os from 'node:os';
import fs from 'node:fs';
import { start_sandbox } from './sandbox.js';
@@ -50,6 +50,26 @@ vi.mock('node:util', async (importOriginal) => {
return { stdout: '', stderr: '' };
};
}
+ if (fn === execFile) {
+ return async (file: string, args: string[]) => {
+ if (file === 'lxc' && args[0] === 'list') {
+ const output = process.env['TEST_LXC_LIST_OUTPUT'];
+ if (output === 'throw') {
+ throw new Error('lxc command not found');
+ }
+ return { stdout: output ?? '[]', stderr: '' };
+ }
+ if (
+ file === 'lxc' &&
+ args[0] === 'config' &&
+ args[1] === 'device' &&
+ args[2] === 'add'
+ ) {
+ return { stdout: '', stderr: '' };
+ }
+ return { stdout: '', stderr: '' };
+ };
+ }
return actual.promisify(fn);
},
};
@@ -473,5 +493,137 @@ describe('sandbox', () => {
expect(entrypointCmd).toContain('useradd');
expect(entrypointCmd).toContain('su -p gemini');
});
+
+ describe('LXC sandbox', () => {
+ const LXC_RUNNING = JSON.stringify([
+ { name: 'gemini-sandbox', status: 'Running' },
+ ]);
+ const LXC_STOPPED = JSON.stringify([
+ { name: 'gemini-sandbox', status: 'Stopped' },
+ ]);
+
+ beforeEach(() => {
+ delete process.env['TEST_LXC_LIST_OUTPUT'];
+ });
+
+ it('should run lxc exec with correct args for a running container', async () => {
+ process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING;
+ const config: SandboxConfig = {
+ command: 'lxc',
+ image: 'gemini-sandbox',
+ };
+
+ const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
+ typeof spawn
+ >;
+ mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
+ if (event === 'close') {
+ setTimeout(() => cb(0), 10);
+ }
+ return mockSpawnProcess;
+ });
+
+ vi.mocked(spawn).mockImplementation((cmd) => {
+ if (cmd === 'lxc') {
+ return mockSpawnProcess;
+ }
+ return new EventEmitter() as unknown as ReturnType;
+ });
+
+ const promise = start_sandbox(config, [], undefined, ['arg1']);
+ await expect(promise).resolves.toBe(0);
+
+ expect(spawn).toHaveBeenCalledWith(
+ 'lxc',
+ expect.arrayContaining(['exec', 'gemini-sandbox', '--cwd']),
+ expect.objectContaining({ stdio: 'inherit' }),
+ );
+ });
+
+ it('should throw FatalSandboxError if lxc list fails', async () => {
+ process.env['TEST_LXC_LIST_OUTPUT'] = 'throw';
+ const config: SandboxConfig = {
+ command: 'lxc',
+ image: 'gemini-sandbox',
+ };
+
+ await expect(start_sandbox(config)).rejects.toThrow(
+ /Failed to query LXC container/,
+ );
+ });
+
+ it('should throw FatalSandboxError if container is not running', async () => {
+ process.env['TEST_LXC_LIST_OUTPUT'] = LXC_STOPPED;
+ const config: SandboxConfig = {
+ command: 'lxc',
+ image: 'gemini-sandbox',
+ };
+
+ await expect(start_sandbox(config)).rejects.toThrow(/is not running/);
+ });
+
+ it('should throw FatalSandboxError if container is not found in list', async () => {
+ process.env['TEST_LXC_LIST_OUTPUT'] = '[]';
+ const config: SandboxConfig = {
+ command: 'lxc',
+ image: 'gemini-sandbox',
+ };
+
+ await expect(start_sandbox(config)).rejects.toThrow(/not found/);
+ });
+ });
+ });
+
+ describe('gVisor (runsc)', () => {
+ it('should use docker with --runtime=runsc on Linux', async () => {
+ vi.mocked(os.platform).mockReturnValue('linux');
+ const config: SandboxConfig = {
+ command: 'runsc',
+ image: 'gemini-cli-sandbox',
+ };
+
+ // Mock image check
+ interface MockProcessWithStdout extends EventEmitter {
+ stdout: EventEmitter;
+ }
+ const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
+ mockImageCheckProcess.stdout = new EventEmitter();
+ vi.mocked(spawn).mockImplementationOnce(() => {
+ setTimeout(() => {
+ mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
+ mockImageCheckProcess.emit('close', 0);
+ }, 1);
+ return mockImageCheckProcess as unknown as ReturnType;
+ });
+
+ // Mock docker run
+ const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
+ typeof spawn
+ >;
+ mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
+ if (event === 'close') {
+ setTimeout(() => cb(0), 10);
+ }
+ return mockSpawnProcess;
+ });
+ vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
+
+ await start_sandbox(config, [], undefined, ['arg1']);
+
+ // Verify docker (not runsc) is called for image check
+ expect(spawn).toHaveBeenNthCalledWith(
+ 1,
+ 'docker',
+ expect.arrayContaining(['images', '-q', 'gemini-cli-sandbox']),
+ );
+
+ // Verify docker run includes --runtime=runsc
+ expect(spawn).toHaveBeenNthCalledWith(
+ 2,
+ 'docker',
+ expect.arrayContaining(['run', '--runtime=runsc']),
+ expect.objectContaining({ stdio: 'inherit' }),
+ );
+ });
});
});
diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts
index ffd77fb119..df9a88cc4c 100644
--- a/packages/cli/src/utils/sandbox.ts
+++ b/packages/cli/src/utils/sandbox.ts
@@ -4,7 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
+import {
+ exec,
+ execFile,
+ execFileSync,
+ execSync,
+ spawn,
+ type ChildProcess,
+} from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
@@ -34,6 +41,7 @@ import {
} from './sandboxUtils.js';
const execAsync = promisify(exec);
+const execFileAsync = promisify(execFile);
export async function start_sandbox(
config: SandboxConfig,
@@ -203,7 +211,14 @@ export async function start_sandbox(
});
}
- debugLogger.log(`hopping into sandbox (command: ${config.command}) ...`);
+ if (config.command === 'lxc') {
+ return await start_lxc_sandbox(config, nodeArgs, cliArgs);
+ }
+
+ // runsc uses docker with --runtime=runsc
+ const command = config.command === 'runsc' ? 'docker' : config.command;
+
+ debugLogger.log(`hopping into sandbox (command: ${command}) ...`);
// determine full path for gemini-cli to distinguish linked vs installed setting
const gcPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
@@ -246,7 +261,7 @@ export async function start_sandbox(
stdio: 'inherit',
env: {
...process.env,
- GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package)
+ GEMINI_SANDBOX: command, // in case sandbox is enabled via flags (see config.ts under cli package)
},
},
);
@@ -254,9 +269,7 @@ export async function start_sandbox(
}
// stop if image is missing
- if (
- !(await ensureSandboxImageIsPresent(config.command, image, cliConfig))
- ) {
+ if (!(await ensureSandboxImageIsPresent(command, image, cliConfig))) {
const remedy =
image === LOCAL_DEV_SANDBOX_IMAGE_NAME
? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.'
@@ -270,11 +283,17 @@ export async function start_sandbox(
// run init binary inside container to forward signals & reap zombies
const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];
+ // add runsc runtime if using runsc
+ if (config.command === 'runsc') {
+ args.push('--runtime=runsc');
+ }
+
// add custom flags from SANDBOX_FLAGS
if (process.env['SANDBOX_FLAGS']) {
const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter(
(f): f is string => typeof f === 'string',
);
+
args.push(...flags);
}
@@ -410,7 +429,7 @@ export async function start_sandbox(
// if using proxy, switch to internal networking through proxy
if (proxy) {
execSync(
- `${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`,
+ `${command} network inspect ${SANDBOX_NETWORK_NAME} || ${command} network create --internal ${SANDBOX_NETWORK_NAME}`,
);
args.push('--network', SANDBOX_NETWORK_NAME);
// if proxy command is set, create a separate network w/ host access (i.e. non-internal)
@@ -418,7 +437,7 @@ export async function start_sandbox(
// this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation
if (proxyCommand) {
execSync(
- `${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`,
+ `${command} network inspect ${SANDBOX_PROXY_NAME} || ${command} network create ${SANDBOX_PROXY_NAME}`,
);
}
}
@@ -437,7 +456,7 @@ export async function start_sandbox(
} else {
let index = 0;
const containerNameCheck = (
- await execAsync(`${config.command} ps -a --format "{{.Names}}"`)
+ await execAsync(`${command} ps -a --format "{{.Names}}"`)
).stdout.trim();
while (containerNameCheck.includes(`${imageName}-${index}`)) {
index++;
@@ -587,7 +606,7 @@ export async function start_sandbox(
args.push('--env', `SANDBOX=${containerName}`);
// for podman only, use empty --authfile to skip unnecessary auth refresh overhead
- if (config.command === 'podman') {
+ if (command === 'podman') {
const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json');
fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8');
args.push('--authfile', emptyAuthFilePath);
@@ -651,16 +670,38 @@ export async function start_sandbox(
if (proxyCommand) {
// run proxyCommand in its own container
- const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`;
- proxyProcess = spawn(proxyContainerCommand, {
+ // build args array to prevent command injection
+ const proxyContainerArgs = [
+ 'run',
+ '--rm',
+ '--init',
+ ...(userFlag ? userFlag.split(' ') : []),
+ '--name',
+ SANDBOX_PROXY_NAME,
+ '--network',
+ SANDBOX_PROXY_NAME,
+ '-p',
+ '8877:8877',
+ '-v',
+ `${process.cwd()}:${workdir}`,
+ '--workdir',
+ workdir,
+ image,
+ // proxyCommand may be a shell string, so parse it into tokens safely
+ ...parse(proxyCommand, process.env).filter(
+ (f): f is string => typeof f === 'string',
+ ),
+ ];
+
+ proxyProcess = spawn(command, proxyContainerArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
- shell: true,
+ shell: false, // <-- no shell; args are passed directly
detached: true,
});
// install handlers to stop proxy on exit/signal
const stopProxy = () => {
debugLogger.log('stopping proxy container ...');
- execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`);
+ execSync(`${command} rm -f ${SANDBOX_PROXY_NAME}`);
};
process.off('exit', stopProxy);
process.on('exit', stopProxy);
@@ -681,7 +722,7 @@ export async function start_sandbox(
process.kill(-sandboxProcess.pid, 'SIGTERM');
}
throw new FatalSandboxError(
- `Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`,
+ `Proxy container command '${command} ${proxyContainerArgs.join(' ')}' exited with code ${code}, signal ${signal}`,
);
});
debugLogger.log('waiting for proxy to start ...');
@@ -691,13 +732,13 @@ export async function start_sandbox(
// connect proxy container to sandbox network
// (workaround for older versions of docker that don't support multiple --network args)
await execAsync(
- `${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,
+ `${command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,
);
}
// spawn child and let it inherit stdio
process.stdin.pause();
- sandboxProcess = spawn(config.command, args, {
+ sandboxProcess = spawn(command, args, {
stdio: 'inherit',
});
@@ -722,6 +763,208 @@ export async function start_sandbox(
}
}
+// Helper function to start a sandbox using LXC/LXD.
+// Unlike Docker/Podman, LXC does not launch a transient container from an
+// image. The user creates and manages their own LXC container; Gemini runs
+// inside it via `lxc exec`. The container name is stored in config.image
+// (default: "gemini-sandbox"). The workspace is bind-mounted into the
+// container at the same absolute path.
+async function start_lxc_sandbox(
+ config: SandboxConfig,
+ nodeArgs: string[] = [],
+ cliArgs: string[] = [],
+): Promise {
+ const containerName = config.image || 'gemini-sandbox';
+ const workdir = path.resolve(process.cwd());
+
+ debugLogger.log(
+ `starting lxc sandbox (container: ${containerName}, workdir: ${workdir}) ...`,
+ );
+
+ // Verify the container exists and is running.
+ let listOutput: string;
+ try {
+ const { stdout } = await execFileAsync('lxc', [
+ 'list',
+ containerName,
+ '--format=json',
+ ]);
+ listOutput = stdout.trim();
+ } catch (err) {
+ throw new FatalSandboxError(
+ `Failed to query LXC container '${containerName}': ${err instanceof Error ? err.message : String(err)}. ` +
+ `Make sure LXC/LXD is installed and '${containerName}' container exists. ` +
+ `Create one with: lxc launch ubuntu:24.04 ${containerName}`,
+ );
+ }
+
+ let containers: Array<{ name: string; status: string }> = [];
+ try {
+ const parsed: unknown = JSON.parse(listOutput);
+ if (Array.isArray(parsed)) {
+ containers = parsed
+ .filter(
+ (item): item is Record =>
+ item !== null &&
+ typeof item === 'object' &&
+ 'name' in item &&
+ 'status' in item,
+ )
+ .map((item) => ({
+ name: String(item['name']),
+ status: String(item['status']),
+ }));
+ }
+ } catch {
+ containers = [];
+ }
+
+ const container = containers.find((c) => c.name === containerName);
+ if (!container) {
+ throw new FatalSandboxError(
+ `LXC container '${containerName}' not found. ` +
+ `Create one with: lxc launch ubuntu:24.04 ${containerName}`,
+ );
+ }
+ if (container.status.toLowerCase() !== 'running') {
+ throw new FatalSandboxError(
+ `LXC container '${containerName}' is not running (current status: ${container.status}). ` +
+ `Start it with: lxc start ${containerName}`,
+ );
+ }
+
+ // Bind-mount the working directory into the container at the same path.
+ // Using "lxc config device add" is idempotent when the device name matches.
+ const deviceName = `gemini-workspace-${randomBytes(4).toString('hex')}`;
+ try {
+ await execFileAsync('lxc', [
+ 'config',
+ 'device',
+ 'add',
+ containerName,
+ deviceName,
+ 'disk',
+ `source=${workdir}`,
+ `path=${workdir}`,
+ ]);
+ debugLogger.log(
+ `mounted workspace '${workdir}' into container as device '${deviceName}'`,
+ );
+ } catch (err) {
+ throw new FatalSandboxError(
+ `Failed to mount workspace into LXC container '${containerName}': ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
+ // Remove the workspace device from the container when the process exits.
+ // Only the 'exit' event is needed — the CLI's cleanup.ts already handles
+ // SIGINT and SIGTERM by calling process.exit(), which fires 'exit'.
+ const removeDevice = () => {
+ try {
+ execFileSync(
+ 'lxc',
+ ['config', 'device', 'remove', containerName, deviceName],
+ { timeout: 2000 },
+ );
+ } catch {
+ // Best-effort cleanup; ignore errors on exit.
+ }
+ };
+ process.on('exit', removeDevice);
+
+ // Build the environment variable arguments for `lxc exec`.
+ const envArgs: string[] = [];
+ const envVarsToForward: Record = {
+ GEMINI_API_KEY: process.env['GEMINI_API_KEY'],
+ GOOGLE_API_KEY: process.env['GOOGLE_API_KEY'],
+ GOOGLE_GEMINI_BASE_URL: process.env['GOOGLE_GEMINI_BASE_URL'],
+ GOOGLE_VERTEX_BASE_URL: process.env['GOOGLE_VERTEX_BASE_URL'],
+ GOOGLE_GENAI_USE_VERTEXAI: process.env['GOOGLE_GENAI_USE_VERTEXAI'],
+ GOOGLE_GENAI_USE_GCA: process.env['GOOGLE_GENAI_USE_GCA'],
+ GOOGLE_CLOUD_PROJECT: process.env['GOOGLE_CLOUD_PROJECT'],
+ GOOGLE_CLOUD_LOCATION: process.env['GOOGLE_CLOUD_LOCATION'],
+ GEMINI_MODEL: process.env['GEMINI_MODEL'],
+ TERM: process.env['TERM'],
+ COLORTERM: process.env['COLORTERM'],
+ GEMINI_CLI_IDE_SERVER_PORT: process.env['GEMINI_CLI_IDE_SERVER_PORT'],
+ GEMINI_CLI_IDE_WORKSPACE_PATH: process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'],
+ TERM_PROGRAM: process.env['TERM_PROGRAM'],
+ };
+ for (const [key, value] of Object.entries(envVarsToForward)) {
+ if (value) {
+ envArgs.push('--env', `${key}=${value}`);
+ }
+ }
+
+ // Forward SANDBOX_ENV key=value pairs
+ if (process.env['SANDBOX_ENV']) {
+ for (let env of process.env['SANDBOX_ENV'].split(',')) {
+ if ((env = env.trim())) {
+ if (env.includes('=')) {
+ envArgs.push('--env', env);
+ } else {
+ throw new FatalSandboxError(
+ 'SANDBOX_ENV must be a comma-separated list of key=value pairs',
+ );
+ }
+ }
+ }
+ }
+
+ // Forward NODE_OPTIONS (e.g. from --inspect flags)
+ const existingNodeOptions = process.env['NODE_OPTIONS'] || '';
+ const allNodeOptions = [
+ ...(existingNodeOptions ? [existingNodeOptions] : []),
+ ...nodeArgs,
+ ].join(' ');
+ if (allNodeOptions.length > 0) {
+ envArgs.push('--env', `NODE_OPTIONS=${allNodeOptions}`);
+ }
+
+ // Mark that we're running inside an LXC sandbox.
+ envArgs.push('--env', `SANDBOX=${containerName}`);
+
+ // Build the command entrypoint (same logic as Docker path).
+ const finalEntrypoint = entrypoint(workdir, cliArgs);
+
+ // Build the full lxc exec command args.
+ const args = [
+ 'exec',
+ containerName,
+ '--cwd',
+ workdir,
+ ...envArgs,
+ '--',
+ ...finalEntrypoint,
+ ];
+
+ debugLogger.log(`lxc exec args: ${args.join(' ')}`);
+
+ process.stdin.pause();
+ const sandboxProcess = spawn('lxc', args, {
+ stdio: 'inherit',
+ });
+
+ return new Promise((resolve, reject) => {
+ sandboxProcess.on('error', (err) => {
+ coreEvents.emitFeedback('error', 'LXC sandbox process error', err);
+ reject(err);
+ });
+
+ sandboxProcess.on('close', (code, signal) => {
+ process.stdin.resume();
+ process.off('exit', removeDevice);
+ removeDevice();
+ if (code !== 0 && code !== null) {
+ debugLogger.log(
+ `LXC sandbox process exited with code: ${code}, signal: ${signal}`,
+ );
+ }
+ resolve(code ?? 1);
+ });
+ });
+}
+
// Helper functions to ensure sandbox image is present
async function imageExists(sandbox: string, image: string): Promise {
return new Promise((resolve) => {
diff --git a/packages/cli/src/utils/skillSettings.test.ts b/packages/cli/src/utils/skillSettings.test.ts
new file mode 100644
index 0000000000..3a03e9ca9a
--- /dev/null
+++ b/packages/cli/src/utils/skillSettings.test.ts
@@ -0,0 +1,196 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import {
+ SettingScope,
+ type LoadedSettings,
+ type LoadableSettingScope,
+} from '../config/settings.js';
+import { enableSkill, disableSkill } from './skillSettings.js';
+
+function createMockLoadedSettings(opts: {
+ userSettings?: Record;
+ workspaceSettings?: Record;
+ userPath?: string;
+ workspacePath?: string;
+}): LoadedSettings {
+ const scopes: Record<
+ string,
+ {
+ settings: Record;
+ originalSettings: Record;
+ path: string;
+ }
+ > = {
+ [SettingScope.User]: {
+ settings: opts.userSettings ?? {},
+ originalSettings: opts.userSettings ?? {},
+ path: opts.userPath ?? '/home/user/.gemini/settings.json',
+ },
+ [SettingScope.Workspace]: {
+ settings: opts.workspaceSettings ?? {},
+ originalSettings: opts.workspaceSettings ?? {},
+ path: opts.workspacePath ?? '/project/.gemini/settings.json',
+ },
+ };
+
+ return {
+ forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]),
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+}
+
+describe('skillSettings', () => {
+ describe('skillStrategy (via enableSkill / disableSkill)', () => {
+ describe('enableSkill', () => {
+ it('should return no-op when the skill is not in any disabled list', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: { skills: { disabled: [] } },
+ workspaceSettings: { skills: { disabled: [] } },
+ });
+
+ const result = enableSkill(settings, 'my-skill');
+
+ expect(result.status).toBe('no-op');
+ expect(result.action).toBe('enable');
+ expect(result.skillName).toBe('my-skill');
+ expect(result.modifiedScopes).toHaveLength(0);
+ expect(settings.setValue).not.toHaveBeenCalled();
+ });
+
+ it('should return no-op when skills.disabled is undefined', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: {},
+ workspaceSettings: {},
+ });
+
+ const result = enableSkill(settings, 'my-skill');
+
+ expect(result.status).toBe('no-op');
+ expect(result.action).toBe('enable');
+ expect(result.modifiedScopes).toHaveLength(0);
+ });
+
+ it('should enable the skill when it is in the disabled list of one scope', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: { skills: { disabled: ['my-skill'] } },
+ workspaceSettings: { skills: { disabled: [] } },
+ });
+
+ const result = enableSkill(settings, 'my-skill');
+
+ expect(result.status).toBe('success');
+ expect(result.action).toBe('enable');
+ expect(result.modifiedScopes).toHaveLength(1);
+ expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
+ expect(result.alreadyInStateScopes).toHaveLength(1);
+ expect(result.alreadyInStateScopes[0].scope).toBe(
+ SettingScope.Workspace,
+ );
+ expect(settings.setValue).toHaveBeenCalledTimes(1);
+ });
+
+ it('should enable the skill when it is in the disabled list of both scopes', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: { skills: { disabled: ['my-skill', 'other-skill'] } },
+ workspaceSettings: { skills: { disabled: ['my-skill'] } },
+ });
+
+ const result = enableSkill(settings, 'my-skill');
+
+ expect(result.status).toBe('success');
+ expect(result.modifiedScopes).toHaveLength(2);
+ expect(result.alreadyInStateScopes).toHaveLength(0);
+ expect(settings.setValue).toHaveBeenCalledTimes(2);
+ });
+
+ it('should not affect other skills in the disabled list', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: { skills: { disabled: ['my-skill', 'keep-disabled'] } },
+ workspaceSettings: { skills: { disabled: [] } },
+ });
+
+ const result = enableSkill(settings, 'my-skill');
+
+ expect(result.status).toBe('success');
+ expect(settings.setValue).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('disableSkill', () => {
+ it('should return no-op when the skill is already in the disabled list', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: { skills: { disabled: ['my-skill'] } },
+ });
+
+ const result = disableSkill(settings, 'my-skill', SettingScope.User);
+
+ expect(result.status).toBe('no-op');
+ expect(result.action).toBe('disable');
+ expect(result.skillName).toBe('my-skill');
+ expect(result.modifiedScopes).toHaveLength(0);
+ expect(result.alreadyInStateScopes).toHaveLength(1);
+ expect(settings.setValue).not.toHaveBeenCalled();
+ });
+
+ it('should disable the skill when it is not in the disabled list', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: { skills: { disabled: [] } },
+ });
+
+ const result = disableSkill(settings, 'my-skill', SettingScope.User);
+
+ expect(result.status).toBe('success');
+ expect(result.action).toBe('disable');
+ expect(result.modifiedScopes).toHaveLength(1);
+ expect(result.modifiedScopes[0].scope).toBe(SettingScope.User);
+ expect(settings.setValue).toHaveBeenCalledTimes(1);
+ });
+
+ it('should disable the skill when skills.disabled is undefined', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: {},
+ });
+
+ const result = disableSkill(settings, 'my-skill', SettingScope.User);
+
+ expect(result.status).toBe('success');
+ expect(result.action).toBe('disable');
+ expect(result.modifiedScopes).toHaveLength(1);
+ expect(settings.setValue).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return error for an invalid scope', () => {
+ const settings = createMockLoadedSettings({});
+
+ const result = disableSkill(settings, 'my-skill', SettingScope.Session);
+
+ expect(result.status).toBe('error');
+ expect(result.error).toContain('Invalid settings scope');
+ });
+
+ it('should disable in workspace and report user as already disabled', () => {
+ const settings = createMockLoadedSettings({
+ userSettings: { skills: { disabled: ['my-skill'] } },
+ workspaceSettings: { skills: { disabled: [] } },
+ });
+
+ const result = disableSkill(
+ settings,
+ 'my-skill',
+ SettingScope.Workspace,
+ );
+
+ expect(result.status).toBe('success');
+ expect(result.modifiedScopes).toHaveLength(1);
+ expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace);
+ expect(result.alreadyInStateScopes).toHaveLength(1);
+ expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User);
+ });
+ });
+ });
+});
diff --git a/packages/cli/src/utils/skillSettings.ts b/packages/cli/src/utils/skillSettings.ts
index 78921b7219..4d1eb38b23 100644
--- a/packages/cli/src/utils/skillSettings.ts
+++ b/packages/cli/src/utils/skillSettings.ts
@@ -4,34 +4,58 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import {
- SettingScope,
- isLoadableSettingScope,
- type LoadedSettings,
-} from '../config/settings.js';
+import type { SettingScope, LoadedSettings } from '../config/settings.js';
-export interface ModifiedScope {
- scope: SettingScope;
- path: string;
-}
+import {
+ type FeatureActionResult,
+ type FeatureToggleStrategy,
+ enableFeature,
+ disableFeature,
+} from './featureToggleUtils.js';
+
+export type { ModifiedScope } from './featureToggleUtils.js';
export type SkillActionStatus = 'success' | 'no-op' | 'error';
/**
* Metadata representing the result of a skill settings operation.
*/
-export interface SkillActionResult {
- status: SkillActionStatus;
+export interface SkillActionResult
+ extends Omit {
skillName: string;
- action: 'enable' | 'disable';
- /** Scopes where the skill's state was actually changed. */
- modifiedScopes: ModifiedScope[];
- /** Scopes where the skill was already in the desired state. */
- alreadyInStateScopes: ModifiedScope[];
- /** Error message if status is 'error'. */
- error?: string;
}
+const skillStrategy: FeatureToggleStrategy = {
+ needsEnabling: (settings, scope, skillName) => {
+ const scopeDisabled = settings.forScope(scope).settings.skills?.disabled;
+ return !!scopeDisabled?.includes(skillName);
+ },
+ enable: (settings, scope, skillName) => {
+ const currentScopeDisabled =
+ settings.forScope(scope).settings.skills?.disabled ?? [];
+ const newDisabled = currentScopeDisabled.filter(
+ (name) => name !== skillName,
+ );
+ settings.setValue(scope, 'skills.disabled', newDisabled);
+ },
+ isExplicitlyDisabled: (settings, scope, skillName) => {
+ const currentScopeDisabled =
+ settings.forScope(scope).settings.skills?.disabled ?? [];
+ return currentScopeDisabled.includes(skillName);
+ },
+ disable: (settings, scope, skillName) => {
+ const currentScopeDisabled =
+ settings.forScope(scope).settings.skills?.disabled ?? [];
+ // The generic utility checks isExplicitlyDisabled before calling this,
+ // but just to be safe and idempotent, we check or we assume the utility did its job.
+ // The utility does check isExplicitlyDisabled first.
+ // So we can blindly add it, but since we are modifying an array, pushing is fine.
+ // However, if we assume purely that we must disable it:
+ const newDisabled = [...currentScopeDisabled, skillName];
+ settings.setValue(scope, 'skills.disabled', newDisabled);
+ },
+};
+
/**
* Enables a skill by removing it from all writable disabled lists (User and Workspace).
*/
@@ -39,51 +63,14 @@ export function enableSkill(
settings: LoadedSettings,
skillName: string,
): SkillActionResult {
- const writableScopes = [SettingScope.Workspace, SettingScope.User];
- const foundInDisabledScopes: ModifiedScope[] = [];
- const alreadyEnabledScopes: ModifiedScope[] = [];
-
- for (const scope of writableScopes) {
- if (isLoadableSettingScope(scope)) {
- const scopePath = settings.forScope(scope).path;
- const scopeDisabled = settings.forScope(scope).settings.skills?.disabled;
- if (scopeDisabled?.includes(skillName)) {
- foundInDisabledScopes.push({ scope, path: scopePath });
- } else {
- alreadyEnabledScopes.push({ scope, path: scopePath });
- }
- }
- }
-
- if (foundInDisabledScopes.length === 0) {
- return {
- status: 'no-op',
- skillName,
- action: 'enable',
- modifiedScopes: [],
- alreadyInStateScopes: alreadyEnabledScopes,
- };
- }
-
- const modifiedScopes: ModifiedScope[] = [];
- for (const { scope, path } of foundInDisabledScopes) {
- if (isLoadableSettingScope(scope)) {
- const currentScopeDisabled =
- settings.forScope(scope).settings.skills?.disabled ?? [];
- const newDisabled = currentScopeDisabled.filter(
- (name) => name !== skillName,
- );
- settings.setValue(scope, 'skills.disabled', newDisabled);
- modifiedScopes.push({ scope, path });
- }
- }
-
- return {
- status: 'success',
+ const { featureName, ...rest } = enableFeature(
+ settings,
skillName,
- action: 'enable',
- modifiedScopes,
- alreadyInStateScopes: alreadyEnabledScopes,
+ skillStrategy,
+ );
+ return {
+ ...rest,
+ skillName: featureName,
};
}
@@ -95,57 +82,14 @@ export function disableSkill(
skillName: string,
scope: SettingScope,
): SkillActionResult {
- if (!isLoadableSettingScope(scope)) {
- return {
- status: 'error',
- skillName,
- action: 'disable',
- modifiedScopes: [],
- alreadyInStateScopes: [],
- error: `Invalid settings scope: ${scope}`,
- };
- }
-
- const scopePath = settings.forScope(scope).path;
- const currentScopeDisabled =
- settings.forScope(scope).settings.skills?.disabled ?? [];
-
- if (currentScopeDisabled.includes(skillName)) {
- return {
- status: 'no-op',
- skillName,
- action: 'disable',
- modifiedScopes: [],
- alreadyInStateScopes: [{ scope, path: scopePath }],
- };
- }
-
- // Check if it's already disabled in the other writable scope
- const otherScope =
- scope === SettingScope.Workspace
- ? SettingScope.User
- : SettingScope.Workspace;
- const alreadyDisabledInOther: ModifiedScope[] = [];
-
- if (isLoadableSettingScope(otherScope)) {
- const otherScopeDisabled =
- settings.forScope(otherScope).settings.skills?.disabled;
- if (otherScopeDisabled?.includes(skillName)) {
- alreadyDisabledInOther.push({
- scope: otherScope,
- path: settings.forScope(otherScope).path,
- });
- }
- }
-
- const newDisabled = [...currentScopeDisabled, skillName];
- settings.setValue(scope, 'skills.disabled', newDisabled);
-
- return {
- status: 'success',
+ const { featureName, ...rest } = disableFeature(
+ settings,
skillName,
- action: 'disable',
- modifiedScopes: [{ scope, path: scopePath }],
- alreadyInStateScopes: alreadyDisabledInOther,
+ scope,
+ skillStrategy,
+ );
+ return {
+ ...rest,
+ skillName: featureName,
};
}
diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts
index aee1c1345e..1b7645c3f4 100644
--- a/packages/cli/test-setup.ts
+++ b/packages/cli/test-setup.ts
@@ -60,6 +60,10 @@ beforeEach(() => {
? stackLines.slice(lastReactFrameIndex + 1).join('\n')
: stackLines.slice(1).join('\n');
+ if (relevantStack.includes('OverflowContext.tsx')) {
+ return;
+ }
+
actWarnings.push({
message: format(...args),
stack: relevantStack,
diff --git a/packages/core/package.json b/packages/core/package.json
index 17bae2d0c8..827c09bc61 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
diff --git a/packages/core/src/agents/a2a-client-manager.test.ts b/packages/core/src/agents/a2a-client-manager.test.ts
index 58e68759fe..68189a6771 100644
--- a/packages/core/src/agents/a2a-client-manager.test.ts
+++ b/packages/core/src/agents/a2a-client-manager.test.ts
@@ -10,12 +10,13 @@ import {
type SendMessageResult,
} from './a2a-client-manager.js';
import type { AgentCard, Task } from '@a2a-js/sdk';
-import type { AuthenticationHandler, Client } from '@a2a-js/sdk/client';
import {
ClientFactory,
DefaultAgentCardResolver,
createAuthenticatingFetchWithRetry,
ClientFactoryOptions,
+ type AuthenticationHandler,
+ type Client,
} from '@a2a-js/sdk/client';
import { debugLogger } from '../utils/debugLogger.js';
diff --git a/packages/core/src/agents/a2a-client-manager.ts b/packages/core/src/agents/a2a-client-manager.ts
index 694905cdc5..e7070f3dfa 100644
--- a/packages/core/src/agents/a2a-client-manager.ts
+++ b/packages/core/src/agents/a2a-client-manager.ts
@@ -23,8 +23,20 @@ import {
createAuthenticatingFetchWithRetry,
} from '@a2a-js/sdk/client';
import { v4 as uuidv4 } from 'uuid';
+import { Agent as UndiciAgent } from 'undici';
import { debugLogger } from '../utils/debugLogger.js';
+// Remote agents can take 10+ minutes (e.g. Deep Research).
+// Use a dedicated dispatcher so the global 5-min timeout isn't affected.
+const A2A_TIMEOUT = 1800000; // 30 minutes
+const a2aDispatcher = new UndiciAgent({
+ headersTimeout: A2A_TIMEOUT,
+ bodyTimeout: A2A_TIMEOUT,
+});
+const a2aFetch: typeof fetch = (input, init) =>
+ // @ts-expect-error The `dispatcher` property is a Node.js extension to fetch not present in standard types.
+ fetch(input, { ...init, dispatcher: a2aDispatcher });
+
export type SendMessageResult =
| Message
| Task
@@ -79,9 +91,9 @@ export class A2AClientManager {
throw new Error(`Agent with name '${name}' is already loaded.`);
}
- let fetchImpl: typeof fetch = fetch;
+ let fetchImpl: typeof fetch = a2aFetch;
if (authHandler) {
- fetchImpl = createAuthenticatingFetchWithRetry(fetch, authHandler);
+ fetchImpl = createAuthenticatingFetchWithRetry(a2aFetch, authHandler);
}
const resolver = new DefaultAgentCardResolver({ fetchImpl });
diff --git a/packages/core/src/agents/a2aUtils.test.ts b/packages/core/src/agents/a2aUtils.test.ts
index f0ea746025..2bcdad2c40 100644
--- a/packages/core/src/agents/a2aUtils.test.ts
+++ b/packages/core/src/agents/a2aUtils.test.ts
@@ -10,6 +10,7 @@ import {
extractIdsFromResponse,
isTerminalState,
A2AResultReassembler,
+ AUTH_REQUIRED_MSG,
} from './a2aUtils.js';
import type { SendMessageResult } from './a2a-client-manager.js';
import type {
@@ -285,6 +286,66 @@ describe('a2aUtils', () => {
);
});
+ it('should handle auth-required state with a message', () => {
+ const reassembler = new A2AResultReassembler();
+
+ reassembler.update({
+ kind: 'status-update',
+ status: {
+ state: 'auth-required',
+ message: {
+ kind: 'message',
+ role: 'agent',
+ parts: [{ kind: 'text', text: 'I need your permission.' }],
+ } as Message,
+ },
+ } as unknown as SendMessageResult);
+
+ expect(reassembler.toString()).toContain('I need your permission.');
+ expect(reassembler.toString()).toContain(AUTH_REQUIRED_MSG);
+ });
+
+ it('should handle auth-required state without relying on metadata', () => {
+ const reassembler = new A2AResultReassembler();
+
+ reassembler.update({
+ kind: 'status-update',
+ status: {
+ state: 'auth-required',
+ },
+ } as unknown as SendMessageResult);
+
+ expect(reassembler.toString()).toContain(AUTH_REQUIRED_MSG);
+ });
+
+ it('should not duplicate the auth instruction OR agent message if multiple identical auth-required chunks arrive', () => {
+ const reassembler = new A2AResultReassembler();
+
+ const chunk = {
+ kind: 'status-update',
+ status: {
+ state: 'auth-required',
+ message: {
+ kind: 'message',
+ role: 'agent',
+ parts: [{ kind: 'text', text: 'You need to login here.' }],
+ } as Message,
+ },
+ } as unknown as SendMessageResult;
+
+ reassembler.update(chunk);
+ // Simulate multiple updates with the same overall state
+ reassembler.update(chunk);
+ reassembler.update(chunk);
+
+ const output = reassembler.toString();
+ // The substring should only appear exactly once
+ expect(output.split(AUTH_REQUIRED_MSG).length - 1).toBe(1);
+
+ // Crucially, the agent's actual custom message should ALSO only appear exactly once
+ expect(output.split('You need to login here.').length - 1).toBe(1);
+ });
+
it('should fallback to history in a task chunk if no message or artifacts exist and task is terminal', () => {
const reassembler = new A2AResultReassembler();
diff --git a/packages/core/src/agents/a2aUtils.ts b/packages/core/src/agents/a2aUtils.ts
index 52817f4971..dc39f4e660 100644
--- a/packages/core/src/agents/a2aUtils.ts
+++ b/packages/core/src/agents/a2aUtils.ts
@@ -16,6 +16,8 @@ import type {
} from '@a2a-js/sdk';
import type { SendMessageResult } from './a2a-client-manager.js';
+export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`;
+
/**
* Reassembles incremental A2A streaming updates into a coherent result.
* Shows sequential status/messages followed by all reassembled artifacts.
@@ -33,6 +35,7 @@ export class A2AResultReassembler {
switch (chunk.kind) {
case 'status-update':
+ this.appendStateInstructions(chunk.status?.state);
this.pushMessage(chunk.status?.message);
break;
@@ -65,6 +68,7 @@ export class A2AResultReassembler {
break;
case 'task':
+ this.appendStateInstructions(chunk.status?.state);
this.pushMessage(chunk.status?.message);
if (chunk.artifacts) {
for (const art of chunk.artifacts) {
@@ -106,6 +110,17 @@ export class A2AResultReassembler {
}
}
+ private appendStateInstructions(state: TaskState | undefined) {
+ if (state !== 'auth-required') {
+ return;
+ }
+
+ // Prevent duplicate instructions if multiple chunks report auth-required
+ if (!this.messageLog.includes(AUTH_REQUIRED_MSG)) {
+ this.messageLog.push(AUTH_REQUIRED_MSG);
+ }
+ }
+
private pushMessage(message: Message | undefined) {
if (!message) return;
const text = extractPartsText(message.parts, '\n');
diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts
index 7d264ad299..a7ef62318f 100644
--- a/packages/core/src/agents/agentLoader.test.ts
+++ b/packages/core/src/agents/agentLoader.test.ts
@@ -15,8 +15,11 @@ import {
AgentLoadError,
} from './agentLoader.js';
import { GEMINI_MODEL_ALIAS_PRO } from '../config/models.js';
-import type { LocalAgentDefinition } from './types.js';
-import { DEFAULT_MAX_TIME_MINUTES, DEFAULT_MAX_TURNS } from './types.js';
+import {
+ DEFAULT_MAX_TIME_MINUTES,
+ DEFAULT_MAX_TURNS,
+ type LocalAgentDefinition,
+} from './types.js';
describe('loader', () => {
let tempDir: string;
diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts
index 6c25181afe..81d85bb505 100644
--- a/packages/core/src/agents/browser/browserManager.test.ts
+++ b/packages/core/src/agents/browser/browserManager.test.ts
@@ -147,7 +147,7 @@ describe('BrowserManager', () => {
// Verify StdioClientTransport was created with correct args
expect(StdioClientTransport).toHaveBeenCalledWith(
expect.objectContaining({
- command: 'npx',
+ command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
args: expect.arrayContaining([
'-y',
expect.stringMatching(/chrome-devtools-mcp@/),
@@ -185,7 +185,7 @@ describe('BrowserManager', () => {
expect(StdioClientTransport).toHaveBeenCalledWith(
expect.objectContaining({
- command: 'npx',
+ command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
args: expect.arrayContaining(['--headless']),
}),
);
@@ -210,7 +210,7 @@ describe('BrowserManager', () => {
expect(StdioClientTransport).toHaveBeenCalledWith(
expect.objectContaining({
- command: 'npx',
+ command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
args: expect.arrayContaining(['--userDataDir', '/path/to/profile']),
}),
);
diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts
index 205eb11a1f..67626c63e9 100644
--- a/packages/core/src/agents/browser/browserManager.ts
+++ b/packages/core/src/agents/browser/browserManager.ts
@@ -283,7 +283,7 @@ export class BrowserManager {
// stderr is piped (not inherited) to prevent MCP server banners and
// warnings from corrupting the UI in alternate buffer mode.
this.mcpTransport = new StdioClientTransport({
- command: 'npx',
+ command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
args: mcpArgs,
stderr: 'pipe',
});
diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts
index 5fb28d0e8a..50eb30da76 100644
--- a/packages/core/src/agents/local-executor.test.ts
+++ b/packages/core/src/agents/local-executor.test.ts
@@ -54,13 +54,13 @@ import {
AgentFinishEvent,
RecoveryAttemptEvent,
} from '../telemetry/types.js';
-import type {
- AgentInputs,
- LocalAgentDefinition,
- SubagentActivityEvent,
- OutputConfig,
+import {
+ AgentTerminateMode,
+ type AgentInputs,
+ type LocalAgentDefinition,
+ type SubagentActivityEvent,
+ type OutputConfig,
} from './types.js';
-import { AgentTerminateMode } from './types.js';
import type { AnyDeclarativeTool, AnyToolInvocation } from '../tools/tools.js';
import type { ToolCallRequestInfo } from '../scheduler/types.js';
import { CompressionStatus } from '../core/turn.js';
@@ -69,8 +69,7 @@ import type {
ModelConfigKey,
ResolvedModelConfig,
} from '../services/modelConfigService.js';
-import type { AgentRegistry } from './registry.js';
-import { getModelConfigAlias } from './registry.js';
+import { getModelConfigAlias, type AgentRegistry } from './registry.js';
import type { ModelRouterService } from '../routing/modelRouterService.js';
const {
diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts
index 44616d29fa..7bbecdac7c 100644
--- a/packages/core/src/agents/local-executor.ts
+++ b/packages/core/src/agents/local-executor.ts
@@ -7,13 +7,13 @@
import type { Config } from '../config/config.js';
import { reportError } from '../utils/errorReporting.js';
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';
-import { Type } from '@google/genai';
-import type {
- Content,
- Part,
- FunctionCall,
- FunctionDeclaration,
- Schema,
+import {
+ Type,
+ type Content,
+ type Part,
+ type FunctionCall,
+ type FunctionDeclaration,
+ type Schema,
} from '@google/genai';
import { ToolRegistry } from '../tools/tool-registry.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
@@ -33,17 +33,15 @@ import {
LlmRole,
RecoveryAttemptEvent,
} from '../telemetry/types.js';
-import type {
- LocalAgentDefinition,
- AgentInputs,
- OutputObject,
- SubagentActivityEvent,
-} from './types.js';
import {
AgentTerminateMode,
DEFAULT_QUERY_STRING,
DEFAULT_MAX_TURNS,
DEFAULT_MAX_TIME_MINUTES,
+ type LocalAgentDefinition,
+ type AgentInputs,
+ type OutputObject,
+ type SubagentActivityEvent,
} from './types.js';
import { getErrorMessage } from '../utils/errors.js';
import { templateString } from './utils.js';
diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts
index 77509881af..c0be41442b 100644
--- a/packages/core/src/agents/local-invocation.test.ts
+++ b/packages/core/src/agents/local-invocation.test.ts
@@ -13,15 +13,15 @@ import {
afterEach,
type Mocked,
} from 'vitest';
-import type {
- LocalAgentDefinition,
- SubagentActivityEvent,
- AgentInputs,
- SubagentProgress,
+import {
+ AgentTerminateMode,
+ type LocalAgentDefinition,
+ type SubagentActivityEvent,
+ type AgentInputs,
+ type SubagentProgress,
} from './types.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { LocalAgentExecutor } from './local-executor.js';
-import { AgentTerminateMode } from './types.js';
import { makeFakeConfig } from '../test-utils/config.js';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
diff --git a/packages/core/src/agents/remote-invocation.ts b/packages/core/src/agents/remote-invocation.ts
index dad7f8167d..a8c75ec51c 100644
--- a/packages/core/src/agents/remote-invocation.ts
+++ b/packages/core/src/agents/remote-invocation.ts
@@ -4,26 +4,28 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type {
- ToolConfirmationOutcome,
- ToolResult,
- ToolCallConfirmationDetails,
+import {
+ BaseToolInvocation,
+ type ToolConfirmationOutcome,
+ type ToolResult,
+ type ToolCallConfirmationDetails,
} from '../tools/tools.js';
-import { BaseToolInvocation } from '../tools/tools.js';
-import { DEFAULT_QUERY_STRING } from './types.js';
-import type {
- RemoteAgentInputs,
- RemoteAgentDefinition,
- AgentInputs,
+import {
+ DEFAULT_QUERY_STRING,
+ type RemoteAgentInputs,
+ type RemoteAgentDefinition,
+ type AgentInputs,
} from './types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
-import { A2AClientManager } from './a2a-client-manager.js';
+import {
+ A2AClientManager,
+ type SendMessageResult,
+} from './a2a-client-manager.js';
import { extractIdsFromResponse, A2AResultReassembler } from './a2aUtils.js';
import { GoogleAuth } from 'google-auth-library';
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
import { debugLogger } from '../utils/debugLogger.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
-import type { SendMessageResult } from './a2a-client-manager.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
/**
diff --git a/packages/core/src/agents/subagent-tool.test.ts b/packages/core/src/agents/subagent-tool.test.ts
index c6e90ea198..622fd054f0 100644
--- a/packages/core/src/agents/subagent-tool.test.ts
+++ b/packages/core/src/agents/subagent-tool.test.ts
@@ -7,7 +7,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SubagentTool } from './subagent-tool.js';
import { SubagentToolWrapper } from './subagent-tool-wrapper.js';
-import { Kind } from '../tools/tools.js';
+import {
+ Kind,
+ type DeclarativeTool,
+ type ToolCallConfirmationDetails,
+ type ToolInvocation,
+ type ToolResult,
+} from '../tools/tools.js';
import type {
LocalAgentDefinition,
RemoteAgentDefinition,
@@ -17,12 +23,6 @@ import { makeFakeConfig } from '../test-utils/config.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
-import type {
- DeclarativeTool,
- ToolCallConfirmationDetails,
- ToolInvocation,
- ToolResult,
-} from '../tools/tools.js';
import {
GeminiCliOperation,
GEN_AI_AGENT_DESCRIPTION,
diff --git a/packages/core/src/availability/policyHelpers.ts b/packages/core/src/availability/policyHelpers.ts
index 05c1dd19f9..47c465585c 100644
--- a/packages/core/src/availability/policyHelpers.ts
+++ b/packages/core/src/availability/policyHelpers.ts
@@ -49,15 +49,16 @@ export function resolvePolicyChain(
const useCustomToolModel =
useGemini31 &&
config.getContentGeneratorConfig?.()?.authType === AuthType.USE_GEMINI;
+ const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true;
const resolvedModel = resolveModel(
modelFromConfig,
useGemini31,
useCustomToolModel,
+ hasAccessToPreview,
);
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false;
const isAutoConfigured = isAutoModel(configuredModel);
- const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true;
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
chain = getFlashLitePolicyChain();
@@ -80,7 +81,7 @@ export function resolvePolicyChain(
} else {
// User requested Gemini 3 but has no access. Proactively downgrade
// to the stable Gemini 2.5 chain.
- return getModelPolicyChain({
+ chain = getModelPolicyChain({
previewEnabled: false,
userTier: config.getUserTier(),
useGemini31,
diff --git a/packages/core/src/code_assist/codeAssist.ts b/packages/core/src/code_assist/codeAssist.ts
index caec96a4a3..3c3487bcff 100644
--- a/packages/core/src/code_assist/codeAssist.ts
+++ b/packages/core/src/code_assist/codeAssist.ts
@@ -4,12 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type { ContentGenerator } from '../core/contentGenerator.js';
-import { AuthType } from '../core/contentGenerator.js';
+import { AuthType, type ContentGenerator } from '../core/contentGenerator.js';
import { getOauthClient } from './oauth2.js';
import { setupUser } from './setup.js';
-import type { HttpOptions } from './server.js';
-import { CodeAssistServer } from './server.js';
+import { CodeAssistServer, type HttpOptions } from './server.js';
import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from '../core/loggingContentGenerator.js';
diff --git a/packages/core/src/code_assist/converter.test.ts b/packages/core/src/code_assist/converter.test.ts
index 674bbaf70e..8330941203 100644
--- a/packages/core/src/code_assist/converter.test.ts
+++ b/packages/core/src/code_assist/converter.test.ts
@@ -5,21 +5,19 @@
*/
import { describe, it, expect } from 'vitest';
-import type { CaGenerateContentResponse } from './converter.js';
import {
toGenerateContentRequest,
fromGenerateContentResponse,
toContents,
+ type CaGenerateContentResponse,
} from './converter.js';
-import type {
- ContentListUnion,
- GenerateContentParameters,
- Part,
-} from '@google/genai';
import {
GenerateContentResponse,
FinishReason,
BlockedReason,
+ type ContentListUnion,
+ type GenerateContentParameters,
+ type Part,
} from '@google/genai';
describe('converter', () => {
diff --git a/packages/core/src/code_assist/converter.ts b/packages/core/src/code_assist/converter.ts
index 81bda4adc6..005a8cf85d 100644
--- a/packages/core/src/code_assist/converter.ts
+++ b/packages/core/src/code_assist/converter.ts
@@ -4,29 +4,29 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type {
- Content,
- ContentListUnion,
- ContentUnion,
- GenerateContentConfig,
- GenerateContentParameters,
- CountTokensParameters,
- CountTokensResponse,
- GenerationConfigRoutingConfig,
- MediaResolution,
- Candidate,
- ModelSelectionConfig,
- GenerateContentResponsePromptFeedback,
- GenerateContentResponseUsageMetadata,
- Part,
- SafetySetting,
- PartUnion,
- SpeechConfigUnion,
- ThinkingConfig,
- ToolListUnion,
- ToolConfig,
+import {
+ GenerateContentResponse,
+ type Content,
+ type ContentListUnion,
+ type ContentUnion,
+ type GenerateContentConfig,
+ type GenerateContentParameters,
+ type CountTokensParameters,
+ type CountTokensResponse,
+ type GenerationConfigRoutingConfig,
+ type MediaResolution,
+ type Candidate,
+ type ModelSelectionConfig,
+ type GenerateContentResponsePromptFeedback,
+ type GenerateContentResponseUsageMetadata,
+ type Part,
+ type SafetySetting,
+ type PartUnion,
+ type SpeechConfigUnion,
+ type ThinkingConfig,
+ type ToolListUnion,
+ type ToolConfig,
} from '@google/genai';
-import { GenerateContentResponse } from '@google/genai';
import { debugLogger } from '../utils/debugLogger.js';
import type { Credits } from './types.js';
diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts
index 2db30c7fd3..134244c294 100644
--- a/packages/core/src/code_assist/oauth2.test.ts
+++ b/packages/core/src/code_assist/oauth2.test.ts
@@ -4,9 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type { Credentials } from 'google-auth-library';
-import type { Mock } from 'vitest';
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ OAuth2Client,
+ Compute,
+ GoogleAuth,
+ type Credentials,
+} from 'google-auth-library';
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ afterEach,
+ type Mock,
+} from 'vitest';
import {
getOauthClient,
resetOauthClientForTesting,
@@ -19,7 +31,6 @@ import {
recordGoogleAuthEnd,
} from '../telemetry/metrics.js';
import { UserAccountManager } from '../utils/userAccountManager.js';
-import { OAuth2Client, Compute, GoogleAuth } from 'google-auth-library';
import * as fs from 'node:fs';
import * as path from 'node:path';
import http from 'node:http';
@@ -33,7 +44,10 @@ import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js';
import { GEMINI_DIR, homedir as pathsHomedir } from '../utils/paths.js';
import { debugLogger } from '../utils/debugLogger.js';
import { writeToStdout } from '../utils/stdio.js';
-import { FatalCancellationError } from '../utils/errors.js';
+import {
+ FatalCancellationError,
+ FatalAuthenticationError,
+} from '../utils/errors.js';
import process from 'node:process';
import { coreEvents } from '../utils/events.js';
import { isHeadlessMode } from '../utils/headless.js';
@@ -104,7 +118,8 @@ const mockConfig = {
getNoBrowser: () => false,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => false,
- getExperimentalZedIntegration: () => false,
+ getAcpMode: () => false,
+ isInteractive: () => true,
} as unknown as Config;
// Mock fetch globally
@@ -314,11 +329,31 @@ describe('oauth2', () => {
await eventPromise;
});
+ it('should throw FatalAuthenticationError in non-interactive session when manual auth is required', async () => {
+ const mockConfigNonInteractive = {
+ getNoBrowser: () => true,
+ getProxy: () => 'http://test.proxy.com:8080',
+ isBrowserLaunchSuppressed: () => true,
+ isInteractive: () => false,
+ } as unknown as Config;
+
+ await expect(
+ getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfigNonInteractive),
+ ).rejects.toThrow(FatalAuthenticationError);
+
+ await expect(
+ getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfigNonInteractive),
+ ).rejects.toThrow(
+ 'Manual authorization is required but the current session is non-interactive.',
+ );
+ });
+
it('should perform login with user code', async () => {
const mockConfigWithNoBrowser = {
getNoBrowser: () => true,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => true,
+ isInteractive: () => true,
} as unknown as Config;
const mockCodeVerifier = {
@@ -389,6 +424,7 @@ describe('oauth2', () => {
getNoBrowser: () => true,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => true,
+ isInteractive: () => true,
} as unknown as Config;
const mockCodeVerifier = {
@@ -1169,6 +1205,7 @@ describe('oauth2', () => {
getNoBrowser: () => true,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => true,
+ isInteractive: () => true,
} as unknown as Config;
const mockOAuth2Client = {
diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts
index 8f766d4e02..0540ff4565 100644
--- a/packages/core/src/code_assist/oauth2.ts
+++ b/packages/core/src/code_assist/oauth2.ts
@@ -4,12 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type { Credentials, AuthClient, JWTInput } from 'google-auth-library';
import {
OAuth2Client,
Compute,
CodeChallengeMethod,
GoogleAuth,
+ type Credentials,
+ type AuthClient,
+ type JWTInput,
} from 'google-auth-library';
import * as http from 'node:http';
import url from 'node:url';
@@ -240,6 +242,13 @@ async function initOauthClient(
}
if (config.isBrowserLaunchSuppressed()) {
+ if (!config.isInteractive()) {
+ throw new FatalAuthenticationError(
+ 'Manual authorization is required but the current session is non-interactive. ' +
+ 'Please run the Gemini CLI in an interactive terminal to log in, ' +
+ 'provide a GEMINI_API_KEY, or ensure Application Default Credentials are configured.',
+ );
+ }
let success = false;
const maxRetries = 2;
// Enter alternate buffer
@@ -288,8 +297,8 @@ async function initOauthClient(
await triggerPostAuthCallbacks(client.credentials);
recordGoogleAuthEndIfApplicable();
} else {
- // In Zed integration, we skip the interactive consent and directly open the browser
- if (!config.getExperimentalZedIntegration()) {
+ // In ACP mode, we skip the interactive consent and directly open the browser
+ if (!config.getAcpMode()) {
const userConsent = await getConsentForOauth('');
if (!userConsent) {
throw new FatalCancellationError('Authentication cancelled by user.');
@@ -428,14 +437,24 @@ async function authWithUserCode(client: OAuth2Client): Promise {
'\n\n',
);
- const code = await new Promise((resolve, _) => {
+ const code = await new Promise((resolve, reject) => {
const rl = readline.createInterface({
input: process.stdin,
output: createWorkingStdio().stdout,
terminal: true,
});
+ const timeout = setTimeout(() => {
+ rl.close();
+ reject(
+ new FatalAuthenticationError(
+ 'Authorization timed out after 5 minutes.',
+ ),
+ );
+ }, 300000); // 5 minute timeout
+
rl.question('Enter the authorization code: ', (code) => {
+ clearTimeout(timeout);
rl.close();
resolve(code.trim());
});
diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts
index 63566c4662..93eaa19419 100644
--- a/packages/core/src/code_assist/server.test.ts
+++ b/packages/core/src/code_assist/server.test.ts
@@ -7,11 +7,24 @@
import { beforeEach, describe, it, expect, vi, afterEach } from 'vitest';
import { CodeAssistServer } from './server.js';
import { OAuth2Client } from 'google-auth-library';
-import { UserTierId, ActionStatus } from './types.js';
+import {
+ UserTierId,
+ ActionStatus,
+ type LoadCodeAssistResponse,
+ type GeminiUserTier,
+ type SetCodeAssistGlobalUserSettingRequest,
+ type CodeAssistGlobalUserSettingResponse,
+} from './types.js';
import { FinishReason } from '@google/genai';
import { LlmRole } from '../telemetry/types.js';
+import { logInvalidChunk } from '../telemetry/loggers.js';
+import { makeFakeConfig } from '../test-utils/config.js';
vi.mock('google-auth-library');
+vi.mock('../telemetry/loggers.js', () => ({
+ logBillingEvent: vi.fn(),
+ logInvalidChunk: vi.fn(),
+}));
function createTestServer(headers: Record = {}) {
const mockRequest = vi.fn();
@@ -116,7 +129,7 @@ describe('CodeAssistServer', () => {
role: 'model',
parts: [
{ text: 'response' },
- { functionCall: { name: 'test', args: {} } },
+ { functionCall: { name: 'replace', args: {} } },
],
},
finishReason: FinishReason.SAFETY,
@@ -160,7 +173,7 @@ describe('CodeAssistServer', () => {
role: 'model',
parts: [
{ text: 'response' },
- { functionCall: { name: 'test', args: {} } },
+ { functionCall: { name: 'replace', args: {} } },
],
},
finishReason: FinishReason.STOP,
@@ -233,7 +246,7 @@ describe('CodeAssistServer', () => {
content: {
parts: [
{ text: 'chunk' },
- { functionCall: { name: 'test', args: {} } },
+ { functionCall: { name: 'replace', args: {} } },
],
},
},
@@ -671,4 +684,435 @@ describe('CodeAssistServer', () => {
expect(requestPostSpy).toHaveBeenCalledWith('retrieveUserQuota', req);
expect(response).toEqual(mockResponse);
});
+
+ it('should call fetchAdminControls endpoint', async () => {
+ const { server } = createTestServer();
+ const mockResponse = { adminControlsApplicable: true };
+ const requestPostSpy = vi
+ .spyOn(server, 'requestPost')
+ .mockResolvedValue(mockResponse);
+
+ const req = { project: 'test-project' };
+ const response = await server.fetchAdminControls(req);
+
+ expect(requestPostSpy).toHaveBeenCalledWith('fetchAdminControls', req);
+ expect(response).toEqual(mockResponse);
+ });
+
+ it('should call getCodeAssistGlobalUserSetting endpoint', async () => {
+ const { server } = createTestServer();
+ const mockResponse: CodeAssistGlobalUserSettingResponse = {
+ freeTierDataCollectionOptin: true,
+ };
+ const requestGetSpy = vi
+ .spyOn(server, 'requestGet')
+ .mockResolvedValue(mockResponse);
+
+ const response = await server.getCodeAssistGlobalUserSetting();
+
+ expect(requestGetSpy).toHaveBeenCalledWith(
+ 'getCodeAssistGlobalUserSetting',
+ );
+ expect(response).toEqual(mockResponse);
+ });
+
+ it('should call setCodeAssistGlobalUserSetting endpoint', async () => {
+ const { server } = createTestServer();
+ const mockResponse: CodeAssistGlobalUserSettingResponse = {
+ freeTierDataCollectionOptin: true,
+ };
+ const requestPostSpy = vi
+ .spyOn(server, 'requestPost')
+ .mockResolvedValue(mockResponse);
+
+ const req: SetCodeAssistGlobalUserSettingRequest = {
+ freeTierDataCollectionOptin: true,
+ };
+ const response = await server.setCodeAssistGlobalUserSetting(req);
+
+ expect(requestPostSpy).toHaveBeenCalledWith(
+ 'setCodeAssistGlobalUserSetting',
+ req,
+ );
+ expect(response).toEqual(mockResponse);
+ });
+
+ it('should call loadCodeAssist during refreshAvailableCredits', async () => {
+ const { server } = createTestServer();
+ const mockPaidTier = {
+ id: 'test-tier',
+ name: 'tier',
+ availableCredits: [{ creditType: 'G1', creditAmount: '50' }],
+ };
+ const mockResponse = { paidTier: mockPaidTier };
+
+ vi.spyOn(server, 'loadCodeAssist').mockResolvedValue(
+ mockResponse as unknown as LoadCodeAssistResponse,
+ );
+
+ // Initial state: server has a paidTier without availableCredits
+ (server as unknown as { paidTier: GeminiUserTier }).paidTier = {
+ id: 'test-tier',
+ name: 'tier',
+ };
+
+ await server.refreshAvailableCredits();
+
+ expect(server.loadCodeAssist).toHaveBeenCalled();
+ expect(server.paidTier?.availableCredits).toEqual(
+ mockPaidTier.availableCredits,
+ );
+ });
+
+ describe('robustness testing', () => {
+ it('should not crash on random error objects in loadCodeAssist (isVpcScAffectedUser)', async () => {
+ const { server } = createTestServer();
+ const errors = [
+ null,
+ undefined,
+ 'string error',
+ 123,
+ { some: 'object' },
+ new Error('standard error'),
+ { response: {} },
+ { response: { data: {} } },
+ ];
+
+ for (const err of errors) {
+ vi.spyOn(server, 'requestPost').mockRejectedValueOnce(err);
+ try {
+ await server.loadCodeAssist({ metadata: {} });
+ } catch (e) {
+ expect(e).toBe(err);
+ }
+ }
+ });
+
+ it('should handle randomly fragmented SSE streams gracefully', async () => {
+ const { server, mockRequest } = createTestServer();
+ const { Readable } = await import('node:stream');
+
+ const fragmentedCases = [
+ {
+ chunks: ['d', 'ata: {"foo":', ' "bar"}\n\n'],
+ expected: [{ foo: 'bar' }],
+ },
+ {
+ chunks: ['data: {"foo": "bar"}\n', '\n'],
+ expected: [{ foo: 'bar' }],
+ },
+ {
+ chunks: ['data: ', '{"foo": "bar"}', '\n\n'],
+ expected: [{ foo: 'bar' }],
+ },
+ {
+ chunks: ['data: {"foo": "bar"}\n\n', 'data: {"baz": 1}\n\n'],
+ expected: [{ foo: 'bar' }, { baz: 1 }],
+ },
+ ];
+
+ for (const { chunks, expected } of fragmentedCases) {
+ const mockStream = new Readable({
+ read() {
+ for (const chunk of chunks) {
+ this.push(chunk);
+ }
+ this.push(null);
+ },
+ });
+ mockRequest.mockResolvedValueOnce({ data: mockStream });
+
+ const stream = await server.requestStreamingPost('testStream', {});
+ const results = [];
+ for await (const res of stream) {
+ results.push(res);
+ }
+ expect(results).toEqual(expected);
+ }
+ });
+
+ it('should correctly parse valid JSON split across multiple data lines', async () => {
+ const { server, mockRequest } = createTestServer();
+ const { Readable } = await import('node:stream');
+ const jsonObj = {
+ complex: { structure: [1, 2, 3] },
+ bool: true,
+ str: 'value',
+ };
+ const jsonString = JSON.stringify(jsonObj, null, 2);
+ const lines = jsonString.split('\n');
+ const ssePayload = lines.map((line) => `data: ${line}\n`).join('') + '\n';
+
+ const mockStream = new Readable({
+ read() {
+ this.push(ssePayload);
+ this.push(null);
+ },
+ });
+ mockRequest.mockResolvedValueOnce({ data: mockStream });
+
+ const stream = await server.requestStreamingPost('testStream', {});
+ const results = [];
+ for await (const res of stream) {
+ results.push(res);
+ }
+ expect(results).toHaveLength(1);
+ expect(results[0]).toEqual(jsonObj);
+ });
+
+ it('should not crash on objects partially matching VPC SC error structure', async () => {
+ const { server } = createTestServer();
+ const partialErrors = [
+ { response: { data: { error: { details: [{ reason: 'OTHER' }] } } } },
+ { response: { data: { error: { details: [] } } } },
+ { response: { data: { error: {} } } },
+ { response: { data: {} } },
+ ];
+
+ for (const err of partialErrors) {
+ vi.spyOn(server, 'requestPost').mockRejectedValueOnce(err);
+ try {
+ await server.loadCodeAssist({ metadata: {} });
+ } catch (e) {
+ expect(e).toBe(err);
+ }
+ }
+ });
+
+ it('should correctly ignore arbitrary SSE comments and ID lines and empty lines before data', async () => {
+ const { server, mockRequest } = createTestServer();
+ const { Readable } = await import('node:stream');
+ const jsonObj = { foo: 'bar' };
+ const jsonString = JSON.stringify(jsonObj);
+
+ const ssePayload = `id: 123
+:comment
+retry: 100
+
+data: ${jsonString}
+
+`;
+
+ const mockStream = new Readable({
+ read() {
+ this.push(ssePayload);
+ this.push(null);
+ },
+ });
+ mockRequest.mockResolvedValueOnce({ data: mockStream });
+
+ const stream = await server.requestStreamingPost('testStream', {});
+ const results = [];
+ for await (const res of stream) {
+ results.push(res);
+ }
+ expect(results).toHaveLength(1);
+ expect(results[0]).toEqual(jsonObj);
+ });
+
+ it('should log InvalidChunkEvent when SSE chunk is not valid JSON', async () => {
+ const config = makeFakeConfig();
+ const mockRequest = vi.fn();
+ const client = { request: mockRequest } as unknown as OAuth2Client;
+ const server = new CodeAssistServer(
+ client,
+ 'test-project',
+ {},
+ 'test-session',
+ UserTierId.FREE,
+ undefined,
+ undefined,
+ config,
+ );
+
+ const { Readable } = await import('node:stream');
+ const mockStream = new Readable({
+ read() {},
+ });
+
+ mockRequest.mockResolvedValue({ data: mockStream });
+
+ const stream = await server.requestStreamingPost('testStream', {});
+
+ setTimeout(() => {
+ mockStream.push('data: { "invalid": json }\n\n');
+ mockStream.push(null);
+ }, 0);
+
+ const results = [];
+ for await (const res of stream) {
+ results.push(res);
+ }
+
+ expect(results).toHaveLength(0);
+ expect(logInvalidChunk).toHaveBeenCalledWith(
+ config,
+ expect.objectContaining({
+ error_message: 'Malformed JSON chunk',
+ }),
+ );
+ });
+
+ it('should handle malformed JSON within a multi-line data block', async () => {
+ const config = makeFakeConfig();
+ const mockRequest = vi.fn();
+ const client = { request: mockRequest } as unknown as OAuth2Client;
+ const server = new CodeAssistServer(
+ client,
+ 'test-project',
+ {},
+ 'test-session',
+ UserTierId.FREE,
+ undefined,
+ undefined,
+ config,
+ );
+
+ const { Readable } = await import('node:stream');
+ const mockStream = new Readable({
+ read() {},
+ });
+
+ mockRequest.mockResolvedValue({ data: mockStream });
+
+ const stream = await server.requestStreamingPost('testStream', {});
+
+ setTimeout(() => {
+ mockStream.push('data: {\n');
+ mockStream.push('data: "invalid": json\n');
+ mockStream.push('data: }\n\n');
+ mockStream.push(null);
+ }, 0);
+
+ const results = [];
+ for await (const res of stream) {
+ results.push(res);
+ }
+
+ expect(results).toHaveLength(0);
+ expect(logInvalidChunk).toHaveBeenCalled();
+ });
+
+ it('should safely process random response streams in generateContentStream (consumed/remaining credits)', async () => {
+ const { mockRequest, client } = createTestServer();
+ const testServer = new CodeAssistServer(
+ client,
+ 'test-project',
+ {},
+ 'test-session',
+ UserTierId.FREE,
+ undefined,
+ { id: 'test-tier', name: 'tier', availableCredits: [] },
+ );
+ const { Readable } = await import('node:stream');
+
+ const streamResponses = [
+ {
+ traceId: '1',
+ consumedCredits: [{ creditType: 'A', creditAmount: '10' }],
+ },
+ { traceId: '2', remainingCredits: [{ creditType: 'B' }] },
+ { traceId: '3' },
+ { traceId: '4', consumedCredits: null, remainingCredits: undefined },
+ ];
+
+ const mockStream = new Readable({
+ read() {
+ for (const resp of streamResponses) {
+ this.push(`data: ${JSON.stringify(resp)}\n\n`);
+ }
+ this.push(null);
+ },
+ });
+ mockRequest.mockResolvedValueOnce({ data: mockStream });
+ vi.spyOn(testServer, 'recordCodeAssistMetrics').mockResolvedValue(
+ undefined,
+ );
+
+ const stream = await testServer.generateContentStream(
+ { model: 'test-model', contents: [] },
+ 'user-prompt-id',
+ LlmRole.MAIN,
+ );
+
+ for await (const _ of stream) {
+ // Drain stream
+ }
+ // Should not crash
+ });
+
+ it('should be resilient to metadata-only chunks without candidates in generateContentStream', async () => {
+ const { mockRequest, client } = createTestServer();
+ const testServer = new CodeAssistServer(
+ client,
+ 'test-project',
+ {},
+ 'test-session',
+ UserTierId.FREE,
+ );
+ const { Readable } = await import('node:stream');
+
+ // Chunk 2 is metadata-only, no candidates
+ const streamResponses = [
+ {
+ traceId: '1',
+ response: {
+ candidates: [{ content: { parts: [{ text: 'Hello' }] }, index: 0 }],
+ },
+ },
+ {
+ traceId: '2',
+ consumedCredits: [{ creditType: 'GOOGLE_ONE_AI', creditAmount: '5' }],
+ response: {
+ usageMetadata: { promptTokenCount: 10, totalTokenCount: 15 },
+ },
+ },
+ {
+ traceId: '3',
+ response: {
+ candidates: [
+ { content: { parts: [{ text: ' World' }] }, index: 0 },
+ ],
+ },
+ },
+ ];
+
+ const mockStream = new Readable({
+ read() {
+ for (const resp of streamResponses) {
+ this.push(`data: ${JSON.stringify(resp)}\n\n`);
+ }
+ this.push(null);
+ },
+ });
+ mockRequest.mockResolvedValueOnce({ data: mockStream });
+ vi.spyOn(testServer, 'recordCodeAssistMetrics').mockResolvedValue(
+ undefined,
+ );
+
+ const stream = await testServer.generateContentStream(
+ { model: 'test-model', contents: [] },
+ 'user-prompt-id',
+ LlmRole.MAIN,
+ );
+
+ const results = [];
+ for await (const res of stream) {
+ results.push(res);
+ }
+
+ expect(results).toHaveLength(3);
+ expect(results[0].candidates).toHaveLength(1);
+ expect(results[0].candidates?.[0].content?.parts?.[0].text).toBe('Hello');
+
+ // Chunk 2 (metadata-only) should still be yielded but with empty candidates
+ expect(results[1].candidates).toHaveLength(0);
+ expect(results[1].usageMetadata?.promptTokenCount).toBe(10);
+
+ expect(results[2].candidates).toHaveLength(1);
+ expect(results[2].candidates?.[0].content?.parts?.[0].text).toBe(
+ ' World',
+ );
+ });
+ });
});
diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts
index 536bf0c31a..114fa60092 100644
--- a/packages/core/src/code_assist/server.ts
+++ b/packages/core/src/code_assist/server.ts
@@ -5,26 +5,26 @@
*/
import type { AuthClient } from 'google-auth-library';
-import type {
- CodeAssistGlobalUserSettingResponse,
- LoadCodeAssistRequest,
- LoadCodeAssistResponse,
- LongRunningOperationResponse,
- OnboardUserRequest,
- SetCodeAssistGlobalUserSettingRequest,
- ClientMetadata,
- RetrieveUserQuotaRequest,
- RetrieveUserQuotaResponse,
- FetchAdminControlsRequest,
- FetchAdminControlsResponse,
- ConversationOffered,
- ConversationInteraction,
- StreamingLatency,
- RecordCodeAssistMetricsRequest,
- GeminiUserTier,
- Credits,
+import {
+ UserTierId,
+ type CodeAssistGlobalUserSettingResponse,
+ type LoadCodeAssistRequest,
+ type LoadCodeAssistResponse,
+ type LongRunningOperationResponse,
+ type OnboardUserRequest,
+ type SetCodeAssistGlobalUserSettingRequest,
+ type ClientMetadata,
+ type RetrieveUserQuotaRequest,
+ type RetrieveUserQuotaResponse,
+ type FetchAdminControlsRequest,
+ type FetchAdminControlsResponse,
+ type ConversationOffered,
+ type ConversationInteraction,
+ type StreamingLatency,
+ type RecordCodeAssistMetricsRequest,
+ type GeminiUserTier,
+ type Credits,
} from './types.js';
-import { UserTierId } from './types.js';
import type {
ListExperimentsRequest,
ListExperimentsResponse,
@@ -47,24 +47,22 @@ import {
isOverageEligibleModel,
shouldAutoUseCredits,
} from '../billing/billing.js';
-import { logBillingEvent } from '../telemetry/loggers.js';
+import { logBillingEvent, logInvalidChunk } from '../telemetry/loggers.js';
import { CreditsUsedEvent } from '../telemetry/billingEvents.js';
-import type {
- CaCountTokenResponse,
- CaGenerateContentResponse,
-} from './converter.js';
import {
fromCountTokenResponse,
fromGenerateContentResponse,
toCountTokenRequest,
toGenerateContentRequest,
+ type CaCountTokenResponse,
+ type CaGenerateContentResponse,
} from './converter.js';
import {
formatProtoJsonDuration,
recordConversationOffered,
} from './telemetry.js';
import { getClientMetadata } from './experiments/client_metadata.js';
-import type { LlmRole } from '../telemetry/types.js';
+import { InvalidChunkEvent, type LlmRole } from '../telemetry/types.js';
/** HTTP options to be used in each of the requests. */
export interface HttpOptions {
/** Additional HTTP headers to be sent with the request. */
@@ -468,7 +466,7 @@ export class CodeAssistServer implements ContentGenerator {
retry: false,
});
- return (async function* (): AsyncGenerator {
+ return (async function* (server: CodeAssistServer): AsyncGenerator {
const rl = readline.createInterface({
input: Readable.from(res.data),
crlfDelay: Infinity, // Recognizes '\r\n' and '\n' as line breaks
@@ -482,12 +480,23 @@ export class CodeAssistServer implements ContentGenerator {
if (bufferedLines.length === 0) {
continue; // no data to yield
}
- yield JSON.parse(bufferedLines.join('\n'));
+ const chunk = bufferedLines.join('\n');
+ try {
+ yield JSON.parse(chunk);
+ } catch (_e) {
+ if (server.config) {
+ logInvalidChunk(
+ server.config,
+ // Don't include the chunk content in the log for security/privacy reasons.
+ new InvalidChunkEvent('Malformed JSON chunk'),
+ );
+ }
+ }
bufferedLines = []; // Reset the buffer after yielding
}
// Ignore other lines like comments or id fields
}
- })();
+ })(this);
}
private getBaseUrl(): string {
diff --git a/packages/core/src/code_assist/setup.test.ts b/packages/core/src/code_assist/setup.test.ts
index e4418aeca2..6c6375debc 100644
--- a/packages/core/src/code_assist/setup.test.ts
+++ b/packages/core/src/code_assist/setup.test.ts
@@ -14,8 +14,7 @@ import { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import { ChangeAuthRequestedError } from '../utils/errors.js';
import { CodeAssistServer } from '../code_assist/server.js';
import type { OAuth2Client } from 'google-auth-library';
-import type { GeminiUserTier } from './types.js';
-import { UserTierId } from './types.js';
+import { UserTierId, type GeminiUserTier } from './types.js';
vi.mock('../code_assist/server.js');
diff --git a/packages/core/src/code_assist/setup.ts b/packages/core/src/code_assist/setup.ts
index 3da24c5d05..35ef980db2 100644
--- a/packages/core/src/code_assist/setup.ts
+++ b/packages/core/src/code_assist/setup.ts
@@ -4,16 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type {
- ClientMetadata,
- GeminiUserTier,
- IneligibleTier,
- LoadCodeAssistResponse,
- OnboardUserRequest,
+import {
+ UserTierId,
+ IneligibleTierReasonCode,
+ type ClientMetadata,
+ type GeminiUserTier,
+ type IneligibleTier,
+ type LoadCodeAssistResponse,
+ type OnboardUserRequest,
} from './types.js';
-import { UserTierId, IneligibleTierReasonCode } from './types.js';
-import type { HttpOptions } from './server.js';
-import { CodeAssistServer } from './server.js';
+import { CodeAssistServer, type HttpOptions } from './server.js';
import type { AuthClient } from 'google-auth-library';
import type { ValidationHandler } from '../fallback/types.js';
import { ChangeAuthRequestedError } from '../utils/errors.js';
diff --git a/packages/core/src/code_assist/telemetry.test.ts b/packages/core/src/code_assist/telemetry.test.ts
index c90040f22e..b9452f9e6c 100644
--- a/packages/core/src/code_assist/telemetry.test.ts
+++ b/packages/core/src/code_assist/telemetry.test.ts
@@ -82,7 +82,7 @@ describe('telemetry', () => {
},
],
true,
- [{ name: 'someTool', args: {} }],
+ [{ name: 'replace', args: {} }],
);
const traceId = 'test-trace-id';
const streamingLatency: StreamingLatency = { totalLatency: '1s' };
@@ -130,7 +130,7 @@ describe('telemetry', () => {
it('should set status to CANCELLED if signal is aborted', () => {
const response = createMockResponse([], true, [
- { name: 'tool', args: {} },
+ { name: 'replace', args: {} },
]);
const signal = new AbortController().signal;
vi.spyOn(signal, 'aborted', 'get').mockReturnValue(true);
@@ -147,7 +147,7 @@ describe('telemetry', () => {
it('should set status to ERROR_UNKNOWN if response has error (non-OK SDK response)', () => {
const response = createMockResponse([], false, [
- { name: 'tool', args: {} },
+ { name: 'replace', args: {} },
]);
const result = createConversationOffered(
@@ -169,7 +169,7 @@ describe('telemetry', () => {
},
],
true,
- [{ name: 'tool', args: {} }],
+ [{ name: 'replace', args: {} }],
);
const result = createConversationOffered(
@@ -186,7 +186,7 @@ describe('telemetry', () => {
// We force functionCalls to be present to bypass the guard,
// simulating a state where we want to test the candidates check.
const response = createMockResponse([], true, [
- { name: 'tool', args: {} },
+ { name: 'replace', args: {} },
]);
const result = createConversationOffered(
@@ -212,7 +212,7 @@ describe('telemetry', () => {
},
],
true,
- [{ name: 'tool', args: {} }],
+ [{ name: 'replace', args: {} }],
);
const result = createConversationOffered(response, 'id', undefined, {});
expect(result?.includedCode).toBe(true);
@@ -229,7 +229,7 @@ describe('telemetry', () => {
},
],
true,
- [{ name: 'tool', args: {} }],
+ [{ name: 'replace', args: {} }],
);
const result = createConversationOffered(response, 'id', undefined, {});
expect(result?.includedCode).toBe(false);
@@ -250,7 +250,7 @@ describe('telemetry', () => {
} as unknown as CodeAssistServer;
const response = createMockResponse([], true, [
- { name: 'tool', args: {} },
+ { name: 'replace', args: {} },
]);
const streamingLatency = {};
@@ -274,7 +274,7 @@ describe('telemetry', () => {
recordConversationOffered: vi.fn(),
} as unknown as CodeAssistServer;
const response = createMockResponse([], true, [
- { name: 'tool', args: {} },
+ { name: 'replace', args: {} },
]);
await recordConversationOffered(
@@ -331,17 +331,89 @@ describe('telemetry', () => {
await recordToolCallInteractions({} as Config, toolCalls);
- expect(mockServer.recordConversationInteraction).toHaveBeenCalledWith({
- traceId: 'trace-1',
- status: ActionStatus.ACTION_STATUS_NO_ERROR,
- interaction: ConversationInteractionInteraction.ACCEPT_FILE,
- acceptedLines: '5',
- removedLines: '3',
- isAgentic: true,
- });
+ expect(mockServer.recordConversationInteraction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ traceId: 'trace-1',
+ status: ActionStatus.ACTION_STATUS_NO_ERROR,
+ interaction: ConversationInteractionInteraction.ACCEPT_FILE,
+ acceptedLines: '8',
+ removedLines: '3',
+ isAgentic: true,
+ }),
+ );
});
- it('should record UNKNOWN interaction for other accepted tools', async () => {
+ it('should include language in interaction if file_path is present', async () => {
+ const toolCalls: CompletedToolCall[] = [
+ {
+ request: {
+ name: 'replace',
+ args: {
+ file_path: 'test.ts',
+ old_string: 'old',
+ new_string: 'new',
+ },
+ callId: 'call-1',
+ isClientInitiated: false,
+ prompt_id: 'p1',
+ traceId: 'trace-1',
+ },
+ response: {
+ resultDisplay: {
+ diffStat: {
+ model_added_lines: 5,
+ model_removed_lines: 3,
+ },
+ },
+ },
+ outcome: ToolConfirmationOutcome.ProceedOnce,
+ status: 'success',
+ } as unknown as CompletedToolCall,
+ ];
+
+ await recordToolCallInteractions({} as Config, toolCalls);
+
+ expect(mockServer.recordConversationInteraction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ language: 'TypeScript',
+ }),
+ );
+ });
+
+ it('should include language in interaction if write_file is used', async () => {
+ const toolCalls: CompletedToolCall[] = [
+ {
+ request: {
+ name: 'write_file',
+ args: { file_path: 'test.py', content: 'test' },
+ callId: 'call-1',
+ isClientInitiated: false,
+ prompt_id: 'p1',
+ traceId: 'trace-1',
+ },
+ response: {
+ resultDisplay: {
+ diffStat: {
+ model_added_lines: 5,
+ model_removed_lines: 3,
+ },
+ },
+ },
+ outcome: ToolConfirmationOutcome.ProceedOnce,
+ status: 'success',
+ } as unknown as CompletedToolCall,
+ ];
+
+ await recordToolCallInteractions({} as Config, toolCalls);
+
+ expect(mockServer.recordConversationInteraction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ language: 'Python',
+ }),
+ );
+ });
+
+ it('should not record interaction for other accepted tools', async () => {
const toolCalls: CompletedToolCall[] = [
{
request: {
@@ -359,19 +431,14 @@ describe('telemetry', () => {
await recordToolCallInteractions({} as Config, toolCalls);
- expect(mockServer.recordConversationInteraction).toHaveBeenCalledWith({
- traceId: 'trace-2',
- status: ActionStatus.ACTION_STATUS_NO_ERROR,
- interaction: ConversationInteractionInteraction.UNKNOWN,
- isAgentic: true,
- });
+ expect(mockServer.recordConversationInteraction).not.toHaveBeenCalled();
});
it('should not record interaction for cancelled status', async () => {
const toolCalls: CompletedToolCall[] = [
{
request: {
- name: 'tool',
+ name: 'replace',
args: {},
callId: 'call-3',
isClientInitiated: false,
@@ -394,7 +461,7 @@ describe('telemetry', () => {
const toolCalls: CompletedToolCall[] = [
{
request: {
- name: 'tool',
+ name: 'replace',
args: {},
callId: 'call-4',
isClientInitiated: false,
diff --git a/packages/core/src/code_assist/telemetry.ts b/packages/core/src/code_assist/telemetry.ts
index 59ff179c50..c0a4e614ea 100644
--- a/packages/core/src/code_assist/telemetry.ts
+++ b/packages/core/src/code_assist/telemetry.ts
@@ -22,10 +22,13 @@ import { EDIT_TOOL_NAMES } from '../tools/tool-names.js';
import { getErrorMessage } from '../utils/errors.js';
import type { CodeAssistServer } from './server.js';
import { ToolConfirmationOutcome } from '../tools/tools.js';
+import { getLanguageFromFilePath } from '../utils/language-detection.js';
import {
computeModelAddedAndRemovedLines,
getFileDiffFromResultDisplay,
} from '../utils/fileDiffUtils.js';
+import { isEditToolParams } from '../tools/edit.js';
+import { isWriteFileToolParams } from '../tools/write-file.js';
export async function recordConversationOffered(
server: CodeAssistServer,
@@ -85,10 +88,12 @@ export function createConversationOffered(
signal: AbortSignal | undefined,
streamingLatency: StreamingLatency,
): ConversationOffered | undefined {
- // Only send conversation offered events for responses that contain function
- // calls. Non-function call events don't represent user actionable
- // 'suggestions'.
- if ((response.functionCalls?.length || 0) === 0) {
+ // Only send conversation offered events for responses that contain edit
+ // function calls. Non-edit function calls don't represent file modifications.
+ if (
+ !response.functionCalls ||
+ !response.functionCalls.some((call) => EDIT_TOOL_NAMES.has(call.name || ''))
+ ) {
return;
}
@@ -116,6 +121,7 @@ function summarizeToolCalls(
let isEdit = false;
let acceptedLines = 0;
let removedLines = 0;
+ let language = undefined;
// Iterate the tool calls and summarize them into a single conversation
// interaction so that the ConversationOffered and ConversationInteraction
@@ -144,13 +150,23 @@ function summarizeToolCalls(
if (EDIT_TOOL_NAMES.has(toolCall.request.name)) {
isEdit = true;
+ if (
+ !language &&
+ (isEditToolParams(toolCall.request.args) ||
+ isWriteFileToolParams(toolCall.request.args))
+ ) {
+ language = getLanguageFromFilePath(toolCall.request.args.file_path);
+ }
+
if (toolCall.status === 'success') {
const fileDiff = getFileDiffFromResultDisplay(
toolCall.response.resultDisplay,
);
if (fileDiff?.diffStat) {
const lines = computeModelAddedAndRemovedLines(fileDiff.diffStat);
- acceptedLines += lines.addedLines;
+
+ // The API expects acceptedLines to be addedLines + removedLines.
+ acceptedLines += lines.addedLines + lines.removedLines;
removedLines += lines.removedLines;
}
}
@@ -158,16 +174,16 @@ function summarizeToolCalls(
}
}
- // Only file interaction telemetry if 100% of the tool calls were accepted.
- return traceId && acceptedToolCalls / toolCalls.length >= 1
+ // Only file interaction telemetry if 100% of the tool calls were accepted
+ // and at least one of them was an edit.
+ return traceId && acceptedToolCalls / toolCalls.length >= 1 && isEdit
? createConversationInteraction(
traceId,
actionStatus || ActionStatus.ACTION_STATUS_NO_ERROR,
- isEdit
- ? ConversationInteractionInteraction.ACCEPT_FILE
- : ConversationInteractionInteraction.UNKNOWN,
- isEdit ? String(acceptedLines) : undefined,
- isEdit ? String(removedLines) : undefined,
+ ConversationInteractionInteraction.ACCEPT_FILE,
+ String(acceptedLines),
+ String(removedLines),
+ language,
)
: undefined;
}
@@ -178,6 +194,7 @@ function createConversationInteraction(
interaction: ConversationInteractionInteraction,
acceptedLines?: string,
removedLines?: string,
+ language?: string,
): ConversationInteraction {
return {
traceId,
@@ -185,9 +202,11 @@ function createConversationInteraction(
interaction,
acceptedLines,
removedLines,
+ language,
isAgentic: true,
};
}
+
function includesCode(resp: GenerateContentResponse): boolean {
if (!resp.candidates) {
return false;
diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts
index e587fc2e2e..da30b13377 100644
--- a/packages/core/src/config/config.test.ts
+++ b/packages/core/src/config/config.test.ts
@@ -4,16 +4,30 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import type { Mock } from 'vitest';
-import type { ConfigParameters, SandboxConfig } from './config.js';
-import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from './config.js';
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ afterEach,
+ type Mock,
+} from 'vitest';
+import {
+ Config,
+ DEFAULT_FILE_FILTERING_OPTIONS,
+ type ConfigParameters,
+ type SandboxConfig,
+} from './config.js';
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
import { debugLogger } from '../utils/debugLogger.js';
import { ApprovalMode } from '../policy/types.js';
-import type { HookDefinition } from '../hooks/types.js';
-import { HookType, HookEventName } from '../hooks/types.js';
+import {
+ HookType,
+ HookEventName,
+ type HookDefinition,
+} from '../hooks/types.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import * as path from 'node:path';
import * as fs from 'node:fs';
@@ -23,14 +37,12 @@ import {
DEFAULT_OTLP_ENDPOINT,
uiTelemetryService,
} from '../telemetry/index.js';
-import type {
- ContentGeneratorConfig,
- ContentGenerator,
-} from '../core/contentGenerator.js';
import {
AuthType,
createContentGenerator,
createContentGeneratorConfig,
+ type ContentGeneratorConfig,
+ type ContentGenerator,
} from '../core/contentGenerator.js';
import { GeminiClient } from '../core/client.js';
import { GitService } from '../services/gitService.js';
@@ -500,6 +512,8 @@ describe('Server Config (config.ts)', () => {
config,
authType,
undefined,
+ undefined,
+ undefined,
);
// Verify that contentGeneratorConfig is updated
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
@@ -2186,6 +2200,23 @@ describe('Config getHooks', () => {
expect(onModelChange).not.toHaveBeenCalled();
});
+
+ it('should call onModelChange when persisting a model that was previously temporary', () => {
+ const onModelChange = vi.fn();
+ const config = new Config({
+ ...baseParams,
+ model: 'some-other-model',
+ onModelChange,
+ });
+
+ // Temporary selection
+ config.setModel(DEFAULT_GEMINI_MODEL, true);
+ expect(onModelChange).not.toHaveBeenCalled();
+
+ // Persist selection of the same model
+ config.setModel(DEFAULT_GEMINI_MODEL, false);
+ expect(onModelChange).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL);
+ });
});
});
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 258bd78f93..e4c0fef6eb 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -9,16 +9,14 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { inspect } from 'node:util';
import process from 'node:process';
-import type {
- ContentGenerator,
- ContentGeneratorConfig,
-} from '../core/contentGenerator.js';
-import type { OverageStrategy } from '../billing/billing.js';
import {
AuthType,
createContentGenerator,
createContentGeneratorConfig,
+ type ContentGenerator,
+ type ContentGeneratorConfig,
} from '../core/contentGenerator.js';
+import type { OverageStrategy } from '../billing/billing.js';
import { PromptRegistry } from '../prompts/prompt-registry.js';
import { ResourceRegistry } from '../resources/resource-registry.js';
import { ToolRegistry } from '../tools/tool-registry.js';
@@ -43,12 +41,12 @@ import { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js';
import type { HookDefinition, HookEventName } from '../hooks/types.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { GitService } from '../services/gitService.js';
-import type { TelemetryTarget } from '../telemetry/index.js';
import {
initializeTelemetry,
DEFAULT_TELEMETRY_TARGET,
DEFAULT_OTLP_ENDPOINT,
uiTelemetryService,
+ type TelemetryTarget,
} from '../telemetry/index.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
import { tokenLimit } from '../core/tokenLimits.js';
@@ -68,8 +66,18 @@ import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
import { ideContextStore } from '../ide/ideContext.js';
import { WriteTodosTool } from '../tools/write-todos.js';
-import type { FileSystemService } from '../services/fileSystemService.js';
-import { StandardFileSystemService } from '../services/fileSystemService.js';
+import {
+ StandardFileSystemService,
+ type FileSystemService,
+} from '../services/fileSystemService.js';
+import {
+ TrackerCreateTaskTool,
+ TrackerUpdateTaskTool,
+ TrackerGetTaskTool,
+ TrackerListTasksTool,
+ TrackerAddDependencyTool,
+ TrackerVisualizeTool,
+} from '../tools/trackerTools.js';
import {
logRipgrepFallback,
logFlashFallback,
@@ -89,13 +97,14 @@ import type {
import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
import { ModelRouterService } from '../routing/modelRouterService.js';
import { OutputFormat } from '../output/types.js';
-import type {
- ModelConfig,
- ModelConfigServiceConfig,
+import {
+ ModelConfigService,
+ type ModelConfig,
+ type ModelConfigServiceConfig,
} from '../services/modelConfigService.js';
-import { ModelConfigService } from '../services/modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
import { ContextManager } from '../services/contextManager.js';
+import { TrackerService } from '../services/trackerService.js';
import type { GenerateContentParameters } from '@google/genai';
// Re-export OAuth config type
@@ -123,12 +132,14 @@ import type {
} from '../code_assist/types.js';
import type { HierarchicalMemory } from './memory.js';
import { getCodeAssistServer } from '../code_assist/codeAssist.js';
-import type { Experiments } from '../code_assist/experiments/experiments.js';
+import {
+ getExperiments,
+ type Experiments,
+} from '../code_assist/experiments/experiments.js';
import { AgentRegistry } from '../agents/registry.js';
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
import { setGlobalProxy } from '../utils/fetch.js';
import { SubagentTool } from '../agents/subagent-tool.js';
-import { getExperiments } from '../code_assist/experiments/experiments.js';
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
import { debugLogger } from '../utils/debugLogger.js';
import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
@@ -250,11 +261,12 @@ export interface CustomTheme {
};
border?: {
default?: string;
- focused?: string;
};
ui?: {
comment?: string;
symbol?: string;
+ active?: string;
+ focus?: string;
gradient?: string[];
};
status?: {
@@ -360,10 +372,10 @@ export interface ExtensionInstallMetadata {
}
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
-import type { FileFilteringOptions } from './constants.js';
import {
DEFAULT_FILE_FILTERING_OPTIONS,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
+ type FileFilteringOptions,
} from './constants.js';
import {
DEFAULT_TOOL_PROTECTION_THRESHOLD,
@@ -434,7 +446,7 @@ export enum AuthProviderType {
}
export interface SandboxConfig {
- command: 'docker' | 'podman' | 'sandbox-exec';
+ command: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc';
image: string;
}
@@ -503,7 +515,7 @@ export interface ConfigParameters {
model: string;
disableLoopDetection?: boolean;
maxSessionTurns?: number;
- experimentalZedIntegration?: boolean;
+ acpMode?: boolean;
listSessions?: boolean;
deleteSession?: string;
listExtensions?: boolean;
@@ -569,6 +581,7 @@ export interface ConfigParameters {
toolOutputMasking?: Partial;
disableLLMCorrection?: boolean;
plan?: boolean;
+ tracker?: boolean;
planSettings?: PlanSettings;
modelSteering?: boolean;
onModelChange?: (model: string) => void;
@@ -602,6 +615,7 @@ export class Config implements McpContext {
private sessionId: string;
private clientVersion: string;
private fileSystemService: FileSystemService;
+ private trackerService?: TrackerService;
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGenerator!: ContentGenerator;
readonly modelConfigService: ModelConfigService;
@@ -699,7 +713,7 @@ export class Config implements McpContext {
private readonly summarizeToolOutput:
| Record
| undefined;
- private readonly experimentalZedIntegration: boolean = false;
+ private readonly acpMode: boolean = false;
private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly includeDirectoryTree: boolean = true;
private readonly importFormat: 'tree' | 'flat';
@@ -780,6 +794,7 @@ export class Config implements McpContext {
private readonly experimentalJitContext: boolean;
private readonly disableLLMCorrection: boolean;
private readonly planEnabled: boolean;
+ private readonly trackerEnabled: boolean;
private readonly planModeRoutingEnabled: boolean;
private readonly modelSteering: boolean;
private contextManager?: ContextManager;
@@ -870,6 +885,7 @@ export class Config implements McpContext {
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? false;
+ this.trackerEnabled = params.tracker ?? false;
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
this.skillsSupport = params.skillsSupport ?? true;
@@ -894,8 +910,7 @@ export class Config implements McpContext {
DEFAULT_PROTECT_LATEST_TURN,
};
this.maxSessionTurns = params.maxSessionTurns ?? -1;
- this.experimentalZedIntegration =
- params.experimentalZedIntegration ?? false;
+ this.acpMode = params.acpMode ?? false;
this.listSessions = params.listSessions ?? false;
this.deleteSession = params.deleteSession;
this.listExtensions = params.listExtensions ?? false;
@@ -1148,7 +1163,7 @@ export class Config implements McpContext {
}
});
- if (!this.interactive || this.experimentalZedIntegration) {
+ if (!this.interactive || this.acpMode) {
await this.mcpInitializationPromise;
}
@@ -1191,7 +1206,12 @@ export class Config implements McpContext {
return this.contentGenerator;
}
- async refreshAuth(authMethod: AuthType, apiKey?: string) {
+ async refreshAuth(
+ authMethod: AuthType,
+ apiKey?: string,
+ baseUrl?: string,
+ customHeaders?: Record,
+ ) {
// Reset availability service when switching auth
this.modelAvailabilityService.reset();
@@ -1218,6 +1238,8 @@ export class Config implements McpContext {
this,
authMethod,
apiKey,
+ baseUrl,
+ customHeaders,
);
this.contentGenerator = await createContentGenerator(
newContentGeneratorConfig,
@@ -1387,9 +1409,9 @@ export class Config implements McpContext {
// When the user explicitly sets a model, that becomes the active model.
this._activeModel = newModel;
coreEvents.emitModelChanged(newModel);
- if (this.onModelChange && !isTemporary) {
- this.onModelChange(newModel);
- }
+ }
+ if (this.onModelChange && !isTemporary) {
+ this.onModelChange(newModel);
}
this.modelAvailabilityService.reset();
}
@@ -2190,6 +2212,15 @@ export class Config implements McpContext {
return this.bugCommand;
}
+ getTrackerService(): TrackerService {
+ if (!this.trackerService) {
+ this.trackerService = new TrackerService(
+ this.storage.getProjectTempTrackerDir(),
+ );
+ }
+ return this.trackerService;
+ }
+
getFileService(): FileDiscoveryService {
if (!this.fileDiscoveryService) {
this.fileDiscoveryService = new FileDiscoveryService(this.targetDir, {
@@ -2205,8 +2236,8 @@ export class Config implements McpContext {
return this.usageStatisticsEnabled;
}
- getExperimentalZedIntegration(): boolean {
- return this.experimentalZedIntegration;
+ getAcpMode(): boolean {
+ return this.acpMode;
}
async waitForMcpInit(): Promise {
@@ -2257,6 +2288,10 @@ export class Config implements McpContext {
return this.planEnabled;
}
+ isTrackerEnabled(): boolean {
+ return this.trackerEnabled;
+ }
+
getApprovedPlanPath(): string | undefined {
return this.approvedPlanPath;
}
@@ -2822,6 +2857,29 @@ export class Config implements McpContext {
);
}
+ if (this.isTrackerEnabled()) {
+ maybeRegister(TrackerCreateTaskTool, () =>
+ registry.registerTool(new TrackerCreateTaskTool(this, this.messageBus)),
+ );
+ maybeRegister(TrackerUpdateTaskTool, () =>
+ registry.registerTool(new TrackerUpdateTaskTool(this, this.messageBus)),
+ );
+ maybeRegister(TrackerGetTaskTool, () =>
+ registry.registerTool(new TrackerGetTaskTool(this, this.messageBus)),
+ );
+ maybeRegister(TrackerListTasksTool, () =>
+ registry.registerTool(new TrackerListTasksTool(this, this.messageBus)),
+ );
+ maybeRegister(TrackerAddDependencyTool, () =>
+ registry.registerTool(
+ new TrackerAddDependencyTool(this, this.messageBus),
+ ),
+ );
+ maybeRegister(TrackerVisualizeTool, () =>
+ registry.registerTool(new TrackerVisualizeTool(this, this.messageBus)),
+ );
+ }
+
// Register Subagents as Tools
this.registerSubAgentTools(registry);
diff --git a/packages/core/src/config/models.test.ts b/packages/core/src/config/models.test.ts
index 3337151151..d62827ed91 100644
--- a/packages/core/src/config/models.test.ts
+++ b/packages/core/src/config/models.test.ts
@@ -217,6 +217,38 @@ describe('resolveModel', () => {
expect(model).toBe(customModel);
});
});
+
+ describe('hasAccessToPreview logic', () => {
+ it('should return default model when access to preview is false and preview model is requested', () => {
+ expect(resolveModel(PREVIEW_GEMINI_MODEL, false, false, false)).toBe(
+ DEFAULT_GEMINI_MODEL,
+ );
+ });
+
+ it('should return default flash model when access to preview is false and preview flash model is requested', () => {
+ expect(
+ resolveModel(PREVIEW_GEMINI_FLASH_MODEL, false, false, false),
+ ).toBe(DEFAULT_GEMINI_FLASH_MODEL);
+ });
+
+ it('should return default model when access to preview is false and auto-gemini-3 is requested', () => {
+ expect(resolveModel(PREVIEW_GEMINI_MODEL_AUTO, false, false, false)).toBe(
+ DEFAULT_GEMINI_MODEL,
+ );
+ });
+
+ it('should return default model when access to preview is false and Gemini 3.1 is requested', () => {
+ expect(resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, false)).toBe(
+ DEFAULT_GEMINI_MODEL,
+ );
+ });
+
+ it('should still return default model when access to preview is false and auto-gemini-2.5 is requested', () => {
+ expect(resolveModel(DEFAULT_GEMINI_MODEL_AUTO, false, false, false)).toBe(
+ DEFAULT_GEMINI_MODEL,
+ );
+ });
+ });
});
describe('isGemini2Model', () => {
diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts
index 54ea063569..32014d5fbd 100644
--- a/packages/core/src/config/models.ts
+++ b/packages/core/src/config/models.ts
@@ -43,38 +43,70 @@ export const DEFAULT_THINKING_MODE = 8192;
*
* @param requestedModel The model alias or concrete model name requested by the user.
* @param useGemini3_1 Whether to use Gemini 3.1 Pro Preview for auto/pro aliases.
+ * @param hasAccessToPreview Whether the user has access to preview models.
* @returns The resolved concrete model name.
*/
export function resolveModel(
requestedModel: string,
useGemini3_1: boolean = false,
useCustomToolModel: boolean = false,
+ hasAccessToPreview: boolean = true,
): string {
+ let resolved: string;
switch (requestedModel) {
case PREVIEW_GEMINI_MODEL:
case PREVIEW_GEMINI_MODEL_AUTO:
case GEMINI_MODEL_ALIAS_AUTO:
case GEMINI_MODEL_ALIAS_PRO: {
if (useGemini3_1) {
- return useCustomToolModel
+ resolved = useCustomToolModel
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: PREVIEW_GEMINI_3_1_MODEL;
+ } else {
+ resolved = PREVIEW_GEMINI_MODEL;
}
- return PREVIEW_GEMINI_MODEL;
+ break;
}
case DEFAULT_GEMINI_MODEL_AUTO: {
- return DEFAULT_GEMINI_MODEL;
+ resolved = DEFAULT_GEMINI_MODEL;
+ break;
}
case GEMINI_MODEL_ALIAS_FLASH: {
- return PREVIEW_GEMINI_FLASH_MODEL;
+ resolved = PREVIEW_GEMINI_FLASH_MODEL;
+ break;
}
case GEMINI_MODEL_ALIAS_FLASH_LITE: {
- return DEFAULT_GEMINI_FLASH_LITE_MODEL;
+ resolved = DEFAULT_GEMINI_FLASH_LITE_MODEL;
+ break;
}
default: {
- return requestedModel;
+ resolved = requestedModel;
+ break;
}
}
+
+ if (!hasAccessToPreview && isPreviewModel(resolved)) {
+ // Downgrade to stable models if user lacks preview access.
+ switch (resolved) {
+ case PREVIEW_GEMINI_FLASH_MODEL:
+ return DEFAULT_GEMINI_FLASH_MODEL;
+ case PREVIEW_GEMINI_MODEL:
+ case PREVIEW_GEMINI_3_1_MODEL:
+ case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL:
+ return DEFAULT_GEMINI_MODEL;
+ default:
+ // Fallback for unknown preview models, preserving original logic.
+ if (resolved.includes('flash-lite')) {
+ return DEFAULT_GEMINI_FLASH_LITE_MODEL;
+ }
+ if (resolved.includes('flash')) {
+ return DEFAULT_GEMINI_FLASH_MODEL;
+ }
+ return DEFAULT_GEMINI_MODEL;
+ }
+ }
+
+ return resolved;
}
/**
diff --git a/packages/core/src/config/trackerFeatureFlag.test.ts b/packages/core/src/config/trackerFeatureFlag.test.ts
new file mode 100644
index 0000000000..c91dae517f
--- /dev/null
+++ b/packages/core/src/config/trackerFeatureFlag.test.ts
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { Config } from './config.js';
+import { TRACKER_CREATE_TASK_TOOL_NAME } from '../tools/tool-names.js';
+import * as os from 'node:os';
+
+describe('Config Tracker Feature Flag', () => {
+ const baseParams = {
+ sessionId: 'test-session',
+ targetDir: os.tmpdir(),
+ cwd: os.tmpdir(),
+ model: 'gemini-1.5-pro',
+ debugMode: false,
+ };
+
+ it('should not register tracker tools by default', async () => {
+ const config = new Config(baseParams);
+ await config.initialize();
+ const registry = config.getToolRegistry();
+ expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeUndefined();
+ });
+
+ it('should register tracker tools when tracker is enabled', async () => {
+ const config = new Config({
+ ...baseParams,
+ tracker: true,
+ });
+ await config.initialize();
+ const registry = config.getToolRegistry();
+ expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeDefined();
+ });
+
+ it('should not register tracker tools when tracker is explicitly disabled', async () => {
+ const config = new Config({
+ ...baseParams,
+ tracker: false,
+ });
+ await config.initialize();
+ const registry = config.getToolRegistry();
+ expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeUndefined();
+ });
+});
diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap
index 438251ed1f..699503c23f 100644
--- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap
+++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap
@@ -159,7 +159,7 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -333,7 +333,7 @@ An approved plan is available for this task at \`/tmp/plans/feature-x.md\`.
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -439,8 +439,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
-- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+ - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
+ - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -614,7 +614,7 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -765,7 +765,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -1132,8 +1132,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
-- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+ - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
+ - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -1245,8 +1245,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
-- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+ - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
+ - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -1366,8 +1366,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
-- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+ - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
+ - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -1492,8 +1492,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
-- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+ - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
+ - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -1663,7 +1663,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -1814,7 +1814,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -1969,7 +1969,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -2124,7 +2124,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -2275,7 +2275,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -2418,7 +2418,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -2568,7 +2568,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -2719,7 +2719,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -2825,8 +2825,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
-- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+ - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
+ - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -2939,8 +2939,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
-- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+ - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
+ - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@@ -3111,7 +3111,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -3262,7 +3262,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -3525,7 +3525,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -3676,7 +3676,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Memory Tool:** Use \`save_memory\` only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only. If unsure whether a fact is worth remembering globally, ask the user.
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
@@ -3782,8 +3782,8 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION.
## Tool Usage
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first.
-- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
-- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input.
+ - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
+ - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
diff --git a/packages/core/src/core/baseLlmClient.test.ts b/packages/core/src/core/baseLlmClient.test.ts
index db1086fe81..b9711608a7 100644
--- a/packages/core/src/core/baseLlmClient.test.ts
+++ b/packages/core/src/core/baseLlmClient.test.ts
@@ -15,17 +15,16 @@ import {
type Mock,
} from 'vitest';
-import type {
- GenerateContentOptions,
- GenerateJsonOptions,
+import {
+ BaseLlmClient,
+ type GenerateContentOptions,
+ type GenerateJsonOptions,
} from './baseLlmClient.js';
-import { BaseLlmClient } from './baseLlmClient.js';
-import type { ContentGenerator } from './contentGenerator.js';
+import { AuthType, type ContentGenerator } from './contentGenerator.js';
import type { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
import { createAvailabilityServiceMock } from '../availability/testUtils.js';
import type { GenerateContentResponse } from '@google/genai';
import type { Config } from '../config/config.js';
-import { AuthType } from './contentGenerator.js';
import { reportError } from '../utils/errorReporting.js';
import { logMalformedJsonResponse } from '../telemetry/loggers.js';
import { retryWithBackoff } from '../utils/retry.js';
diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts
index 1f9ecf2976..059b72437f 100644
--- a/packages/core/src/core/client.test.ts
+++ b/packages/core/src/core/client.test.ts
@@ -28,6 +28,7 @@ import {
GeminiEventType,
Turn,
type ChatCompressionInfo,
+ type ServerGeminiStreamEvent,
} from './turn.js';
import { getCoreSystemPrompt } from './prompts.js';
import { DEFAULT_GEMINI_MODEL_AUTO } from '../config/models.js';
@@ -47,7 +48,7 @@ import type {
} from '../services/modelConfigService.js';
import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
import * as policyCatalog from '../availability/policyCatalog.js';
-import { LlmRole } from '../telemetry/types.js';
+import { LlmRole, LoopType } from '../telemetry/types.js';
import { partToString } from '../utils/partUtils.js';
import { coreEvents } from '../utils/events.js';
@@ -1118,6 +1119,54 @@ ${JSON.stringify(
// The actual token calculation is unit tested in tokenCalculation.test.ts
});
+ it('should cleanly abort and return Turn on LoopDetected without unhandled promise rejections', async () => {
+ // Arrange
+ const mockStream = (async function* () {
+ // Yield an event that will trigger the loop detector
+ yield { type: 'content', value: 'Looping content' };
+ })();
+ mockTurnRunFn.mockReturnValue(mockStream);
+
+ const mockChat: Partial = {
+ addHistory: vi.fn(),
+ setTools: vi.fn(),
+ getHistory: vi.fn().mockReturnValue([]),
+ getLastPromptTokenCount: vi.fn(),
+ };
+ client['chat'] = mockChat as GeminiChat;
+
+ // Mock loop detector to return count > 1 on the first event (loop detected)
+ vi.spyOn(client['loopDetector'], 'addAndCheck').mockReturnValue({
+ count: 2,
+ });
+
+ const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
+
+ // Act
+ const stream = client.sendMessageStream(
+ [{ text: 'Hi' }],
+ new AbortController().signal,
+ 'prompt-id-1',
+ );
+
+ const events: ServerGeminiStreamEvent[] = [];
+ let finalResult: Turn | undefined;
+
+ while (true) {
+ const result = await stream.next();
+ if (result.done) {
+ finalResult = result.value;
+ break;
+ }
+ events.push(result.value);
+ }
+
+ // Assert
+ expect(events).toContainEqual({ type: GeminiEventType.LoopDetected });
+ expect(abortSpy).toHaveBeenCalled();
+ expect(finalResult).toBeInstanceOf(Turn);
+ });
+
it('should return the turn instance after the stream is complete', async () => {
// Arrange
const mockStream = (async function* () {
@@ -2915,45 +2964,257 @@ ${JSON.stringify(
expect(mockCheckNextSpeaker).not.toHaveBeenCalled();
});
- it('should abort linked signal when loop is detected', async () => {
- // Arrange
- vi.spyOn(client['loopDetector'], 'turnStarted').mockResolvedValue(false);
- vi.spyOn(client['loopDetector'], 'addAndCheck')
- .mockReturnValueOnce(false)
- .mockReturnValueOnce(true);
-
- let capturedSignal: AbortSignal;
- mockTurnRunFn.mockImplementation((_modelConfigKey, _request, signal) => {
- capturedSignal = signal;
- return (async function* () {
- yield { type: 'content', value: 'First event' };
- yield { type: 'content', value: 'Second event' };
- })();
+ describe('Loop Recovery (Two-Strike)', () => {
+ beforeEach(() => {
+ const mockChat: Partial = {
+ addHistory: vi.fn(),
+ setTools: vi.fn(),
+ getHistory: vi.fn().mockReturnValue([]),
+ getLastPromptTokenCount: vi.fn(),
+ };
+ client['chat'] = mockChat as GeminiChat;
+ vi.spyOn(client['loopDetector'], 'clearDetection');
+ vi.spyOn(client['loopDetector'], 'reset');
});
- const mockChat: Partial = {
- addHistory: vi.fn(),
- setTools: vi.fn(),
- getHistory: vi.fn().mockReturnValue([]),
- getLastPromptTokenCount: vi.fn(),
- };
- client['chat'] = mockChat as GeminiChat;
+ it('should trigger recovery (Strike 1) and continue', async () => {
+ // Arrange
+ vi.spyOn(client['loopDetector'], 'turnStarted').mockResolvedValue({
+ count: 0,
+ });
+ vi.spyOn(client['loopDetector'], 'addAndCheck')
+ .mockReturnValueOnce({ count: 0 })
+ .mockReturnValueOnce({ count: 1, detail: 'Repetitive tool call' });
- // Act
- const stream = client.sendMessageStream(
- [{ text: 'Hi' }],
- new AbortController().signal,
- 'prompt-id-loop',
- );
+ const sendMessageStreamSpy = vi.spyOn(client, 'sendMessageStream');
- const events = [];
- for await (const event of stream) {
- events.push(event);
- }
+ mockTurnRunFn.mockImplementation(() =>
+ (async function* () {
+ yield { type: GeminiEventType.Content, value: 'First event' };
+ yield { type: GeminiEventType.Content, value: 'Second event' };
+ })(),
+ );
- // Assert
- expect(events).toContainEqual({ type: GeminiEventType.LoopDetected });
- expect(capturedSignal!.aborted).toBe(true);
+ // Act
+ const stream = client.sendMessageStream(
+ [{ text: 'Hi' }],
+ new AbortController().signal,
+ 'prompt-id-loop-1',
+ );
+
+ const events = [];
+ for await (const event of stream) {
+ events.push(event);
+ }
+
+ // Assert
+ // sendMessageStream should be called twice (original + recovery)
+ expect(sendMessageStreamSpy).toHaveBeenCalledTimes(2);
+
+ // Verify recovery call parameters
+ const recoveryCall = sendMessageStreamSpy.mock.calls[1];
+ expect((recoveryCall[0] as Part[])[0].text).toContain(
+ 'System: Potential loop detected',
+ );
+ expect((recoveryCall[0] as Part[])[0].text).toContain(
+ 'Repetitive tool call',
+ );
+
+ // Verify loopDetector.clearDetection was called
+ expect(client['loopDetector'].clearDetection).toHaveBeenCalled();
+ });
+
+ it('should terminate (Strike 2) after recovery fails', async () => {
+ // Arrange
+ vi.spyOn(client['loopDetector'], 'turnStarted').mockResolvedValue({
+ count: 0,
+ });
+
+ // First call triggers Strike 1, Second call triggers Strike 2
+ vi.spyOn(client['loopDetector'], 'addAndCheck')
+ .mockReturnValueOnce({ count: 0 })
+ .mockReturnValueOnce({ count: 1, detail: 'Strike 1' }) // Triggers recovery in turn 1
+ .mockReturnValueOnce({ count: 2, detail: 'Strike 2' }); // Triggers termination in turn 2 (recovery turn)
+
+ const sendMessageStreamSpy = vi.spyOn(client, 'sendMessageStream');
+
+ mockTurnRunFn.mockImplementation(() =>
+ (async function* () {
+ yield { type: GeminiEventType.Content, value: 'Event' };
+ yield { type: GeminiEventType.Content, value: 'Event' };
+ })(),
+ );
+
+ // Act
+ const stream = client.sendMessageStream(
+ [{ text: 'Hi' }],
+ new AbortController().signal,
+ 'prompt-id-loop-2',
+ );
+
+ const events = [];
+ for await (const event of stream) {
+ events.push(event);
+ }
+
+ // Assert
+ expect(events).toContainEqual({ type: GeminiEventType.LoopDetected });
+ expect(sendMessageStreamSpy).toHaveBeenCalledTimes(2); // One original, one recovery
+ });
+
+ it('should respect boundedTurns during recovery', async () => {
+ // Arrange
+ vi.spyOn(client['loopDetector'], 'turnStarted').mockResolvedValue({
+ count: 0,
+ });
+ vi.spyOn(client['loopDetector'], 'addAndCheck').mockReturnValue({
+ count: 1,
+ detail: 'Loop',
+ });
+
+ const sendMessageStreamSpy = vi.spyOn(client, 'sendMessageStream');
+
+ mockTurnRunFn.mockImplementation(() =>
+ (async function* () {
+ yield { type: GeminiEventType.Content, value: 'Event' };
+ })(),
+ );
+
+ // Act
+ const stream = client.sendMessageStream(
+ [{ text: 'Hi' }],
+ new AbortController().signal,
+ 'prompt-id-loop-3',
+ 1, // Only 1 turn allowed
+ );
+
+ const events = [];
+ for await (const event of stream) {
+ events.push(event);
+ }
+
+ // Assert
+ // Should NOT trigger recovery because boundedTurns would reach 0
+ expect(events).toContainEqual({
+ type: GeminiEventType.MaxSessionTurns,
+ });
+ expect(sendMessageStreamSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should suppress LoopDetected event on Strike 1', async () => {
+ // Arrange
+ vi.spyOn(client['loopDetector'], 'turnStarted').mockResolvedValue({
+ count: 0,
+ });
+ vi.spyOn(client['loopDetector'], 'addAndCheck')
+ .mockReturnValueOnce({ count: 0 })
+ .mockReturnValueOnce({ count: 1, detail: 'Strike 1' });
+
+ const sendMessageStreamSpy = vi.spyOn(client, 'sendMessageStream');
+
+ mockTurnRunFn.mockImplementation(() =>
+ (async function* () {
+ yield { type: GeminiEventType.Content, value: 'Event' };
+ yield { type: GeminiEventType.Content, value: 'Event 2' };
+ })(),
+ );
+
+ // Act
+ const stream = client.sendMessageStream(
+ [{ text: 'Hi' }],
+ new AbortController().signal,
+ 'prompt-telemetry',
+ );
+
+ const events = [];
+ for await (const event of stream) {
+ events.push(event);
+ }
+
+ // Assert
+ // Strike 1 should trigger recovery call but NOT emit LoopDetected event
+ expect(events).not.toContainEqual({
+ type: GeminiEventType.LoopDetected,
+ });
+ expect(sendMessageStreamSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should escalate Strike 2 even if loop type changes', async () => {
+ // Arrange
+ vi.spyOn(client['loopDetector'], 'turnStarted').mockResolvedValue({
+ count: 0,
+ });
+
+ // Strike 1: Tool Call Loop, Strike 2: LLM Detected Loop
+ vi.spyOn(client['loopDetector'], 'addAndCheck')
+ .mockReturnValueOnce({ count: 0 })
+ .mockReturnValueOnce({
+ count: 1,
+ type: LoopType.TOOL_CALL_LOOP,
+ detail: 'Repetitive tool',
+ })
+ .mockReturnValueOnce({
+ count: 2,
+ type: LoopType.LLM_DETECTED_LOOP,
+ detail: 'LLM loop',
+ });
+
+ const sendMessageStreamSpy = vi.spyOn(client, 'sendMessageStream');
+
+ mockTurnRunFn.mockImplementation(() =>
+ (async function* () {
+ yield { type: GeminiEventType.Content, value: 'Event' };
+ yield { type: GeminiEventType.Content, value: 'Event 2' };
+ })(),
+ );
+
+ // Act
+ const stream = client.sendMessageStream(
+ [{ text: 'Hi' }],
+ new AbortController().signal,
+ 'prompt-escalate',
+ );
+
+ const events = [];
+ for await (const event of stream) {
+ events.push(event);
+ }
+
+ // Assert
+ expect(events).toContainEqual({ type: GeminiEventType.LoopDetected });
+ expect(sendMessageStreamSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should reset loop detector on new prompt', async () => {
+ // Arrange
+ vi.spyOn(client['loopDetector'], 'turnStarted').mockResolvedValue({
+ count: 0,
+ });
+ vi.spyOn(client['loopDetector'], 'addAndCheck').mockReturnValue({
+ count: 0,
+ });
+ mockTurnRunFn.mockImplementation(() =>
+ (async function* () {
+ yield { type: GeminiEventType.Content, value: 'Event' };
+ })(),
+ );
+
+ // Act
+ const stream = client.sendMessageStream(
+ [{ text: 'Hi' }],
+ new AbortController().signal,
+ 'prompt-id-new',
+ );
+ for await (const _ of stream) {
+ // Consume stream
+ }
+
+ // Assert
+ expect(client['loopDetector'].reset).toHaveBeenCalledWith(
+ 'prompt-id-new',
+ 'Hi',
+ );
+ });
});
});
diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts
index 18887462f6..14e2f42bc3 100644
--- a/packages/core/src/core/client.ts
+++ b/packages/core/src/core/client.ts
@@ -4,27 +4,35 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type {
- GenerateContentConfig,
- PartListUnion,
- Content,
- Tool,
- GenerateContentResponse,
+import {
+ createUserContent,
+ type GenerateContentConfig,
+ type PartListUnion,
+ type Content,
+ type Tool,
+ type GenerateContentResponse,
} from '@google/genai';
-import { createUserContent } from '@google/genai';
import { partListUnionToString } from './geminiRequest.js';
import {
getDirectoryContextString,
getInitialChatHistory,
} from '../utils/environmentContext.js';
-import type { ServerGeminiStreamEvent, ChatCompressionInfo } from './turn.js';
-import { CompressionStatus, Turn, GeminiEventType } from './turn.js';
+import {
+ CompressionStatus,
+ Turn,
+ GeminiEventType,
+ type ServerGeminiStreamEvent,
+ type ChatCompressionInfo,
+} from './turn.js';
import type { Config } from '../config/config.js';
import { getCoreSystemPrompt } from './prompts.js';
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
import { reportError } from '../utils/errorReporting.js';
import { GeminiChat } from './geminiChat.js';
-import { retryWithBackoff } from '../utils/retry.js';
+import {
+ retryWithBackoff,
+ type RetryAvailabilityContext,
+} from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import { getErrorMessage } from '../utils/errors.js';
import { tokenLimit } from './tokenLimits.js';
@@ -47,6 +55,7 @@ import type {
import {
ContentRetryFailureEvent,
NextSpeakerCheckEvent,
+ type LlmRole,
} from '../telemetry/types.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
import type { IdeContext, File } from '../ide/types.js';
@@ -61,10 +70,8 @@ import {
createAvailabilityContextProvider,
} from '../availability/policyHelpers.js';
import { resolveModel, isGemini2Model } from '../config/models.js';
-import type { RetryAvailabilityContext } from '../utils/retry.js';
import { partToString } from '../utils/partUtils.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
-import type { LlmRole } from '../telemetry/types.js';
const MAX_TURNS = 100;
@@ -635,10 +642,23 @@ export class GeminiClient {
const controller = new AbortController();
const linkedSignal = AbortSignal.any([signal, controller.signal]);
- const loopDetected = await this.loopDetector.turnStarted(signal);
- if (loopDetected) {
+ const loopResult = await this.loopDetector.turnStarted(signal);
+ if (loopResult.count > 1) {
yield { type: GeminiEventType.LoopDetected };
return turn;
+ } else if (loopResult.count === 1) {
+ if (boundedTurns <= 1) {
+ yield { type: GeminiEventType.MaxSessionTurns };
+ return turn;
+ }
+ return yield* this._recoverFromLoop(
+ loopResult,
+ signal,
+ prompt_id,
+ boundedTurns,
+ isInvalidStreamRetry,
+ displayContent,
+ );
}
const routingContext: RoutingContext = {
@@ -688,11 +708,22 @@ export class GeminiClient {
let isError = false;
let isInvalidStream = false;
+ let loopDetectedAbort = false;
+ let loopRecoverResult: { detail?: string } | undefined;
for await (const event of resultStream) {
- if (this.loopDetector.addAndCheck(event)) {
+ const loopResult = this.loopDetector.addAndCheck(event);
+ if (loopResult.count > 1) {
yield { type: GeminiEventType.LoopDetected };
- controller.abort();
- return turn;
+ loopDetectedAbort = true;
+ break;
+ } else if (loopResult.count === 1) {
+ if (boundedTurns <= 1) {
+ yield { type: GeminiEventType.MaxSessionTurns };
+ loopDetectedAbort = true;
+ break;
+ }
+ loopRecoverResult = loopResult;
+ break;
}
yield event;
@@ -706,6 +737,23 @@ export class GeminiClient {
}
}
+ if (loopDetectedAbort) {
+ controller.abort();
+ return turn;
+ }
+
+ if (loopRecoverResult) {
+ return yield* this._recoverFromLoop(
+ loopRecoverResult,
+ signal,
+ prompt_id,
+ boundedTurns,
+ isInvalidStreamRetry,
+ displayContent,
+ controller,
+ );
+ }
+
if (isError) {
return turn;
}
@@ -1121,4 +1169,42 @@ export class GeminiClient {
this.getChat().setHistory(result.newHistory);
}
}
+
+ /**
+ * Handles loop recovery by providing feedback to the model and initiating a new turn.
+ */
+ private _recoverFromLoop(
+ loopResult: { detail?: string },
+ signal: AbortSignal,
+ prompt_id: string,
+ boundedTurns: number,
+ isInvalidStreamRetry: boolean,
+ displayContent?: PartListUnion,
+ controllerToAbort?: AbortController,
+ ): AsyncGenerator {
+ controllerToAbort?.abort();
+
+ // Clear the detection flag so the recursive turn can proceed, but the count remains 1.
+ this.loopDetector.clearDetection();
+
+ const feedbackText = `System: Potential loop detected. Details: ${loopResult.detail || 'Repetitive patterns identified'}. Please take a step back and confirm you're making forward progress. If not, take a step back, analyze your previous actions and rethink how you're approaching the problem. Avoid repeating the same tool calls or responses without new results.`;
+
+ if (this.config.getDebugMode()) {
+ debugLogger.warn(
+ 'Iterative Loop Recovery: Injecting feedback message to model.',
+ );
+ }
+
+ const feedback = [{ text: feedbackText }];
+
+ // Recursive call with feedback
+ return this.sendMessageStream(
+ feedback,
+ signal,
+ prompt_id,
+ boundedTurns - 1,
+ isInvalidStreamRetry,
+ displayContent,
+ );
+ }
}
diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts
index 9b7c3ac802..d86eb6f738 100644
--- a/packages/core/src/core/contentGenerator.test.ts
+++ b/packages/core/src/core/contentGenerator.test.ts
@@ -5,11 +5,11 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import type { ContentGenerator } from './contentGenerator.js';
import {
createContentGenerator,
AuthType,
createContentGeneratorConfig,
+ type ContentGenerator,
} from './contentGenerator.js';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import { GoogleGenAI } from '@google/genai';
diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts
index d9bb02a230..2ce5420335 100644
--- a/packages/core/src/core/contentGenerator.ts
+++ b/packages/core/src/core/contentGenerator.ts
@@ -4,15 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type {
- CountTokensResponse,
- GenerateContentResponse,
- GenerateContentParameters,
- CountTokensParameters,
- EmbedContentResponse,
- EmbedContentParameters,
+import {
+ GoogleGenAI,
+ type CountTokensResponse,
+ type GenerateContentResponse,
+ type GenerateContentParameters,
+ type CountTokensParameters,
+ type EmbedContentResponse,
+ type EmbedContentParameters,
} from '@google/genai';
-import { GoogleGenAI } from '@google/genai';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import type { Config } from '../config/config.js';
import { loadApiKey } from './apiKeyCredentialStorage.js';
@@ -59,6 +59,7 @@ export enum AuthType {
USE_VERTEX_AI = 'vertex-ai',
LEGACY_CLOUD_SHELL = 'cloud-shell',
COMPUTE_ADC = 'compute-default-credentials',
+ GATEWAY = 'gateway',
}
/**
@@ -93,12 +94,16 @@ export type ContentGeneratorConfig = {
vertexai?: boolean;
authType?: AuthType;
proxy?: string;
+ baseUrl?: string;
+ customHeaders?: Record;
};
export async function createContentGeneratorConfig(
config: Config,
authType: AuthType | undefined,
apiKey?: string,
+ baseUrl?: string,
+ customHeaders?: Record,
): Promise {
const geminiApiKey =
apiKey ||
@@ -115,6 +120,8 @@ export async function createContentGeneratorConfig(
const contentGeneratorConfig: ContentGeneratorConfig = {
authType,
proxy: config?.getProxy(),
+ baseUrl,
+ customHeaders,
};
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now
@@ -203,9 +210,13 @@ export async function createContentGenerator(
if (
config.authType === AuthType.USE_GEMINI ||
- config.authType === AuthType.USE_VERTEX_AI
+ config.authType === AuthType.USE_VERTEX_AI ||
+ config.authType === AuthType.GATEWAY
) {
let headers: Record = { ...baseHeaders };
+ if (config.customHeaders) {
+ headers = { ...headers, ...config.customHeaders };
+ }
if (gcConfig?.getUsageStatisticsEnabled()) {
const installationManager = new InstallationManager();
const installationId = installationManager.getInstallationId();
@@ -214,7 +225,14 @@ export async function createContentGenerator(
'x-gemini-api-privileged-user-id': `${installationId}`,
};
}
- const httpOptions = { headers };
+ const httpOptions: {
+ baseUrl?: string;
+ headers: Record;
+ } = { headers };
+
+ if (config.baseUrl) {
+ httpOptions.baseUrl = config.baseUrl;
+ }
const googleGenAI = new GoogleGenAI({
apiKey: config.apiKey === '' ? undefined : config.apiKey,
diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts
index 6bdad0dddb..fcddc05a44 100644
--- a/packages/core/src/core/coreToolScheduler.test.ts
+++ b/packages/core/src/core/coreToolScheduler.test.ts
@@ -4,8 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect, vi } from 'vitest';
-import type { Mock } from 'vitest';
+import { describe, it, expect, vi, type Mock } from 'vitest';
import type { CallableTool } from '@google/genai';
import { CoreToolScheduler } from './coreToolScheduler.js';
import {
diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts
index f8d1b260fd..23473e199d 100644
--- a/packages/core/src/core/coreToolScheduler.ts
+++ b/packages/core/src/core/coreToolScheduler.ts
@@ -20,11 +20,15 @@ import { ToolErrorType } from '../tools/tool-error.js';
import { ToolCallEvent } from '../telemetry/types.js';
import { runInDevTraceSpan } from '../telemetry/trace.js';
import { ToolModificationHandler } from '../scheduler/tool-modifier.js';
-import { getToolSuggestion } from '../utils/tool-utils.js';
+import {
+ getToolSuggestion,
+ isToolCallResponseInfo,
+} from '../utils/tool-utils.js';
import type { ToolConfirmationRequest } from '../confirmation-bus/types.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import {
+ CoreToolCallStatus,
type ToolCall,
type ValidatingToolCall,
type ScheduledToolCall,
@@ -42,7 +46,6 @@ import {
type ToolCallRequestInfo,
type ToolCallResponseInfo,
} from '../scheduler/types.js';
-import { CoreToolCallStatus } from '../scheduler/types.js';
import { ToolExecutor } from '../scheduler/tool-executor.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { getPolicyDenialError } from '../scheduler/policy.js';
@@ -225,32 +228,36 @@ export class CoreToolScheduler {
const durationMs = existingStartTime
? Date.now() - existingStartTime
: undefined;
- return {
- request: currentCall.request,
- tool: toolInstance,
- invocation,
- status: CoreToolCallStatus.Success,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- response: auxiliaryData as ToolCallResponseInfo,
- durationMs,
- outcome,
- approvalMode,
- } as SuccessfulToolCall;
+ if (isToolCallResponseInfo(auxiliaryData)) {
+ return {
+ request: currentCall.request,
+ tool: toolInstance,
+ invocation,
+ status: CoreToolCallStatus.Success,
+ response: auxiliaryData,
+ durationMs,
+ outcome,
+ approvalMode,
+ } as SuccessfulToolCall;
+ }
+ throw new Error('Invalid response data for tool success');
}
case CoreToolCallStatus.Error: {
const durationMs = existingStartTime
? Date.now() - existingStartTime
: undefined;
- return {
- request: currentCall.request,
- status: CoreToolCallStatus.Error,
- tool: toolInstance,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- response: auxiliaryData as ToolCallResponseInfo,
- durationMs,
- outcome,
- approvalMode,
- } as ErroredToolCall;
+ if (isToolCallResponseInfo(auxiliaryData)) {
+ return {
+ request: currentCall.request,
+ status: CoreToolCallStatus.Error,
+ tool: toolInstance,
+ response: auxiliaryData,
+ durationMs,
+ outcome,
+ approvalMode,
+ } as ErroredToolCall;
+ }
+ throw new Error('Invalid response data for tool error');
}
case CoreToolCallStatus.AwaitingApproval:
return {
@@ -280,6 +287,19 @@ export class CoreToolScheduler {
? Date.now() - existingStartTime
: undefined;
+ if (isToolCallResponseInfo(auxiliaryData)) {
+ return {
+ request: currentCall.request,
+ tool: toolInstance,
+ invocation,
+ status: CoreToolCallStatus.Cancelled,
+ response: auxiliaryData,
+ durationMs,
+ outcome,
+ approvalMode,
+ } as CancelledToolCall;
+ }
+
// Preserve diff for cancelled edit operations
let resultDisplay: ToolResultDisplay | undefined = undefined;
if (currentCall.status === CoreToolCallStatus.AwaitingApproval) {
diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts
index 770a594bda..105d70e49f 100644
--- a/packages/core/src/core/geminiChat.test.ts
+++ b/packages/core/src/core/geminiChat.test.ts
@@ -5,8 +5,12 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import type { Content, GenerateContentResponse } from '@google/genai';
-import { ApiError, ThinkingLevel } from '@google/genai';
+import {
+ ApiError,
+ ThinkingLevel,
+ type Content,
+ type GenerateContentResponse,
+} from '@google/genai';
import type { ContentGenerator } from '../core/contentGenerator.js';
import {
GeminiChat,
diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts
index 789ea73ff1..ae5f46db37 100644
--- a/packages/core/src/core/geminiChat.ts
+++ b/packages/core/src/core/geminiChat.ts
@@ -7,17 +7,18 @@
// DISCLAIMER: This is a copied version of https://github.com/googleapis/js-genai/blob/main/src/chats.ts with the intention of working around a key bug
// where function responses are not treated as "valid" responses: https://b.corp.google.com/issues/420354090
-import type {
- GenerateContentResponse,
- Content,
- Part,
- Tool,
- PartListUnion,
- GenerateContentConfig,
- GenerateContentParameters,
+import {
+ createUserContent,
+ FinishReason,
+ type GenerateContentResponse,
+ type Content,
+ type Part,
+ type Tool,
+ type PartListUnion,
+ type GenerateContentConfig,
+ type GenerateContentParameters,
} from '@google/genai';
import { toParts } from '../code_assist/converter.js';
-import { createUserContent, FinishReason } from '@google/genai';
import { retryWithBackoff, isRetryableError } from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import type { Config } from '../config/config.js';
@@ -40,6 +41,7 @@ import {
import {
ContentRetryEvent,
ContentRetryFailureEvent,
+ type LlmRole,
} from '../telemetry/types.js';
import { handleFallback } from '../fallback/handler.js';
import { isFunctionResponse } from '../utils/messageInspectors.js';
@@ -51,7 +53,6 @@ import {
createAvailabilityContextProvider,
} from '../availability/policyHelpers.js';
import { coreEvents } from '../utils/events.js';
-import type { LlmRole } from '../telemetry/types.js';
export enum StreamEventType {
/** A regular content chunk from the API. */
@@ -690,9 +691,13 @@ export class GeminiChat {
const history = curated
? extractCuratedHistory(this.history)
: this.history;
- // Deep copy the history to avoid mutating the history outside of the
- // chat session.
- return structuredClone(history);
+ // Return a shallow copy of the array to prevent callers from mutating
+ // the internal history array (push/pop/splice). Content objects are
+ // shared references — callers MUST NOT mutate them in place.
+ // This replaces a prior structuredClone() which deep-copied the entire
+ // conversation on every call, causing O(n) memory pressure per turn
+ // that compounded into OOM crashes in long-running sessions.
+ return [...history];
}
/**
diff --git a/packages/core/src/core/geminiChat_network_retry.test.ts b/packages/core/src/core/geminiChat_network_retry.test.ts
index 161cadaf52..1a73b236a2 100644
--- a/packages/core/src/core/geminiChat_network_retry.test.ts
+++ b/packages/core/src/core/geminiChat_network_retry.test.ts
@@ -5,8 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import type { GenerateContentResponse } from '@google/genai';
-import { ApiError } from '@google/genai';
+import { ApiError, type GenerateContentResponse } from '@google/genai';
import type { ContentGenerator } from '../core/contentGenerator.js';
import { GeminiChat, StreamEventType, type StreamEvent } from './geminiChat.js';
import type { Config } from '../config/config.js';
diff --git a/packages/core/src/core/localLiteRtLmClient.ts b/packages/core/src/core/localLiteRtLmClient.ts
index 8f4a020a50..798dcb5765 100644
--- a/packages/core/src/core/localLiteRtLmClient.ts
+++ b/packages/core/src/core/localLiteRtLmClient.ts
@@ -4,10 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { GoogleGenAI } from '@google/genai';
+import { GoogleGenAI, type Content } from '@google/genai';
import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js';
-import type { Content } from '@google/genai';
/**
* A client for making single, non-streaming calls to a local Gemini-compatible API
diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts
index 498aa85ca1..a479654233 100644
--- a/packages/core/src/core/logger.test.ts
+++ b/packages/core/src/core/logger.test.ts
@@ -13,12 +13,12 @@ import {
afterEach,
afterAll,
} from 'vitest';
-import type { LogEntry } from './logger.js';
import {
Logger,
MessageSenderType,
encodeTagName,
decodeTagName,
+ type LogEntry,
} from './logger.js';
import { AuthType } from './contentGenerator.js';
import { Storage } from '../config/storage.js';
diff --git a/packages/core/src/core/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator.ts
index 23416a5202..60144740c2 100644
--- a/packages/core/src/core/loggingContentGenerator.ts
+++ b/packages/core/src/core/loggingContentGenerator.ts
@@ -16,11 +16,12 @@ import type {
GenerateContentResponseUsageMetadata,
GenerateContentResponse,
} from '@google/genai';
-import type { ServerDetails, ContextBreakdown } from '../telemetry/types.js';
import {
ApiRequestEvent,
ApiResponseEvent,
ApiErrorEvent,
+ type ServerDetails,
+ type ContextBreakdown,
} from '../telemetry/types.js';
import type { LlmRole } from '../telemetry/llmRole.js';
import type { Config } from '../config/config.js';
diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts
index 6d65596ce4..4759677696 100644
--- a/packages/core/src/core/prompts.test.ts
+++ b/packages/core/src/core/prompts.test.ts
@@ -599,24 +599,24 @@ describe('Core System Prompt (prompts.ts)', () => {
expect(prompt).not.toContain('via `&`');
});
- it("should include 'ctrl + f' instructions when interactive shell is enabled", () => {
+ it("should include 'tab' instructions when interactive shell is enabled", () => {
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
PREVIEW_GEMINI_MODEL,
);
vi.mocked(mockConfig.isInteractive).mockReturnValue(true);
vi.mocked(mockConfig.isInteractiveShellEnabled).mockReturnValue(true);
const prompt = getCoreSystemPrompt(mockConfig);
- expect(prompt).toContain('ctrl + f');
+ expect(prompt).toContain('tab');
});
- it("should NOT include 'ctrl + f' instructions when interactive shell is disabled", () => {
+ it("should NOT include 'tab' instructions when interactive shell is disabled", () => {
vi.mocked(mockConfig.getActiveModel).mockReturnValue(
PREVIEW_GEMINI_MODEL,
);
vi.mocked(mockConfig.isInteractive).mockReturnValue(true);
vi.mocked(mockConfig.isInteractiveShellEnabled).mockReturnValue(false);
const prompt = getCoreSystemPrompt(mockConfig);
- expect(prompt).not.toContain('ctrl + f');
+ expect(prompt).not.toContain('`tab`');
});
});
diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts
index 6634f6f4c8..435323f73d 100644
--- a/packages/core/src/core/turn.test.ts
+++ b/packages/core/src/core/turn.test.ts
@@ -5,15 +5,19 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import type {
- ServerGeminiToolCallRequestEvent,
- ServerGeminiErrorEvent,
+import {
+ Turn,
+ GeminiEventType,
+ type ServerGeminiToolCallRequestEvent,
+ type ServerGeminiErrorEvent,
} from './turn.js';
-import { Turn, GeminiEventType } from './turn.js';
import type { GenerateContentResponse, Part, Content } from '@google/genai';
import { reportError } from '../utils/errorReporting.js';
-import type { GeminiChat } from './geminiChat.js';
-import { InvalidStreamError, StreamEventType } from './geminiChat.js';
+import {
+ InvalidStreamError,
+ StreamEventType,
+ type GeminiChat,
+} from './geminiChat.js';
import { LlmRole } from '../telemetry/types.js';
const mockSendMessageStream = vi.fn();
diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts
index 23b55afe29..9c0e536c48 100644
--- a/packages/core/src/core/turn.ts
+++ b/packages/core/src/core/turn.ts
@@ -4,13 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type {
- PartListUnion,
- GenerateContentResponse,
- FunctionCall,
- FunctionDeclaration,
- FinishReason,
- GenerateContentResponseUsageMetadata,
+import {
+ createUserContent,
+ type PartListUnion,
+ type GenerateContentResponse,
+ type FunctionCall,
+ type FunctionDeclaration,
+ type FinishReason,
+ type GenerateContentResponseUsageMetadata,
} from '@google/genai';
import type {
ToolCallConfirmationDetails,
@@ -23,10 +24,8 @@ import {
UnauthorizedError,
toFriendlyError,
} from '../utils/errors.js';
-import type { GeminiChat } from './geminiChat.js';
-import { InvalidStreamError } from './geminiChat.js';
+import { InvalidStreamError, type GeminiChat } from './geminiChat.js';
import { parseThought, type ThoughtSummary } from '../utils/thoughtUtils.js';
-import { createUserContent } from '@google/genai';
import type { ModelConfigKey } from '../services/modelConfigService.js';
import { getCitations } from '../utils/generateContentResponseUtilities.js';
import { LlmRole } from '../telemetry/types.js';
@@ -242,6 +241,7 @@ export class Turn {
readonly pendingToolCalls: ToolCallRequestInfo[] = [];
private debugResponses: GenerateContentResponse[] = [];
private pendingCitations = new Set();
+ private cachedResponseText: string | undefined = undefined;
finishReason: FinishReason | undefined = undefined;
constructor(
@@ -433,11 +433,15 @@ export class Turn {
/**
* Get the concatenated response text from all responses in this turn.
* This extracts and joins all text content from the model's responses.
+ * The result is cached since this is called multiple times per turn.
*/
getResponseText(): string {
- return this.debugResponses
- .map((response) => getResponseText(response))
- .filter((text): text is string => text !== null)
- .join(' ');
+ if (this.cachedResponseText === undefined) {
+ this.cachedResponseText = this.debugResponses
+ .map((response) => getResponseText(response))
+ .filter((text): text is string => text !== null)
+ .join(' ');
+ }
+ return this.cachedResponseText;
}
}
diff --git a/packages/core/src/hooks/hookAggregator.test.ts b/packages/core/src/hooks/hookAggregator.test.ts
index ea675464f2..ee9ade9a87 100644
--- a/packages/core/src/hooks/hookAggregator.test.ts
+++ b/packages/core/src/hooks/hookAggregator.test.ts
@@ -6,13 +6,14 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { HookAggregator } from './hookAggregator.js';
-import type {
- HookExecutionResult,
- BeforeToolSelectionOutput,
- BeforeModelOutput,
- HookOutput,
+import {
+ HookType,
+ HookEventName,
+ type HookExecutionResult,
+ type BeforeToolSelectionOutput,
+ type BeforeModelOutput,
+ type HookOutput,
} from './types.js';
-import { HookType, HookEventName } from './types.js';
// Helper function to create proper HookExecutionResult objects
function createHookExecutionResult(
diff --git a/packages/core/src/hooks/hookAggregator.ts b/packages/core/src/hooks/hookAggregator.ts
index 5cd53e8c6e..523bc823fd 100644
--- a/packages/core/src/hooks/hookAggregator.ts
+++ b/packages/core/src/hooks/hookAggregator.ts
@@ -5,11 +5,6 @@
*/
import { FunctionCallingConfigMode } from '@google/genai';
-import type {
- HookOutput,
- HookExecutionResult,
- BeforeToolSelectionOutput,
-} from './types.js';
import {
DefaultHookOutput,
BeforeToolHookOutput,
@@ -18,6 +13,9 @@ import {
AfterModelHookOutput,
AfterAgentHookOutput,
HookEventName,
+ type HookOutput,
+ type HookExecutionResult,
+ type BeforeToolSelectionOutput,
} from './types.js';
/**
diff --git a/packages/core/src/hooks/hookEventHandler.test.ts b/packages/core/src/hooks/hookEventHandler.test.ts
index 9a07d39672..5c1a18c76e 100644
--- a/packages/core/src/hooks/hookEventHandler.test.ts
+++ b/packages/core/src/hooks/hookEventHandler.test.ts
@@ -11,12 +11,13 @@ import type {
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HookEventHandler } from './hookEventHandler.js';
import type { Config } from '../config/config.js';
-import type { HookConfig, HookExecutionResult } from './types.js';
import {
NotificationType,
SessionStartSource,
HookEventName,
HookType,
+ type HookConfig,
+ type HookExecutionResult,
} from './types.js';
import type { HookPlanner } from './hookPlanner.js';
import type { HookRunner } from './hookRunner.js';
diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts
index 00909094ce..7fa45e3271 100644
--- a/packages/core/src/hooks/hookEventHandler.ts
+++ b/packages/core/src/hooks/hookEventHandler.ts
@@ -8,27 +8,28 @@ import type { Config } from '../config/config.js';
import type { HookPlanner, HookEventContext } from './hookPlanner.js';
import type { HookRunner } from './hookRunner.js';
import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js';
-import { HookEventName, HookType } from './types.js';
-import type {
- HookConfig,
- HookInput,
- BeforeToolInput,
- AfterToolInput,
- BeforeAgentInput,
- NotificationInput,
- AfterAgentInput,
- SessionStartInput,
- SessionEndInput,
- PreCompressInput,
- BeforeModelInput,
- AfterModelInput,
- BeforeToolSelectionInput,
- NotificationType,
- SessionStartSource,
- SessionEndReason,
- PreCompressTrigger,
- HookExecutionResult,
- McpToolContext,
+import {
+ HookEventName,
+ HookType,
+ type HookConfig,
+ type HookInput,
+ type BeforeToolInput,
+ type AfterToolInput,
+ type BeforeAgentInput,
+ type NotificationInput,
+ type AfterAgentInput,
+ type SessionStartInput,
+ type SessionEndInput,
+ type PreCompressInput,
+ type BeforeModelInput,
+ type AfterModelInput,
+ type BeforeToolSelectionInput,
+ type NotificationType,
+ type SessionStartSource,
+ type SessionEndReason,
+ type PreCompressTrigger,
+ type HookExecutionResult,
+ type McpToolContext,
} from './types.js';
import { defaultHookTranslator } from './hookTranslator.js';
import type {
diff --git a/packages/core/src/hooks/hookPlanner.ts b/packages/core/src/hooks/hookPlanner.ts
index 3e016efe23..3da7aeec21 100644
--- a/packages/core/src/hooks/hookPlanner.ts
+++ b/packages/core/src/hooks/hookPlanner.ts
@@ -5,8 +5,11 @@
*/
import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js';
-import type { HookExecutionPlan, HookEventName } from './types.js';
-import { getHookKey } from './types.js';
+import {
+ getHookKey,
+ type HookExecutionPlan,
+ type HookEventName,
+} from './types.js';
import { debugLogger } from '../utils/debugLogger.js';
/**
diff --git a/packages/core/src/hooks/hookRegistry.ts b/packages/core/src/hooks/hookRegistry.ts
index b76478d152..1dad67bad5 100644
--- a/packages/core/src/hooks/hookRegistry.ts
+++ b/packages/core/src/hooks/hookRegistry.ts
@@ -5,8 +5,13 @@
*/
import type { Config } from '../config/config.js';
-import type { HookDefinition, HookConfig } from './types.js';
-import { HookEventName, ConfigSource, HOOKS_CONFIG_FIELDS } from './types.js';
+import {
+ HookEventName,
+ ConfigSource,
+ HOOKS_CONFIG_FIELDS,
+ type HookDefinition,
+ type HookConfig,
+} from './types.js';
import { debugLogger } from '../utils/debugLogger.js';
import { TrustedHooksManager } from './trustedHooks.js';
import { coreEvents } from '../utils/events.js';
diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts
index ca88b9411e..eb806aba3d 100644
--- a/packages/core/src/hooks/hookRunner.test.ts
+++ b/packages/core/src/hooks/hookRunner.test.ts
@@ -7,8 +7,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { HookRunner } from './hookRunner.js';
-import { HookEventName, HookType, ConfigSource } from './types.js';
-import type { HookConfig, HookInput } from './types.js';
+import {
+ HookEventName,
+ HookType,
+ ConfigSource,
+ type HookConfig,
+ type HookInput,
+} from './types.js';
import type { Readable, Writable } from 'node:stream';
import type { Config } from '../config/config.js';
diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts
index a9945afbc1..4f44958787 100644
--- a/packages/core/src/hooks/hookRunner.ts
+++ b/packages/core/src/hooks/hookRunner.ts
@@ -5,19 +5,21 @@
*/
import { spawn, execSync } from 'node:child_process';
-import type {
- HookConfig,
- CommandHookConfig,
- RuntimeHookConfig,
- HookInput,
- HookOutput,
- HookExecutionResult,
- BeforeAgentInput,
- BeforeModelInput,
- BeforeModelOutput,
- BeforeToolInput,
+import {
+ HookEventName,
+ ConfigSource,
+ HookType,
+ type HookConfig,
+ type CommandHookConfig,
+ type RuntimeHookConfig,
+ type HookInput,
+ type HookOutput,
+ type HookExecutionResult,
+ type BeforeAgentInput,
+ type BeforeModelInput,
+ type BeforeModelOutput,
+ type BeforeToolInput,
} from './types.js';
-import { HookEventName, ConfigSource, HookType } from './types.js';
import type { Config } from '../config/config.js';
import type { LLMRequest } from './hookTranslator.js';
import { debugLogger } from '../utils/debugLogger.js';
diff --git a/packages/core/src/hooks/hookSystem.test.ts b/packages/core/src/hooks/hookSystem.test.ts
index 85f1a7407b..959aa4591d 100644
--- a/packages/core/src/hooks/hookSystem.test.ts
+++ b/packages/core/src/hooks/hookSystem.test.ts
@@ -8,11 +8,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HookSystem } from './hookSystem.js';
import { Config } from '../config/config.js';
import { HookType } from './types.js';
-import { spawn } from 'node:child_process';
+import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
-import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
// Mock type for the child_process spawn
diff --git a/packages/core/src/hooks/hookSystem.ts b/packages/core/src/hooks/hookSystem.ts
index 84494ee1ea..f748665985 100644
--- a/packages/core/src/hooks/hookSystem.ts
+++ b/packages/core/src/hooks/hookSystem.ts
@@ -5,28 +5,26 @@
*/
import type { Config } from '../config/config.js';
-import { HookRegistry } from './hookRegistry.js';
+import { HookRegistry, type HookRegistryEntry } from './hookRegistry.js';
import { HookRunner } from './hookRunner.js';
-import { HookAggregator } from './hookAggregator.js';
+import { HookAggregator, type AggregatedHookResult } from './hookAggregator.js';
import { HookPlanner } from './hookPlanner.js';
import { HookEventHandler } from './hookEventHandler.js';
-import type { HookRegistryEntry } from './hookRegistry.js';
import { debugLogger } from '../utils/debugLogger.js';
-import type {
- SessionStartSource,
- SessionEndReason,
- PreCompressTrigger,
- DefaultHookOutput,
- BeforeModelHookOutput,
- AfterModelHookOutput,
- BeforeToolSelectionHookOutput,
- McpToolContext,
- HookConfig,
- HookEventName,
- ConfigSource,
+import {
+ NotificationType,
+ type SessionStartSource,
+ type SessionEndReason,
+ type PreCompressTrigger,
+ type DefaultHookOutput,
+ type BeforeModelHookOutput,
+ type AfterModelHookOutput,
+ type BeforeToolSelectionHookOutput,
+ type McpToolContext,
+ type HookConfig,
+ type HookEventName,
+ type ConfigSource,
} from './types.js';
-import { NotificationType } from './types.js';
-import type { AggregatedHookResult } from './hookAggregator.js';
import type {
GenerateContentParameters,
GenerateContentResponse,
diff --git a/packages/core/src/hooks/types.test.ts b/packages/core/src/hooks/types.test.ts
index 933b0425e2..ab809cbec7 100644
--- a/packages/core/src/hooks/types.test.ts
+++ b/packages/core/src/hooks/types.test.ts
@@ -14,15 +14,18 @@ import {
HookEventName,
HookType,
BeforeToolHookOutput,
+ type HookDecision,
} from './types.js';
-import { defaultHookTranslator } from './hookTranslator.js';
+import {
+ defaultHookTranslator,
+ type LLMRequest,
+ type LLMResponse,
+} from './hookTranslator.js';
import type {
GenerateContentParameters,
GenerateContentResponse,
ToolConfig,
} from '@google/genai';
-import type { LLMRequest, LLMResponse } from './hookTranslator.js';
-import type { HookDecision } from './types.js';
vi.mock('./hookTranslator.js', () => ({
defaultHookTranslator: {
diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts
index b053f22f59..9c6217ffa4 100644
--- a/packages/core/src/hooks/types.ts
+++ b/packages/core/src/hooks/types.ts
@@ -10,12 +10,12 @@ import type {
ToolConfig as GenAIToolConfig,
ToolListUnion,
} from '@google/genai';
-import type {
- LLMRequest,
- LLMResponse,
- HookToolConfig,
+import {
+ defaultHookTranslator,
+ type LLMRequest,
+ type LLMResponse,
+ type HookToolConfig,
} from './hookTranslator.js';
-import { defaultHookTranslator } from './hookTranslator.js';
/**
* Configuration source levels in precedence order (highest to lowest)
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8ce5e77d81..c4a9965e41 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -121,6 +121,8 @@ export * from './services/chatRecordingService.js';
export * from './services/fileSystemService.js';
export * from './services/sessionSummaryUtils.js';
export * from './services/contextManager.js';
+export * from './services/trackerService.js';
+export * from './services/trackerTypes.js';
export * from './skills/skillManager.js';
export * from './skills/skillLoader.js';
@@ -128,7 +130,11 @@ export * from './skills/skillLoader.js';
export * from './ide/ide-client.js';
export * from './ide/ideContext.js';
export * from './ide/ide-installer.js';
-export { IDE_DEFINITIONS, type IdeInfo } from './ide/detect-ide.js';
+export {
+ IDE_DEFINITIONS,
+ type IdeInfo,
+ isCloudShell,
+} from './ide/detect-ide.js';
export * from './ide/constants.js';
export * from './ide/types.js';
@@ -167,6 +173,7 @@ export * from './tools/read-many-files.js';
export * from './tools/mcp-client.js';
export * from './tools/mcp-tool.js';
export * from './tools/write-todos.js';
+export * from './tools/trackerTools.js';
export * from './tools/activate-skill.js';
export * from './tools/ask-user.js';
diff --git a/packages/core/src/mcp/google-auth-provider.test.ts b/packages/core/src/mcp/google-auth-provider.test.ts
index 8a25f15ad7..f535f17d83 100644
--- a/packages/core/src/mcp/google-auth-provider.test.ts
+++ b/packages/core/src/mcp/google-auth-provider.test.ts
@@ -6,8 +6,7 @@
import { GoogleAuth } from 'google-auth-library';
import { GoogleCredentialProvider } from './google-auth-provider.js';
-import type { Mock } from 'vitest';
-import { vi, describe, beforeEach, it, expect } from 'vitest';
+import { vi, describe, beforeEach, it, expect, type Mock } from 'vitest';
import type { MCPServerConfig } from '../config/config.js';
vi.mock('google-auth-library');
diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts
index 77c46305a6..5cd4460e97 100644
--- a/packages/core/src/mcp/oauth-provider.test.ts
+++ b/packages/core/src/mcp/oauth-provider.test.ts
@@ -56,12 +56,12 @@ vi.mock('node:readline', () => ({
import * as http from 'node:http';
import * as crypto from 'node:crypto';
-import type {
- MCPOAuthConfig,
- OAuthTokenResponse,
- OAuthClientRegistrationResponse,
+import {
+ MCPOAuthProvider,
+ type MCPOAuthConfig,
+ type OAuthTokenResponse,
+ type OAuthClientRegistrationResponse,
} from './oauth-provider.js';
-import { MCPOAuthProvider } from './oauth-provider.js';
import { getConsentForOauth } from '../utils/authConsent.js';
import type { OAuthToken } from './token-storage/types.js';
import { MCPOAuthTokenStorage } from './oauth-token-storage.js';
diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts
index 95cec40f50..6aaafa6054 100644
--- a/packages/core/src/mcp/oauth-provider.ts
+++ b/packages/core/src/mcp/oauth-provider.ts
@@ -4,9 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import * as http from 'node:http';
import * as crypto from 'node:crypto';
-import type * as net from 'node:net';
import { URL } from 'node:url';
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
import type { OAuthToken } from './token-storage/types.js';
@@ -16,6 +14,23 @@ import { OAuthUtils, ResourceMismatchError } from './oauth-utils.js';
import { coreEvents } from '../utils/events.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getConsentForOauth } from '../utils/authConsent.js';
+import {
+ generatePKCEParams,
+ startCallbackServer,
+ getPortFromUrl,
+ buildAuthorizationUrl,
+ exchangeCodeForToken,
+ refreshAccessToken as refreshAccessTokenShared,
+ REDIRECT_PATH,
+ type OAuthFlowConfig,
+ type OAuthTokenResponse,
+} from '../utils/oauth-flow.js';
+
+// Re-export types that were moved to oauth-flow.ts for backward compatibility.
+export type {
+ OAuthAuthorizationResponse,
+ OAuthTokenResponse,
+} from '../utils/oauth-flow.js';
/**
* OAuth configuration for an MCP server.
@@ -34,25 +49,6 @@ export interface MCPOAuthConfig {
registrationUrl?: string;
}
-/**
- * OAuth authorization response.
- */
-export interface OAuthAuthorizationResponse {
- code: string;
- state: string;
-}
-
-/**
- * OAuth token response from the authorization server.
- */
-export interface OAuthTokenResponse {
- access_token: string;
- token_type: string;
- expires_in?: number;
- refresh_token?: string;
- scope?: string;
-}
-
/**
* Dynamic client registration request (RFC 7591).
*/
@@ -80,18 +76,6 @@ export interface OAuthClientRegistrationResponse {
scope?: string;
}
-/**
- * PKCE (Proof Key for Code Exchange) parameters.
- */
-interface PKCEParams {
- codeVerifier: string;
- codeChallenge: string;
- state: string;
-}
-
-const REDIRECT_PATH = '/oauth/callback';
-const HTTP_OK = 200;
-
/**
* Provider for handling OAuth authentication for MCP servers.
*/
@@ -239,375 +223,18 @@ export class MCPOAuthProvider {
}
/**
- * Generate PKCE parameters for OAuth flow.
- *
- * @returns PKCE parameters including code verifier, challenge, and state
+ * Build the OAuth resource parameter from an MCP server URL, if available.
+ * Returns undefined if the URL is not provided or cannot be processed.
*/
- private generatePKCEParams(): PKCEParams {
- // Generate code verifier (43-128 characters)
- // using 64 bytes results in ~86 characters, safely above the minimum of 43
- const codeVerifier = crypto.randomBytes(64).toString('base64url');
-
- // Generate code challenge using SHA256
- const codeChallenge = crypto
- .createHash('sha256')
- .update(codeVerifier)
- .digest('base64url');
-
- // Generate state for CSRF protection
- const state = crypto.randomBytes(16).toString('base64url');
-
- return { codeVerifier, codeChallenge, state };
- }
-
- /**
- * Start a local HTTP server to handle OAuth callback.
- * The server will listen on the specified port (or port 0 for OS assignment).
- *
- * @param expectedState The state parameter to validate
- * @returns Object containing the port (available immediately) and a promise for the auth response
- */
- private startCallbackServer(
- expectedState: string,
- port?: number,
- ): {
- port: Promise;
- response: Promise;
- } {
- let portResolve: (port: number) => void;
- let portReject: (error: Error) => void;
- const portPromise = new Promise((resolve, reject) => {
- portResolve = resolve;
- portReject = reject;
- });
-
- const responsePromise = new Promise(
- (resolve, reject) => {
- let serverPort: number;
-
- const server = http.createServer(
- async (req: http.IncomingMessage, res: http.ServerResponse) => {
- try {
- const url = new URL(req.url!, `http://localhost:${serverPort}`);
-
- if (url.pathname !== REDIRECT_PATH) {
- res.writeHead(404);
- res.end('Not found');
- return;
- }
-
- const code = url.searchParams.get('code');
- const state = url.searchParams.get('state');
- const error = url.searchParams.get('error');
-
- if (error) {
- res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' });
- res.end(`
-
-
-