mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 00:30:59 -07:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed74c13aed | |||
| 3ece48ebc0 | |||
| 3d4956aa57 | |||
| 9dc6898d28 | |||
| c5112cde46 | |||
| c72cfad92c | |||
| 205d69eb07 | |||
| 3481032980 | |||
| a5fd5d0b9f | |||
| 55db3c776c | |||
| e63d273e4e | |||
| 4c85d14f48 | |||
| efec63658a | |||
| c59ef74837 | |||
| 29b3aa860c | |||
| 1c92824636 | |||
| e200b40408 | |||
| 8f36051f32 | |||
| ac4e65d669 | |||
| 6f3c3c7967 | |||
| 212402bd53 | |||
| 49e4082f38 | |||
| 54885214a1 | |||
| 717660997d | |||
| bc89b05f01 | |||
| 66721379f8 | |||
| df74b89876 | |||
| 25e9613594 | |||
| 94ba7ec319 | |||
| 07d2187a76 | |||
| 352fb0c976 | |||
| 7011c13ee6 | |||
| 12957ea16a | |||
| 1017b78157 | |||
| 7e06559db2 | |||
| bbcfff5cf1 | |||
| 5bd21a240d | |||
| fdfd626f28 | |||
| 0659ad1702 | |||
| a63c76522a | |||
| af424aefa9 | |||
| 28af4e127f | |||
| d25088956d | |||
| 75737c1b44 | |||
| 533b65188d | |||
| 066b0cc15f | |||
| 1124e49f34 | |||
| df14a6c2db | |||
| 34f0c1538b | |||
| c70c95ead3 | |||
| f3bbe6e77a | |||
| 28e79831ac | |||
| fdf5b18cfb | |||
| b5f3eb2c9c | |||
| 2a84090dd5 | |||
| 27d7aeb1ed | |||
| d6c560498b | |||
| 4500da339b | |||
| 4be08a2261 | |||
| fe332bbef7 | |||
| 50af050623 | |||
| e5207eb67f | |||
| f15bcaf499 | |||
| 5f2f60bed6 | |||
| c332d1e636 | |||
| aa158e18d3 | |||
| fca29b0bd8 | |||
| 1e2afbb514 | |||
| 208291f391 | |||
| 8303edbb54 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+6
-1
@@ -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
|
||||
/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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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() }}
|
||||
|
||||
@@ -23,6 +23,10 @@ jobs:
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
env:
|
||||
APP_ID: '${{ secrets.APP_ID }}'
|
||||
if: |-
|
||||
${{ env.APP_ID != '' }}
|
||||
uses: 'actions/create-github-app-token@v2'
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
@@ -33,7 +37,7 @@ jobs:
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
|
||||
script: |
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
const thirtyDaysAgo = new Date();
|
||||
|
||||
@@ -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}`);
|
||||
Vendored
+3
@@ -7,6 +7,9 @@
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -464,8 +488,9 @@ on GitHub.
|
||||
page in their default browser directly from the CLI using the `/extension`
|
||||
explore command. ([pr](https://github.com/google-gemini/gemini-cli/pull/11846)
|
||||
by [@JayadityaGit](https://github.com/JayadityaGit)).
|
||||
- **Configurable compression:** Users can modify the compression threshold in
|
||||
`/settings`. The default has been made more proactive
|
||||
- **Configurable compression:** Users can modify the context compression
|
||||
threshold in `/settings` (decimal with percentage display). The default has
|
||||
been made more proactive
|
||||
([pr](https://github.com/google-gemini/gemini-cli/pull/12317) by
|
||||
[@scidomino](https://github.com/scidomino)).
|
||||
- **API key authentication:** Users can now securely enter and store their
|
||||
|
||||
+190
-397
@@ -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
|
||||
|
||||
+168
-189
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+184
-122
@@ -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,61 +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 research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
|
||||
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
|
||||
- [Automatic Model Routing](#automatic-model-routing)
|
||||
## 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
|
||||
@@ -74,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
|
||||
|
||||
@@ -133,8 +118,9 @@ These are the only allowed tools:
|
||||
|
||||
- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`]
|
||||
- **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/<project>/<session-id>/plans/` directory or your
|
||||
@@ -143,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:
|
||||
|
||||
@@ -163,7 +149,7 @@ To use a skill in Plan Mode, you can explicitly ask Gemini CLI to "use the
|
||||
`<skill-name>` 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)
|
||||
@@ -187,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`
|
||||
|
||||
@@ -203,16 +192,17 @@ priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
#### Example: Enable research subagents in Plan Mode
|
||||
#### Example: Enable custom subagents in Plan Mode
|
||||
|
||||
You can enable experimental research [subagents] like `codebase_investigator` to
|
||||
help gather architecture details during the planning phase.
|
||||
Built-in research [subagents] like [`codebase_investigator`] and [`cli_help`]
|
||||
are enabled by default in Plan Mode. You can enable additional [custom
|
||||
subagents] by adding a rule to your policy.
|
||||
|
||||
`~/.gemini/policies/research-subagents.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
toolName = "my_custom_subagent"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
@@ -221,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/<project>/<session-id>/plans/`.
|
||||
@@ -264,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
|
||||
@@ -290,24 +326,50 @@ performance. You can disable this automatic switching in your settings:
|
||||
}
|
||||
```
|
||||
|
||||
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
|
||||
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
|
||||
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
|
||||
[`write_file`]: /docs/tools/file-system.md#3-write_file-writefile
|
||||
[`glob`]: /docs/tools/file-system.md#4-glob-findfiles
|
||||
[`google_web_search`]: /docs/tools/web-search.md
|
||||
[`replace`]: /docs/tools/file-system.md#6-replace-edit
|
||||
[MCP tools]: /docs/tools/mcp-server.md
|
||||
[`save_memory`]: /docs/tools/memory.md
|
||||
[`activate_skill`]: /docs/cli/skills.md
|
||||
[subagents]: /docs/core/subagents.md
|
||||
[policy engine]: /docs/reference/policy-engine.md
|
||||
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
|
||||
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
|
||||
[`ask_user`]: /docs/tools/ask-user.md
|
||||
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
|
||||
## Cleanup
|
||||
|
||||
By default, Gemini CLI automatically cleans up old session data, including all
|
||||
associated plan files and task trackers.
|
||||
|
||||
- **Default behavior:** Sessions (and their plans) are retained for **30 days**.
|
||||
- **Configuration:** You can customize this behavior via the `/settings` command
|
||||
(search for **Session Retention**) or in your `settings.json` file. See
|
||||
[session retention] for more details.
|
||||
|
||||
Manual deletion also removes all associated artifacts:
|
||||
|
||||
- **Command Line:** Use `gemini --delete-session <index|id>`.
|
||||
- **Session Browser:** Press `/resume`, navigate to a session, and press `x`.
|
||||
|
||||
If you use a [custom plans directory](#custom-plan-directory-and-policies),
|
||||
those files are not automatically deleted and must be managed manually.
|
||||
|
||||
[`list_directory`]: ../tools/file-system.md#1-list_directory-readfolder
|
||||
[`read_file`]: ../tools/file-system.md#2-read_file-readfile
|
||||
[`grep_search`]: ../tools/file-system.md#5-grep_search-searchtext
|
||||
[`write_file`]: ../tools/file-system.md#3-write_file-writefile
|
||||
[`glob`]: ../tools/file-system.md#4-glob-findfiles
|
||||
[`google_web_search`]: ../tools/web-search.md
|
||||
[`replace`]: ../tools/file-system.md#6-replace-edit
|
||||
[MCP tools]: ../tools/mcp-server.md
|
||||
[`save_memory`]: ../tools/memory.md
|
||||
[`activate_skill`]: ./skills.md
|
||||
[`codebase_investigator`]: ../core/subagents.md#codebase_investigator
|
||||
[`cli_help`]: ../core/subagents.md#cli_help
|
||||
[subagents]: ../core/subagents.md
|
||||
[custom subagents]: ../core/subagents.md#creating-custom-subagents
|
||||
[policy engine]: ../reference/policy-engine.md
|
||||
[`enter_plan_mode`]: ../tools/planning.md#1-enter_plan_mode-enterplanmode
|
||||
[`exit_plan_mode`]: ../tools/planning.md#2-exit_plan_mode-exitplanmode
|
||||
[`ask_user`]: ../tools/ask-user.md
|
||||
[YOLO mode]: ../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
|
||||
[model routing]: /docs/cli/telemetry.md#model-routing
|
||||
[preferred external editor]: /docs/reference/configuration.md#general
|
||||
[auto model]: ../reference/configuration.md#model-settings
|
||||
[model routing]: ./telemetry.md#model-routing
|
||||
[preferred external editor]: ../reference/configuration.md#general
|
||||
[session retention]: ./session-management.md#session-retention
|
||||
[extensions]: ../extensions/index.md
|
||||
[Conductor]: https://github.com/gemini-cli-extensions/conductor
|
||||
[open an issue]: https://github.com/google-gemini/gemini-cli/issues
|
||||
[Agent Skills]: ./skills.md
|
||||
|
||||
+46
-1
@@ -50,6 +50,50 @@ 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. 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 +132,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|lxc`
|
||||
3. **Settings file**: `"sandbox": true` in the `tools` object of your
|
||||
`settings.json` file (e.g., `{"tools": {"sandbox": true}}`).
|
||||
|
||||
|
||||
@@ -121,27 +121,36 @@ session lengths.
|
||||
|
||||
### Session retention
|
||||
|
||||
To prevent your history from growing indefinitely, enable automatic cleanup
|
||||
policies in your settings.
|
||||
By default, Gemini CLI automatically cleans up old session data to prevent your
|
||||
history from growing indefinitely. When a session is deleted, Gemini CLI also
|
||||
removes all associated data, including implementation plans, task trackers, tool
|
||||
outputs, and activity logs.
|
||||
|
||||
The default policy is to **retain sessions for 30 days**.
|
||||
|
||||
#### Configuration
|
||||
|
||||
You can customize these policies using the `/settings` command or by manually
|
||||
editing your `settings.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"sessionRetention": {
|
||||
"enabled": true,
|
||||
"maxAge": "30d", // Keep sessions for 30 days
|
||||
"maxCount": 50 // Keep the 50 most recent sessions
|
||||
"maxAge": "30d",
|
||||
"maxCount": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`enabled`**: (boolean) Master switch for session cleanup. Defaults to
|
||||
`false`.
|
||||
`true`.
|
||||
- **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d",
|
||||
"4w"). Sessions older than this are deleted.
|
||||
"4w"). Sessions older than this are deleted. Defaults to `"30d"`.
|
||||
- **`maxCount`**: (number) Maximum number of sessions to retain. The oldest
|
||||
sessions exceeding this count are deleted.
|
||||
sessions exceeding this count are deleted. Defaults to undefined (unlimited).
|
||||
- **`minRetention`**: (string) Minimum retention period (safety limit). Defaults
|
||||
to `"1d"`. Sessions newer than this period are never deleted by automatic
|
||||
cleanup.
|
||||
|
||||
@@ -57,10 +57,10 @@ 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 remaining percentage. | `true` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
|
||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||
@@ -89,13 +89,13 @@ they appear in the UI.
|
||||
|
||||
### Model
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- |
|
||||
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
|
||||
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
|
||||
| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
|
||||
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
|
||||
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- |
|
||||
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
|
||||
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
|
||||
| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
|
||||
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
|
||||
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
|
||||
|
||||
### Context
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -122,7 +122,10 @@ The manifest file defines the extension's behavior and configuration.
|
||||
}
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
"excludeTools": ["run_shell_command"]
|
||||
"excludeTools": ["run_shell_command"],
|
||||
"plan": {
|
||||
"directory": ".gemini/plans"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -157,6 +160,11 @@ The manifest file defines the extension's behavior and configuration.
|
||||
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
|
||||
command. Note that this differs from the MCP server `excludeTools`
|
||||
functionality, which can be listed in the MCP server config.
|
||||
- `plan`: Planning features configuration.
|
||||
- `directory`: The directory where planning artifacts are stored. This serves
|
||||
as a fallback if the user hasn't specified a plan directory in their
|
||||
settings. If not specified by either the extension or the user, the default
|
||||
is `~/.gemini/tmp/<project>/<session-id>/plans/`.
|
||||
|
||||
When Gemini CLI starts, it loads all the extensions and merges their
|
||||
configurations. If there are any conflicts, the workspace configuration takes
|
||||
|
||||
@@ -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__<server_name>__<tool_name>`.
|
||||
- **Regex Support**: Matchers support regular expressions (e.g.,
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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 #<issue-number>` 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.
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -263,7 +273,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.footer.hideContextPercentage`** (boolean):
|
||||
- **Description:** Hides the context window remaining percentage.
|
||||
- **Description:** Hides the context window usage percentage.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.hideFooter`** (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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.<br><br>**Parameters:** `command`, `description`, `dir_path`, `is_background` |
|
||||
| File System | [`glob`](../tools/file-system.md) | `Search` | Finds files matching specific glob patterns across the workspace.<br><br>**Parameters:** `pattern`, `dir_path`, `case_sensitive`, `respect_git_ignore`, `respect_gemini_ignore` |
|
||||
| File System | [`grep_search`](../tools/file-system.md) | `Search` | Searches for a regular expression pattern within file contents. Legacy alias: `search_file_content`.<br><br>**Parameters:** `pattern`, `dir_path`, `include`, `exclude_pattern`, `names_only`, `max_matches_per_file`, `total_max_matches` |
|
||||
| File System | [`list_directory`](../tools/file-system.md) | `Read` | Lists the names of files and subdirectories within a specified path.<br><br>**Parameters:** `dir_path`, `ignore`, `file_filtering_options` |
|
||||
| File System | [`read_file`](../tools/file-system.md) | `Read` | Reads the content of a specific file. Supports text, images, audio, and PDF.<br><br>**Parameters:** `file_path`, `start_line`, `end_line` |
|
||||
| File System | [`read_many_files`](../tools/file-system.md) | `Read` | Reads and concatenates content from multiple files. Often triggered by the `@` symbol in your prompt.<br><br>**Parameters:** `include`, `exclude`, `recursive`, `useDefaultExcludes`, `file_filtering_options` |
|
||||
| File System | [`replace`](../tools/file-system.md) | `Edit` | Performs precise text replacement within a file. Requires manual confirmation.<br><br>**Parameters:** `file_path`, `instruction`, `old_string`, `new_string`, `allow_multiple` |
|
||||
| File System | [`write_file`](../tools/file-system.md) | `Edit` | Creates or overwrites a file with new content. Requires manual confirmation.<br><br>**Parameters:** `file_path`, `content` |
|
||||
| Interaction | [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog.<br><br>**Parameters:** `questions` |
|
||||
| Interaction | [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress and display it to you.<br><br>**Parameters:** `todos` |
|
||||
| Memory | [`activate_skill`](../tools/activate-skill.md) | `Other` | Loads specialized procedural expertise for specific tasks from the `.gemini/skills` directory.<br><br>**Parameters:** `name` |
|
||||
| Memory | [`get_internal_docs`](../tools/internal-docs.md) | `Think` | Accesses Gemini CLI's own documentation to provide more accurate answers about its capabilities.<br><br>**Parameters:** `path` |
|
||||
| Memory | [`save_memory`](../tools/memory.md) | `Think` | Persists specific facts and project details to your `GEMINI.md` file to retain context.<br><br>**Parameters:** `fact` |
|
||||
| Planning | [`enter_plan_mode`](../tools/planning.md) | `Plan` | Switches the CLI to a safe, read-only "Plan Mode" for researching complex changes.<br><br>**Parameters:** `reason` |
|
||||
| Planning | [`exit_plan_mode`](../tools/planning.md) | `Plan` | Finalizes a plan, presents it for review, and requests approval to start implementation.<br><br>**Parameters:** `plan` |
|
||||
| System | `complete_task` | `Other` | Finalizes a subagent's mission and returns the result to the parent agent. This tool is not available to the user.<br><br>**Parameters:** `result` |
|
||||
| Web | [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information.<br><br>**Parameters:** `query` |
|
||||
| Web | [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts.<br><br>**Parameters:** `prompt` |
|
||||
|
||||
## Under the hood
|
||||
|
||||
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.
|
||||
+9
-2
@@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
@@ -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/<project>/plans/`).
|
||||
directory (for example, `~/.gemini/tmp/<project>/plans/`).
|
||||
- **Behavior:**
|
||||
- Validates that the `plan_path` is within the allowed directory and that the
|
||||
file exists and has content.
|
||||
|
||||
+35
-12
@@ -25,6 +25,18 @@ const __dirname = path.dirname(__filename);
|
||||
const projectRoot = __dirname;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const commonRestrictedSyntaxRules = [
|
||||
{
|
||||
selector: 'CallExpression[callee.name="require"]',
|
||||
message: 'Avoid using require(). Use ES6 imports instead.',
|
||||
},
|
||||
{
|
||||
selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])',
|
||||
message:
|
||||
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
|
||||
},
|
||||
];
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
// Global ignores
|
||||
@@ -120,18 +132,7 @@ export default tseslint.config(
|
||||
'no-cond-assign': 'error',
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-case': 'error',
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'CallExpression[callee.name="require"]',
|
||||
message: 'Avoid using require(). Use ES6 imports instead.',
|
||||
},
|
||||
{
|
||||
selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])',
|
||||
message:
|
||||
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
|
||||
},
|
||||
],
|
||||
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
|
||||
'no-unsafe-finally': 'error',
|
||||
'no-unused-expressions': 'off', // Disable base rule
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
@@ -171,6 +172,28 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// API Response Optionality enforcement for Code Assist
|
||||
files: ['packages/core/src/code_assist/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...commonRestrictedSyntaxRules,
|
||||
{
|
||||
selector:
|
||||
'TSInterfaceDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])',
|
||||
message:
|
||||
'All fields in API response interfaces (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'TSTypeAliasDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])',
|
||||
message:
|
||||
'All fields in API response types (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Rules that only apply to product code
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'],
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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}}]}
|
||||
@@ -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}]}}]}
|
||||
@@ -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}}]}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Generated
+244
-71
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']}`,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
debugLogger,
|
||||
ApprovalMode,
|
||||
type MCPServerConfig,
|
||||
type GeminiCLIExtension,
|
||||
Storage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import {
|
||||
@@ -951,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(() => {
|
||||
@@ -1078,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 () => {
|
||||
@@ -1161,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);
|
||||
});
|
||||
|
||||
@@ -1182,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);
|
||||
});
|
||||
|
||||
@@ -1203,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);
|
||||
@@ -1249,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);
|
||||
});
|
||||
|
||||
@@ -1313,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 () => {
|
||||
@@ -2162,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');
|
||||
});
|
||||
|
||||
@@ -2202,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());
|
||||
@@ -2211,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 () => {
|
||||
@@ -3324,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(),
|
||||
@@ -3524,4 +3519,101 @@ describe('loadCliConfig mcpEnabled', () => {
|
||||
expect(config.getAllowedMcpServers()).toEqual(['serverA']);
|
||||
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
|
||||
});
|
||||
|
||||
describe('extension plan settings', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue(
|
||||
'/mock/home/user/.gemini/tmp/test-project',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use plan directory from active extension when user has not specified one', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-plan',
|
||||
isActive: true,
|
||||
plan: { directory: 'ext-plans-dir' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
|
||||
});
|
||||
|
||||
it('should NOT use plan directory from active extension when user has specified one', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
general: {
|
||||
plan: { directory: 'user-plans-dir' },
|
||||
},
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-plan',
|
||||
isActive: true,
|
||||
plan: { directory: 'ext-plans-dir' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getPlansDir()).toContain('user-plans-dir');
|
||||
expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir');
|
||||
});
|
||||
|
||||
it('should NOT use plan directory from inactive extension', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'ext-plan',
|
||||
isActive: false,
|
||||
plan: { directory: 'ext-plans-dir-inactive' },
|
||||
} as unknown as GeminiCLIExtension,
|
||||
]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.storage.getPlansDir()).not.toContain(
|
||||
'ext-plans-dir-inactive',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default path if neither user nor extension settings provide a plan directory', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
|
||||
// No extensions providing plan directory
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
// Should return the default managed temp directory path
|
||||
expect(config.storage.getPlansDir()).toBe(
|
||||
path.join(
|
||||
'/mock',
|
||||
'home',
|
||||
'user',
|
||||
'.gemini',
|
||||
'tmp',
|
||||
'test-project',
|
||||
'test-session',
|
||||
'plans',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -395,36 +390,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<string>,
|
||||
) {
|
||||
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 ||
|
||||
@@ -511,6 +476,10 @@ export async function loadCliConfig(
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
const extensionPlanSettings = extensionManager
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
|
||||
const experimentalJitContext = settings.experimental?.jitContext ?? false;
|
||||
|
||||
let memoryContent: string | HierarchicalMemory = '';
|
||||
@@ -633,49 +602,14 @@ export async function loadCliConfig(
|
||||
!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);
|
||||
@@ -826,8 +760,11 @@ 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,
|
||||
planSettings: settings.general?.plan?.directory
|
||||
? settings.general.plan
|
||||
: (extensionPlanSettings ?? settings.general?.plan),
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`,
|
||||
@@ -886,6 +888,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
themes: config.themes,
|
||||
rules,
|
||||
checkers,
|
||||
plan: config.plan,
|
||||
};
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
|
||||
@@ -33,6 +33,15 @@ export interface ExtensionConfig {
|
||||
* These themes will be registered when the extension is activated.
|
||||
*/
|
||||
themes?: CustomTheme[];
|
||||
/**
|
||||
* Planning features configuration contributed by this extension.
|
||||
*/
|
||||
plan?: {
|
||||
/**
|
||||
* The directory where planning artifacts are stored.
|
||||
*/
|
||||
directory?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
|
||||
+7
-3
@@ -10,11 +10,15 @@
|
||||
<text x="0" y="53" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> * server2 (remote): https://remote.com </text>
|
||||
<text x="0" y="70" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">This extension will append info to your gemini.md context using my-context.md </text>
|
||||
<text x="0" y="87" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">This extension will exclude the following core tools: tool1,tool2 </text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Agent Skills: </text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-weight="bold">Agent Skills:</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">This extension will install the following agent skills: </text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> * skill1: desc1 </text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs"> * </text>
|
||||
<text x="36" y="189" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">skill1</text>
|
||||
<text x="90" y="189" fill="#ffffff" textLength="810" lengthAdjust="spacingAndGlyphs">: desc1 </text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> (Source: /mock/temp/dir/skill1/SKILL.md) (2 items in directory) </text>
|
||||
<text x="0" y="240" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> * skill2: desc2 </text>
|
||||
<text x="0" y="240" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs"> * </text>
|
||||
<text x="36" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">skill2</text>
|
||||
<text x="90" y="240" fill="#ffffff" textLength="810" lengthAdjust="spacingAndGlyphs">: desc2 </text>
|
||||
<text x="0" y="257" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> (Source: /mock/temp/dir/skill2/SKILL.md) (1 items in directory) </text>
|
||||
<text x="0" y="308" fill="#cdcd00" textLength="891" lengthAdjust="spacingAndGlyphs">The extension you are about to install may have been created by a third-party developer and sourced</text>
|
||||
<text x="0" y="325" fill="#cdcd00" textLength="882" lengthAdjust="spacingAndGlyphs">from a public repository. Google does not vet, endorse, or guarantee the functionality or security</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.4 KiB |
+4
-2
@@ -5,9 +5,11 @@
|
||||
<rect width="920" height="343" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Installing extension "test-ext". </text>
|
||||
<text x="0" y="36" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Agent Skills: </text>
|
||||
<text x="0" y="36" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-weight="bold">Agent Skills:</text>
|
||||
<text x="0" y="70" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">This extension will install the following agent skills: </text>
|
||||
<text x="0" y="104" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> * locked-skill: A skill in a locked dir </text>
|
||||
<text x="0" y="104" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs"> * </text>
|
||||
<text x="36" y="104" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs" font-weight="bold">locked-skill</text>
|
||||
<text x="144" y="104" fill="#ffffff" textLength="756" lengthAdjust="spacingAndGlyphs">: A skill in a locked dir </text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="405" lengthAdjust="spacingAndGlyphs"> (Source: /mock/temp/dir/locked/SKILL.md) </text>
|
||||
<text x="405" y="121" fill="#cd0000" textLength="342" lengthAdjust="spacingAndGlyphs">⚠️ (Could not count items in directory)</text>
|
||||
<text x="0" y="172" fill="#cdcd00" textLength="891" lengthAdjust="spacingAndGlyphs">The extension you are about to install may have been created by a third-party developer and sourced</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.9 KiB |
+3
-1
@@ -6,7 +6,9 @@
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Installing agent skill(s) from "https://example.com/repo.git". </text>
|
||||
<text x="0" y="36" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">The following agent skill(s) will be installing: </text>
|
||||
<text x="0" y="70" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> * skill1: desc1 </text>
|
||||
<text x="0" y="70" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs"> * </text>
|
||||
<text x="36" y="70" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">skill1</text>
|
||||
<text x="90" y="70" fill="#ffffff" textLength="810" lengthAdjust="spacingAndGlyphs">: desc1 </text>
|
||||
<text x="0" y="87" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> (Source: /mock/temp/dir/skill1/SKILL.md) (1 items in directory) </text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Install Destination: /mock/target/dir </text>
|
||||
<text x="0" y="155" fill="#cdcd00" textLength="882" lengthAdjust="spacingAndGlyphs">Agent skills inject specialized instructions and domain-specific knowledge into the agent's system</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.2 KiB |
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string> = 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<string>;
|
||||
} {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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, 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', () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
|
||||
'docker',
|
||||
'podman',
|
||||
'sandbox-exec',
|
||||
'lxc',
|
||||
];
|
||||
|
||||
function isSandboxCommand(value: string): value is SandboxConfig['command'] {
|
||||
@@ -91,6 +92,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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<LoadableSettingScope, string[]> = new Map();
|
||||
|
||||
@@ -117,6 +117,10 @@ export interface SettingDefinition {
|
||||
* For map-like objects without explicit `properties`, describes the shape of the values.
|
||||
*/
|
||||
additionalProperties?: SettingCollectionDefinition;
|
||||
/**
|
||||
* Optional unit to display after the value (e.g. '%').
|
||||
*/
|
||||
unit?: string;
|
||||
/**
|
||||
* Optional reference identifier for generators that emit a `$ref`.
|
||||
*/
|
||||
@@ -561,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: {
|
||||
@@ -595,7 +619,7 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Hides the context window remaining percentage.',
|
||||
description: 'Hides the context window usage percentage.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
@@ -913,13 +937,14 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
compressionThreshold: {
|
||||
type: 'number',
|
||||
label: 'Compression Threshold',
|
||||
label: 'Context Compression Threshold',
|
||||
category: 'Model',
|
||||
requiresRestart: true,
|
||||
default: 0.5 as number,
|
||||
description:
|
||||
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
|
||||
showInDialog: true,
|
||||
unit: '%',
|
||||
},
|
||||
disableLoopDetection: {
|
||||
type: 'boolean',
|
||||
@@ -1231,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,
|
||||
},
|
||||
@@ -1801,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',
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}]}]}
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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 += ` <rect x="${xPos}" y="${yPos}" width="${rectWidth}" height="${charHeight}" fill="${currentBgHex}" />
|
||||
`;
|
||||
}
|
||||
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 += ` <text x="${xPos}" y="${yPos + 2}" fill="${fill}" textLength="${textWidth}" lengthAdjust="spacingAndGlyphs">${escapeXml(currentBlockText)}</text>
|
||||
`;
|
||||
const textElement = `<text x="${xPos}" y="${yPos + 2}" fill="${fill}" textLength="${textWidth}" lengthAdjust="spacingAndGlyphs"${extraAttrs}>${escapeXml(currentBlockText)}</text>`;
|
||||
|
||||
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 += ` </g>\n</svg>`;
|
||||
return svg;
|
||||
};
|
||||
|
||||
@@ -2544,136 +2544,6 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Expansion Persistence', () => {
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof render>['stdin'];
|
||||
|
||||
const setupExpansionPersistenceTest = async (
|
||||
HighPriorityChild?: React.FC,
|
||||
) => {
|
||||
const getTree = () => (
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<KeypressProvider config={mockConfig}>
|
||||
<OverflowProvider>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
{HighPriorityChild && <HighPriorityChild />}
|
||||
</OverflowProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -98,7 +98,7 @@ export function ApiAuthDialog({
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.focused}
|
||||
borderColor={theme.ui.focus}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
|
||||
@@ -21,9 +21,8 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { AuthState } from '../types.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { validateAuthMethodWithSettings } from './useAuth.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
|
||||
interface AuthDialogProps {
|
||||
config: Config;
|
||||
@@ -133,10 +132,7 @@ export function AuthDialog({
|
||||
config.isBrowserLaunchSuppressed()
|
||||
) {
|
||||
setExiting(true);
|
||||
setTimeout(async () => {
|
||||
await runExitCleanup();
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
}, 100);
|
||||
setTimeout(relaunchApp, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -193,7 +189,7 @@ export function AuthDialog({
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.focused}
|
||||
borderColor={theme.ui.focus}
|
||||
flexDirection="row"
|
||||
padding={1}
|
||||
width="100%"
|
||||
@@ -209,7 +205,7 @@ export function AuthDialog({
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.focused}
|
||||
borderColor={theme.ui.focus}
|
||||
flexDirection="row"
|
||||
padding={1}
|
||||
width="100%"
|
||||
|
||||
@@ -9,7 +9,10 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
|
||||
import {
|
||||
RELAUNCH_EXIT_CODE,
|
||||
_resetRelaunchStateForTesting,
|
||||
} from '../../utils/processUtils.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
|
||||
// Mocks
|
||||
@@ -38,6 +41,7 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
vi.clearAllMocks();
|
||||
exitSpy.mockClear();
|
||||
vi.useRealTimers();
|
||||
_resetRelaunchStateForTesting();
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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: <FooterConfigDialog onClose={context.ui.removeComponent} />,
|
||||
}),
|
||||
};
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<Box
|
||||
flexDirection="row"
|
||||
@@ -438,7 +438,7 @@ export const BackgroundShellDisplay = ({
|
||||
borderRight={false}
|
||||
borderTop={false}
|
||||
paddingX={1}
|
||||
borderColor={isFocused ? theme.border.focused : undefined}
|
||||
borderColor={isFocused ? theme.ui.focus : undefined}
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
{renderTabs()}
|
||||
|
||||
@@ -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(
|
||||
<ColorsDisplay activeTheme={mockTheme} />,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
'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<ColorsDisplayProps> = ({
|
||||
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 (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={0}
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text bold color={theme.text.accent}>
|
||||
DEVELOPER TOOLS (Not visible to users)
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold>How do colors get applied?</Text>
|
||||
</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Text color={theme.text.primary}>
|
||||
• <Text bold>Hex:</Text> Rendered exactly by modern terminals. Not
|
||||
overridden by app themes.
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
• <Text bold>Blank:</Text> Uses your terminal's default
|
||||
foreground/background.
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
• <Text bold>Compatibility:</Text> On older terminals, hex is
|
||||
approximated to the nearest ANSI color.
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
• <Text bold>ANSI Names:</Text> 'red',
|
||||
'green', etc. are mapped to your terminal app's
|
||||
palette.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Header */}
|
||||
<Box flexDirection="row" marginBottom={0} paddingX={1}>
|
||||
<Box width={VALUE_COLUMN_WIDTH}>
|
||||
<Text bold color={theme.text.link} dimColor>
|
||||
Value
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text bold color={theme.text.link} dimColor>
|
||||
Name
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* All Rows */}
|
||||
<Box flexDirection="column">
|
||||
{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;
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function renderStandardRow({ name, value }: StandardColorRow) {
|
||||
const isHex = value.startsWith('#');
|
||||
const displayColor = isHex ? value : theme.text.primary;
|
||||
const description = COLOR_DESCRIPTIONS[name] || '';
|
||||
|
||||
return (
|
||||
<Box key={name} flexDirection="row" paddingX={1}>
|
||||
<Box width={VALUE_COLUMN_WIDTH}>
|
||||
<Text color={displayColor}>{value || '(blank)'}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="row">
|
||||
<Box width="30%">
|
||||
<Text color={displayColor}>{name}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} paddingLeft={1}>
|
||||
<Text color={theme.text.secondary}>{description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGradientRow({ name, value }: GradientColorRow) {
|
||||
const description = COLOR_DESCRIPTIONS[name] || '';
|
||||
|
||||
return (
|
||||
<Box key={name} flexDirection="row" paddingX={1}>
|
||||
<Box width={VALUE_COLUMN_WIDTH} flexDirection="column">
|
||||
{value.map((c, i) => (
|
||||
<Text key={i} color={c}>
|
||||
{c}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="row">
|
||||
<Box width="30%">
|
||||
<Gradient colors={value}>
|
||||
<Text>{name}</Text>
|
||||
</Gradient>
|
||||
</Box>
|
||||
<Box flexGrow={1} paddingLeft={1}>
|
||||
<Text color={theme.text.secondary}>{description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBackgroundRow({ name, value }: BackgroundColorRow) {
|
||||
const description = COLOR_DESCRIPTIONS[name] || '';
|
||||
|
||||
return (
|
||||
<Box key={name} flexDirection="row" paddingX={1}>
|
||||
<Box
|
||||
width={VALUE_COLUMN_WIDTH}
|
||||
backgroundColor={value}
|
||||
justifyContent="center"
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color={getContrastingTextColor(value)} bold wrap="truncate">
|
||||
{value || 'default'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="row" paddingLeft={1}>
|
||||
<Box width="30%">
|
||||
<Text color={theme.text.primary}>{name}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} paddingLeft={1}>
|
||||
<Text color={theme.text.secondary}>{description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
@@ -17,18 +17,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
DEFAULT_MODEL_CONFIGS: {},
|
||||
LoadedSettings: class {
|
||||
constructor() {
|
||||
// this.merged = {};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ContextUsageDisplay', () => {
|
||||
it('renders correct percentage left', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
it('renders correct percentage used', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={5000}
|
||||
model="gemini-pro"
|
||||
@@ -37,27 +28,56 @@ describe('ContextUsageDisplay', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('50% context left');
|
||||
expect(output).toContain('50% used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders short label when terminal width is small', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
it('renders correctly when usage is 0%', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={0}
|
||||
model="gemini-pro"
|
||||
terminalWidth={120}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('0% used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders abbreviated label when terminal width is small', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={2000}
|
||||
model="gemini-pro"
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('80%');
|
||||
expect(output).not.toContain('context left');
|
||||
expect(output).toContain('20%');
|
||||
expect(output).not.toContain('context used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders 0% when full', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = render(
|
||||
it('renders 80% correctly', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={8000}
|
||||
model="gemini-pro"
|
||||
terminalWidth={120}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('80% used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders 100% when full', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={10000}
|
||||
model="gemini-pro"
|
||||
@@ -66,7 +86,7 @@ describe('ContextUsageDisplay', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('0% context left');
|
||||
expect(output).toContain('100% used');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { getContextUsagePercentage } from '../utils/contextUsage.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import {
|
||||
MIN_TERMINAL_WIDTH_FOR_FULL_LABEL,
|
||||
DEFAULT_COMPRESSION_THRESHOLD,
|
||||
} from '../constants.js';
|
||||
|
||||
export const ContextUsageDisplay = ({
|
||||
promptTokenCount,
|
||||
@@ -14,17 +19,30 @@ export const ContextUsageDisplay = ({
|
||||
terminalWidth,
|
||||
}: {
|
||||
promptTokenCount: number;
|
||||
model: string;
|
||||
model: string | undefined;
|
||||
terminalWidth: number;
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const percentage = getContextUsagePercentage(promptTokenCount, model);
|
||||
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
|
||||
const percentageUsed = (percentage * 100).toFixed(0);
|
||||
|
||||
const label = terminalWidth < 100 ? '%' : '% context left';
|
||||
const threshold =
|
||||
settings.merged.model?.compressionThreshold ??
|
||||
DEFAULT_COMPRESSION_THRESHOLD;
|
||||
|
||||
let textColor = theme.text.secondary;
|
||||
if (percentage >= 1.0) {
|
||||
textColor = theme.status.error;
|
||||
} else if (percentage >= threshold) {
|
||||
textColor = theme.status.warning;
|
||||
}
|
||||
|
||||
const label =
|
||||
terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% used';
|
||||
|
||||
return (
|
||||
<Text color={theme.text.secondary}>
|
||||
{percentageLeft}
|
||||
<Text color={textColor}>
|
||||
{percentageUsed}
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -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--;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('DetailedMessagesDisplay', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides the F12 hint in low error verbosity mode', async () => {
|
||||
it('shows the F12 hint even in low error verbosity mode', async () => {
|
||||
const messages: ConsoleMessageItem[] = [
|
||||
{ type: 'error', content: 'Error message', count: 1 },
|
||||
];
|
||||
@@ -95,7 +95,7 @@ describe('DetailedMessagesDisplay', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('(F12 to close)');
|
||||
expect(lastFrame()).toContain('(F12 to close)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
interface DetailedMessagesDisplayProps {
|
||||
messages: ConsoleMessageItem[];
|
||||
@@ -29,10 +27,6 @@ export const DetailedMessagesDisplay: React.FC<
|
||||
DetailedMessagesDisplayProps
|
||||
> = ({ messages, maxHeight, width, hasFocus }) => {
|
||||
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const showHotkeyHint =
|
||||
settings.merged.ui.errorVerbosity === 'full' || config.getDebugMode();
|
||||
|
||||
const borderAndPadding = 3;
|
||||
|
||||
@@ -71,10 +65,7 @@ export const DetailedMessagesDisplay: React.FC<
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Debug Console{' '}
|
||||
{showHotkeyHint && (
|
||||
<Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||
)}
|
||||
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={maxHeight} width={width - borderAndPadding}>
|
||||
|
||||
@@ -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 = ({
|
||||
<Box flexDirection="column">
|
||||
<SettingsDialog
|
||||
onSelect={() => uiActions.closeSettingsDialog()}
|
||||
onRestartRequest={async () => {
|
||||
await runExitCleanup();
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
}}
|
||||
onRestartRequest={relaunchApp}
|
||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -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(
|
||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
|
||||
);
|
||||
@@ -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(
|
||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
|
||||
);
|
||||
|
||||
@@ -54,9 +54,7 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
if (isRestarting) {
|
||||
timer = setTimeout(async () => {
|
||||
await relaunchApp();
|
||||
}, 250);
|
||||
timer = setTimeout(relaunchApp, 250);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
|
||||
@@ -4,16 +4,30 @@
|
||||
* 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;
|
||||
|
||||
vi.mock('../../utils/installationInfo.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('../../utils/installationInfo.js')>();
|
||||
return {
|
||||
...original,
|
||||
get isDevelopment() {
|
||||
return mockIsDevelopment;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
@@ -36,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,
|
||||
@@ -52,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('<Footer />', () => {
|
||||
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(
|
||||
<Footer />,
|
||||
@@ -90,11 +129,12 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -107,10 +147,11 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -127,7 +168,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
|
||||
expect(lastFrame()).toContain(defaultProps.branchName);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -140,7 +181,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
|
||||
expect(lastFrame()).not.toContain('Branch');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -149,7 +190,13 @@ describe('<Footer />', () => {
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
uiState: {
|
||||
currentModel: defaultProps.model,
|
||||
sessionStats: {
|
||||
...mockSessionStats,
|
||||
lastPromptTokenCount: 1000,
|
||||
},
|
||||
},
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
@@ -161,7 +208,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\d+% context left/);
|
||||
expect(lastFrame()).toMatch(/\d+% used/);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -189,7 +236,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('15%');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -216,8 +263,8 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('Usage remaining');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(normalizeFrame(lastFrame())).not.toContain('used');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -244,12 +291,12 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Limit reached');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()?.toLowerCase()).toContain('limit reached');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('displays the model name and abbreviated context percentage', async () => {
|
||||
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
@@ -267,6 +314,7 @@ describe('<Footer />', () => {
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\d+%/);
|
||||
expect(lastFrame()).not.toContain('context used');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -377,7 +425,9 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
|
||||
'complete-footer-wide',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -399,7 +449,9 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toMatchSnapshot('footer-minimal');
|
||||
expect(normalizeFrame(lastFrame({ allowEmpty: true }))).toMatchSnapshot(
|
||||
'footer-minimal',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -421,7 +473,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot('footer-no-model');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot('footer-no-model');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -443,7 +495,9 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
|
||||
'footer-only-sandbox',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -464,7 +518,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).not.toMatch(/\d+% context left/);
|
||||
expect(lastFrame()).not.toMatch(/\d+% used/);
|
||||
unmount();
|
||||
});
|
||||
it('shows the context percentage when hideContextPercentage is false', async () => {
|
||||
@@ -484,7 +538,7 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\d+% context left/);
|
||||
expect(lastFrame()).toMatch(/\d+% used/);
|
||||
unmount();
|
||||
});
|
||||
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
|
||||
@@ -503,13 +557,91 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
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(<Footer />, {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error summary visibility', () => {
|
||||
it('hides error summary in low verbosity mode', async () => {
|
||||
beforeEach(() => {
|
||||
mockIsDevelopment = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockIsDevelopment = false;
|
||||
});
|
||||
|
||||
it('hides error summary in low verbosity mode out of dev mode', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
@@ -526,7 +658,28 @@ describe('<Footer />', () => {
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('F12 for details');
|
||||
expect(lastFrame()).not.toContain('2 errors');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows error summary in low verbosity mode in dev mode', async () => {
|
||||
mockIsDevelopment = true;
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
errorCount: 2,
|
||||
showErrorDetails: false,
|
||||
},
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { errorVerbosity: 'low' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('F12 for details');
|
||||
expect(lastFrame()).toContain('2 errors');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -550,68 +703,159 @@ describe('<Footer />', () => {
|
||||
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(
|
||||
<Footer />,
|
||||
{
|
||||
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(
|
||||
<Footer />,
|
||||
{
|
||||
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(
|
||||
<Footer />,
|
||||
{
|
||||
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(
|
||||
<Footer />,
|
||||
{
|
||||
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(
|
||||
<Footer />,
|
||||
{
|
||||
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(
|
||||
<Footer />,
|
||||
{
|
||||
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(
|
||||
<Footer />,
|
||||
{
|
||||
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(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('gemini-2.5-pro');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CwdIndicatorProps> = ({
|
||||
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 (
|
||||
<Text color={color}>
|
||||
{displayPath}
|
||||
{debugMode && <Text color={theme.status.error}>{debugSuffix}</Text>}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface SandboxIndicatorProps {
|
||||
isTrustedFolder: boolean | undefined;
|
||||
}
|
||||
|
||||
const SandboxIndicator: React.FC<SandboxIndicatorProps> = ({
|
||||
isTrustedFolder,
|
||||
}) => {
|
||||
if (isTrustedFolder === false) {
|
||||
return <Text color={theme.status.warning}>untrusted</Text>;
|
||||
}
|
||||
|
||||
const sandbox = process.env['SANDBOX'];
|
||||
if (sandbox && sandbox !== 'sandbox-exec') {
|
||||
return (
|
||||
<Text color="green">{sandbox.replace(/^gemini-(?:cli-)?/, '')}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (sandbox === 'sandbox-exec') {
|
||||
return (
|
||||
<Text color={theme.status.warning}>
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={theme.ui.comment}>
|
||||
({process.env['SEATBELT_PROFILE']})
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text color={theme.status.error}>no sandbox</Text>;
|
||||
};
|
||||
|
||||
const CorgiIndicator: React.FC = () => (
|
||||
<Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
<Text color={theme.text.primary}>(´</Text>
|
||||
<Text color={theme.status.error}>ᴥ</Text>
|
||||
<Text color={theme.text.primary}>`)</Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
</Text>
|
||||
);
|
||||
|
||||
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(
|
||||
<Box key={`sep-${item.key}`} height={1}>
|
||||
<Text color={theme.ui.comment}> · </Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<Box key={item.key} flexDirection="column">
|
||||
{showLabels && (
|
||||
<Box height={1}>
|
||||
<Text color={theme.ui.comment}>{item.header}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box height={1}>{item.element}</Box>
|
||||
</Box>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
flexWrap="nowrap"
|
||||
columnGap={showLabels ? COLUMN_GAP : 0}
|
||||
>
|
||||
{elements}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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,140 +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);
|
||||
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';
|
||||
!showErrorDetails &&
|
||||
errorCount > 0 &&
|
||||
(isFullErrorVerbosity || debugMode || isDevelopment);
|
||||
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', '', () => <DebugProfiler />, 45, true);
|
||||
}
|
||||
if (displayVimMode) {
|
||||
const vimStr = `[${displayVimMode}]`;
|
||||
addCol(
|
||||
'vim',
|
||||
'',
|
||||
() => <Text color={theme.text.accent}>{vimStr}</Text>,
|
||||
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) => (
|
||||
<CwdIndicator
|
||||
targetDir={targetDir}
|
||||
maxWidth={maxWidth}
|
||||
debugMode={debugMode}
|
||||
debugMessage={debugMessage}
|
||||
color={itemColor}
|
||||
/>
|
||||
),
|
||||
fullPath.length + debugSuffix.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'git-branch': {
|
||||
if (branchName) {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => <Text color={itemColor}>{branchName}</Text>,
|
||||
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,
|
||||
() => <SandboxIndicator isTrustedFolder={isTrustedFolder} />,
|
||||
str.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'model-name': {
|
||||
const str = getDisplayString(model);
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => <Text color={itemColor}>{str}</Text>,
|
||||
str.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'context-used': {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
),
|
||||
10, // "100% used" is 9 chars
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'quota': {
|
||||
if (quotaStats?.remaining !== undefined && quotaStats.limit) {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<QuotaDisplay
|
||||
remaining={quotaStats.remaining}
|
||||
limit={quotaStats.limit}
|
||||
resetTime={quotaStats.resetTime}
|
||||
terse={true}
|
||||
forceShow={true}
|
||||
lowercase={true}
|
||||
/>
|
||||
),
|
||||
10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'memory-usage': {
|
||||
addCol(id, header, () => <MemoryUsageDisplay color={itemColor} />, 10);
|
||||
break;
|
||||
}
|
||||
case 'session-id': {
|
||||
addCol(
|
||||
id,
|
||||
header,
|
||||
() => (
|
||||
<Text color={itemColor}>
|
||||
{uiState.sessionStats.sessionId.slice(0, 8)}
|
||||
</Text>
|
||||
),
|
||||
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,
|
||||
() => (
|
||||
<Text>
|
||||
<Text color={theme.status.success}>+{added}</Text>{' '}
|
||||
<Text color={theme.status.error}>-{removed}</Text>
|
||||
</Text>
|
||||
),
|
||||
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,
|
||||
() => <Text color={itemColor}>{formatted} tokens</Text>,
|
||||
formatted.length + 7,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
checkExhaustive(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Transients
|
||||
if (corgiMode) addCol('corgi', '', () => <CorgiIndicator />, 5);
|
||||
if (showErrorSummary) {
|
||||
addCol(
|
||||
'error-count',
|
||||
'',
|
||||
() => <ConsoleSummaryDisplay errorCount={errorCount} />,
|
||||
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: <Text color={theme.ui.comment}>…</Text>,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
justifyContent={justifyContent}
|
||||
width={terminalWidth}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
paddingX={1}
|
||||
>
|
||||
{(showDebugProfiler || displayVimMode || !hideCWD) && (
|
||||
<Box>
|
||||
{showDebugProfiler && <DebugProfiler />}
|
||||
{displayVimMode && (
|
||||
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
|
||||
)}
|
||||
{!hideCWD && (
|
||||
<Text color={theme.text.primary}>
|
||||
{displayPath}
|
||||
{branchName && (
|
||||
<Text color={theme.text.secondary}> ({branchName}*)</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{debugMode && (
|
||||
<Text color={theme.status.error}>
|
||||
{' ' + (debugMessage || '--debug')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Middle Section: Centered Trust/Sandbox Info */}
|
||||
{!hideSandboxStatus && (
|
||||
<Box
|
||||
flexGrow={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
display="flex"
|
||||
>
|
||||
{isTrustedFolder === false ? (
|
||||
<Text color={theme.status.warning}>untrusted</Text>
|
||||
) : process.env['SANDBOX'] &&
|
||||
process.env['SANDBOX'] !== 'sandbox-exec' ? (
|
||||
<Text color="green">
|
||||
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
|
||||
</Text>
|
||||
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
|
||||
<Text color={theme.status.warning}>
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
({process.env['SEATBELT_PROFILE']})
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.status.error}>
|
||||
no sandbox
|
||||
{terminalWidth >= 100 && (
|
||||
<Text color={theme.text.secondary}> (see /docs)</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Right Section: Gemini Label and Console Summary */}
|
||||
{!hideModelInfo && (
|
||||
<Box alignItems="center" justifyContent="flex-end">
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.primary}>
|
||||
<Text color={theme.text.secondary}>/model </Text>
|
||||
{getDisplayString(model)}
|
||||
{!hideContextPercentage && (
|
||||
<>
|
||||
{' '}
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{quotaStats && (
|
||||
<>
|
||||
{' '}
|
||||
<QuotaDisplay
|
||||
remaining={quotaStats.remaining}
|
||||
limit={quotaStats.limit}
|
||||
resetTime={quotaStats.resetTime}
|
||||
terse={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
</Box>
|
||||
<Box alignItems="center">
|
||||
{corgiMode && (
|
||||
<Box paddingLeft={1} flexDirection="row">
|
||||
<Text>
|
||||
<Text color={theme.ui.symbol}>| </Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
<Text color={theme.text.primary}>(´</Text>
|
||||
<Text color={theme.status.error}>ᴥ</Text>
|
||||
<Text color={theme.text.primary}>`)</Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{showErrorSummary && (
|
||||
<Box paddingLeft={1} flexDirection="row">
|
||||
<Text color={theme.ui.comment}>| </Text>
|
||||
<ConsoleSummaryDisplay errorCount={errorCount} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box width={terminalWidth} paddingX={1} overflow="hidden" flexWrap="nowrap">
|
||||
<FooterRow items={rowItems} showLabels={showLabels} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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('<FooterConfigDialog />', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly with default settings', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, waitUntilReady } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ settings },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('toggles an item when enter is pressed', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin, waitUntilReady } = renderWithProviders(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ 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(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ 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(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ 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(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ 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(
|
||||
<FooterConfigDialog onClose={mockOnClose} />,
|
||||
{ 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string>;
|
||||
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<FooterConfigState> }
|
||||
| { 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<FooterConfigDialogProps> = ({
|
||||
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<typeof i> => 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 (
|
||||
<Text color={theme.ui.comment} italic>
|
||||
Default footer (uses legacy settings)
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, React.ReactNode> = {
|
||||
workspace: (
|
||||
<Text color={getColor('workspace', itemColor)}>~/project/path</Text>
|
||||
),
|
||||
'git-branch': <Text color={getColor('git-branch', itemColor)}>main</Text>,
|
||||
sandbox: <Text color={getColor('sandbox', 'green')}>docker</Text>,
|
||||
'model-name': (
|
||||
<Text color={getColor('model-name', itemColor)}>gemini-2.5-pro</Text>
|
||||
),
|
||||
'context-used': (
|
||||
<Text color={getColor('context-used', itemColor)}>85% used</Text>
|
||||
),
|
||||
quota: <Text color={getColor('quota', itemColor)}>97%</Text>,
|
||||
'memory-usage': (
|
||||
<Text color={getColor('memory-usage', itemColor)}>260 MB</Text>
|
||||
),
|
||||
'session-id': (
|
||||
<Text color={getColor('session-id', itemColor)}>769992f9</Text>
|
||||
),
|
||||
'code-changes': (
|
||||
<Box flexDirection="row">
|
||||
<Text color={getColor('code-changes', theme.status.success)}>
|
||||
+12
|
||||
</Text>
|
||||
<Text color={getColor('code-changes')}> </Text>
|
||||
<Text color={getColor('code-changes', theme.status.error)}>-4</Text>
|
||||
</Box>
|
||||
),
|
||||
'token-count': (
|
||||
<Text color={getColor('token-count', itemColor)}>1.5k tokens</Text>
|
||||
),
|
||||
};
|
||||
|
||||
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 (
|
||||
<Box overflow="hidden" flexWrap="nowrap">
|
||||
<Box flexShrink={0}>
|
||||
<FooterRow items={rowItems} showLabels={showLabels} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, [orderedIds, selectedIds, activeId, isResetFocused, showLabels]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>Configure Footer{'\n'}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Select which items to display in the footer.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1} minHeight={maxItemsToShow}>
|
||||
{visibleItems.length === 0 ? (
|
||||
<Text color={theme.text.secondary}>No items found.</Text>
|
||||
) : (
|
||||
visibleItems.map((item, idx) => {
|
||||
const index = scrollOffset + idx;
|
||||
const isFocused = index === activeIndex;
|
||||
const isChecked = selectedIds.has(item.key);
|
||||
|
||||
return (
|
||||
<Box key={item.key} flexDirection="row">
|
||||
<Text color={isFocused ? theme.status.success : undefined}>
|
||||
{isFocused ? '> ' : ' '}
|
||||
</Text>
|
||||
<Text
|
||||
color={isFocused ? theme.status.success : theme.text.primary}
|
||||
>
|
||||
[{isChecked ? '✓' : ' '}]{' '}
|
||||
{item.label.padEnd(maxLabelWidth + 1)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> {item.description}</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text color={isShowLabelsFocused ? theme.status.success : undefined}>
|
||||
{isShowLabelsFocused ? '> ' : ' '}
|
||||
</Text>
|
||||
<Text color={isShowLabelsFocused ? theme.status.success : undefined}>
|
||||
[{showLabels ? '✓' : ' '}] Show footer labels
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color={isResetFocused ? theme.status.warning : undefined}>
|
||||
{isResetFocused ? '> ' : ' '}
|
||||
</Text>
|
||||
<Text
|
||||
color={isResetFocused ? theme.status.warning : theme.text.secondary}
|
||||
>
|
||||
Reset to default footer
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
↑/↓ navigate · ←/→ reorder · enter/space select · esc close
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Text bold>Preview:</Text>
|
||||
<Box flexDirection="row">{previewContent}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
|
||||
@@ -98,16 +98,18 @@ describe('<Header />', () => {
|
||||
primary: '',
|
||||
message: '',
|
||||
input: '',
|
||||
focus: '',
|
||||
diff: { added: '', removed: '' },
|
||||
},
|
||||
border: {
|
||||
default: '',
|
||||
focused: '',
|
||||
},
|
||||
ui: {
|
||||
comment: '',
|
||||
symbol: '',
|
||||
active: '',
|
||||
dark: '',
|
||||
focus: '',
|
||||
gradient: undefined,
|
||||
},
|
||||
status: {
|
||||
|
||||
@@ -99,6 +99,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
{itemForDisplay.type === 'info' && (
|
||||
<InfoMessage
|
||||
text={itemForDisplay.text}
|
||||
secondaryText={itemForDisplay.secondaryText}
|
||||
icon={itemForDisplay.icon}
|
||||
color={itemForDisplay.color}
|
||||
marginBottom={itemForDisplay.marginBottom}
|
||||
|
||||
@@ -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(
|
||||
<IdeTrustChangeDialog reason="NONE" />,
|
||||
);
|
||||
@@ -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(
|
||||
<IdeTrustChangeDialog reason="CONNECTION_CHANGE" />,
|
||||
);
|
||||
@@ -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(
|
||||
<IdeTrustChangeDialog reason="CONNECTION_CHANGE" />,
|
||||
);
|
||||
|
||||
@@ -279,6 +279,9 @@ describe('InputPrompt', () => {
|
||||
},
|
||||
getCompletedText: vi.fn().mockReturnValue(null),
|
||||
completionMode: CompletionMode.IDLE,
|
||||
forceShowShellSuggestions: false,
|
||||
setForceShowShellSuggestions: vi.fn(),
|
||||
isShellSuggestionsVisible: true,
|
||||
};
|
||||
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
|
||||
|
||||
|
||||
@@ -301,6 +301,27 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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 +384,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
userMessages,
|
||||
onSubmit: handleSubmitAndClear,
|
||||
isActive:
|
||||
(!completion.showSuggestions || completion.suggestions.length === 1) &&
|
||||
(!(completion.showSuggestions && isShellSuggestionsVisible) ||
|
||||
completion.suggestions.length === 1) &&
|
||||
!shellModeActive,
|
||||
currentQuery: buffer.text,
|
||||
currentCursorOffset: buffer.getOffset(),
|
||||
@@ -595,9 +617,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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 +632,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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 +665,25 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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) {
|
||||
@@ -752,7 +786,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (
|
||||
key.sequence === '!' &&
|
||||
buffer.text === '' &&
|
||||
!completion.showSuggestions
|
||||
!(completion.showSuggestions && isShellSuggestionsVisible)
|
||||
) {
|
||||
setShellModeActive(!shellModeActive);
|
||||
buffer.setText(''); // Clear the '!' from input
|
||||
@@ -791,15 +825,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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 +929,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
completion.isPerfectMatch &&
|
||||
keyMatchers[Command.SUBMIT](key) &&
|
||||
recentUnsafePasteTime === null &&
|
||||
(!completion.showSuggestions ||
|
||||
(!(completion.showSuggestions && isShellSuggestionsVisible) ||
|
||||
(completion.activeSuggestionIndex <= 0 &&
|
||||
!hasUserNavigatedSuggestions.current))
|
||||
) {
|
||||
@@ -909,7 +943,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
if (completion.showSuggestions && isShellSuggestionsVisible) {
|
||||
if (completion.suggestions.length > 1) {
|
||||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||
completion.navigateUp();
|
||||
@@ -1007,7 +1041,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (
|
||||
key.name === 'tab' &&
|
||||
!key.shift &&
|
||||
!completion.showSuggestions &&
|
||||
!(completion.showSuggestions && isShellSuggestionsVisible) &&
|
||||
completion.promptCompletion.text
|
||||
) {
|
||||
completion.promptCompletion.accept();
|
||||
@@ -1190,6 +1224,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
focus,
|
||||
buffer,
|
||||
completion,
|
||||
setForceShowShellSuggestions,
|
||||
shellModeActive,
|
||||
setShellModeActive,
|
||||
onClearScreen,
|
||||
@@ -1221,6 +1256,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
registerPlainTabPress,
|
||||
resetPlainTabPress,
|
||||
toggleCleanUiDetailsVisible,
|
||||
shouldShowSuggestions,
|
||||
isShellSuggestionsVisible,
|
||||
forceShowShellSuggestions,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1346,14 +1384,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
]);
|
||||
|
||||
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 +1457,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const borderColor =
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
? (statusColor ?? theme.ui.focus)
|
||||
: theme.border.default;
|
||||
|
||||
return (
|
||||
|
||||
@@ -79,10 +79,18 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
{thinkingIndicator}
|
||||
{primaryText}
|
||||
</Text>
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
{thinkingIndicator}
|
||||
{primaryText}
|
||||
</Text>
|
||||
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
|
||||
<Text color={theme.ui.active} italic>
|
||||
{' '}
|
||||
(press tab to focus)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{cancelAndTimerContent && (
|
||||
<>
|
||||
@@ -113,10 +121,18 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
{thinkingIndicator}
|
||||
{primaryText}
|
||||
</Text>
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
{thinkingIndicator}
|
||||
{primaryText}
|
||||
</Text>
|
||||
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
|
||||
<Text color={theme.ui.active} italic>
|
||||
{' '}
|
||||
(press tab to focus)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!isNarrow && cancelAndTimerContent && (
|
||||
<>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user