diff --git a/.gemini/skills/docs-writer/SKILL.md b/.gemini/skills/docs-writer/SKILL.md
index 13fc91765e..d7cf7b81be 100644
--- a/.gemini/skills/docs-writer/SKILL.md
+++ b/.gemini/skills/docs-writer/SKILL.md
@@ -45,6 +45,10 @@ Write precisely to ensure your instructions are unambiguous.
specific verbs.
- **Examples:** Use meaningful names in examples; avoid placeholders like
"foo" or "bar."
+- **Quota and limit terminology:** For any content involving resource capacity
+ or using the word "quota" or "limit", strictly adhere to the guidelines in
+ the `quota-limit-style-guide.md` resource file. Generally, Use "quota" for the
+ administrative bucket and "limit" for the numerical ceiling.
### Formatting and syntax
Apply consistent formatting to make documentation visually organized and
@@ -114,6 +118,8 @@ documentation.
reflects existing code.
- **Structure:** Apply "Structure (New Docs)" rules (BLUF, headings, etc.) when
adding new sections to existing pages.
+- **Headers**: If you change a header, you must check for links that lead to
+ that header and update them.
- **Tone:** Ensure the tone is active and engaging. Use "you" and contractions.
- **Clarity:** Correct awkward wording, spelling, and grammar. Rephrase
sentences to make them easier for users to understand.
@@ -129,7 +135,8 @@ and that all links are functional.
technical behavior.
2. **Self-review:** Re-read changes for formatting, correctness, and flow.
3. **Link check:** Verify all new and existing links leading to or from modified
- pages.
+ pages. If you changed a header, ensure that any links that lead to it are
+ updated.
4. **Format:** Once all changes are complete, ask to execute `npm run format`
to ensure consistent formatting across the project. If the user confirms,
execute the command.
diff --git a/.gemini/skills/docs-writer/quota-limit-style-guide.md b/.gemini/skills/docs-writer/quota-limit-style-guide.md
new file mode 100644
index 0000000000..b26c160cb5
--- /dev/null
+++ b/.gemini/skills/docs-writer/quota-limit-style-guide.md
@@ -0,0 +1,61 @@
+# Style Guide: Quota vs. Limit
+
+This guide defines the usage of "quota," "limit," and related terms in
+user-facing interfaces.
+
+## TL;DR
+
+- **`quota`**: The administrative "bucket." Use for settings, billing, and
+ requesting increases. (e.g., "Adjust your storage **quota**.")
+- **`limit`**: The real-time numerical "ceiling." Use for error messages when a
+ user is blocked. (e.g., "You've reached your request **limit**.")
+- **When blocked, combine them:** Explain the **limit** that was hit and the
+ **quota** that is the remedy. (e.g., "You've reached the request **limit** for
+ your developer **quota**.")
+- **Related terms:** Use `usage` for consumption tracking, `restriction` for
+ fixed rules, and `reset` for when a limit refreshes.
+
+---
+
+## Detailed Guidelines
+
+### Definitions
+
+- **Quota is the "what":** It identifies the category of resource being managed
+ (e.g., storage quota, GPU quota, request/prompt quota).
+- **Limit is the "how much":** It defines the numerical boundary.
+
+Use **quota** when referring to the administrative concept or the request for
+more. Use **limit** when discussing the specific point of exhaustion.
+
+### When to use "quota"
+
+Use this term for **account management, billing, and settings.** It describes
+the entitlement the user has purchased or been assigned.
+
+**Examples:**
+
+- **Navigation label:** Quota and usage
+- **Contextual help:** Your **usage quota** is managed by your organization. To
+ request an increase, contact your administrator.
+
+### When to use "limit"
+
+Use this term for **real-time feedback, notifications, and error messages.** It
+identifies the specific wall the user just hit.
+
+**Examples:**
+
+- **Error message:** You’ve reached the 50-request-per-minute **limit**.
+- **Inline warning:** Input exceeds the 32k token **limit**.
+
+### How to use both together
+
+When a user is blocked, combine both terms to explain the **event** (limit) and
+the **remedy** (quota).
+
+**Example:**
+
+- **Heading:** Daily usage limit reached
+- **Body:** You've reached the maximum daily capacity for your developer quota.
+ To continue working today, upgrade your quota.
diff --git a/.gemini/skills/string-reviewer/SKILL.md b/.gemini/skills/string-reviewer/SKILL.md
new file mode 100644
index 0000000000..f37d83b4ad
--- /dev/null
+++ b/.gemini/skills/string-reviewer/SKILL.md
@@ -0,0 +1,99 @@
+---
+name: string-reviewer
+description: >
+ Use this skill when asked to review text and user-facing strings within the codebase. It ensures that these strings follow rules on clarity,
+ usefulness, brevity and style.
+---
+
+# String Reviewer
+
+## Instructions
+
+Act as a Senior UX Writer. Look for user-facing strings that are too long,
+unclear, or inconsistent. This includes inline text, error messages, and other
+user-facing text.
+
+Do NOT automatically change strings without user approval. You must only suggest
+changes and do not attempt to rewrite them directly unless the user explicitly
+asks you to do so.
+
+## Core voice principles
+
+The system prioritizes deterministic clarity over conversational fluff. We
+provide telemetry, not etiquette, ensuring the user retains absolute agency..
+
+1. **Deterministic clarity:** Distinguish between certain system/service states
+ (Cloud Billing, IAM, the System) and probabilistic AI analysis (Gemini).
+2. **System transparency:** Replace "Loading..." with active technical telemetry
+ (e.g., Tracing stack traces...). Keep status updates under 5 words.
+3. **Front-loaded actionability:** Always use the [Goal] + [Action] pattern.
+ Lead with intent so users can scan left-to-right.
+4. **Agentic error recovery:** Every error must be a pivot point. Pair failures
+ with one-click recovery commands or suggested prompts.
+5. **Contextual humility:** Reserve disclaimers and "be careful" warnings for P0
+ (destructive/irreversible) tasks only. Stop warning-fatigue.
+
+## The writing checklist
+
+Use this checklist to audit UI strings and AI responses.
+
+### Identity and voice
+- **Eliminate the "I":** Remove all first-person pronouns (I, me, my, mine).
+- **Subject attribution:** Refer to the AI as Gemini and the infrastructure as
+ the - system or the CLI.
+- **Active voice:** Ensure the subject (Gemini or the system) is clearly
+ performing the action.
+- **Ownership rule:** Use the system for execution (doing) and Gemini for
+ analysis (thinking)
+
+### Structural scannability
+- **The skip test:** Do the first 3 words describe the user’s intent? If not,
+ rewrite.
+- **Goal-first sequence:** Use the template: [To Accomplish X] + [Do Y].
+- **The 5-word rule:** Keep status updates and loading states under 5 words.
+- **Telemetry over etiquette:** Remove polite filler (Please wait, Thank you,
+ Certainly). Replace with raw data or progress indicators.
+- **Micro-state cycles:** For tasks $> 3$ seconds, cycle through specific
+ sub-states (e.g., Parsing logs... ➔ Identifying patterns...) to show momentum.
+
+
+### Technical accuracy and humility
+- **Verb signal check:** Use deterministic verbs (is, will, must) for system
+ state/infrastructure.
+ - Use probabilistic verbs (suggests, appears, may, identifies) for AI output.
+- **No 100% certainty:** Never attribute absolute certainty to model-generated
+ content.
+- **Precision over fuzziness:** Use technical metrics (latency, tokens, compute) instead of "speed" or "cost."
+- **Instructional warnings:** Every warning must include a specific corrective action (e.g., "Perform a dry-run first" or "Review line 42").
+
+### Agentic error recovery
+- **The one-step rule:** Pair every error message with exactly one immediate
+ path to a fix (command, link, or prompt).
+- **Human-first:** Provide a human-readable explanation before machine error
+ codes (e.g., 404, 500).
+- **Suggested prompts:** Offer specific text for the user to copy/click like
+ “Ask Gemini: 'Explain this port error.'”
+
+### Use consistent terminology
+
+Ensure all terminology aligns with the project [word
+list](./references/word-list.md).
+
+If a string uses a term marked "do not use" or "use with caution," provide a
+correction based on the preferred terms.
+
+## Ensure consistent style for settings
+
+If `packages/cli/src/config/settingsSchema.ts` is modified, confirm labels and
+descriptions specifically follow the unique [Settings
+guidelines](./references/settings.md).
+
+## Output format
+When suggesting changes, always present your review using the following list
+format. Do not provide suggestions outside of this list..
+
+```
+1. **{Rationale/Principle Violated}**
+ - ❌ "{incorrect phrase}"
+ - ✅ `"{corrected phrase}"`
+```
\ No newline at end of file
diff --git a/.gemini/skills/string-reviewer/references/settings.md b/.gemini/skills/string-reviewer/references/settings.md
new file mode 100644
index 0000000000..df054127a8
--- /dev/null
+++ b/.gemini/skills/string-reviewer/references/settings.md
@@ -0,0 +1,28 @@
+# Settings
+
+## Noun-First Labeling (Scannability)
+
+Labels must start with the subject of the setting, not the action. This allows
+users to scan for the feature they want to change.
+
+- **Rule:** `[Noun]` `[Attribute/Action]`
+- **Example:** `Show line numbers` becomes simply `Line numbers`
+
+## Positive Boolean Logic (Cognitive Ease)
+
+Eliminate "double negatives." Booleans should represent the presence of a
+feature, not its absence.
+
+- **Rule:** Replace `Disable {feature}` or `Hide {Feature}` with
+ `{Feature} enabled` or simply `{Feature}`.
+- **Example:** Change "Disable auto update" to "Auto update".
+- **Implementation:** Invert the boolean value in your config loader so true
+ always equals `On`
+
+## Verb Stripping (Brevity)
+
+Remove redundant leading verbs like "Enable," "Use," "Display," or "Show" unless
+they are part of a specific technical term.
+
+- **Rule**: If the label works without the verb, remove it
+- **Example**: Change `Enable prompt completion` to `Prompt completion`
diff --git a/.gemini/skills/string-reviewer/references/word-list.md b/.gemini/skills/string-reviewer/references/word-list.md
new file mode 100644
index 0000000000..1bb04b9817
--- /dev/null
+++ b/.gemini/skills/string-reviewer/references/word-list.md
@@ -0,0 +1,61 @@
+## Terms
+
+### Preferred
+
+- Use **create** when a user is creating or setting up something.
+- Use **allow** instead of **may** to indicate that permission has been granted
+ to perform some action.
+- Use **canceled**, not **cancelled**.
+- Use **configure** to refer to the process of changing the attributes of a
+ feature, even if that includes turning on or off the feature.
+- Use **delete** when the action being performed is destructive.
+- Use **enable** for binary operations that turn a feature or API on. Use "turn
+ on" and "turn off" instead of "enable" and "disable" for other situations.
+- Use **key combination** to refer to pressing multiple keys simultaneously.
+- Use **key sequence** to refer to pressing multiple keys separately in order.
+- Use **modify** to refer to something that has changed vs obtaining the latest
+ version of something.
+- Use **remove** when the action being performed takes an item out of a larger
+ whole, but doesn't destroy the item itself.
+- Use **set up** as a verb. Use **setup** as a noun or adjective.
+- Use **show**. In general, use paired with **hide**.
+- Use **sign in**, **sign out** as a verb. Use **sign-in** or **sign-out** as a
+ noun or adjective.
+- Use **update** when you mean to obtain the latest version of something.
+- Use **want** instead of **like** or **would like**.
+
+#### Don't use
+
+- Don't use **etc.** It's redundant. To convey that a series is incomplete,
+ introduce it with "such as" instead.
+- Don't use **hostname**, use "host name" instead.
+- Don't use **in order to**. It's too formal. "Before you can" is usually better
+ in UI text.
+- Don't use **one or more**. Specify the quantity where possible. Use "at least
+ one" when the quantity is 1+ but you can't be sure of the number. Likewise,
+ use "at least one" when the user must choose a quantity of 1+.
+- Don't use the terms **log in**, **log on**, **login**, **logout** or **log
+ out**.
+- Don't use **like** or **would you like**. Use **want** instead. Better yet,
+ rephrase so that it's not referring to the user's emotional state, but rather
+ what is required.
+
+#### Use with caution
+
+- Avoid using **leverage**, especially as a verb. "Leverage" is considered a
+ buzzword largely devoid of meaning apart from the simpler "use".
+- Avoid using **once** as a synonym for "after". Typically, when "once" is used
+ in this way, it is followed by a verb in the perfect tense.
+- Don't use **e.g.** Use "example", "such as", "like", or "for example". The
+ phrase is always followed by a comma.
+- Don't use **i.e.** unless absolutely essential to make text fit. Use "that is"
+ instead.
+- Use **disable** for binary operations that turn a feature or API off. Use
+ "turn on" and "turn off" instead of "enable" and "disable" for other
+ situations. For UI elements that are not available, use "dimmed" instead of
+ "disabled".
+- Use **please** only when you're asking the user to do something inconvenient,
+ not just following the instructions in a typical flow.
+- Use **really** sparingly in such constructions as "Do you really want to..."
+ Because of the weight it puts on the decision, it should be used to confirm
+ actions that the user is extremely unlikely to make.
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 8377d34af0..0da8dd1a0b 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -14,3 +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
+
+# Prompt contents, tool definitions, and evals require reviews from prompt approvers
+/packages/core/src/prompts/ @google-gemini/gemini-cli-prompt-approvers
+/packages/core/src/tools/ @google-gemini/gemini-cli-prompt-approvers
+/evals/ @google-gemini/gemini-cli-prompt-approvers
diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml
index 8f062205cb..70a413f13a 100644
--- a/.github/actions/publish-release/action.yml
+++ b/.github/actions/publish-release/action.yml
@@ -192,6 +192,13 @@ runs:
INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}'
+ - name: '📦 Prepare bundled CLI for npm release'
+ if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/'"
+ working-directory: '${{ inputs.working-directory }}'
+ shell: 'bash'
+ run: |
+ node ${{ github.workspace }}/scripts/prepare-npm-release.js
+
- name: 'Get CLI Token'
uses: './.github/actions/npm-auth-token'
id: 'cli-token'
diff --git a/.github/actions/push-sandbox/action.yml b/.github/actions/push-sandbox/action.yml
index e2d1ac942c..bab85af453 100644
--- a/.github/actions/push-sandbox/action.yml
+++ b/.github/actions/push-sandbox/action.yml
@@ -44,6 +44,8 @@ runs:
- name: 'npm build'
shell: 'bash'
run: 'npm run build'
+ - name: 'Set up QEMU'
+ uses: 'docker/setup-qemu-action@v3'
- name: 'Set up Docker Buildx'
uses: 'docker/setup-buildx-action@v3'
- name: 'Log in to GitHub Container Registry'
@@ -69,16 +71,19 @@ runs:
env:
INPUTS_GITHUB_REF_NAME: '${{ inputs.github-ref-name }}'
INPUTS_GITHUB_SHA: '${{ inputs.github-sha }}'
+ # We build amd64 just so we can verify it.
+ # We build and push both amd64 and arm64 in the publish step.
- name: 'build'
id: 'docker_build'
shell: 'bash'
env:
GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
GEMINI_SANDBOX: 'docker'
+ BUILD_SANDBOX_FLAGS: '--platform linux/amd64 --load'
STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
run: |-
npm run build:sandbox -- \
- --image google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG} \
+ --image "google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG}" \
--output-file final_image_uri.txt
echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT
- name: 'verify'
@@ -92,10 +97,14 @@ runs:
- name: 'publish'
shell: 'bash'
if: "${{ inputs.dry-run != 'true' }}"
- run: |-
- docker push "${STEPS_DOCKER_BUILD_OUTPUTS_URI}"
env:
- STEPS_DOCKER_BUILD_OUTPUTS_URI: '${{ steps.docker_build.outputs.uri }}'
+ GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
+ GEMINI_SANDBOX: 'docker'
+ BUILD_SANDBOX_FLAGS: '--platform linux/amd64,linux/arm64 --push'
+ STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}'
+ run: |-
+ npm run build:sandbox -- \
+ --image "google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG}"
- name: 'Create issue on failure'
if: |-
${{ failure() }}
diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml
index 7d13a23938..8d714b34b0 100644
--- a/.github/workflows/chained_e2e.yml
+++ b/.github/workflows/chained_e2e.yml
@@ -264,6 +264,27 @@ jobs:
run: 'npm run build'
shell: 'pwsh'
+ - name: 'Ensure Chrome is available'
+ shell: 'pwsh'
+ run: |
+ $chromePaths = @(
+ "${env:ProgramFiles}\Google\Chrome\Application\chrome.exe",
+ "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe"
+ )
+ $chromeExists = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
+ if (-not $chromeExists) {
+ Write-Host 'Chrome not found, installing via Chocolatey...'
+ choco install googlechrome -y --no-progress --ignore-checksums
+ }
+ $installed = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
+ if ($installed) {
+ Write-Host "Chrome found at: $installed"
+ & $installed --version
+ } else {
+ Write-Error 'Chrome installation failed'
+ exit 1
+ }
+
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
@@ -290,6 +311,7 @@ jobs:
with:
ref: '${{ needs.parse_run_context.outputs.sha }}'
repository: '${{ needs.parse_run_context.outputs.repository }}'
+ fetch-depth: 0
- name: 'Set up Node.js 20.x'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
@@ -302,7 +324,14 @@ jobs:
- name: 'Build project'
run: 'npm run build'
+ - name: 'Check if evals should run'
+ id: 'check_evals'
+ run: |
+ SHOULD_RUN=$(node scripts/changed_prompt.js)
+ echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
+
- name: 'Run Evals (Required to pass)'
+ if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
run: 'npm run test:always_passing_evals'
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a358ad8b07..973d88f5f8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -169,7 +169,7 @@ jobs:
npm run test:ci --workspace @google/gemini-cli
else
# Explicitly list non-cli packages to ensure they are sharded correctly
- npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present
+ npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false
npm run test:scripts
fi
diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml
index fbb3e2d8d7..98635dbda7 100644
--- a/.github/workflows/deflake.yml
+++ b/.github/workflows/deflake.yml
@@ -117,7 +117,6 @@ jobs:
name: 'Slow E2E - Win'
runs-on: 'gemini-cli-windows-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
-
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml
index fe4c52292a..1cab2abaa9 100644
--- a/.github/workflows/gemini-automated-issue-triage.yml
+++ b/.github/workflows/gemini-automated-issue-triage.yml
@@ -121,6 +121,7 @@ jobs:
'area/security',
'area/platform',
'area/extensions',
+ 'area/documentation',
'area/unknown'
];
const labelNames = labels.map(label => label.name).filter(name => allowedLabels.includes(name));
@@ -255,6 +256,14 @@ jobs:
"Issues with a specific extension."
"Feature request for the extension ecosystem."
+ area/documentation
+ - Description: Issues related to user-facing documentation and other content on the documentation website.
+ - Example Issues:
+ "A typo in a README file."
+ "DOCS: A command is not working as described in the documentation."
+ "A request for a new documentation page."
+ "Instructions missing for skills feature"
+
area/unknown
- Description: Issues that do not clearly fit into any other defined area/ category, or where information is too limited to make a determination. Use this when no other area is appropriate.
diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml
index 25b0cdf4ec..50dd56883e 100644
--- a/.github/workflows/gemini-scheduled-issue-triage.yml
+++ b/.github/workflows/gemini-scheduled-issue-triage.yml
@@ -63,7 +63,7 @@ jobs:
echo '🔍 Finding issues missing area labels...'
NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
- --search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/unknown' --limit 100 --json number,title,body)"
+ --search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
echo '🔍 Finding issues missing kind labels...'
NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
@@ -204,6 +204,7 @@ jobs:
Categorization Guidelines (Area):
area/agent: Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality
area/core: User Interface, OS Support, Core Functionality
+ area/documentation: End-user and contributor-facing documentation, website-related
area/enterprise: Telemetry, Policy, Quota / Licensing
area/extensions: Gemini CLI extensions capability
area/non-interactive: GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation
diff --git a/.github/workflows/gemini-scheduled-stale-pr-closer.yml b/.github/workflows/gemini-scheduled-stale-pr-closer.yml
index 4198945159..366564d56e 100644
--- a/.github/workflows/gemini-scheduled-stale-pr-closer.yml
+++ b/.github/workflows/gemini-scheduled-stale-pr-closer.yml
@@ -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();
diff --git a/.github/workflows/gemini-self-assign-issue.yml b/.github/workflows/gemini-self-assign-issue.yml
index c0c79e5c04..454fc4f41b 100644
--- a/.github/workflows/gemini-self-assign-issue.yml
+++ b/.github/workflows/gemini-self-assign-issue.yml
@@ -25,7 +25,7 @@ jobs:
if: |-
github.repository == 'google-gemini/gemini-cli' &&
github.event_name == 'issue_comment' &&
- contains(github.event.comment.body, '/assign')
+ (contains(github.event.comment.body, '/assign') || contains(github.event.comment.body, '/unassign'))
runs-on: 'ubuntu-latest'
steps:
- name: 'Generate GitHub App Token'
@@ -38,6 +38,7 @@ jobs:
permission-issues: 'write'
- name: 'Assign issue to user'
+ if: "contains(github.event.comment.body, '/assign')"
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
@@ -108,3 +109,42 @@ jobs:
issue_number: issueNumber,
body: `👋 @${commenter}, you've been assigned to this issue! Thank you for taking the time to contribute. Make sure to check out our [contributing guidelines](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md).`
});
+
+ - name: 'Unassign issue from user'
+ if: "contains(github.event.comment.body, '/unassign')"
+ uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
+ with:
+ github-token: '${{ steps.generate_token.outputs.token }}'
+ script: |
+ const issueNumber = context.issue.number;
+ const commenter = context.actor;
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const commentBody = context.payload.comment.body.trim();
+
+ if (commentBody !== '/unassign') {
+ return;
+ }
+
+ const issue = await github.rest.issues.get({
+ owner: owner,
+ repo: repo,
+ issue_number: issueNumber,
+ });
+
+ const isAssigned = issue.data.assignees.some(assignee => assignee.login === commenter);
+
+ if (isAssigned) {
+ await github.rest.issues.removeAssignees({
+ owner: owner,
+ repo: repo,
+ issue_number: issueNumber,
+ assignees: [commenter]
+ });
+ await github.rest.issues.createComment({
+ owner: owner,
+ repo: repo,
+ issue_number: issueNumber,
+ body: `👋 @${commenter}, you have been unassigned from this issue.`
+ });
+ }
diff --git a/.github/workflows/test-build-binary.yml b/.github/workflows/test-build-binary.yml
new file mode 100644
index 0000000000..f11181a9f0
--- /dev/null
+++ b/.github/workflows/test-build-binary.yml
@@ -0,0 +1,160 @@
+name: 'Test Build Binary'
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: 'read'
+
+defaults:
+ run:
+ shell: 'bash'
+
+jobs:
+ build-node-binary:
+ name: 'Build Binary (${{ matrix.os }})'
+ runs-on: '${{ matrix.os }}'
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: 'ubuntu-latest'
+ platform_name: 'linux-x64'
+ arch: 'x64'
+ - os: 'windows-latest'
+ platform_name: 'win32-x64'
+ arch: 'x64'
+ - os: 'macos-latest' # Apple Silicon (ARM64)
+ platform_name: 'darwin-arm64'
+ arch: 'arm64'
+ - os: 'macos-latest' # Intel (x64) running on ARM via Rosetta
+ platform_name: 'darwin-x64'
+ arch: 'x64'
+
+ steps:
+ - name: 'Checkout'
+ uses: 'actions/checkout@v4'
+
+ - name: 'Optimize Windows Performance'
+ if: "matrix.os == 'windows-latest'"
+ run: |
+ Set-MpPreference -DisableRealtimeMonitoring $true
+ Stop-Service -Name "wsearch" -Force -ErrorAction SilentlyContinue
+ Set-Service -Name "wsearch" -StartupType Disabled
+ Stop-Service -Name "SysMain" -Force -ErrorAction SilentlyContinue
+ Set-Service -Name "SysMain" -StartupType Disabled
+ shell: 'powershell'
+
+ - name: 'Set up Node.js'
+ uses: 'actions/setup-node@v4'
+ with:
+ node-version-file: '.nvmrc'
+ architecture: '${{ matrix.arch }}'
+ cache: 'npm'
+
+ - name: 'Install dependencies'
+ run: 'npm ci'
+
+ - name: 'Check Secrets'
+ id: 'check_secrets'
+ run: |
+ echo "has_win_cert=${{ secrets.WINDOWS_PFX_BASE64 != '' }}" >> "$GITHUB_OUTPUT"
+ echo "has_mac_cert=${{ secrets.MACOS_CERT_P12_BASE64 != '' }}" >> "$GITHUB_OUTPUT"
+
+ - name: 'Setup Windows SDK (Windows)'
+ if: "matrix.os == 'windows-latest'"
+ uses: 'microsoft/setup-msbuild@v2'
+
+ - name: 'Add Signtool to Path (Windows)'
+ if: "matrix.os == 'windows-latest'"
+ run: |
+ $signtoolPath = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter "signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty DirectoryName
+ echo "Found signtool at: $signtoolPath"
+ echo "$signtoolPath" >> $env:GITHUB_PATH
+ shell: 'pwsh'
+
+ - name: 'Setup macOS Keychain'
+ if: "startsWith(matrix.os, 'macos') && steps.check_secrets.outputs.has_mac_cert == 'true' && github.event_name != 'pull_request'"
+ env:
+ BUILD_CERTIFICATE_BASE64: '${{ secrets.MACOS_CERT_P12_BASE64 }}'
+ P12_PASSWORD: '${{ secrets.MACOS_CERT_PASSWORD }}'
+ KEYCHAIN_PASSWORD: 'temp-password'
+ run: |
+ # Create the P12 file
+ echo "$BUILD_CERTIFICATE_BASE64" | base64 --decode > certificate.p12
+
+ # Create a temporary keychain
+ security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
+ security default-keychain -s build.keychain
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
+
+ # Import the certificate
+ security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign
+
+ # Allow codesign to access it
+ security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
+
+ # Set Identity for build script
+ echo "APPLE_IDENTITY=${{ secrets.MACOS_CERT_IDENTITY }}" >> "$GITHUB_ENV"
+
+ - name: 'Setup Windows Certificate'
+ if: "matrix.os == 'windows-latest' && steps.check_secrets.outputs.has_win_cert == 'true' && github.event_name != 'pull_request'"
+ env:
+ PFX_BASE64: '${{ secrets.WINDOWS_PFX_BASE64 }}'
+ PFX_PASSWORD: '${{ secrets.WINDOWS_PFX_PASSWORD }}'
+ run: |
+ $pfx_cert_byte = [System.Convert]::FromBase64String("$env:PFX_BASE64")
+ $certPath = Join-Path (Get-Location) "cert.pfx"
+ [IO.File]::WriteAllBytes($certPath, $pfx_cert_byte)
+ echo "WINDOWS_PFX_FILE=$certPath" >> $env:GITHUB_ENV
+ echo "WINDOWS_PFX_PASSWORD=$env:PFX_PASSWORD" >> $env:GITHUB_ENV
+ shell: 'pwsh'
+
+ - name: 'Build Binary'
+ run: 'npm run build:binary'
+
+ - name: 'Build Core Package'
+ run: 'npm run build -w @google/gemini-cli-core'
+
+ - name: 'Verify Output Exists'
+ run: |
+ if [ -f "dist/${{ matrix.platform_name }}/gemini" ]; then
+ echo "Binary found at dist/${{ matrix.platform_name }}/gemini"
+ elif [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then
+ echo "Binary found at dist/${{ matrix.platform_name }}/gemini.exe"
+ else
+ echo "Error: Binary not found in dist/${{ matrix.platform_name }}/"
+ ls -R dist/
+ exit 1
+ fi
+
+ - name: 'Smoke Test Binary'
+ run: |
+ echo "Running binary smoke test..."
+ if [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then
+ "./dist/${{ matrix.platform_name }}/gemini.exe" --version
+ else
+ "./dist/${{ matrix.platform_name }}/gemini" --version
+ fi
+
+ - name: 'Run Integration Tests'
+ if: "github.event_name != 'pull_request'"
+ env:
+ GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
+ run: |
+ echo "Running integration tests with binary..."
+ if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then
+ BINARY_PATH="$(cygpath -m "$(pwd)/dist/${{ matrix.platform_name }}/gemini.exe")"
+ else
+ BINARY_PATH="$(pwd)/dist/${{ matrix.platform_name }}/gemini"
+ fi
+ echo "Using binary at $BINARY_PATH"
+ export INTEGRATION_TEST_GEMINI_BINARY_PATH="$BINARY_PATH"
+ npm run test:integration:sandbox:none -- --testTimeout=600000
+
+ - name: 'Upload Artifact'
+ uses: 'actions/upload-artifact@v4'
+ with:
+ name: 'gemini-cli-${{ matrix.platform_name }}'
+ path: 'dist/${{ matrix.platform_name }}/'
+ retention-days: 5
diff --git a/.github/workflows/unassign-inactive-assignees.yml b/.github/workflows/unassign-inactive-assignees.yml
new file mode 100644
index 0000000000..dd09f0feaf
--- /dev/null
+++ b/.github/workflows/unassign-inactive-assignees.yml
@@ -0,0 +1,315 @@
+name: 'Unassign Inactive Issue Assignees'
+
+# This workflow runs daily and scans every open "help wanted" issue that has
+# one or more assignees. For each assignee it checks whether they have a
+# non-draft pull request (open and ready for review, or already merged) that
+# is linked to the issue. Draft PRs are intentionally excluded so that
+# contributors cannot reset the check by opening a no-op PR. If no
+# qualifying PR is found within 7 days of assignment the assignee is
+# automatically removed and a friendly comment is posted so that other
+# contributors can pick up the work.
+# Maintainers, org members, and collaborators (anyone with write access or
+# above) are always exempted and will never be auto-unassigned.
+
+on:
+ schedule:
+ - cron: '0 9 * * *' # Every day at 09:00 UTC
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: 'Run in dry-run mode (no changes will be applied)'
+ required: false
+ default: false
+ type: 'boolean'
+
+concurrency:
+ group: '${{ github.workflow }}'
+ cancel-in-progress: true
+
+defaults:
+ run:
+ shell: 'bash'
+
+jobs:
+ unassign-inactive-assignees:
+ if: "github.repository == 'google-gemini/gemini-cli'"
+ runs-on: 'ubuntu-latest'
+ permissions:
+ issues: 'write'
+
+ steps:
+ - name: 'Generate GitHub App Token'
+ id: 'generate_token'
+ uses: 'actions/create-github-app-token@v2'
+ with:
+ app-id: '${{ secrets.APP_ID }}'
+ private-key: '${{ secrets.PRIVATE_KEY }}'
+
+ - name: 'Unassign inactive assignees'
+ uses: 'actions/github-script@v7'
+ env:
+ DRY_RUN: '${{ inputs.dry_run }}'
+ with:
+ github-token: '${{ steps.generate_token.outputs.token }}'
+ script: |
+ const dryRun = process.env.DRY_RUN === 'true';
+ if (dryRun) {
+ core.info('DRY RUN MODE ENABLED: No changes will be applied.');
+ }
+
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const GRACE_PERIOD_DAYS = 7;
+ const now = new Date();
+
+ let maintainerLogins = new Set();
+ const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs'];
+
+ for (const team_slug of teams) {
+ try {
+ const members = await github.paginate(github.rest.teams.listMembersInOrg, {
+ org: owner,
+ team_slug,
+ });
+ for (const m of members) maintainerLogins.add(m.login.toLowerCase());
+ core.info(`Fetched ${members.length} members from team ${team_slug}.`);
+ } catch (e) {
+ core.warning(`Could not fetch team ${team_slug}: ${e.message}`);
+ }
+ }
+
+ const isGooglerCache = new Map();
+ const isGoogler = async (login) => {
+ if (isGooglerCache.has(login)) return isGooglerCache.get(login);
+ try {
+ for (const org of ['googlers', 'google']) {
+ try {
+ await github.rest.orgs.checkMembershipForUser({ org, username: login });
+ isGooglerCache.set(login, true);
+ return true;
+ } catch (e) {
+ if (e.status !== 404) throw e;
+ }
+ }
+ } catch (e) {
+ core.warning(`Could not check org membership for ${login}: ${e.message}`);
+ }
+ isGooglerCache.set(login, false);
+ return false;
+ };
+
+ const permissionCache = new Map();
+ const isPrivilegedUser = async (login) => {
+ if (maintainerLogins.has(login.toLowerCase())) return true;
+
+ if (permissionCache.has(login)) return permissionCache.get(login);
+
+ try {
+ const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
+ owner,
+ repo,
+ username: login,
+ });
+ const privileged = ['admin', 'maintain', 'write', 'triage'].includes(data.permission);
+ permissionCache.set(login, privileged);
+ if (privileged) {
+ core.info(` @${login} is a repo collaborator (${data.permission}) — exempt.`);
+ return true;
+ }
+ } catch (e) {
+ if (e.status !== 404) {
+ core.warning(`Could not check permission for ${login}: ${e.message}`);
+ }
+ }
+
+ const googler = await isGoogler(login);
+ permissionCache.set(login, googler);
+ return googler;
+ };
+
+ core.info('Fetching open "help wanted" issues with assignees...');
+
+ const issues = await github.paginate(github.rest.issues.listForRepo, {
+ owner,
+ repo,
+ state: 'open',
+ labels: 'help wanted',
+ per_page: 100,
+ });
+
+ const assignedIssues = issues.filter(
+ (issue) => !issue.pull_request && issue.assignees && issue.assignees.length > 0
+ );
+
+ core.info(`Found ${assignedIssues.length} assigned "help wanted" issues.`);
+
+ let totalUnassigned = 0;
+
+ let timelineEvents = [];
+ try {
+ timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, {
+ owner,
+ repo,
+ issue_number: issue.number,
+ per_page: 100,
+ mediaType: { previews: ['mockingbird'] },
+ });
+ } catch (err) {
+ core.warning(`Could not fetch timeline for issue #${issue.number}: ${err.message}`);
+ continue;
+ }
+
+ const assignedAtMap = new Map();
+
+ for (const event of timelineEvents) {
+ if (event.event === 'assigned' && event.assignee) {
+ const login = event.assignee.login.toLowerCase();
+ const at = new Date(event.created_at);
+ assignedAtMap.set(login, at);
+ } else if (event.event === 'unassigned' && event.assignee) {
+ assignedAtMap.delete(event.assignee.login.toLowerCase());
+ }
+ }
+
+ const linkedPRAuthorSet = new Set();
+ const seenPRKeys = new Set();
+
+ for (const event of timelineEvents) {
+ if (
+ event.event !== 'cross-referenced' ||
+ !event.source ||
+ event.source.type !== 'pull_request' ||
+ !event.source.issue ||
+ !event.source.issue.user ||
+ !event.source.issue.number ||
+ !event.source.issue.repository
+ ) continue;
+
+ const prOwner = event.source.issue.repository.owner.login;
+ const prRepo = event.source.issue.repository.name;
+ const prNumber = event.source.issue.number;
+ const prAuthor = event.source.issue.user.login.toLowerCase();
+ const prKey = `${prOwner}/${prRepo}#${prNumber}`;
+
+ if (seenPRKeys.has(prKey)) continue;
+ seenPRKeys.add(prKey);
+
+ try {
+ const { data: pr } = await github.rest.pulls.get({
+ owner: prOwner,
+ repo: prRepo,
+ pull_number: prNumber,
+ });
+
+ const isReady = (pr.state === 'open' && !pr.draft) ||
+ (pr.state === 'closed' && pr.merged_at !== null);
+
+ core.info(
+ ` PR ${prKey} by @${prAuthor}: ` +
+ `state=${pr.state}, draft=${pr.draft}, merged=${!!pr.merged_at} → ` +
+ (isReady ? 'qualifies' : 'does NOT qualify (draft or closed without merge)')
+ );
+
+ if (isReady) linkedPRAuthorSet.add(prAuthor);
+ } catch (err) {
+ core.warning(`Could not fetch PR ${prKey}: ${err.message}`);
+ }
+ }
+
+ const assigneesToRemove = [];
+
+ for (const assignee of issue.assignees) {
+ const login = assignee.login.toLowerCase();
+
+ if (await isPrivilegedUser(assignee.login)) {
+ core.info(` @${assignee.login}: privileged user — skipping.`);
+ continue;
+ }
+
+ const assignedAt = assignedAtMap.get(login);
+
+ if (!assignedAt) {
+ core.warning(
+ `No 'assigned' event found for @${login} on issue #${issue.number}; ` +
+ `falling back to issue creation date (${issue.created_at}).`
+ );
+ assignedAtMap.set(login, new Date(issue.created_at));
+ }
+ const resolvedAssignedAt = assignedAtMap.get(login);
+
+ const daysSinceAssignment = (now - resolvedAssignedAt) / (1000 * 60 * 60 * 24);
+
+ core.info(
+ ` @${login}: assigned ${daysSinceAssignment.toFixed(1)} day(s) ago, ` +
+ `ready-for-review PR: ${linkedPRAuthorSet.has(login) ? 'yes' : 'no'}`
+ );
+
+ if (daysSinceAssignment < GRACE_PERIOD_DAYS) {
+ core.info(` → within grace period, skipping.`);
+ continue;
+ }
+
+ if (linkedPRAuthorSet.has(login)) {
+ core.info(` → ready-for-review PR found, keeping assignment.`);
+ continue;
+ }
+
+ core.info(` → no ready-for-review PR after ${GRACE_PERIOD_DAYS} days, will unassign.`);
+ assigneesToRemove.push(assignee.login);
+ }
+
+ if (assigneesToRemove.length === 0) {
+ continue;
+ }
+
+ if (!dryRun) {
+ try {
+ await github.rest.issues.removeAssignees({
+ owner,
+ repo,
+ issue_number: issue.number,
+ assignees: assigneesToRemove,
+ });
+ } catch (err) {
+ core.warning(
+ `Failed to unassign ${assigneesToRemove.join(', ')} from issue #${issue.number}: ${err.message}`
+ );
+ continue;
+ }
+
+ const mentionList = assigneesToRemove.map((l) => `@${l}`).join(', ');
+ const commentBody =
+ `👋 ${mentionList} — it has been more than ${GRACE_PERIOD_DAYS} days since ` +
+ `you were assigned to this issue and we could not find a pull request ` +
+ `ready for review.\n\n` +
+ `To keep the backlog moving and ensure issues stay accessible to all ` +
+ `contributors, we require a PR that is open and ready for review (not a ` +
+ `draft) within ${GRACE_PERIOD_DAYS} days of assignment.\n\n` +
+ `We are automatically unassigning you so that other contributors can pick ` +
+ `this up. If you are still actively working on this, please:\n` +
+ `1. Re-assign yourself by commenting \`/assign\`.\n` +
+ `2. Open a PR (not a draft) linked to this issue (e.g. \`Fixes #${issue.number}\`) ` +
+ `within ${GRACE_PERIOD_DAYS} days so the automation knows real progress is being made.\n\n` +
+ `Thank you for your contribution — we hope to see a PR from you soon! 🙏`;
+
+ try {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: issue.number,
+ body: commentBody,
+ });
+ } catch (err) {
+ core.warning(
+ `Failed to post comment on issue #${issue.number}: ${err.message}`
+ );
+ }
+ }
+
+ totalUnassigned += assigneesToRemove.length;
+ core.info(
+ ` ${dryRun ? '[DRY RUN] Would have unassigned' : 'Unassigned'}: ${assigneesToRemove.join(', ')}`
+ );
+ }
+
+ core.info(`\nDone. Total assignees ${dryRun ? 'that would be' : ''} unassigned: ${totalUnassigned}`);
diff --git a/.gitignore b/.gitignore
index 0438549485..a2a6553cd3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,4 +61,4 @@ gemini-debug.log
.genkit
.gemini-clipboard/
.eslintcache
-evals/logs/
\ No newline at end of file
+evals/logs/
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 3661ecf9c2..3197edbbfc 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -7,6 +7,9 @@
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 28e3c775d3..5d08e91455 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -60,26 +60,50 @@ All submissions, including submissions by project members, require review. We
use [GitHub pull requests](https://docs.github.com/articles/about-pull-requests)
for this purpose.
-If your pull request involves changes to `packages/cli` (the frontend), we
-recommend running our automated frontend review tool. **Note: This tool is
-currently experimental.** It helps detect common React anti-patterns, testing
-issues, and other frontend-specific best practices that are easy to miss.
+To assist with the review process, we provide an automated review tool that
+helps detect common anti-patterns, testing issues, and other best practices that
+are easy to miss.
-To run the review tool, enter the following command from within Gemini CLI:
+#### Using the automated review tool
-```text
-/review-frontend
-```
+You can run the review tool in two ways:
-Replace `` with your pull request number. Authors are encouraged to
-run this on their own PRs for self-review, and reviewers should use it to
-augment their manual review process.
+1. **Using the helper script (Recommended):** We provide a script that
+ automatically handles checking out the PR into a separate worktree,
+ installing dependencies, building the project, and launching the review
+ tool.
-### Self assigning issues
+ ```bash
+ ./scripts/review.sh [model]
+ ```
-To assign an issue to yourself, simply add a comment with the text `/assign`.
-The comment must contain only that text and nothing else. This command will
-assign the issue to you, provided it is not already assigned.
+ **Authors are strongly encouraged to run this script on their own PRs**
+ immediately after creation. This allows you to catch and fix simple issues
+ locally before a maintainer performs a full review.
+
+ **Note on Models:** By default, the script uses the latest Pro model
+ (`gemini-3.1-pro-preview`). If you do not have enough Pro quota, you can run
+ it with the latest Flash model instead:
+ `./scripts/review.sh gemini-3-flash-preview`.
+
+2. **Manually from within Gemini CLI:** If you already have the PR checked out
+ and built, you can run the tool directly from the CLI prompt:
+
+ ```text
+ /review-frontend
+ ```
+
+Replace `` with your pull request number. Reviewers should use this
+tool to augment, not replace, their manual review process.
+
+### Self-assigning and unassigning issues
+
+To assign an issue to yourself, simply add a comment with the text `/assign`. To
+unassign yourself from an issue, add a comment with the text `/unassign`.
+
+The comment must contain only that text and nothing else. These commands will
+assign or unassign the issue as requested, provided the conditions are met
+(e.g., an issue must be unassigned to be assigned).
Please note that you can have a maximum of 3 issues assigned to you at any given
time.
@@ -264,7 +288,8 @@ npm run test:e2e
```
For more detailed information on the integration testing framework, please see
-the [Integration Tests documentation](/docs/integration-tests.md).
+the
+[Integration Tests documentation](https://geminicli.com/docs/integration-tests).
### Linting and preflight checks
@@ -317,11 +342,9 @@ npm run lint
- Please adhere to the coding style, patterns, and conventions used throughout
the existing codebase.
-- Consult
- [GEMINI.md](https://github.com/google-gemini/gemini-cli/blob/main/GEMINI.md)
- (typically found in the project root) for specific instructions related to
- AI-assisted development, including conventions for React, comments, and Git
- usage.
+- Consult [GEMINI.md](../GEMINI.md) (typically found in the project root) for
+ specific instructions related to AI-assisted development, including
+ conventions for React, comments, and Git usage.
- **Imports:** Pay special attention to import paths. The project uses ESLint to
enforce restrictions on relative imports between packages.
@@ -545,7 +568,7 @@ Before submitting your documentation pull request, please:
If you have questions about contributing documentation:
-- Check our [FAQ](/docs/resources/faq.md).
+- Check our [FAQ](https://geminicli.com/docs/resources/faq).
- Review existing documentation for examples.
- Open [an issue](https://github.com/google-gemini/gemini-cli/issues) to discuss
your proposed changes.
diff --git a/README.md b/README.md
index f44a2e238d..959b5a9534 100644
--- a/README.md
+++ b/README.md
@@ -77,7 +77,7 @@ See [Releases](./docs/releases.md) for more details.
### Preview
-New preview releases will be published each week at UTC 2359 on Tuesdays. These
+New preview releases will be published each week at UTC 23:59 on Tuesdays. These
releases will not have been fully vetted and may contain regressions or other
outstanding issues. Please help us test and install with `preview` tag.
@@ -87,7 +87,7 @@ npm install -g @google/gemini-cli@preview
### Stable
-- New stable releases will be published each week at UTC 2000 on Tuesdays, this
+- New stable releases will be published each week at UTC 20:00 on Tuesdays, this
will be the full promotion of last week's `preview` release + any bug fixes
and validations. Use `latest` tag.
@@ -97,7 +97,7 @@ npm install -g @google/gemini-cli@latest
### Nightly
-- New releases will be published each day at UTC 0000. This will be all changes
+- New releases will be published each day at UTC 00:00. This will be all changes
from the main branch as represented at time of release. It should be assumed
there are pending validations and issues. Use `nightly` tag.
@@ -282,14 +282,14 @@ gemini
quickly.
- [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed
auth configuration.
-- [**Configuration Guide**](./docs/get-started/configuration.md) - Settings and
+- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and
customization.
-- [**Keyboard Shortcuts**](./docs/cli/keyboard-shortcuts.md) - Productivity
- tips.
+- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) -
+ Productivity tips.
### Core Features
-- [**Commands Reference**](./docs/cli/commands.md) - All slash commands
+- [**Commands Reference**](./docs/reference/commands.md) - All slash commands
(`/help`, `/chat`, etc).
- [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own
reusable commands.
@@ -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,15 +323,15 @@ 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/core/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.
### Troubleshooting & Support
-- [**Troubleshooting Guide**](./docs/troubleshooting.md) - Common issues and
- solutions.
-- [**FAQ**](./docs/faq.md) - Frequently asked questions.
+- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common
+ issues and solutions.
+- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions.
- Use `/bug` command to report issues directly from the CLI.
### Using MCP Servers
@@ -377,7 +377,8 @@ for planned features and priorities.
### Uninstall
-See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
+See the [Uninstall Guide](./docs/resources/uninstall.md) for removal
+instructions.
## 📄 Legal
diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md
index 758976b85b..33c179072a 100644
--- a/docs/changelogs/index.md
+++ b/docs/changelogs/index.md
@@ -18,6 +18,30 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
+## Announcements: v0.32.0 - 2026-03-03
+
+- **Generalist Agent:** The generalist agent is now enabled to improve task
+ delegation and routing
+ ([#19665](https://github.com/google-gemini/gemini-cli/pull/19665) by
+ @joshualitt).
+- **Model Steering in Workspace:** Added support for model steering directly in
+ the workspace
+ ([#20343](https://github.com/google-gemini/gemini-cli/pull/20343) by
+ @joshualitt).
+- **Plan Mode Enhancements:** Users can now open and modify plans in an external
+ editor, and the planning workflow has been adapted to handle complex tasks
+ more effectively with multi-select options
+ ([#20348](https://github.com/google-gemini/gemini-cli/pull/20348) by @Adib234,
+ [#20465](https://github.com/google-gemini/gemini-cli/pull/20465) by @jerop).
+- **Interactive Shell Autocompletion:** Introduced interactive shell
+ autocompletion for a more seamless experience
+ ([#20082](https://github.com/google-gemini/gemini-cli/pull/20082) by
+ @mrpmohiburrahman).
+- **Parallel Extension Loading:** Extensions are now loaded in parallel to
+ improve startup times
+ ([#20229](https://github.com/google-gemini/gemini-cli/pull/20229) by
+ @scidomino).
+
## Announcements: v0.31.0 - 2026-02-27
- **Gemini 3.1 Pro Preview:** Gemini CLI now supports the new Gemini 3.1 Pro
@@ -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
diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md
index 760e070bd9..d5d13717c7 100644
--- a/docs/changelogs/latest.md
+++ b/docs/changelogs/latest.md
@@ -1,6 +1,6 @@
-# Latest stable release: v0.31.0
+# Latest stable release: v0.32.1
-Released: February 27, 2026
+Released: March 4, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,405 +11,198 @@ npm install -g @google/gemini-cli
## Highlights
-- **Gemini 3.1 Pro Preview:** Gemini CLI now supports the new Gemini 3.1 Pro
- Preview model.
-- **Experimental Browser Agent:** We've introduced a new experimental browser
- agent to directly interact with web pages and retrieve context.
-- **Policy Engine Updates:** The policy engine has been expanded to support
- project-level policies, MCP server wildcards, and tool annotation matching,
- providing greater control over tool executions.
-- **Web Fetch Enhancements:** A new experimental direct web fetch tool has been
- implemented, alongside rate-limiting features for enhanced security.
-- **Improved Plan Mode:** Plan Mode now includes support for custom storage
- directories, automatic model switching, and summarizing work after execution.
+- **Plan Mode Enhancements**: Significant updates to Plan Mode, including the
+ ability to open and modify plans in an external editor, adaptations for
+ complex tasks with multi-select options, and integration tests for plan mode.
+- **Agent and Steering Improvements**: The generalist agent has been enabled to
+ enhance task delegation, model steering is now supported directly within the
+ workspace, and contiguous parallel admission is enabled for `Kind.Agent`
+ tools.
+- **Interactive Shell**: Interactive shell autocompletion has been introduced,
+ significantly enhancing the user experience.
+- **Core Stability and Performance**: Extensions are now loaded in parallel,
+ fetch timeouts have been increased, robust A2A streaming reassembly was
+ implemented, and orphaned processes when terminal closes have been prevented.
+- **Billing and Quota Handling**: Implemented G1 AI credits overage flow with
+ billing telemetry and added support for quota error fallbacks across all
+ authentication types.
## What's Changed
-- Use ranged reads and limited searches and fuzzy editing improvements by
- @gundermanc in
- [#19240](https://github.com/google-gemini/gemini-cli/pull/19240)
-- Fix bottom border color by @jacob314 in
- [#19266](https://github.com/google-gemini/gemini-cli/pull/19266)
-- Release note generator fix by @g-samroberts in
- [#19363](https://github.com/google-gemini/gemini-cli/pull/19363)
-- test(evals): add behavioral tests for tool output masking by @NTaylorMullen in
- [#19172](https://github.com/google-gemini/gemini-cli/pull/19172)
-- docs: clarify preflight instructions in GEMINI.md by @NTaylorMullen in
- [#19377](https://github.com/google-gemini/gemini-cli/pull/19377)
-- feat(cli): add gemini --resume hint on exit by @Mag1ck in
- [#16285](https://github.com/google-gemini/gemini-cli/pull/16285)
-- fix: optimize height calculations for ask_user dialog by @jackwotherspoon in
- [#19017](https://github.com/google-gemini/gemini-cli/pull/19017)
-- feat(cli): add Alt+D for forward word deletion by @scidomino in
- [#19300](https://github.com/google-gemini/gemini-cli/pull/19300)
-- Disable failing eval test by @chrstnb in
- [#19455](https://github.com/google-gemini/gemini-cli/pull/19455)
-- fix(cli): support legacy onConfirm callback in ToolActionsContext by
+- fix(patch): cherry-pick 0659ad1 to release/v0.32.0-pr-21042 to patch version
+ v0.32.0 and create version 0.32.1 by @gemini-cli-robot in
+ [#21048](https://github.com/google-gemini/gemini-cli/pull/21048)
+- feat(plan): add integration tests for plan mode by @Adib234 in
+ [#20214](https://github.com/google-gemini/gemini-cli/pull/20214)
+- fix(acp): update auth handshake to spec by @skeshive in
+ [#19725](https://github.com/google-gemini/gemini-cli/pull/19725)
+- feat(core): implement robust A2A streaming reassembly and fix task continuity
+ by @adamfweidman in
+ [#20091](https://github.com/google-gemini/gemini-cli/pull/20091)
+- feat(cli): load extensions in parallel by @scidomino in
+ [#20229](https://github.com/google-gemini/gemini-cli/pull/20229)
+- Plumb the maxAttempts setting through Config args by @kevinjwang1 in
+ [#20239](https://github.com/google-gemini/gemini-cli/pull/20239)
+- fix(cli): skip 404 errors in setup-github file downloads by @h30s in
+ [#20287](https://github.com/google-gemini/gemini-cli/pull/20287)
+- fix(cli): expose model.name setting in settings dialog for persistence by
+ @achaljhawar in
+ [#19605](https://github.com/google-gemini/gemini-cli/pull/19605)
+- docs: remove legacy cmd examples in favor of powershell by @scidomino in
+ [#20323](https://github.com/google-gemini/gemini-cli/pull/20323)
+- feat(core): Enable model steering in workspace. by @joshualitt in
+ [#20343](https://github.com/google-gemini/gemini-cli/pull/20343)
+- fix: remove trailing comma in issue triage workflow settings json by @Nixxx19
+ in [#20265](https://github.com/google-gemini/gemini-cli/pull/20265)
+- feat(core): implement task tracker foundation and service by @anj-s in
+ [#19464](https://github.com/google-gemini/gemini-cli/pull/19464)
+- test: support tests that include color information by @jacob314 in
+ [#20220](https://github.com/google-gemini/gemini-cli/pull/20220)
+- feat(core): introduce Kind.Agent for sub-agent classification by @abhipatel12
+ in [#20369](https://github.com/google-gemini/gemini-cli/pull/20369)
+- Changelog for v0.30.0 by @gemini-cli-robot in
+ [#20252](https://github.com/google-gemini/gemini-cli/pull/20252)
+- Update changelog workflow to reject nightly builds by @g-samroberts in
+ [#20248](https://github.com/google-gemini/gemini-cli/pull/20248)
+- Changelog for v0.31.0-preview.0 by @gemini-cli-robot in
+ [#20249](https://github.com/google-gemini/gemini-cli/pull/20249)
+- feat(cli): hide workspace policy update dialog and auto-accept by default by
+ @Abhijit-2592 in
+ [#20351](https://github.com/google-gemini/gemini-cli/pull/20351)
+- feat(core): rename grep_search include parameter to include_pattern by
@SandyTao520 in
- [#19369](https://github.com/google-gemini/gemini-cli/pull/19369)
-- chore(deps): bump tar from 7.5.7 to 7.5.8 by @.github/dependabot.yml[bot] in
- [#19367](https://github.com/google-gemini/gemini-cli/pull/19367)
-- fix(plan): allow safe fallback when experiment setting for plan is not enabled
- but approval mode at startup is plan by @Adib234 in
- [#19439](https://github.com/google-gemini/gemini-cli/pull/19439)
-- Add explicit color-convert dependency by @chrstnb in
- [#19460](https://github.com/google-gemini/gemini-cli/pull/19460)
-- feat(devtools): migrate devtools package into monorepo by @SandyTao520 in
- [#18936](https://github.com/google-gemini/gemini-cli/pull/18936)
-- fix(core): clarify plan mode constraints and exit mechanism by @jerop in
- [#19438](https://github.com/google-gemini/gemini-cli/pull/19438)
-- feat(cli): add macOS run-event notifications (interactive only) by
- @LyalinDotCom in
- [#19056](https://github.com/google-gemini/gemini-cli/pull/19056)
-- Changelog for v0.29.0 by @gemini-cli-robot in
- [#19361](https://github.com/google-gemini/gemini-cli/pull/19361)
-- fix(ui): preventing empty history items from being added by @devr0306 in
- [#19014](https://github.com/google-gemini/gemini-cli/pull/19014)
-- Changelog for v0.30.0-preview.0 by @gemini-cli-robot in
- [#19364](https://github.com/google-gemini/gemini-cli/pull/19364)
-- feat(core): add support for MCP progress updates by @NTaylorMullen in
- [#19046](https://github.com/google-gemini/gemini-cli/pull/19046)
-- fix(core): ensure directory exists before writing conversation file by
- @godwiniheuwa in
- [#18429](https://github.com/google-gemini/gemini-cli/pull/18429)
-- fix(ui): move margin from top to bottom in ToolGroupMessage by @imadraude in
- [#17198](https://github.com/google-gemini/gemini-cli/pull/17198)
-- fix(cli): treat unknown slash commands as regular input instead of showing
- error by @skyvanguard in
- [#17393](https://github.com/google-gemini/gemini-cli/pull/17393)
-- feat(core): experimental in-progress steering hints (2 of 2) by @joshualitt in
- [#19307](https://github.com/google-gemini/gemini-cli/pull/19307)
-- docs(plan): add documentation for plan mode command by @Adib234 in
- [#19467](https://github.com/google-gemini/gemini-cli/pull/19467)
-- fix(core): ripgrep fails when pattern looks like ripgrep flag by @syvb in
- [#18858](https://github.com/google-gemini/gemini-cli/pull/18858)
-- fix(cli): disable auto-completion on Shift+Tab to preserve mode cycling by
- @NTaylorMullen in
- [#19451](https://github.com/google-gemini/gemini-cli/pull/19451)
-- use issuer instead of authorization_endpoint for oauth discovery by
- @garrettsparks in
- [#17332](https://github.com/google-gemini/gemini-cli/pull/17332)
-- feat(cli): include `/dir add` directories in @ autocomplete suggestions by
- @jasmeetsb in [#19246](https://github.com/google-gemini/gemini-cli/pull/19246)
-- feat(admin): Admin settings should only apply if adminControlsApplicable =
- true and fetch errors should be fatal by @skeshive in
- [#19453](https://github.com/google-gemini/gemini-cli/pull/19453)
-- Format strict-development-rules command by @g-samroberts in
- [#19484](https://github.com/google-gemini/gemini-cli/pull/19484)
-- feat(core): centralize compatibility checks and add TrueColor detection by
+ [#20328](https://github.com/google-gemini/gemini-cli/pull/20328)
+- feat(plan): support opening and modifying plan in external editor by @Adib234
+ in [#20348](https://github.com/google-gemini/gemini-cli/pull/20348)
+- feat(cli): implement interactive shell autocompletion by @mrpmohiburrahman in
+ [#20082](https://github.com/google-gemini/gemini-cli/pull/20082)
+- fix(core): allow /memory add to work in plan mode by @Jefftree in
+ [#20353](https://github.com/google-gemini/gemini-cli/pull/20353)
+- feat(core): add HTTP 499 to retryable errors and map to RetryableQuotaError by
+ @bdmorgan in [#20432](https://github.com/google-gemini/gemini-cli/pull/20432)
+- feat(core): Enable generalist agent by @joshualitt in
+ [#19665](https://github.com/google-gemini/gemini-cli/pull/19665)
+- Updated tests in TableRenderer.test.tsx to use SVG snapshots by @devr0306 in
+ [#20450](https://github.com/google-gemini/gemini-cli/pull/20450)
+- Refactor Github Action per b/485167538 by @google-admin in
+ [#19443](https://github.com/google-gemini/gemini-cli/pull/19443)
+- fix(github): resolve actionlint and yamllint regressions from #19443 by @jerop
+ in [#20467](https://github.com/google-gemini/gemini-cli/pull/20467)
+- fix: action var usage by @galz10 in
+ [#20492](https://github.com/google-gemini/gemini-cli/pull/20492)
+- feat(core): improve A2A content extraction by @adamfweidman in
+ [#20487](https://github.com/google-gemini/gemini-cli/pull/20487)
+- fix(cli): support quota error fallbacks for all authentication types by
+ @sehoon38 in [#20475](https://github.com/google-gemini/gemini-cli/pull/20475)
+- fix(core): flush transcript for pure tool-call responses to ensure BeforeTool
+ hooks see complete state by @krishdef7 in
+ [#20419](https://github.com/google-gemini/gemini-cli/pull/20419)
+- feat(plan): adapt planning workflow based on complexity of task by @jerop in
+ [#20465](https://github.com/google-gemini/gemini-cli/pull/20465)
+- fix: prevent orphaned processes from consuming 100% CPU when terminal closes
+ by @yuvrajangadsingh in
+ [#16965](https://github.com/google-gemini/gemini-cli/pull/16965)
+- feat(core): increase fetch timeout and fix [object Object] error
+ stringification by @bdmorgan in
+ [#20441](https://github.com/google-gemini/gemini-cli/pull/20441)
+- [Gemma x Gemini CLI] Add an Experimental Gemma Router that uses a LiteRT-LM
+ shim into the Composite Model Classifier Strategy by @sidwan02 in
+ [#17231](https://github.com/google-gemini/gemini-cli/pull/17231)
+- docs(plan): update documentation regarding supporting editing of plan files
+ during plan approval by @Adib234 in
+ [#20452](https://github.com/google-gemini/gemini-cli/pull/20452)
+- test(cli): fix flaky ToolResultDisplay overflow test by @jwhelangoog in
+ [#20518](https://github.com/google-gemini/gemini-cli/pull/20518)
+- ui(cli): reduce length of Ctrl+O hint by @jwhelangoog in
+ [#20490](https://github.com/google-gemini/gemini-cli/pull/20490)
+- fix(ui): correct styled table width calculations by @devr0306 in
+ [#20042](https://github.com/google-gemini/gemini-cli/pull/20042)
+- Avoid overaggressive unescaping by @scidomino in
+ [#20520](https://github.com/google-gemini/gemini-cli/pull/20520)
+- feat(telemetry) Instrument traces with more attributes and make them available
+ to OTEL users by @heaventourist in
+ [#20237](https://github.com/google-gemini/gemini-cli/pull/20237)
+- Add support for policy engine in extensions by @chrstnb in
+ [#20049](https://github.com/google-gemini/gemini-cli/pull/20049)
+- Docs: Update to Terms of Service & FAQ by @jkcinouye in
+ [#20488](https://github.com/google-gemini/gemini-cli/pull/20488)
+- Fix bottom border rendering for search and add a regression test. by @jacob314
+ in [#20517](https://github.com/google-gemini/gemini-cli/pull/20517)
+- fix(core): apply retry logic to CodeAssistServer for all users by @bdmorgan in
+ [#20507](https://github.com/google-gemini/gemini-cli/pull/20507)
+- Fix extension MCP server env var loading by @chrstnb in
+ [#20374](https://github.com/google-gemini/gemini-cli/pull/20374)
+- feat(ui): add 'ctrl+o' hint to truncated content message by @jerop in
+ [#20529](https://github.com/google-gemini/gemini-cli/pull/20529)
+- Fix flicker showing message to press ctrl-O again to collapse. by @jacob314 in
+ [#20414](https://github.com/google-gemini/gemini-cli/pull/20414)
+- fix(cli): hide shortcuts hint while model is thinking or the user has typed a
+ prompt + add debounce to avoid flicker by @jacob314 in
+ [#19389](https://github.com/google-gemini/gemini-cli/pull/19389)
+- feat(plan): update planning workflow to encourage multi-select with
+ descriptions of options by @Adib234 in
+ [#20491](https://github.com/google-gemini/gemini-cli/pull/20491)
+- refactor(core,cli): useAlternateBuffer read from config by @psinha40898 in
+ [#20346](https://github.com/google-gemini/gemini-cli/pull/20346)
+- fix(cli): ensure dialogs stay scrolled to bottom in alternate buffer mode by
+ @jacob314 in [#20527](https://github.com/google-gemini/gemini-cli/pull/20527)
+- fix(core): revert auto-save of policies to user space by @Abhijit-2592 in
+ [#20531](https://github.com/google-gemini/gemini-cli/pull/20531)
+- Demote unreliable test. by @gundermanc in
+ [#20571](https://github.com/google-gemini/gemini-cli/pull/20571)
+- fix(core): handle optional response fields from code assist API by @sehoon38
+ in [#20345](https://github.com/google-gemini/gemini-cli/pull/20345)
+- fix(cli): keep thought summary when loading phrases are off by @LyalinDotCom
+ in [#20497](https://github.com/google-gemini/gemini-cli/pull/20497)
+- feat(cli): add temporary flag to disable workspace policies by @Abhijit-2592
+ in [#20523](https://github.com/google-gemini/gemini-cli/pull/20523)
+- Disable expensive and scheduled workflows on personal forks by @dewitt in
+ [#20449](https://github.com/google-gemini/gemini-cli/pull/20449)
+- Moved markdown parsing logic to a separate util file by @devr0306 in
+ [#20526](https://github.com/google-gemini/gemini-cli/pull/20526)
+- fix(plan): prevent agent from using ask_user for shell command confirmation by
+ @Adib234 in [#20504](https://github.com/google-gemini/gemini-cli/pull/20504)
+- fix(core): disable retries for code assist streaming requests by @sehoon38 in
+ [#20561](https://github.com/google-gemini/gemini-cli/pull/20561)
+- feat(billing): implement G1 AI credits overage flow with billing telemetry by
+ @gsquared94 in
+ [#18590](https://github.com/google-gemini/gemini-cli/pull/18590)
+- feat: better error messages by @gsquared94 in
+ [#20577](https://github.com/google-gemini/gemini-cli/pull/20577)
+- fix(ui): persist expansion in AskUser dialog when navigating options by @jerop
+ in [#20559](https://github.com/google-gemini/gemini-cli/pull/20559)
+- fix(cli): prevent sub-agent tool calls from leaking into UI by @abhipatel12 in
+ [#20580](https://github.com/google-gemini/gemini-cli/pull/20580)
+- fix(cli): Shell autocomplete polish by @jacob314 in
+ [#20411](https://github.com/google-gemini/gemini-cli/pull/20411)
+- Changelog for v0.31.0-preview.1 by @gemini-cli-robot in
+ [#20590](https://github.com/google-gemini/gemini-cli/pull/20590)
+- Add slash command for promoting behavioral evals to CI blocking by @gundermanc
+ in [#20575](https://github.com/google-gemini/gemini-cli/pull/20575)
+- Changelog for v0.30.1 by @gemini-cli-robot in
+ [#20589](https://github.com/google-gemini/gemini-cli/pull/20589)
+- Add low/full CLI error verbosity mode for cleaner UI by @LyalinDotCom in
+ [#20399](https://github.com/google-gemini/gemini-cli/pull/20399)
+- Disable Gemini PR reviews on draft PRs. by @gundermanc in
+ [#20362](https://github.com/google-gemini/gemini-cli/pull/20362)
+- Docs: FAQ update by @jkcinouye in
+ [#20585](https://github.com/google-gemini/gemini-cli/pull/20585)
+- fix(core): reduce intrusive MCP errors and deduplicate diagnostics by
@spencer426 in
- [#19478](https://github.com/google-gemini/gemini-cli/pull/19478)
-- Remove unused files and update index and sidebar. by @g-samroberts in
- [#19479](https://github.com/google-gemini/gemini-cli/pull/19479)
-- Migrate core render util to use xterm.js as part of the rendering loop. by
- @jacob314 in [#19044](https://github.com/google-gemini/gemini-cli/pull/19044)
-- Changelog for v0.30.0-preview.1 by @gemini-cli-robot in
- [#19496](https://github.com/google-gemini/gemini-cli/pull/19496)
-- build: replace deprecated built-in punycode with userland package by @jacob314
- in [#19502](https://github.com/google-gemini/gemini-cli/pull/19502)
-- Speculative fixes to try to fix react error. by @jacob314 in
- [#19508](https://github.com/google-gemini/gemini-cli/pull/19508)
-- fix spacing by @jacob314 in
- [#19494](https://github.com/google-gemini/gemini-cli/pull/19494)
-- fix(core): ensure user rejections update tool outcome for telemetry by
- @abhiasap in [#18982](https://github.com/google-gemini/gemini-cli/pull/18982)
-- fix(acp): Initialize config (#18897) by @Mervap in
- [#18898](https://github.com/google-gemini/gemini-cli/pull/18898)
-- fix(core): add error logging for IDE fetch failures by @yuvrajangadsingh in
- [#17981](https://github.com/google-gemini/gemini-cli/pull/17981)
-- feat(acp): support set_mode interface (#18890) by @Mervap in
- [#18891](https://github.com/google-gemini/gemini-cli/pull/18891)
-- fix(core): robust workspace-based IDE connection discovery by @ehedlund in
- [#18443](https://github.com/google-gemini/gemini-cli/pull/18443)
-- Deflake windows tests. by @jacob314 in
- [#19511](https://github.com/google-gemini/gemini-cli/pull/19511)
-- Fix: Avoid tool confirmation timeout when no UI listeners are present by
- @pdHaku0 in [#17955](https://github.com/google-gemini/gemini-cli/pull/17955)
-- format md file by @scidomino in
- [#19474](https://github.com/google-gemini/gemini-cli/pull/19474)
-- feat(cli): add experimental.useOSC52Copy setting by @scidomino in
- [#19488](https://github.com/google-gemini/gemini-cli/pull/19488)
-- feat(cli): replace loading phrases boolean with enum setting by @LyalinDotCom
- in [#19347](https://github.com/google-gemini/gemini-cli/pull/19347)
-- Update skill to adjust for generated results. by @g-samroberts in
- [#19500](https://github.com/google-gemini/gemini-cli/pull/19500)
-- Fix message too large issue. by @gundermanc in
- [#19499](https://github.com/google-gemini/gemini-cli/pull/19499)
-- fix(core): prevent duplicate tool approval entries in auto-saved.toml by
- @Abhijit-2592 in
- [#19487](https://github.com/google-gemini/gemini-cli/pull/19487)
-- fix(core): resolve crash in ClearcutLogger when os.cpus() is empty by @Adib234
- in [#19555](https://github.com/google-gemini/gemini-cli/pull/19555)
-- chore(core): improve encapsulation and remove unused exports by @adamfweidman
- in [#19556](https://github.com/google-gemini/gemini-cli/pull/19556)
-- Revert "Add generic searchable list to back settings and extensions (… by
- @chrstnb in [#19434](https://github.com/google-gemini/gemini-cli/pull/19434)
-- fix(core): improve error type extraction for telemetry by @yunaseoul in
- [#19565](https://github.com/google-gemini/gemini-cli/pull/19565)
-- fix: remove extra padding in Composer by @jackwotherspoon in
- [#19529](https://github.com/google-gemini/gemini-cli/pull/19529)
-- feat(plan): support configuring custom plans storage directory by @jerop in
- [#19577](https://github.com/google-gemini/gemini-cli/pull/19577)
-- Migrate files to resource or references folder. by @g-samroberts in
- [#19503](https://github.com/google-gemini/gemini-cli/pull/19503)
-- feat(policy): implement project-level policy support by @Abhijit-2592 in
- [#18682](https://github.com/google-gemini/gemini-cli/pull/18682)
-- feat(core): Implement parallel FC for read only tools. by @joshualitt in
- [#18791](https://github.com/google-gemini/gemini-cli/pull/18791)
-- chore(skills): adds pr-address-comments skill to work on PR feedback by
- @mbleigh in [#19576](https://github.com/google-gemini/gemini-cli/pull/19576)
-- refactor(sdk): introduce session-based architecture by @mbleigh in
- [#19180](https://github.com/google-gemini/gemini-cli/pull/19180)
-- fix(ci): add fallback JSON extraction to issue triage workflow by @bdmorgan in
- [#19593](https://github.com/google-gemini/gemini-cli/pull/19593)
-- feat(core): refine Edit and WriteFile tool schemas for Gemini 3 by
- @SandyTao520 in
- [#19476](https://github.com/google-gemini/gemini-cli/pull/19476)
-- Changelog for v0.30.0-preview.3 by @gemini-cli-robot in
- [#19585](https://github.com/google-gemini/gemini-cli/pull/19585)
-- fix(plan): exclude EnterPlanMode tool from YOLO mode by @Adib234 in
- [#19570](https://github.com/google-gemini/gemini-cli/pull/19570)
-- chore: resolve build warnings and update dependencies by @mattKorwel in
- [#18880](https://github.com/google-gemini/gemini-cli/pull/18880)
-- feat(ui): add source indicators to slash commands by @ehedlund in
- [#18839](https://github.com/google-gemini/gemini-cli/pull/18839)
-- docs: refine Plan Mode documentation structure and workflow by @jerop in
- [#19644](https://github.com/google-gemini/gemini-cli/pull/19644)
-- Docs: Update release information regarding Gemini 3.1 by @jkcinouye in
- [#19568](https://github.com/google-gemini/gemini-cli/pull/19568)
-- fix(security): rate limit web_fetch tool to mitigate DDoS via prompt injection
- by @mattKorwel in
- [#19567](https://github.com/google-gemini/gemini-cli/pull/19567)
-- Add initial implementation of /extensions explore command by @chrstnb in
- [#19029](https://github.com/google-gemini/gemini-cli/pull/19029)
-- fix: use discoverOAuthFromWWWAuthenticate for reactive OAuth flow (#18760) by
- @maximus12793 in
- [#19038](https://github.com/google-gemini/gemini-cli/pull/19038)
-- Search updates by @alisa-alisa in
- [#19482](https://github.com/google-gemini/gemini-cli/pull/19482)
-- feat(cli): add support for numpad SS3 sequences by @scidomino in
- [#19659](https://github.com/google-gemini/gemini-cli/pull/19659)
-- feat(cli): enhance folder trust with configuration discovery and security
- warnings by @galz10 in
- [#19492](https://github.com/google-gemini/gemini-cli/pull/19492)
-- feat(ui): improve startup warnings UX with dismissal and show-count limits by
- @spencer426 in
- [#19584](https://github.com/google-gemini/gemini-cli/pull/19584)
-- feat(a2a): Add API key authentication provider by @adamfweidman in
- [#19548](https://github.com/google-gemini/gemini-cli/pull/19548)
-- Send accepted/removed lines with ACCEPT_FILE telemetry. by @gundermanc in
- [#19670](https://github.com/google-gemini/gemini-cli/pull/19670)
-- feat(models): support Gemini 3.1 Pro Preview and fixes by @sehoon38 in
- [#19676](https://github.com/google-gemini/gemini-cli/pull/19676)
-- feat(plan): enforce read-only constraints in Plan Mode by @mattKorwel in
- [#19433](https://github.com/google-gemini/gemini-cli/pull/19433)
-- fix(cli): allow perfect match @scripts/test-windows-paths.js completions to
- submit on Enter by @spencer426 in
- [#19562](https://github.com/google-gemini/gemini-cli/pull/19562)
-- fix(core): treat 503 Service Unavailable as retryable quota error by @sehoon38
- in [#19642](https://github.com/google-gemini/gemini-cli/pull/19642)
-- Update sidebar.json for to allow top nav tabs. by @g-samroberts in
- [#19595](https://github.com/google-gemini/gemini-cli/pull/19595)
-- security: strip deceptive Unicode characters from terminal output by @ehedlund
- in [#19026](https://github.com/google-gemini/gemini-cli/pull/19026)
-- Fixes 'input.on' is not a function error in Gemini CLI by @gundermanc in
- [#19691](https://github.com/google-gemini/gemini-cli/pull/19691)
-- Revert "feat(ui): add source indicators to slash commands" by @ehedlund in
- [#19695](https://github.com/google-gemini/gemini-cli/pull/19695)
-- security: implement deceptive URL detection and disclosure in tool
- confirmations by @ehedlund in
- [#19288](https://github.com/google-gemini/gemini-cli/pull/19288)
-- fix(core): restore auth consent in headless mode and add unit tests by
- @ehedlund in [#19689](https://github.com/google-gemini/gemini-cli/pull/19689)
-- Fix unsafe assertions in code_assist folder. by @gundermanc in
- [#19706](https://github.com/google-gemini/gemini-cli/pull/19706)
-- feat(cli): make JetBrains warning more specific by @jacob314 in
- [#19687](https://github.com/google-gemini/gemini-cli/pull/19687)
-- fix(cli): extensions dialog UX polish by @jacob314 in
- [#19685](https://github.com/google-gemini/gemini-cli/pull/19685)
-- fix(cli): use getDisplayString for manual model selection in dialog by
- @sehoon38 in [#19726](https://github.com/google-gemini/gemini-cli/pull/19726)
-- feat(policy): repurpose "Always Allow" persistence to workspace level by
- @Abhijit-2592 in
- [#19707](https://github.com/google-gemini/gemini-cli/pull/19707)
-- fix(cli): re-enable CLI banner by @sehoon38 in
- [#19741](https://github.com/google-gemini/gemini-cli/pull/19741)
-- Disallow and suppress unsafe assignment by @gundermanc in
- [#19736](https://github.com/google-gemini/gemini-cli/pull/19736)
-- feat(core): migrate read_file to 1-based start_line/end_line parameters by
- @adamfweidman in
- [#19526](https://github.com/google-gemini/gemini-cli/pull/19526)
-- feat(cli): improve CTRL+O experience for both standard and alternate screen
- buffer (ASB) modes by @jwhelangoog in
- [#19010](https://github.com/google-gemini/gemini-cli/pull/19010)
-- Utilize pipelining of grep_search -> read_file to eliminate turns by
- @gundermanc in
- [#19574](https://github.com/google-gemini/gemini-cli/pull/19574)
-- refactor(core): remove unsafe type assertions in error utils (Phase 1.1) by
- @mattKorwel in
- [#19750](https://github.com/google-gemini/gemini-cli/pull/19750)
-- Disallow unsafe returns. by @gundermanc in
- [#19767](https://github.com/google-gemini/gemini-cli/pull/19767)
-- fix(cli): filter subagent sessions from resume history by @abhipatel12 in
- [#19698](https://github.com/google-gemini/gemini-cli/pull/19698)
-- chore(lint): fix lint errors seen when running npm run lint by @abhipatel12 in
- [#19844](https://github.com/google-gemini/gemini-cli/pull/19844)
-- feat(core): remove unnecessary login verbiage from Code Assist auth by
- @NTaylorMullen in
- [#19861](https://github.com/google-gemini/gemini-cli/pull/19861)
-- fix(plan): time share by approval mode dashboard reporting negative time
- shares by @Adib234 in
- [#19847](https://github.com/google-gemini/gemini-cli/pull/19847)
-- fix(core): allow any preview model in quota access check by @bdmorgan in
- [#19867](https://github.com/google-gemini/gemini-cli/pull/19867)
-- fix(core): prevent omission placeholder deletions in replace/write_file by
- @nsalerni in [#19870](https://github.com/google-gemini/gemini-cli/pull/19870)
-- fix(core): add uniqueness guard to edit tool by @Shivangisharma4 in
- [#19890](https://github.com/google-gemini/gemini-cli/pull/19890)
-- refactor(config): remove enablePromptCompletion from settings by @sehoon38 in
- [#19974](https://github.com/google-gemini/gemini-cli/pull/19974)
-- refactor(core): move session conversion logic to core by @abhipatel12 in
- [#19972](https://github.com/google-gemini/gemini-cli/pull/19972)
-- Fix: Persist manual model selection on restart #19864 by @Nixxx19 in
- [#19891](https://github.com/google-gemini/gemini-cli/pull/19891)
-- fix(core): increase default retry attempts and add quota error backoff by
- @sehoon38 in [#19949](https://github.com/google-gemini/gemini-cli/pull/19949)
-- feat(core): add policy chain support for Gemini 3.1 by @sehoon38 in
- [#19991](https://github.com/google-gemini/gemini-cli/pull/19991)
-- Updates command reference and /stats command. by @g-samroberts in
- [#19794](https://github.com/google-gemini/gemini-cli/pull/19794)
-- Fix for silent failures in non-interactive mode by @owenofbrien in
- [#19905](https://github.com/google-gemini/gemini-cli/pull/19905)
-- fix(plan): allow plan mode writes on Windows and fix prompt paths by @Adib234
- in [#19658](https://github.com/google-gemini/gemini-cli/pull/19658)
-- fix(core): prevent OAuth server crash on unexpected requests by @reyyanxahmed
- in [#19668](https://github.com/google-gemini/gemini-cli/pull/19668)
-- feat: Map tool kinds to explicit ACP.ToolKind values and update test … by
- @sripasg in [#19547](https://github.com/google-gemini/gemini-cli/pull/19547)
-- chore: restrict gemini-automted-issue-triage to only allow echo by @galz10 in
- [#20047](https://github.com/google-gemini/gemini-cli/pull/20047)
-- Allow ask headers longer than 16 chars by @scidomino in
- [#20041](https://github.com/google-gemini/gemini-cli/pull/20041)
-- fix(core): prevent state corruption in McpClientManager during collis by @h30s
- in [#19782](https://github.com/google-gemini/gemini-cli/pull/19782)
-- fix(bundling): copy devtools package to bundle for runtime resolution by
- @SandyTao520 in
- [#19766](https://github.com/google-gemini/gemini-cli/pull/19766)
-- feat(policy): Support MCP Server Wildcards in Policy Engine by @jerop in
- [#20024](https://github.com/google-gemini/gemini-cli/pull/20024)
-- docs(CONTRIBUTING): update React DevTools version to 6 by @mmgok in
- [#20014](https://github.com/google-gemini/gemini-cli/pull/20014)
-- feat(core): optimize tool descriptions and schemas for Gemini 3 by
- @aishaneeshah in
- [#19643](https://github.com/google-gemini/gemini-cli/pull/19643)
-- feat(core): implement experimental direct web fetch by @mbleigh in
- [#19557](https://github.com/google-gemini/gemini-cli/pull/19557)
-- feat(core): replace expected_replacements with allow_multiple in replace tool
- by @SandyTao520 in
- [#20033](https://github.com/google-gemini/gemini-cli/pull/20033)
-- fix(sandbox): harden image packaging integrity checks by @aviralgarg05 in
- [#19552](https://github.com/google-gemini/gemini-cli/pull/19552)
-- fix(core): allow environment variable expansion and explicit overrides for MCP
- servers by @galz10 in
- [#18837](https://github.com/google-gemini/gemini-cli/pull/18837)
-- feat(policy): Implement Tool Annotation Matching in Policy Engine by @jerop in
- [#20029](https://github.com/google-gemini/gemini-cli/pull/20029)
-- fix(core): prevent utility calls from changing session active model by
- @adamfweidman in
- [#20035](https://github.com/google-gemini/gemini-cli/pull/20035)
-- fix(cli): skip workspace policy loading when in home directory by
- @Abhijit-2592 in
- [#20054](https://github.com/google-gemini/gemini-cli/pull/20054)
-- fix(scripts): Add Windows (win32/x64) support to lint.js by @ZafeerMahmood in
- [#16193](https://github.com/google-gemini/gemini-cli/pull/16193)
-- fix(a2a-server): Remove unsafe type assertions in agent by @Nixxx19 in
- [#19723](https://github.com/google-gemini/gemini-cli/pull/19723)
-- Fix: Handle corrupted token file gracefully when switching auth types (#19845)
- by @Nixxx19 in
- [#19850](https://github.com/google-gemini/gemini-cli/pull/19850)
-- fix critical dep vulnerability by @scidomino in
- [#20087](https://github.com/google-gemini/gemini-cli/pull/20087)
-- Add new setting to configure maxRetries by @kevinjwang1 in
- [#20064](https://github.com/google-gemini/gemini-cli/pull/20064)
-- Stabilize tests. by @gundermanc in
- [#20095](https://github.com/google-gemini/gemini-cli/pull/20095)
-- make windows tests mandatory by @scidomino in
- [#20096](https://github.com/google-gemini/gemini-cli/pull/20096)
-- Add 3.1 pro preview to behavioral evals. by @gundermanc in
- [#20088](https://github.com/google-gemini/gemini-cli/pull/20088)
-- feat:PR-rate-limit by @JagjeevanAK in
- [#19804](https://github.com/google-gemini/gemini-cli/pull/19804)
-- feat(cli): allow expanding full details of MCP tool on approval by @y-okt in
- [#19916](https://github.com/google-gemini/gemini-cli/pull/19916)
-- feat(security): Introduce Conseca framework by @shrishabh in
- [#13193](https://github.com/google-gemini/gemini-cli/pull/13193)
-- fix(cli): Remove unsafe type assertions in activityLogger #19713 by @Nixxx19
- in [#19745](https://github.com/google-gemini/gemini-cli/pull/19745)
-- feat: implement AfterTool tail tool calls by @googlestrobe in
- [#18486](https://github.com/google-gemini/gemini-cli/pull/18486)
-- ci(actions): fix PR rate limiter excluding maintainers by @scidomino in
- [#20117](https://github.com/google-gemini/gemini-cli/pull/20117)
-- Shortcuts: Move SectionHeader title below top line and refine styling by
- @keithguerin in
- [#18721](https://github.com/google-gemini/gemini-cli/pull/18721)
-- refactor(ui): Update and simplify use of gray colors in themes by @keithguerin
- in [#20141](https://github.com/google-gemini/gemini-cli/pull/20141)
-- fix punycode2 by @jacob314 in
- [#20154](https://github.com/google-gemini/gemini-cli/pull/20154)
-- feat(ide): add GEMINI_CLI_IDE_PID env var to override IDE process detection by
- @kiryltech in [#15842](https://github.com/google-gemini/gemini-cli/pull/15842)
-- feat(policy): Propagate Tool Annotations for MCP Servers by @jerop in
- [#20083](https://github.com/google-gemini/gemini-cli/pull/20083)
-- fix(a2a-server): pass allowedTools settings to core Config by @reyyanxahmed in
- [#19680](https://github.com/google-gemini/gemini-cli/pull/19680)
-- feat(mcp): add progress bar, throttling, and input validation for MCP tool
- progress by @jasmeetsb in
- [#19772](https://github.com/google-gemini/gemini-cli/pull/19772)
-- feat(policy): centralize plan mode tool visibility in policy engine by @jerop
- in [#20178](https://github.com/google-gemini/gemini-cli/pull/20178)
-- feat(browser): implement experimental browser agent by @gsquared94 in
- [#19284](https://github.com/google-gemini/gemini-cli/pull/19284)
-- feat(plan): summarize work after executing a plan by @jerop in
- [#19432](https://github.com/google-gemini/gemini-cli/pull/19432)
-- fix(core): create new McpClient on restart to apply updated config by @h30s in
- [#20126](https://github.com/google-gemini/gemini-cli/pull/20126)
-- Changelog for v0.30.0-preview.5 by @gemini-cli-robot in
- [#20107](https://github.com/google-gemini/gemini-cli/pull/20107)
-- Update packages. by @jacob314 in
- [#20152](https://github.com/google-gemini/gemini-cli/pull/20152)
-- Fix extension env dir loading issue by @chrstnb in
- [#20198](https://github.com/google-gemini/gemini-cli/pull/20198)
-- restrict /assign to help-wanted issues by @scidomino in
- [#20207](https://github.com/google-gemini/gemini-cli/pull/20207)
-- feat(plan): inject message when user manually exits Plan mode by @jerop in
- [#20203](https://github.com/google-gemini/gemini-cli/pull/20203)
-- feat(extensions): enforce folder trust for local extension install by @galz10
- in [#19703](https://github.com/google-gemini/gemini-cli/pull/19703)
-- feat(hooks): adds support for RuntimeHook functions. by @mbleigh in
- [#19598](https://github.com/google-gemini/gemini-cli/pull/19598)
-- Docs: Update UI links. by @jkcinouye in
- [#20224](https://github.com/google-gemini/gemini-cli/pull/20224)
-- feat: prompt users to run /terminal-setup with yes/no by @ishaanxgupta in
- [#16235](https://github.com/google-gemini/gemini-cli/pull/16235)
-- fix: additional high vulnerabilities (minimatch, cross-spawn) by @adamfweidman
- in [#20221](https://github.com/google-gemini/gemini-cli/pull/20221)
-- feat(telemetry): Add context breakdown to API response event by @SandyTao520
- in [#19699](https://github.com/google-gemini/gemini-cli/pull/19699)
-- Docs: Add nested sub-folders for related topics by @g-samroberts in
- [#20235](https://github.com/google-gemini/gemini-cli/pull/20235)
-- feat(plan): support automatic model switching for Plan Mode by @jerop in
- [#20240](https://github.com/google-gemini/gemini-cli/pull/20240)
-- fix(patch): cherry-pick 58df1c6 to release/v0.31.0-preview.0-pr-20374 to patch
- version v0.31.0-preview.0 and create version 0.31.0-preview.1 by
- @gemini-cli-robot in
- [#20568](https://github.com/google-gemini/gemini-cli/pull/20568)
-- fix(patch): cherry-pick ea48bd9 to release/v0.31.0-preview.1-pr-20577
- [CONFLICTS] by @gemini-cli-robot in
- [#20592](https://github.com/google-gemini/gemini-cli/pull/20592)
-- fix(patch): cherry-pick 32e777f to release/v0.31.0-preview.2-pr-20531 to patch
- version v0.31.0-preview.2 and create version 0.31.0-preview.3 by
- @gemini-cli-robot in
- [#20607](https://github.com/google-gemini/gemini-cli/pull/20607)
+ [#20232](https://github.com/google-gemini/gemini-cli/pull/20232)
+- docs: fix spelling typos in installation guide by @campox747 in
+ [#20579](https://github.com/google-gemini/gemini-cli/pull/20579)
+- Promote stable tests to CI blocking. by @gundermanc in
+ [#20581](https://github.com/google-gemini/gemini-cli/pull/20581)
+- feat(core): enable contiguous parallel admission for Kind.Agent tools by
+ @abhipatel12 in
+ [#20583](https://github.com/google-gemini/gemini-cli/pull/20583)
+- Enforce import/no-duplicates as error by @Nixxx19 in
+ [#19797](https://github.com/google-gemini/gemini-cli/pull/19797)
+- fix: merge duplicate imports in sdk and test-utils packages (1/4) by @Nixxx19
+ in [#19777](https://github.com/google-gemini/gemini-cli/pull/19777)
+- fix: merge duplicate imports in a2a-server package (2/4) by @Nixxx19 in
+ [#19781](https://github.com/google-gemini/gemini-cli/pull/19781)
**Full Changelog**:
-https://github.com/google-gemini/gemini-cli/compare/v0.30.1...v0.31.0
+https://github.com/google-gemini/gemini-cli/compare/v0.31.0...v0.32.1
diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md
index b08f4fa1b0..cc5c559365 100644
--- a/docs/changelogs/preview.md
+++ b/docs/changelogs/preview.md
@@ -1,6 +1,6 @@
-# Preview release: v0.32.0-preview.0
+# Preview release: v0.33.0-preview.4
-Released: February 27, 2026
+Released: March 06, 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,189 @@ 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 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch
+ version v0.33.0-preview.3 and create version 0.33.0-preview.4 by
+ @gemini-cli-robot in
+ [#21349](https://github.com/google-gemini/gemini-cli/pull/21349)
+- fix(patch): cherry-pick 0135b03 to release/v0.33.0-preview.2-pr-21171
+ [CONFLICTS] by @gemini-cli-robot in
+ [#21336](https://github.com/google-gemini/gemini-cli/pull/21336)
+- fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch
+ version v0.33.0-preview.1 and create version 0.33.0-preview.2 by
+ @gemini-cli-robot in
+ [#21300](https://github.com/google-gemini/gemini-cli/pull/21300)
+- fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch
+ version v0.33.0-preview.1 and create version 0.33.0-preview.2 by
+ @gemini-cli-robot in
+ [#21300](https://github.com/google-gemini/gemini-cli/pull/21300)
+- fix(patch): cherry-pick 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.4
diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md
index c1599df69e..167801ca05 100644
--- a/docs/cli/cli-reference.md
+++ b/docs/cli/cli-reference.md
@@ -8,7 +8,8 @@ and parameters.
| Command | Description | Example |
| ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ |
| `gemini` | Start interactive REPL | `gemini` |
-| `gemini "query"` | Query non-interactively, then exit | `gemini "explain this project"` |
+| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` |
+| `gemini "query"` | Query and continue interactively | `gemini "explain this project"` |
| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini` `Get-Content logs.txt \| gemini` |
| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` |
| `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` |
@@ -20,9 +21,24 @@ and parameters.
### Positional arguments
-| Argument | Type | Description |
-| -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ |
-| `query` | string (variadic) | Positional prompt. Defaults to one-shot mode. Use `-i/--prompt-interactive` to execute and continue interactively. |
+| Argument | Type | Description |
+| -------- | ----------------- | ---------------------------------------------------------------------------------------------------------- |
+| `query` | string (variadic) | Positional prompt. Defaults to interactive mode in a TTY. Use `-p/--prompt` for non-interactive execution. |
+
+## Interactive commands
+
+These commands are available within the interactive REPL.
+
+| Command | Description |
+| -------------------- | ---------------------------------------- |
+| `/skills reload` | Reload discovered skills from disk |
+| `/agents reload` | Reload the agent registry |
+| `/commands reload` | Reload custom slash commands |
+| `/memory reload` | Reload context files (e.g., `GEMINI.md`) |
+| `/mcp reload` | Restart and reload MCP servers |
+| `/extensions reload` | Reload all active extensions |
+| `/help` | Show help for all commands |
+| `/quit` | Exit the interactive session |
## CLI Options
@@ -32,7 +48,7 @@ and parameters.
| `--version` | `-v` | - | - | Show CLI version number and exit |
| `--help` | `-h` | - | - | Show help information |
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
-| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. |
+| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. |
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md
index 44d8ba9467..39c0f7c5c1 100644
--- a/docs/cli/enterprise.md
+++ b/docs/cli/enterprise.md
@@ -244,7 +244,7 @@ gemini
You can significantly enhance security by controlling which tools the Gemini
model can use. This is achieved through the `tools.core` setting and the
[Policy Engine](../reference/policy-engine.md). For a list of available tools,
-see the [Tools documentation](../tools/index.md).
+see the [Tools reference](../reference/tools.md).
### Allowlisting with `coreTools`
@@ -308,8 +308,8 @@ unintended tool execution.
## Managing custom tools (MCP servers)
If your organization uses custom tools via
-[Model-Context Protocol (MCP) servers](../reference/tools-api.md), it is crucial
-to understand how server configurations are managed to apply security policies
+[Model-Context Protocol (MCP) servers](../tools/mcp-server.md), it is crucial to
+understand how server configurations are managed to apply security policies
effectively.
### How MCP server configurations are merged
diff --git a/docs/cli/gemini-md.md b/docs/cli/gemini-md.md
index 95f46ae095..624b2fc566 100644
--- a/docs/cli/gemini-md.md
+++ b/docs/cli/gemini-md.md
@@ -63,7 +63,7 @@ You can interact with the loaded context files by using the `/memory` command.
- **`/memory show`**: Displays the full, concatenated content of the current
hierarchical memory. This lets you inspect the exact instructional context
being provided to the model.
-- **`/memory refresh`**: Forces a re-scan and reload of all `GEMINI.md` files
+- **`/memory reload`**: Forces a re-scan and reload of all `GEMINI.md` files
from all configured locations.
- **`/memory add `**: Appends your text to your global
`~/.gemini/GEMINI.md` file. This lets you add persistent memories on the fly.
diff --git a/docs/cli/headless.md b/docs/cli/headless.md
index 7de3287639..dd9a385313 100644
--- a/docs/cli/headless.md
+++ b/docs/cli/headless.md
@@ -6,7 +6,7 @@ structured text or JSON output without an interactive terminal UI.
## Technical reference
Headless mode is triggered when the CLI is run in a non-TTY environment or when
-providing a query as a positional argument without the interactive flag.
+providing a query with the `-p` (or `--prompt`) flag.
### Output formats
diff --git a/docs/cli/notifications.md b/docs/cli/notifications.md
new file mode 100644
index 0000000000..8326a1efb2
--- /dev/null
+++ b/docs/cli/notifications.md
@@ -0,0 +1,58 @@
+# Notifications (experimental)
+
+Gemini CLI can send system notifications to alert you when a session completes
+or when it needs your attention, such as when it's waiting for you to approve a
+tool call.
+
+> **Note:** This is a preview feature currently under active development.
+> Preview features may be available on the **Preview** channel or may need to be
+> enabled under `/settings`.
+
+Notifications are particularly useful when running long-running tasks or using
+[Plan Mode](./plan-mode.md), letting you switch to other windows while Gemini
+CLI works in the background.
+
+## Requirements
+
+Currently, system notifications are only supported on macOS.
+
+### Terminal support
+
+The CLI uses the OSC 9 terminal escape sequence to trigger system notifications.
+This is supported by several modern terminal emulators. If your terminal does
+not support OSC 9 notifications, Gemini CLI falls back to a system alert sound
+to get your attention.
+
+## Enable notifications
+
+Notifications are disabled by default. You can enable them using the `/settings`
+command or by updating your `settings.json` file.
+
+1. Open the settings dialog by typing `/settings` in an interactive session.
+2. Navigate to the **General** category.
+3. Toggle the **Enable Notifications** setting to **On**.
+
+Alternatively, add the following to your `settings.json`:
+
+```json
+{
+ "general": {
+ "enableNotifications": true
+ }
+}
+```
+
+## Types of notifications
+
+Gemini CLI sends notifications for the following events:
+
+- **Action required:** Triggered when the model is waiting for user input or
+ tool approval. This helps you know when the CLI has paused and needs you to
+ intervene.
+- **Session complete:** Triggered when a session finishes successfully. This is
+ useful for tracking the completion of automated tasks.
+
+## Next steps
+
+- Start planning with [Plan Mode](./plan-mode.md).
+- Configure your experience with other [settings](./settings.md).
diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md
index 03dd92967f..617f8492fb 100644
--- a/docs/cli/plan-mode.md
+++ b/docs/cli/plan-mode.md
@@ -1,72 +1,40 @@
-# Plan Mode (experimental)
+# Plan Mode
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.
- **Design:** Understand problems, evaluate trade-offs, and choose a solution.
- **Plan:** Align on an execution strategy before any code is modified.
-> **Note:** This is a preview feature currently under active development. Your
-> 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.
-> - Use the **/bug** command within Gemini CLI to file an issue.
+Plan Mode is enabled by default. You can manage this setting using the
+`/settings` command.
-- [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 enter Plan Mode
-## Enabling Plan Mode
+Plan Mode integrates seamlessly into your workflow, letting you switch between
+planning and execution as needed.
-To use Plan Mode, enable it via **/settings** (search for **Plan**) or add the
-following to your `settings.json`:
+You can either configure Gemini CLI to start in Plan Mode by default or enter
+Plan Mode manually during a session.
-```json
-{
- "experimental": {
- "plan": true
- }
-}
-```
+### Launch in Plan Mode
-## How to use Plan Mode
+To start Gemini CLI directly in Plan Mode by default:
-### Entering Plan Mode
+1. Use the `/settings` command.
+2. Set **Default Approval Mode** to `Plan`.
-You can configure Gemini CLI to start in Plan Mode by default or enter it
-manually during a session.
+To launch Gemini CLI in Plan Mode once:
-- **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**.
+1. Use `gemini --approval-mode=plan` when launching Gemini CLI.
- Alternatively, use the `gemini --approval-mode=plan` CLI flag or manually
- update:
+### Enter Plan Mode manually
- ```json
- {
- "general": {
- "defaultApprovalMode": "plan"
- }
- }
- ```
+To start Plan Mode while using Gemini CLI:
-- **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
+- **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,55 +42,58 @@ manually during a session.
- **Command:** Type `/plan` in the input box.
-- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI then
- calls the [`enter_plan_mode`] tool to switch modes.
- > **Note:** This tool is not available when Gemini CLI is in [YOLO mode].
+- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
+ calls the
+ [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode) tool
+ to switch modes.
+ > **Note:** This tool is not available when Gemini CLI is in
+ > [YOLO mode](../reference/configuration.md#command-line-arguments).
-### 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. The CLI will automatically refresh and show the
- updated plan after you save and close the editor.
+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`](../tools/ask-user.md). 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
@@ -130,24 +101,33 @@ Plan Mode enforces strict safety policies to prevent accidental changes.
These are the only allowed tools:
-- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`]
-- **Search:** [`grep_search`], [`google_web_search`]
-- **Interaction:** [`ask_user`]
-- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
- `postgres_read_schema`) are allowed.
-- **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md`
+- **FileSystem (Read):**
+ [`read_file`](../tools/file-system.md#2-read_file-readfile),
+ [`list_directory`](../tools/file-system.md#1-list_directory-readfolder),
+ [`glob`](../tools/file-system.md#4-glob-findfiles)
+- **Search:** [`grep_search`](../tools/file-system.md#5-grep_search-searchtext),
+ [`google_web_search`](../tools/web-search.md)
+- **Research Subagents:**
+ [`codebase_investigator`](../core/subagents.md#codebase-investigator),
+ [`cli_help`](../core/subagents.md#cli-help-agent)
+- **Interaction:** [`ask_user`](../tools/ask-user.md)
+- **MCP tools (Read):** Read-only [MCP tools](../tools/mcp-server.md) (for
+ example, `github_read_issue`, `postgres_read_schema`) are allowed.
+- **Planning (Write):**
+ [`write_file`](../tools/file-system.md#3-write_file-writefile) and
+ [`replace`](../tools/file-system.md#6-replace-edit) only allowed for `.md`
files in the `~/.gemini/tmp///plans/` directory or your
[custom plans directory](#custom-plan-directory-and-policies).
-- **Memory:** [`save_memory`]
-- **Skills:** [`activate_skill`] (allows loading specialized instructions and
- resources in a read-only manner)
+- **Memory:** [`save_memory`](../tools/memory.md)
+- **Skills:** [`activate_skill`](../cli/skills.md) (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](../cli/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.
For example:
@@ -162,12 +142,13 @@ To use a skill in Plan Mode, you can explicitly ask Gemini CLI to "use the
`` skill to plan..." or Gemini CLI may autonomously activate it
based on the task description.
-### Customizing Policies
+### Custom policies
-Plan Mode's default tool restrictions are managed by the [policy engine] and
-defined in the built-in [`plan.toml`] file. The built-in policy (Tier 1)
-enforces the read-only state, but you can customize these rules by creating your
-own policies in your `~/.gemini/policies/` directory (Tier 2).
+Plan Mode's default tool restrictions are managed by the
+[policy engine](../reference/policy-engine.md) and defined in the built-in
+[`plan.toml`] file. The built-in policy (Tier 1) enforces the read-only state,
+but you can customize these rules by creating your own policies in your
+`~/.gemini/policies/` directory (Tier 2).
#### Example: Automatically approve read-only MCP tools
@@ -186,10 +167,13 @@ priority = 100
modes = ["plan"]
```
+For more information on how the policy engine works, see the
+[policy engine](../reference/policy-engine.md) 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`
@@ -202,16 +186,20 @@ 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](../core/subagents.md) like
+[`codebase_investigator`](../core/subagents.md#codebase-investigator) and
+[`cli_help`](../core/subagents.md#cli-help-agent) are enabled by default in Plan
+Mode. You can enable additional
+[custom subagents](../core/subagents.md#creating-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"]
@@ -220,10 +208,7 @@ modes = ["plan"]
Tell Gemini CLI it can use these tools in your prompt, for example: _"You can
check ongoing changes in git."_
-For more information on how the policy engine works, see the [policy engine]
-docs.
-
-### Custom Plan Directory and Policies
+### Custom plan directory and policies
By default, planning artifacts are stored in a managed temporary directory
outside your project: `~/.gemini/tmp///plans/`.
@@ -247,10 +232,11 @@ locations defined within a project's workspace cannot be used to escape and
overwrite sensitive files elsewhere. Any user-configured directory must reside
within the project boundary.
-Using a custom directory requires updating your [policy engine] configurations
-to allow `write_file` and `replace` in that specific location. For example, to
-allow writing to the `.gemini/plans` directory within your project, create a
-policy file at `~/.gemini/policies/plan-custom-directory.toml`:
+Using a custom directory requires updating your
+[policy engine](../reference/policy-engine.md) configurations to allow
+`write_file` and `replace` in that specific location. For example, to allow
+writing to the `.gemini/plans` directory within your project, create a policy
+file at `~/.gemini/policies/plan-custom-directory.toml`:
```toml
[[rule]]
@@ -263,10 +249,69 @@ 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](../extensions/index.md) using core planning tools
+like [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode),
+[`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode), and
+[`ask_user`](../tools/ask-user.md).
+
+### Built-in planning workflow
+
+The built-in planner uses an adaptive workflow to analyze your project, consult
+you on trade-offs via [`ask_user`](../tools/ask-user.md), 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`](../tools/planning.md#1-enter_plan_mode-enterplanmode).
+- **Streamline decisions:** Uses [`ask_user`](../tools/ask-user.md) 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`](../tools/planning.md#2-exit_plan_mode-exitplanmode).
+
+#### Build your own
+
+Since Plan Mode is built on modular building blocks, you can develop your own
+custom planning workflow as an [extensions](../extensions/index.md). 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`](../tools/planning.md#1-enter_plan_mode-enterplanmode),
+ [`ask_user`](../tools/ask-user.md), and
+ [`exit_plan_mode`](../tools/planning.md#2-exit_plan_mode-exitplanmode) 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](../reference/configuration.md#model), Gemini CLI
+automatically optimizes [model routing](../cli/telemetry.md#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
@@ -289,24 +334,26 @@ 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](../cli/session-management.md#session-retention) for more
+ details.
+
+Manual deletion also removes all associated artifacts:
+
+- **Command Line:** Use `gemini --delete-session `.
+- **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.
+
[`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
+[Conductor]: https://github.com/gemini-cli-extensions/conductor
+[open an issue]: https://github.com/google-gemini/gemini-cli/issues
diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md
index 1d075989af..ec7e88f624 100644
--- a/docs/cli/sandbox.md
+++ b/docs/cli/sandbox.md
@@ -50,6 +50,74 @@ Cross-platform sandboxing with complete process isolation.
**Note**: Requires building the sandbox image locally or using a published image
from your organization's registry.
+### 3. gVisor / runsc (Linux only)
+
+Strongest isolation available: runs containers inside a user-space kernel via
+[gVisor](https://github.com/google/gvisor). gVisor intercepts all container
+system calls and handles them in a sandboxed kernel written in Go, providing a
+strong security barrier between AI operations and the host OS.
+
+**Prerequisites:**
+
+- Linux (gVisor supports Linux only)
+- Docker installed and running
+- gVisor/runsc runtime configured
+
+When you set `sandbox: "runsc"`, Gemini CLI runs
+`docker run --runtime=runsc ...` to execute containers with gVisor isolation.
+runsc is not auto-detected; you must specify it explicitly (e.g.
+`GEMINI_SANDBOX=runsc` or `sandbox: "runsc"`).
+
+To set up runsc:
+
+1. Install the runsc binary.
+2. Configure the Docker daemon to use the runsc runtime.
+3. Verify the installation.
+
+### 4. LXC/LXD (Linux only, experimental)
+
+Full-system container sandboxing using LXC/LXD. Unlike Docker/Podman, LXC
+containers run a complete Linux system with `systemd`, `snapd`, and other system
+services. This is ideal for tools that don't work in standard Docker containers,
+such as Snapcraft and Rockcraft.
+
+**Prerequisites**:
+
+- Linux only.
+- LXC/LXD must be installed (`snap install lxd` or `apt install lxd`).
+- A container must be created and running before starting Gemini CLI. Gemini
+ does **not** create the container automatically.
+
+**Quick setup**:
+
+```bash
+# Initialize LXD (first time only)
+lxd init --auto
+
+# Create and start an Ubuntu container
+lxc launch ubuntu:24.04 gemini-sandbox
+
+# Enable LXC sandboxing
+export GEMINI_SANDBOX=lxc
+gemini -p "build the project"
+```
+
+**Custom container name**:
+
+```bash
+export GEMINI_SANDBOX=lxc
+export GEMINI_SANDBOX_IMAGE=my-snapcraft-container
+gemini -p "build the snap"
+```
+
+**Limitations**:
+
+- Linux only (LXC is not available on macOS or Windows).
+- The container must already exist and be running.
+- The workspace directory is bind-mounted into the container at the same
+ absolute path — the path must be writable inside the container.
+- Used with tools like Snapcraft or Rockcraft that require a full system.
+
## Quickstart
```bash
@@ -88,7 +156,8 @@ gemini -p "run the test suite"
### Enable sandboxing (in order of precedence)
1. **Command flag**: `-s` or `--sandbox`
-2. **Environment variable**: `GEMINI_SANDBOX=true|docker|podman|sandbox-exec`
+2. **Environment variable**:
+ `GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc`
3. **Settings file**: `"sandbox": true` in the `tools` object of your
`settings.json` file (e.g., `{"tools": {"sandbox": true}}`).
diff --git a/docs/cli/session-management.md b/docs/cli/session-management.md
index a1453148ae..8e60f61630 100644
--- a/docs/cli/session-management.md
+++ b/docs/cli/session-management.md
@@ -61,6 +61,15 @@ Browser**:
/resume
```
+When typing `/resume` (or `/chat`) in slash completion, commands are grouped
+under titled separators:
+
+- `-- auto --` (session browser)
+ - `list` is selectable and opens the session browser
+- `-- checkpoints --` (manual tagged checkpoint commands)
+
+Unique prefixes such as `/resum` and `/cha` resolve to the same grouped menu.
+
The Session Browser provides an interactive interface where you can perform the
following actions:
@@ -72,6 +81,21 @@ following actions:
- **Select:** Press **Enter** to resume the selected session.
- **Esc:** Press **Esc** to exit the Session Browser.
+### Manual chat checkpoints
+
+For named branch points inside a session, use chat checkpoints:
+
+```text
+/resume save decision-point
+/resume list
+/resume resume decision-point
+```
+
+Compatibility aliases:
+
+- `/chat ...` works for the same commands.
+- `/resume checkpoints ...` also remains supported during migration.
+
## Managing sessions
You can list and delete sessions to keep your history organized and manage disk
@@ -121,27 +145,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.
diff --git a/docs/cli/settings.md b/docs/cli/settings.md
index c13262e86b..59f6521d9f 100644
--- a/docs/cli/settings.md
+++ b/docs/cli/settings.md
@@ -32,8 +32,8 @@ they appear in the UI.
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
-| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
-| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
+| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` |
+| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` |
### Output
@@ -60,7 +60,7 @@ they appear in the UI.
| Hide Workspace Path | `ui.footer.hideCWD` | Hide the workspace path 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,20 +89,20 @@ 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
| UI Label | Setting | Description | Default |
| ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` |
-| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
+| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory reload loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` |
| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` |
| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` |
@@ -144,10 +144,9 @@ they appear in the UI.
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
-| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
+| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
-| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
### Skills
diff --git a/docs/cli/tutorials/automation.md b/docs/cli/tutorials/automation.md
index fb1d8d48d2..4285cdcf3b 100644
--- a/docs/cli/tutorials/automation.md
+++ b/docs/cli/tutorials/automation.md
@@ -19,14 +19,15 @@ Headless mode runs Gemini CLI once and exits. It's perfect for:
## How to use headless mode
-Run Gemini CLI in headless mode by providing a prompt as a positional argument.
-This bypasses the interactive chat interface and prints the response to standard
-output (stdout).
+Run Gemini CLI in headless mode by providing a prompt with the `-p` (or
+`--prompt`) flag. This bypasses the interactive chat interface and prints the
+response to standard output (stdout). Positional arguments without the flag
+default to interactive mode, unless the input or output is piped or redirected.
Run a single command:
```bash
-gemini "Write a poem about TypeScript"
+gemini -p "Write a poem about TypeScript"
```
## How to pipe input to Gemini CLI
@@ -40,19 +41,19 @@ Pipe a file:
**macOS/Linux**
```bash
-cat error.log | gemini "Explain why this failed"
+cat error.log | gemini -p "Explain why this failed"
```
**Windows (PowerShell)**
```powershell
-Get-Content error.log | gemini "Explain why this failed"
+Get-Content error.log | gemini -p "Explain why this failed"
```
Pipe a command:
```bash
-git diff | gemini "Write a commit message for these changes"
+git diff | gemini -p "Write a commit message for these changes"
```
## Use Gemini CLI output in scripts
@@ -78,7 +79,7 @@ one.
echo "Generating docs for $file..."
# Ask Gemini CLI to generate the documentation and print it to stdout
- gemini "Generate a Markdown documentation summary for @$file. Print the
+ gemini -p "Generate a Markdown documentation summary for @$file. Print the
result to standard output." > "${file%.py}.md"
done
```
@@ -92,7 +93,7 @@ one.
$newName = $_.Name -replace '\.py$', '.md'
# Ask Gemini CLI to generate the documentation and print it to stdout
- gemini "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8
+ gemini -p "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8
}
```
@@ -214,7 +215,7 @@ wrapper that writes the message for you.
# Ask Gemini to write the message
echo "Generating commit message..."
- msg=$(echo "$diff" | gemini "Write a concise Conventional Commit message for this diff. Output ONLY the message.")
+ msg=$(echo "$diff" | gemini -p "Write a concise Conventional Commit message for this diff. Output ONLY the message.")
# Commit with the generated message
git commit -m "$msg"
@@ -251,7 +252,7 @@ wrapper that writes the message for you.
# Ask Gemini to write the message
Write-Host "Generating commit message..."
- $msg = $diff | gemini "Write a concise Conventional Commit message for this diff. Output ONLY the message."
+ $msg = $diff | gemini -p "Write a concise Conventional Commit message for this diff. Output ONLY the message."
# Commit with the generated message
git commit -m "$msg"
diff --git a/docs/cli/tutorials/mcp-setup.md b/docs/cli/tutorials/mcp-setup.md
index 03b6e56376..8723a65892 100644
--- a/docs/cli/tutorials/mcp-setup.md
+++ b/docs/cli/tutorials/mcp-setup.md
@@ -101,8 +101,8 @@ The agent will:
- **Server won't start?** Try running the docker command manually in your
terminal to see if it prints an error (e.g., "image not found").
-- **Tools not found?** Run `/mcp refresh` to force the CLI to re-query the
- server for its capabilities.
+- **Tools not found?** Run `/mcp reload` to force the CLI to re-query the server
+ for its capabilities.
## Next steps
diff --git a/docs/cli/tutorials/memory-management.md b/docs/cli/tutorials/memory-management.md
index 829fbecbd4..4cbca4bda9 100644
--- a/docs/cli/tutorials/memory-management.md
+++ b/docs/cli/tutorials/memory-management.md
@@ -105,7 +105,7 @@ excellent for debugging why the agent might be ignoring a rule.
If you edit a `GEMINI.md` file while a session is running, the agent won't know
immediately. Force a reload with:
-**Command:** `/memory refresh`
+**Command:** `/memory reload`
## Best practices
diff --git a/docs/cli/tutorials/session-management.md b/docs/cli/tutorials/session-management.md
index 7815aa94d6..6b50358b2c 100644
--- a/docs/cli/tutorials/session-management.md
+++ b/docs/cli/tutorials/session-management.md
@@ -89,9 +89,9 @@ Gemini gives you granular control over the undo process. You can choose to:
Sometimes you want to try two different approaches to the same problem.
1. Start a session and get to a decision point.
-2. Save the current state with `/chat save decision-point`.
+2. Save the current state with `/resume save decision-point`.
3. Try your first approach.
-4. Later, use `/chat resume decision-point` to fork the conversation back to
+4. Later, use `/resume resume decision-point` to fork the conversation back to
that moment and try a different approach.
This creates a new branch of history without losing your original work.
@@ -101,5 +101,5 @@ This creates a new branch of history without losing your original work.
- Learn about [Checkpointing](../../cli/checkpointing.md) to understand the
underlying safety mechanism.
- Explore [Task planning](task-planning.md) to keep complex sessions organized.
-- See the [Command reference](../../reference/commands.md) for all `/chat` and
- `/resume` options.
+- See the [Command reference](../../reference/commands.md) for `/resume`
+ options, grouped checkpoint menus, and `/chat` compatibility aliases.
diff --git a/docs/cli/tutorials/shell-commands.md b/docs/cli/tutorials/shell-commands.md
index 22e945407e..3eaaf2049e 100644
--- a/docs/cli/tutorials/shell-commands.md
+++ b/docs/cli/tutorials/shell-commands.md
@@ -17,9 +17,10 @@ prefix.
**Example:** `!ls -la`
-This executes `ls -la` immediately and prints the output to your terminal. The
-AI doesn't "see" this output unless you paste it back into the chat or use it in
-a prompt.
+This executes `ls -la` immediately and prints the output to your terminal.
+Gemini CLI also records the command and its output in the current session
+context, so the model can reference it in follow-up prompts. Very large outputs
+may be truncated.
### Scenario: Entering Shell mode
diff --git a/docs/core/index.md b/docs/core/index.md
index 53aa647dc2..adf186116f 100644
--- a/docs/core/index.md
+++ b/docs/core/index.md
@@ -9,8 +9,8 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
- **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use
specialized sub-agents for complex tasks.
-- **[Core tools API](../reference/tools-api.md):** Information on how tools are
- defined, registered, and used by the core.
+- **[Core tools reference](../reference/tools.md):** Information on how tools
+ are defined, registered, and used by the core.
- **[Memory Import Processor](../reference/memport.md):** Documentation for the
modular GEMINI.md import feature using @file.md syntax.
- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for
diff --git a/docs/core/remote-agents.md b/docs/core/remote-agents.md
index 3e5b8b06d1..a01f015672 100644
--- a/docs/core/remote-agents.md
+++ b/docs/core/remote-agents.md
@@ -75,7 +75,7 @@ Markdown file.
Users can manage subagents using the following commands within the Gemini CLI:
- `/agents list`: Displays all available local and remote subagents.
-- `/agents refresh`: Reloads the agent registry. Use this after adding or
+- `/agents reload`: Reloads the agent registry. Use this after adding or
modifying agent definition files.
- `/agents enable `: Enables a specific subagent.
- `/agents disable `: Disables a specific subagent.
diff --git a/docs/core/subagents.md b/docs/core/subagents.md
index e84f46dd8c..37085569af 100644
--- a/docs/core/subagents.md
+++ b/docs/core/subagents.md
@@ -297,7 +297,7 @@ Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
> **Note: Remote subagents are currently an experimental feature.**
-See the [Remote Subagents documentation](/docs/core/remote-agents) for detailed
+See the [Remote Subagents documentation](remote-agents) for detailed
configuration and usage instructions.
## Extension subagents
diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md
index 2c2b730126..46d43225b2 100644
--- a/docs/extensions/reference.md
+++ b/docs/extensions/reference.md
@@ -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///plans/`.
When Gemini CLI starts, it loads all the extensions and merges their
configurations. If there are any conflicts, the workspace configuration takes
diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md
index 61d4a5c040..bc603bbdf3 100644
--- a/docs/get-started/authentication.md
+++ b/docs/get-started/authentication.md
@@ -4,6 +4,10 @@ To use Gemini CLI, you'll need to authenticate with Google. This guide helps you
quickly find the best way to sign in based on your account type and how you're
using the CLI.
+> **Note:** Looking for a high-level comparison of all available subscriptions?
+> To compare features and find the right quota for your needs, see our
+> [Plans page](https://geminicli.com/plans/).
+
For most users, we recommend starting Gemini CLI and logging in with your
personal Google account.
diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md
index a5eed9ab1d..d22baaa0c0 100644
--- a/docs/get-started/gemini-3.md
+++ b/docs/get-started/gemini-3.md
@@ -39,6 +39,10 @@ When you encounter that limit, you’ll be given the option to switch to Gemini
2.5 Pro, upgrade for higher limits, or stop. You’ll also be told when your usage
limit resets and Gemini 3 Pro can be used again.
+> **Note:** Looking to upgrade for higher limits? To compare subscription
+> options and find the right quota for your needs, see our
+> [Plans page](https://geminicli.com/plans/).
+
Similarly, when you reach your daily usage limit for Gemini 2.5 Pro, you’ll see
a message prompting fallback to Gemini 2.5 Flash.
diff --git a/docs/get-started/index.md b/docs/get-started/index.md
index bc29581d2f..c516f90ac4 100644
--- a/docs/get-started/index.md
+++ b/docs/get-started/index.md
@@ -72,7 +72,7 @@ session's token usage, as well as your overall quota and usage for the supported
models.
For more information on the `/stats` command and its subcommands, see the
-[Command Reference](../../reference/commands.md#stats).
+[Command Reference](../reference/commands.md#stats).
## Next steps
diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md
index c345584b69..e56d98d889 100644
--- a/docs/get-started/installation.md
+++ b/docs/get-started/installation.md
@@ -70,7 +70,7 @@ gemini
```
For a list of options and additional commands, see the
-[CLI cheatsheet](/docs/cli/cli-reference.md).
+[CLI cheatsheet](../cli/cli-reference.md).
You can also run Gemini CLI using one of the following advanced methods:
diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md
index 08dae0fdf8..5158cfc5eb 100644
--- a/docs/hooks/best-practices.md
+++ b/docs/hooks/best-practices.md
@@ -449,7 +449,7 @@ When you open a project with hooks defined in `.gemini/settings.json`:
Hooks inherit the environment of the Gemini CLI process, which may include
sensitive API keys. Gemini CLI provides a
-[redaction system](/docs/reference/configuration.md#environment-variable-redaction)
+[redaction system](../reference/configuration.md#environment-variable-redaction)
that automatically filters variables matching sensitive patterns (e.g., `KEY`,
`TOKEN`).
diff --git a/docs/hooks/index.md b/docs/hooks/index.md
index b19ceab438..7d526dd885 100644
--- a/docs/hooks/index.md
+++ b/docs/hooks/index.md
@@ -22,11 +22,11 @@ With hooks, you can:
### Getting started
-- **[Writing hooks guide](/docs/hooks/writing-hooks)**: A tutorial on creating
- your first hook with comprehensive examples.
-- **[Best practices](/docs/hooks/best-practices)**: Guidelines on security,
+- **[Writing hooks guide](../hooks/writing-hooks)**: A tutorial on creating your
+ first hook with comprehensive examples.
+- **[Best practices](../hooks/best-practices)**: Guidelines on security,
performance, and debugging.
-- **[Hooks reference](/docs/hooks/reference)**: The definitive technical
+- **[Hooks reference](../hooks/reference)**: The definitive technical
specification of I/O schemas and exit codes.
## Core concepts
@@ -152,8 +152,8 @@ Gemini CLI **fingerprints** project hooks. If a hook's name or command changes
(e.g., via `git pull`), it is treated as a **new, untrusted hook** and you will
be warned before it executes.
-See [Security Considerations](/docs/hooks/best-practices#using-hooks-securely)
-for a detailed threat model.
+See [Security Considerations](../hooks/best-practices#using-hooks-securely) for
+a detailed threat model.
## Managing hooks
diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md
index 9b7226ac05..a750bc94b3 100644
--- a/docs/hooks/reference.md
+++ b/docs/hooks/reference.md
@@ -82,8 +82,8 @@ For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is
compared against the name of the tool being executed.
- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`,
- `run_shell_command`). See the [Tools Reference](/docs/tools) for a full list
- of available tool names.
+ `run_shell_command`). See the [Tools Reference](../reference/tools) for a full
+ list of available tool names.
- **MCP Tools**: Tools from MCP servers follow the naming pattern
`mcp____`.
- **Regex Support**: Matchers support regular expressions (e.g.,
diff --git a/docs/index.md b/docs/index.md
index 3ccaf3b797..af1915bb8f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -108,8 +108,8 @@ Deep technical documentation and API specifications.
processes memory from various sources.
- **[Policy engine](./reference/policy-engine.md):** Fine-grained execution
control.
-- **[Tools API](./reference/tools-api.md):** The API for defining and using
- tools.
+- **[Tools reference](./reference/tools.md):** Information on how tools are
+ defined, registered, and used.
## Resources
diff --git a/docs/issue-and-pr-automation.md b/docs/issue-and-pr-automation.md
index 27185de11c..6c023b651b 100644
--- a/docs/issue-and-pr-automation.md
+++ b/docs/issue-and-pr-automation.md
@@ -113,7 +113,45 @@ process.
ensure every issue is eventually categorized, even if the initial triage
fails.
-### 5. Release automation
+### 5. Automatic unassignment of inactive contributors: `Unassign Inactive Issue Assignees`
+
+To keep the list of open `help wanted` issues accessible to all contributors,
+this workflow automatically removes **external contributors** who have not
+opened a linked pull request within **7 days** of being assigned. Maintainers,
+org members, and repo collaborators with write access or above are always exempt
+and will never be auto-unassigned.
+
+- **Workflow File**: `.github/workflows/unassign-inactive-assignees.yml`
+- **When it runs**: Every day at 09:00 UTC, and can be triggered manually with
+ an optional `dry_run` mode.
+- **What it does**:
+ 1. Finds every open issue labeled `help wanted` that has at least one
+ assignee.
+ 2. Identifies privileged users (team members, repo collaborators with write+
+ access, maintainers) and skips them entirely.
+ 3. For each remaining (external) assignee it reads the issue's timeline to
+ determine:
+ - The exact date they were assigned (using `assigned` timeline events).
+ - Whether they have opened a PR that is already linked/cross-referenced to
+ the issue.
+ 4. Each cross-referenced PR is fetched to verify it is **ready for review**:
+ open and non-draft, or already merged. Draft PRs do not count.
+ 5. If an assignee has been assigned for **more than 7 days** and no qualifying
+ PR is found, they are automatically unassigned and a comment is posted
+ explaining the reason and how to re-claim the issue.
+ 6. Assignees who have a non-draft, open or merged PR linked to the issue are
+ **never** unassigned by this workflow.
+- **What you should do**:
+ - **Open a real PR, not a draft**: Within 7 days of being assigned, open a PR
+ that is ready for review and include `Fixes #` in the
+ description. Draft PRs do not satisfy the requirement and will not prevent
+ auto-unassignment.
+ - **Re-assign if unassigned by mistake**: Comment `/assign` on the issue to
+ assign yourself again.
+ - **Unassign yourself** if you can no longer work on the issue by commenting
+ `/unassign`, so other contributors can pick it up right away.
+
+### 6. Release automation
This workflow handles the process of packaging and publishing new versions of
the Gemini CLI.
diff --git a/docs/redirects.json b/docs/redirects.json
index 5183d0d476..598f42cccf 100644
--- a/docs/redirects.json
+++ b/docs/redirects.json
@@ -8,7 +8,8 @@
"/docs/core/concepts": "/docs",
"/docs/core/memport": "/docs/reference/memport",
"/docs/core/policy-engine": "/docs/reference/policy-engine",
- "/docs/core/tools-api": "/docs/reference/tools-api",
+ "/docs/core/tools-api": "/docs/reference/tools",
+ "/docs/reference/tools-api": "/docs/reference/tools",
"/docs/faq": "/docs/resources/faq",
"/docs/get-started/configuration": "/docs/reference/configuration",
"/docs/get-started/configuration-v1": "/docs/reference/configuration",
diff --git a/docs/reference/commands.md b/docs/reference/commands.md
index 1ff221afba..b129ed2b01 100644
--- a/docs/reference/commands.md
+++ b/docs/reference/commands.md
@@ -28,24 +28,33 @@ Slash commands provide meta-level control over the CLI itself.
### `/chat`
-- **Description:** Save and resume conversation history for branching
- conversation state interactively, or resuming a previous state from a later
- session.
+- **Description:** Alias for `/resume`. Both commands now expose the same
+ session browser action and checkpoint subcommands.
+- **Menu layout when typing `/chat` (or `/resume`)**:
+ - `-- auto --`
+ - `list` (selecting this opens the auto-saved session browser)
+ - `-- checkpoints --`
+ - `list`, `save`, `resume`, `delete`, `share` (manual tagged checkpoints)
+ - **Note:** Unique prefixes (for example `/cha` or `/resum`) resolve to the
+ same grouped menu.
- **Sub-commands:**
- **`debug`**
- **Description:** Export the most recent API request as a JSON payload.
- **`delete `**
- **Description:** Deletes a saved conversation checkpoint.
+ - **Equivalent:** `/resume delete `
- **`list`**
- - **Description:** Lists available tags for chat state resumption.
+ - **Description:** Lists available tags for manually saved checkpoints.
- **Note:** This command only lists chats saved within the current project.
Because chat history is project-scoped, chats saved in other project
directories will not be displayed.
+ - **Equivalent:** `/resume list`
- **`resume `**
- **Description:** Resumes a conversation from a previous save.
- **Note:** You can only resume chats that were saved within the current
project. To resume a chat from a different project, you must run the
Gemini CLI from that project's directory.
+ - **Equivalent:** `/resume resume `
- **`save `**
- **Description:** Saves the current conversation history. You must add a
`` for identifying the conversation state.
@@ -60,10 +69,12 @@ Slash commands provide meta-level control over the CLI itself.
conversation states. For automatic checkpoints created before file
modifications, see the
[Checkpointing documentation](../cli/checkpointing.md).
+ - **Equivalent:** `/resume save `
- **`share [filename]`**
- **Description** Writes the current conversation to a provided Markdown or
JSON file. If no filename is provided, then the CLI will generate one.
- **Usage** `/chat share file.md` or `/chat share file.json`.
+ - **Equivalent:** `/resume share [filename]`
### `/clear`
@@ -268,8 +279,11 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Switch to Plan Mode (read-only) and view the current plan if
one has been generated.
- - **Note:** This feature requires the `experimental.plan` setting to be
- enabled in your configuration.
+ - **Note:** This feature is enabled by default. It can be disabled via the
+ `experimental.plan` setting in your configuration.
+- **Sub-commands:**
+ - **`copy`**:
+ - **Description:** Copy the currently approved plan to your clipboard.
### `/policies`
@@ -311,10 +325,13 @@ Slash commands provide meta-level control over the CLI itself.
### `/resume`
-- **Description:** Browse and resume previous conversation sessions. Opens an
- interactive session browser where you can search, filter, and select from
- automatically saved conversations.
+- **Description:** Browse and resume previous conversation sessions, and manage
+ manual chat checkpoints.
- **Features:**
+ - **Auto sessions:** Run `/resume` to open the interactive session browser for
+ automatically saved conversations.
+ - **Chat checkpoints:** Use checkpoint subcommands directly (`/resume save`,
+ `/resume resume`, etc.).
- **Management:** Delete unwanted sessions directly from the browser
- **Resume:** Select any session to resume and continue the conversation
- **Search:** Use `/` to search through conversation content across all
@@ -325,6 +342,23 @@ Slash commands provide meta-level control over the CLI itself.
- **Note:** All conversations are automatically saved as you chat - no manual
saving required. See [Session Management](../cli/session-management.md) for
complete details.
+- **Alias:** `/chat` provides the same behavior and subcommands.
+- **Sub-commands:**
+ - **`list`**
+ - **Description:** Lists available tags for manual chat checkpoints.
+ - **`save `**
+ - **Description:** Saves the current conversation as a tagged checkpoint.
+ - **`resume `** (alias: `load`)
+ - **Description:** Loads a previously saved tagged checkpoint.
+ - **`delete `**
+ - **Description:** Deletes a tagged checkpoint.
+ - **`share [filename]`**
+ - **Description:** Exports the current conversation to Markdown or JSON.
+ - **`debug`**
+ - **Description:** Export the most recent API request as JSON payload
+ (nightly builds).
+ - **Compatibility alias:** `/resume checkpoints ...` is still accepted for the
+ same checkpoint commands.
### `/settings`
@@ -405,6 +439,12 @@ Slash commands provide meta-level control over the CLI itself.
- **`nodesc`** or **`nodescriptions`**:
- **Description:** Hide tool descriptions, showing only the tool names.
+### `/upgrade`
+
+- **Description:** Open the Gemini Code Assist upgrade page in your browser.
+ This lets you upgrade your tier for higher usage limits.
+- **Note:** This command is only available when logged in with Google.
+
### `/vim`
- **Description:** Toggle vim mode on or off. When vim mode is enabled, the
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index cc98e6739b..42bad660da 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -161,12 +161,12 @@ their corresponding top-level category object in your `settings.json` file.
- **`general.sessionRetention.enabled`** (boolean):
- **Description:** Enable automatic session cleanup
- - **Default:** `false`
+ - **Default:** `true`
- **`general.sessionRetention.maxAge`** (string):
- **Description:** Automatically delete chats older than this time period
(e.g., "30d", "7d", "24h", "1w")
- - **Default:** `undefined`
+ - **Default:** `"30d"`
- **`general.sessionRetention.maxCount`** (number):
- **Description:** Alternative: Maximum number of sessions to keep (most
@@ -177,11 +177,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Minimum retention period (safety limit, defaults to "1d")
- **Default:** `"1d"`
-- **`general.sessionRetention.warningAcknowledged`** (boolean):
- - **Description:** INTERNAL: Whether the user has acknowledged the session
- retention warning
- - **Default:** `false`
-
#### `output`
- **`output.format`** (enum):
@@ -257,6 +252,16 @@ 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 workspace path in the footer.
- **Default:** `false`
@@ -270,7 +275,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):
@@ -716,7 +721,7 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `[]`
- **`context.loadMemoryFromIncludeDirectories`** (boolean):
- - **Description:** Controls how /memory refresh loads GEMINI.md files. When
+ - **Description:** Controls how /memory reload loads GEMINI.md files. When
true, include directories are scanned; when false, only the current
directory is used.
- **Default:** `false`
@@ -754,7 +759,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
@@ -1017,7 +1023,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **`experimental.plan`** (boolean):
- - **Description:** Enable planning features (Plan Mode and tools).
+ - **Description:** Enable Plan Mode.
+ - **Default:** `true`
+ - **Requires restart:** Yes
+
+- **`experimental.taskTracker`** (boolean):
+ - **Description:** Enable task tracker tools.
- **Default:** `false`
- **Requires restart:** Yes
@@ -1032,8 +1043,8 @@ their corresponding top-level category object in your `settings.json` file.
- **Requires restart:** Yes
- **`experimental.gemmaModelRouter.enabled`** (boolean):
- - **Description:** Enable the Gemma Model Router. Requires a local endpoint
- serving Gemma via the Gemini API using LiteRT-LM shim.
+ - **Description:** Enable the Gemma Model Router (experimental). Requires a
+ local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.
- **Default:** `false`
- **Requires restart:** Yes
@@ -1696,7 +1707,7 @@ conventions and context.
loaded, allowing you to verify the hierarchy and content being used by the
AI.
- See the [Commands documentation](./commands.md#memory) for full details on
- the `/memory` command and its sub-commands (`show` and `refresh`).
+ the `/memory` command and its sub-commands (`show` and `reload`).
By understanding and utilizing these configuration layers and the hierarchical
nature of context files, you can effectively manage the AI's memory and tailor
diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md
index 4fc28804f7..7b396b73d4 100644
--- a/docs/reference/keyboard-shortcuts.md
+++ b/docs/reference/keyboard-shortcuts.md
@@ -8,119 +8,119 @@ available combinations.
#### Basic Controls
-| Action | Keys |
-| --------------------------------------------------------------- | --------------------- |
-| Confirm the current selection or choice. | `Enter` |
-| Dismiss dialogs or cancel the current focus. | `Esc` `Ctrl + [` |
-| Cancel the current request or quit the CLI when input is empty. | `Ctrl + C` |
-| Exit the CLI when the input buffer is empty. | `Ctrl + D` |
+| Action | Keys |
+| --------------------------------------------------------------- | ------------------- |
+| Confirm the current selection or choice. | `Enter` |
+| Dismiss dialogs or cancel the current focus. | `Esc` `Ctrl+[` |
+| Cancel the current request or quit the CLI when input is empty. | `Ctrl+C` |
+| Exit the CLI when the input buffer is empty. | `Ctrl+D` |
#### Cursor Movement
-| Action | Keys |
-| ------------------------------------------- | ------------------------------------------------------------ |
-| Move the cursor to the start of the line. | `Ctrl + A` `Home (no Shift, Ctrl)` |
-| Move the cursor to the end of the line. | `Ctrl + E` `End (no Shift, Ctrl)` |
-| Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` |
-| Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` |
-| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)` |
-| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)` `Ctrl + F` |
-| Move the cursor one word to the left. | `Ctrl + Left Arrow` `Alt + Left Arrow` `Alt + B` |
-| Move the cursor one word to the right. | `Ctrl + Right Arrow` `Alt + Right Arrow` `Alt + F` |
+| Action | Keys |
+| ------------------------------------------- | ------------------------------------------ |
+| Move the cursor to the start of the line. | `Ctrl+A` `Home` |
+| Move the cursor to the end of the line. | `Ctrl+E` `End` |
+| Move the cursor up one line. | `Up` |
+| Move the cursor down one line. | `Down` |
+| Move the cursor one character to the left. | `Left` |
+| Move the cursor one character to the right. | `Right` `Ctrl+F` |
+| Move the cursor one word to the left. | `Ctrl+Left` `Alt+Left` `Alt+B` |
+| Move the cursor one word to the right. | `Ctrl+Right` `Alt+Right` `Alt+F` |
#### Editing
-| Action | Keys |
-| ------------------------------------------------ | ---------------------------------------------------------------- |
-| Delete from the cursor to the end of the line. | `Ctrl + K` |
-| Delete from the cursor to the start of the line. | `Ctrl + U` |
-| Clear all text in the input field. | `Ctrl + C` |
-| Delete the previous word. | `Ctrl + Backspace` `Alt + Backspace` `Ctrl + W` |
-| Delete the next word. | `Ctrl + Delete` `Alt + Delete` `Alt + D` |
-| Delete the character to the left. | `Backspace` `Ctrl + H` |
-| Delete the character to the right. | `Delete` `Ctrl + D` |
-| Undo the most recent text edit. | `Cmd + Z (no Shift)` `Alt + Z (no Shift)` |
-| Redo the most recent undone text edit. | `Shift + Ctrl + Z` `Shift + Cmd + Z` `Shift + Alt + Z` |
+| Action | Keys |
+| ------------------------------------------------ | -------------------------------------------------------- |
+| Delete from the cursor to the end of the line. | `Ctrl+K` |
+| Delete from the cursor to the start of the line. | `Ctrl+U` |
+| Clear all text in the input field. | `Ctrl+C` |
+| Delete the previous word. | `Ctrl+Backspace` `Alt+Backspace` `Ctrl+W` |
+| Delete the next word. | `Ctrl+Delete` `Alt+Delete` `Alt+D` |
+| Delete the character to the left. | `Backspace` `Ctrl+H` |
+| Delete the character to the right. | `Delete` `Ctrl+D` |
+| Undo the most recent text edit. | `Cmd/Win+Z` `Alt+Z` |
+| Redo the most recent undone text edit. | `Ctrl+Shift+Z` `Shift+Cmd/Win+Z` `Alt+Shift+Z` |
#### Scrolling
-| Action | Keys |
-| ------------------------ | --------------------------------- |
-| Scroll content up. | `Shift + Up Arrow` |
-| Scroll content down. | `Shift + Down Arrow` |
-| Scroll to the top. | `Ctrl + Home` `Shift + Home` |
-| Scroll to the bottom. | `Ctrl + End` `Shift + End` |
-| Scroll up by one page. | `Page Up` |
-| Scroll down by one page. | `Page Down` |
+| Action | Keys |
+| ------------------------ | ----------------------------- |
+| Scroll content up. | `Shift+Up` |
+| Scroll content down. | `Shift+Down` |
+| Scroll to the top. | `Ctrl+Home` `Shift+Home` |
+| Scroll to the bottom. | `Ctrl+End` `Shift+End` |
+| Scroll up by one page. | `Page Up` |
+| Scroll down by one page. | `Page Down` |
#### History & Search
-| Action | Keys |
-| -------------------------------------------- | --------------------- |
-| Show the previous entry in history. | `Ctrl + P (no Shift)` |
-| Show the next entry in history. | `Ctrl + N (no Shift)` |
-| Start reverse search through history. | `Ctrl + R` |
-| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
-| Accept a suggestion while reverse searching. | `Tab (no Shift)` |
-| Browse and rewind previous interactions. | `Double Esc` |
+| Action | Keys |
+| -------------------------------------------- | ------------ |
+| Show the previous entry in history. | `Ctrl+P` |
+| Show the next entry in history. | `Ctrl+N` |
+| Start reverse search through history. | `Ctrl+R` |
+| Submit the selected reverse-search match. | `Enter` |
+| Accept a suggestion while reverse searching. | `Tab` |
+| Browse and rewind previous interactions. | `Double Esc` |
#### Navigation
-| Action | Keys |
-| -------------------------------------------------- | ------------------------------------------- |
-| Move selection up in lists. | `Up Arrow (no Shift)` |
-| Move selection down in lists. | `Down Arrow (no Shift)` |
-| Move up within dialog options. | `Up Arrow (no Shift)` `K (no Shift)` |
-| Move down within dialog options. | `Down Arrow (no Shift)` `J (no Shift)` |
-| Move to the next item or question in a dialog. | `Tab (no Shift)` |
-| Move to the previous item or question in a dialog. | `Shift + Tab` |
+| Action | Keys |
+| -------------------------------------------------- | --------------- |
+| Move selection up in lists. | `Up` |
+| Move selection down in lists. | `Down` |
+| Move up within dialog options. | `Up` `K` |
+| Move down within dialog options. | `Down` `J` |
+| Move to the next item or question in a dialog. | `Tab` |
+| Move to the previous item or question in a dialog. | `Shift+Tab` |
#### Suggestions & Completions
-| Action | Keys |
-| --------------------------------------- | -------------------------------------------------- |
-| Accept the inline suggestion. | `Tab (no Shift)` `Enter (no Ctrl)` |
-| Move to the previous completion option. | `Up Arrow (no Shift)` `Ctrl + P (no Shift)` |
-| Move to the next completion option. | `Down Arrow (no Shift)` `Ctrl + N (no Shift)` |
-| Expand an inline suggestion. | `Right Arrow` |
-| Collapse an inline suggestion. | `Left Arrow` |
+| Action | Keys |
+| --------------------------------------- | -------------------- |
+| Accept the inline suggestion. | `Tab` `Enter` |
+| Move to the previous completion option. | `Up` `Ctrl+P` |
+| Move to the next completion option. | `Down` `Ctrl+N` |
+| Expand an inline suggestion. | `Right` |
+| Collapse an inline suggestion. | `Left` |
#### Text Input
-| Action | Keys |
-| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
-| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` |
-| Insert a newline without submitting. | `Ctrl + Enter` `Cmd + Enter` `Alt + Enter` `Shift + Enter` `Ctrl + J` |
-| Open the current prompt or the plan in an external editor. | `Ctrl + X` |
-| Paste from the clipboard. | `Ctrl + V` `Cmd + V` `Alt + V` |
+| Action | Keys |
+| ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
+| Submit the current prompt. | `Enter` |
+| Insert a newline without submitting. | `Ctrl+Enter` `Cmd/Win+Enter` `Alt+Enter` `Shift+Enter` `Ctrl+J` |
+| Open the current prompt or the plan in an external editor. | `Ctrl+X` |
+| Paste from the clipboard. | `Ctrl+V` `Cmd/Win+V` `Alt+V` |
#### App Controls
-| Action | Keys |
-| -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
-| Toggle detailed error information. | `F12` |
-| Toggle the full TODO list. | `Ctrl + T` |
-| Show IDE context details. | `Ctrl + G` |
-| Toggle Markdown rendering. | `Alt + M` |
-| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
-| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
-| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift + Tab` |
-| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl + O` |
-| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` |
-| Toggle current background shell visibility. | `Ctrl + B` |
-| Toggle background shell list. | `Ctrl + L` |
-| Kill the active background shell. | `Ctrl + K` |
-| Confirm selection in background shell list. | `Enter` |
-| Dismiss background shell list. | `Esc` |
-| Move focus from background shell to Gemini. | `Shift + Tab` |
-| Move focus from background shell list to Gemini. | `Tab (no Shift)` |
-| Show warning when trying to move focus away from background shell. | `Tab (no Shift)` |
-| Show warning when trying to move focus away from shell input. | `Tab (no Shift)` |
-| Move focus from Gemini to the active shell. | `Tab (no Shift)` |
-| Move focus from the shell back to Gemini. | `Shift + Tab` |
-| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
-| Restart the application. | `R` |
-| Suspend the CLI and move it to the background. | `Ctrl + Z` |
+| Action | Keys |
+| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
+| Toggle detailed error information. | `F12` |
+| Toggle the full TODO list. | `Ctrl+T` |
+| Show IDE context details. | `Ctrl+G` |
+| Toggle Markdown rendering. | `Alt+M` |
+| Toggle copy mode when in alternate buffer mode. | `Ctrl+S` |
+| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl+Y` |
+| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift+Tab` |
+| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl+O` |
+| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl+O` |
+| Toggle current background shell visibility. | `Ctrl+B` |
+| Toggle background shell list. | `Ctrl+L` |
+| Kill the active background shell. | `Ctrl+K` |
+| Confirm selection in background shell list. | `Enter` |
+| Dismiss background shell list. | `Esc` |
+| Move focus from background shell to Gemini. | `Shift+Tab` |
+| Move focus from background shell list to Gemini. | `Tab` |
+| Show warning when trying to move focus away from background shell. | `Tab` |
+| Show warning when trying to move focus away from shell input. | `Tab` |
+| Move focus from Gemini to the active shell. | `Tab` |
+| Move focus from the shell back to Gemini. | `Shift+Tab` |
+| Clear the terminal screen and redraw the UI. | `Ctrl+L` |
+| Restart the application. | `R` `Shift+R` |
+| Suspend the CLI and move it to the background. | `Ctrl+Z` |
@@ -152,3 +152,13 @@ available combinations.
inline when the cursor is over the placeholder.
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
view full content inline. Double-click again to collapse.
+
+## Limitations
+
+- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal):
+ - `shift+enter` is only supported in version 1.25 and higher.
+ - `shift+tab`
+ [is not supported](https://github.com/google-gemini/gemini-cli/issues/20314)
+ on Node 20 and earlier versions of Node 22.
+- On macOS's [Terminal]():
+ - `shift+enter` is not supported.
diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md
index 17d958acd0..c0a331d99d 100644
--- a/docs/reference/policy-engine.md
+++ b/docs/reference/policy-engine.md
@@ -91,10 +91,17 @@ the arguments don't match the pattern, the rule does not apply.
There are three possible decisions a rule can enforce:
- `allow`: The tool call is executed automatically without user interaction.
-- `deny`: The tool call is blocked and is not executed.
+- `deny`: The tool call is blocked and is not executed. For global rules (those
+ without an `argsPattern`), tools that are denied are **completely excluded
+ from the model's memory**. This means the model will not even see the tool as
+ an option, which is more secure and saves context window space.
- `ask_user`: The user is prompted to approve or deny the tool call. (In
non-interactive mode, this is treated as `deny`.)
+> **Note:** The `deny` decision is the recommended way to exclude tools. The
+> legacy `tools.exclude` setting in `settings.json` is deprecated in favor of
+> policy rules with a `deny` decision.
+
### Priority system and tiers
The policy engine uses a sophisticated priority system to resolve conflicts when
@@ -143,8 +150,8 @@ always active.
confirmation.
- `autoEdit`: Optimized for automated code editing; some write tools may be
auto-approved.
-- `plan`: A strict, read-only mode for research and design. See [Customizing
- Plan Mode Policies].
+- `plan`: A strict, read-only mode for research and design. See
+ [Customizing Plan Mode Policies](../cli/plan-mode.md#customizing-policies).
- `yolo`: A mode where all tools are auto-approved (use with extreme caution).
## Rule matching
@@ -212,6 +219,10 @@ Here is a breakdown of the fields available in a TOML policy rule:
# A unique name for the tool, or an array of names.
toolName = "run_shell_command"
+# (Optional) The name of a subagent. If provided, the rule only applies to tool calls
+# made by this specific subagent.
+subagent = "generalist"
+
# (Optional) The name of an MCP server. Can be combined with toolName
# to form a composite name like "mcpName__toolName".
mcpName = "my-custom-server"
@@ -360,5 +371,3 @@ out-of-the-box experience.
- In **`yolo`** mode, a high-priority rule allows all tools.
- In **`autoEdit`** mode, rules allow certain write operations to happen without
prompting.
-
-[Customizing Plan Mode Policies]: /docs/cli/plan-mode.md#customizing-policies
diff --git a/docs/reference/tools-api.md b/docs/reference/tools-api.md
deleted file mode 100644
index 91fae3f720..0000000000
--- a/docs/reference/tools-api.md
+++ /dev/null
@@ -1,131 +0,0 @@
-# Gemini CLI core: Tools API
-
-The Gemini CLI core (`packages/core`) features a robust system for defining,
-registering, and executing tools. These tools extend the capabilities of the
-Gemini model, allowing it to interact with the local environment, fetch web
-content, and perform various actions beyond simple text generation.
-
-## Core concepts
-
-- **Tool (`tools.ts`):** An interface and base class (`BaseTool`) that defines
- the contract for all tools. Each tool must have:
- - `name`: A unique internal name (used in API calls to Gemini).
- - `displayName`: A user-friendly name.
- - `description`: A clear explanation of what the tool does, which is provided
- to the Gemini model.
- - `parameterSchema`: A JSON schema defining the parameters that the tool
- accepts. This is crucial for the Gemini model to understand how to call the
- tool correctly.
- - `validateToolParams()`: A method to validate incoming parameters.
- - `getDescription()`: A method to provide a human-readable description of what
- the tool will do with specific parameters before execution.
- - `shouldConfirmExecute()`: A method to determine if user confirmation is
- required before execution (e.g., for potentially destructive operations).
- - `execute()`: The core method that performs the tool's action and returns a
- `ToolResult`.
-
-- **`ToolResult` (`tools.ts`):** An interface defining the structure of a tool's
- execution outcome:
- - `llmContent`: The factual content to be included in the history sent back to
- the LLM for context. This can be a simple string or a `PartListUnion` (an
- array of `Part` objects and strings) for rich content.
- - `returnDisplay`: A user-friendly string (often Markdown) or a special object
- (like `FileDiff`) for display in the CLI.
-
-- **Returning rich content:** Tools are not limited to returning simple text.
- The `llmContent` can be a `PartListUnion`, which is an array that can contain
- a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a
- single tool execution to return multiple pieces of rich content.
-
-- **Tool registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible
- for:
- - **Registering tools:** Holding a collection of all available built-in tools
- (e.g., `ReadFileTool`, `ShellTool`).
- - **Discovering tools:** It can also discover tools dynamically:
- - **Command-based discovery:** If `tools.discoveryCommand` is configured in
- settings, this command is executed. It's expected to output JSON
- describing custom tools, which are then registered as `DiscoveredTool`
- instances.
- - **MCP-based discovery:** If `mcp.serverCommand` is configured, the
- registry can connect to a Model Context Protocol (MCP) server to list and
- register tools (`DiscoveredMCPTool`).
- - **Providing schemas:** Exposing the `FunctionDeclaration` schemas of all
- registered tools to the Gemini model, so it knows what tools are available
- and how to use them.
- - **Retrieving tools:** Allowing the core to get a specific tool by name for
- execution.
-
-## Built-in tools
-
-The core comes with a suite of pre-defined tools, typically found in
-`packages/core/src/tools/`. These include:
-
-- **File system tools:**
- - `LSTool` (`ls.ts`): Lists directory contents.
- - `ReadFileTool` (`read-file.ts`): Reads the content of a single file.
- - `WriteFileTool` (`write-file.ts`): Writes content to a file.
- - `GrepTool` (`grep.ts`): Searches for patterns in files.
- - `GlobTool` (`glob.ts`): Finds files matching glob patterns.
- - `EditTool` (`edit.ts`): Performs in-place modifications to files (often
- requiring confirmation).
- - `ReadManyFilesTool` (`read-many-files.ts`): Reads and concatenates content
- from multiple files or glob patterns (used by the `@` command in CLI).
-- **Execution tools:**
- - `ShellTool` (`shell.ts`): Executes arbitrary shell commands (requires
- careful sandboxing and user confirmation).
-- **Web tools:**
- - `WebFetchTool` (`web-fetch.ts`): Fetches content from a URL.
- - `WebSearchTool` (`web-search.ts`): Performs a web search.
-- **Memory tools:**
- - `MemoryTool` (`memoryTool.ts`): Interacts with the AI's memory.
-
-Each of these tools extends `BaseTool` and implements the required methods for
-its specific functionality.
-
-## Tool execution flow
-
-1. **Model request:** The Gemini model, based on the user's prompt and the
- provided tool schemas, decides to use a tool and returns a `FunctionCall`
- part in its response, specifying the tool name and arguments.
-2. **Core receives request:** The core parses this `FunctionCall`.
-3. **Tool retrieval:** It looks up the requested tool in the `ToolRegistry`.
-4. **Parameter validation:** The tool's `validateToolParams()` method is
- called.
-5. **Confirmation (if needed):**
- - The tool's `shouldConfirmExecute()` method is called.
- - If it returns details for confirmation, the core communicates this back to
- the CLI, which prompts the user.
- - The user's decision (e.g., proceed, cancel) is sent back to the core.
-6. **Execution:** If validated and confirmed (or if no confirmation is needed),
- the core calls the tool's `execute()` method with the provided arguments and
- an `AbortSignal` (for potential cancellation).
-7. **Result processing:** The `ToolResult` from `execute()` is received by the
- core.
-8. **Response to model:** The `llmContent` from the `ToolResult` is packaged as
- a `FunctionResponse` and sent back to the Gemini model so it can continue
- generating a user-facing response.
-9. **Display to user:** The `returnDisplay` from the `ToolResult` is sent to
- the CLI to show the user what the tool did.
-
-## Extending with custom tools
-
-While direct programmatic registration of new tools by users isn't explicitly
-detailed as a primary workflow in the provided files for typical end-users, the
-architecture supports extension through:
-
-- **Command-based discovery:** Advanced users or project administrators can
- define a `tools.discoveryCommand` in `settings.json`. This command, when run
- by the Gemini CLI core, should output a JSON array of `FunctionDeclaration`
- objects. The core will then make these available as `DiscoveredTool`
- instances. The corresponding `tools.callCommand` would then be responsible for
- actually executing these custom tools.
-- **MCP server(s):** For more complex scenarios, one or more MCP servers can be
- set up and configured via the `mcpServers` setting in `settings.json`. The
- Gemini CLI core can then discover and use tools exposed by these servers. As
- mentioned, if you have multiple MCP servers, the tool names will be prefixed
- with the server name from your configuration (e.g.,
- `serverAlias__actualToolName`).
-
-This tool system provides a flexible and powerful way to augment the Gemini
-model's capabilities, making the Gemini CLI a versatile assistant for a wide
-range of tasks.
diff --git a/docs/reference/tools.md b/docs/reference/tools.md
new file mode 100644
index 0000000000..e1a0958866
--- /dev/null
+++ b/docs/reference/tools.md
@@ -0,0 +1,106 @@
+# Tools reference
+
+Gemini CLI uses tools to interact with your local environment, access
+information, and perform actions on your behalf. These tools extend the model's
+capabilities beyond text generation, letting it read files, execute commands,
+and search the web.
+
+## How to use Gemini CLI's tools
+
+Tools are generally invoked automatically by Gemini CLI when it needs to perform
+an action. However, you can also trigger specific tools manually using shorthand
+syntax.
+
+### Automatic execution and security
+
+When the model wants to use a tool, Gemini CLI evaluates the request against its
+security policies.
+
+- **User confirmation:** You must manually approve tools that modify files or
+ execute shell commands (mutators). The CLI shows you a diff or the exact
+ command before you confirm.
+- **Sandboxing:** You can run tool executions in secure, containerized
+ environments to isolate changes from your host system. For more details, see
+ the [Sandboxing](../cli/sandbox.md) guide.
+- **Trusted folders:** You can configure which directories allow the model to
+ use system tools. For more details, see the
+ [Trusted folders](../cli/trusted-folders.md) guide.
+
+Review confirmation prompts carefully before allowing a tool to execute.
+
+### How to use manually-triggered tools
+
+You can directly trigger key tools using special syntax in your prompt:
+
+- **[File access](../tools/file-system.md#read_many_files) (`@`):** Use the `@`
+ symbol followed by a file or directory path to include its content in your
+ prompt. This triggers the `read_many_files` tool.
+- **[Shell commands](../tools/shell.md) (`!`):** Use the `!` symbol followed by
+ a system command to execute it directly. This triggers the `run_shell_command`
+ tool.
+
+## How to manage tools
+
+Using built-in commands, you can inspect available tools and configure how they
+behave.
+
+### Tool discovery
+
+Use the `/tools` command to see what tools are currently active in your session.
+
+- **`/tools`**: Lists all registered tools with their display names.
+- **`/tools desc`**: Lists all tools with their full descriptions.
+
+This is especially useful for verifying that
+[MCP servers](../tools/mcp-server.md) or custom tools are loaded correctly.
+
+### Tool configuration
+
+You can enable, disable, or configure specific tools in your settings. For
+example, you can set a specific pager for shell commands or configure the
+browser used for web searches. See the [Settings](../cli/settings.md) guide for
+details.
+
+## Available tools
+
+The following table lists all available tools, categorized by their primary
+function.
+
+| Category | Tool | Kind | Description |
+| :---------- | :----------------------------------------------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Execution | [`run_shell_command`](../tools/shell.md) | `Execute` | Executes arbitrary shell commands. Supports interactive sessions and background processes. Requires manual confirmation.
**Parameters:** `command`, `description`, `dir_path`, `is_background` |
+| File System | [`glob`](../tools/file-system.md) | `Search` | Finds files matching specific glob patterns across the workspace.
**Parameters:** `pattern`, `dir_path`, `case_sensitive`, `respect_git_ignore`, `respect_gemini_ignore` |
+| File System | [`grep_search`](../tools/file-system.md) | `Search` | Searches for a regular expression pattern within file contents. Legacy alias: `search_file_content`.
**Parameters:** `pattern`, `dir_path`, `include`, `exclude_pattern`, `names_only`, `max_matches_per_file`, `total_max_matches` |
+| File System | [`list_directory`](../tools/file-system.md) | `Read` | Lists the names of files and subdirectories within a specified path.
**Parameters:** `dir_path`, `ignore`, `file_filtering_options` |
+| File System | [`read_file`](../tools/file-system.md) | `Read` | Reads the content of a specific file. Supports text, images, audio, and PDF.
**Parameters:** `file_path`, `start_line`, `end_line` |
+| File System | [`read_many_files`](../tools/file-system.md) | `Read` | Reads and concatenates content from multiple files. Often triggered by the `@` symbol in your prompt.
**Parameters:** `include`, `exclude`, `recursive`, `useDefaultExcludes`, `file_filtering_options` |
+| File System | [`replace`](../tools/file-system.md) | `Edit` | Performs precise text replacement within a file. Requires manual confirmation.
**Parameters:** `file_path`, `instruction`, `old_string`, `new_string`, `allow_multiple` |
+| File System | [`write_file`](../tools/file-system.md) | `Edit` | Creates or overwrites a file with new content. Requires manual confirmation.
**Parameters:** `file_path`, `content` |
+| Interaction | [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog.
**Parameters:** `questions` |
+| Interaction | [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress and display it to you.
**Parameters:** `todos` |
+| Memory | [`activate_skill`](../tools/activate-skill.md) | `Other` | Loads specialized procedural expertise for specific tasks from the `.gemini/skills` directory.
**Parameters:** `name` |
+| Memory | [`get_internal_docs`](../tools/internal-docs.md) | `Think` | Accesses Gemini CLI's own documentation to provide more accurate answers about its capabilities.
**Parameters:** `path` |
+| Memory | [`save_memory`](../tools/memory.md) | `Think` | Persists specific facts and project details to your `GEMINI.md` file to retain context.
**Parameters:** `fact` |
+| Planning | [`enter_plan_mode`](../tools/planning.md) | `Plan` | Switches the CLI to a safe, read-only "Plan Mode" for researching complex changes.
**Parameters:** `reason` |
+| Planning | [`exit_plan_mode`](../tools/planning.md) | `Plan` | Finalizes a plan, presents it for review, and requests approval to start implementation.
**Parameters:** `plan` |
+| System | `complete_task` | `Other` | Finalizes a subagent's mission and returns the result to the parent agent. This tool is not available to the user.
**Parameters:** `result` |
+| Web | [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information.
**Parameters:** `query` |
+| Web | [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts.
**Parameters:** `prompt` |
+
+## Under the hood
+
+For developers, the tool system is designed to be extensible and robust. The
+`ToolRegistry` class manages all available tools.
+
+You can extend Gemini CLI with custom tools by configuring
+`tools.discoveryCommand` in your settings or by connecting to MCP servers.
+
+> **Note:** For a deep dive into the internal Tool API and how to implement your
+> own tools in the codebase, see the `packages/core/src/tools/` directory in
+> GitHub.
+
+## Next steps
+
+- Learn how to [Set up an MCP server](../tools/mcp-server.md).
+- Explore [Agent Skills](../cli/skills.md) for specialized expertise.
+- See the [Command reference](./commands.md) for slash commands.
diff --git a/docs/resources/quota-and-pricing.md b/docs/resources/quota-and-pricing.md
index d4ed22a1cb..16d6b407b8 100644
--- a/docs/resources/quota-and-pricing.md
+++ b/docs/resources/quota-and-pricing.md
@@ -1,14 +1,13 @@
# Gemini CLI: Quotas and pricing
Gemini CLI offers a generous free tier that covers many individual developers'
-use cases. For enterprise or professional usage, or if you need higher limits,
+use cases. For enterprise or professional usage, or if you need increased quota,
several options are available depending on your authentication account type.
-See [privacy and terms](./tos-privacy.md) for details on the Privacy Policy and
-Terms of Service.
+For a high-level comparison of available subscriptions and to select the right
+quota for your needs, see the [Plans page](https://geminicli.com/plans/).
-> **Note:** Published prices are list price; additional negotiated commercial
-> discounting may apply.
+## Overview
This article outlines the specific quotas and pricing applicable to Gemini CLI
when using different authentication methods.
@@ -23,10 +22,11 @@ Generally, there are three categories to choose from:
## Free usage
-Your journey begins with a generous free tier, perfect for experimentation and
-light use.
+Access to Gemini CLI begins with a generous free tier, perfect for
+experimentation and light use.
-Your free usage limits depend on your authorization type.
+Your free usage is governed by the following limits, which depend on your
+authorization type.
### Log in with Google (Gemini Code Assist for individuals)
@@ -69,6 +69,19 @@ Learn more at
If you use up your initial number of requests, you can continue to benefit from
Gemini CLI by upgrading to one of the following subscriptions:
+### Individuals
+
+These tiers apply when you sign in with a personal account. To verify whether
+you're on a personal account, visit
+[Google One](https://one.google.com/about/plans?hl=en-US&g1_landing_page=0):
+
+- If you are on a personal account, you will see your personal dashboard.
+- If you are not on a personal account, you will see: "You're currently signed
+ in to your Google Workspace Account."
+
+**Supported tiers:** _- Tiers not listed above, including Google AI Plus, are
+not supported._
+
- [Google AI Pro and AI Ultra](https://gemini.google/subscriptions/). This is
recommended for individual developers. Quotas and pricing are based on a fixed
price subscription.
@@ -78,14 +91,26 @@ Gemini CLI by upgrading to one of the following subscriptions:
Learn more at
[Gemini Code Assist Quotas and Limits](https://developers.google.com/gemini-code-assist/resources/quotas)
-- [Purchase a Gemini Code Assist Subscription through Google Cloud ](https://cloud.google.com/gemini/docs/codeassist/overview)
- by signing up in the Google Cloud console. Learn more at
- [Set up Gemini Code Assist](https://cloud.google.com/gemini/docs/discover/set-up-gemini).
+### Through your organization
+
+These tiers are applicable when you are signing in with a Google Workspace
+account.
+
+- To verify your account type, visit
+ [the Google One page](https://one.google.com/about/plans?hl=en-US&g1_landing_page=0).
+- You are on a workspace account if you see the message "You're currently signed
+ in to your Google Workspace Account".
+
+**Supported tiers:** _- Tiers not listed above, including Workspace AI
+Standard/Plus and AI Expanded, are not supported._
+
+- [Workspace AI Ultra Access](https://workspace.google.com/products/ai-ultra/).
+- [Purchase a Gemini Code Assist Subscription through Google Cloud](https://cloud.google.com/gemini/docs/codeassist/overview).
Quotas and pricing are based on a fixed price subscription with assigned
license seats. For predictable costs, you can sign in with Google.
- This includes:
+ This includes the following request limits:
- Gemini Code Assist Standard edition:
- 1500 model requests / user / day
- 120 model requests / user / minute
@@ -95,7 +120,7 @@ Gemini CLI by upgrading to one of the following subscriptions:
- Model requests will be made across the Gemini model family as determined by
Gemini CLI.
- [Learn more about Gemini Code Assist Standard and Enterprise license limits](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli).
+ [Learn more about Gemini Code Assist license limits](https://developers.google.com/gemini-code-assist/resources/quotas#quotas-for-agent-mode-gemini-cli).
## Pay as you go
@@ -106,18 +131,27 @@ recommended path for uninterrupted access.
To do this, log in using a Gemini API key or Vertex AI.
-- Vertex AI (Regular Mode):
- - Quota: Governed by a dynamic shared quota system or pre-purchased
- provisioned throughput.
- - Cost: Based on model and token usage.
+### Vertex AI (regular mode)
+
+An enterprise-grade platform for building, deploying, and managing AI models,
+including Gemini. It offers enhanced security, data governance, and integration
+with other Google Cloud services.
+
+- Quota: Governed by a dynamic shared quota system or pre-purchased provisioned
+ throughput.
+- Cost: Based on model and token usage.
Learn more at
[Vertex AI Dynamic Shared Quota](https://cloud.google.com/vertex-ai/generative-ai/docs/resources/dynamic-shared-quota)
and [Vertex AI Pricing](https://cloud.google.com/vertex-ai/pricing).
-- Gemini API key:
- - Quota: Varies by pricing tier.
- - Cost: Varies by pricing tier and model/token usage.
+### Gemini API key
+
+Ideal for developers who want to quickly build applications with the Gemini
+models. This is the most direct way to use the models.
+
+- Quota: Varies by pricing tier.
+- Cost: Varies by pricing tier and model/token usage.
Learn more at
[Gemini API Rate Limits](https://ai.google.dev/gemini-api/docs/rate-limits),
@@ -125,7 +159,8 @@ Learn more at
It’s important to highlight that when using an API key, you pay per token/call.
This can be more expensive for many small calls with few tokens, but it's the
-only way to ensure your workflow isn't interrupted by quota limits.
+only way to ensure your workflow isn't interrupted by reaching a limit on your
+quota.
## Gemini for workspace plans
@@ -135,31 +170,30 @@ Flow video editor). These plans do not apply to the API usage which powers the
Gemini CLI. Supporting these plans is under active consideration for future
support.
-## Check usage and quota
+## Check usage and limits
-You can check your current token usage and quota information using the
+You can check your current token usage and applicable limits using the
`/stats model` command. This command provides a snapshot of your current
-session's token usage, as well as your overall quota and usage for the supported
-models.
+session's token usage, as well as information about the limits associated with
+your current quota.
For more information on the `/stats` command and its subcommands, see the
-[Command Reference](../../reference/commands.md#stats).
+[Command Reference](../reference/commands.md#stats).
A summary of model usage is also presented on exit at the end of a session.
## Tips to avoid high costs
-When using a Pay as you Go API key, be mindful of your usage to avoid unexpected
+When using a pay-as-you-go plan, be mindful of your usage to avoid unexpected
costs.
-- Don't blindly accept every suggestion, especially for computationally
- intensive tasks like refactoring large codebases.
-- Be intentional with your prompts and commands. You are paying per call, so
- think about the most efficient way to get the job done.
-
-## Gemini API vs. Vertex
-
-- Gemini API (gemini developer api): This is the fastest way to use the Gemini
- models directly.
-- Vertex AI: This is the enterprise-grade platform for building, deploying, and
- managing Gemini models with specific security and control requirements.
+- **Be selective with suggestions**: Before accepting a suggestion, especially
+ for a computationally intensive task like refactoring a large codebase,
+ consider if it's the most cost-effective approach.
+- **Use precise prompts**: You are paying per call, so think about the most
+ efficient way to get your desired result. A well-crafted prompt can often get
+ you the answer you need in a single call, rather than multiple back-and-forth
+ interactions.
+- **Monitor your usage**: Use the `/stats model` command to track your token
+ usage during a session. This can help you stay aware of your spending in real
+ time.
diff --git a/docs/resources/tos-privacy.md b/docs/resources/tos-privacy.md
index 88daf2639c..98d4a58b98 100644
--- a/docs/resources/tos-privacy.md
+++ b/docs/resources/tos-privacy.md
@@ -16,8 +16,8 @@ account.
Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy
Policy.
-**Note:** See [quotas and pricing](/docs/resources/quota-and-pricing.md) for the
-quota and pricing details that apply to your usage of the Gemini CLI.
+**Note:** See [quotas and pricing](quota-and-pricing.md) for the quota and
+pricing details that apply to your usage of the Gemini CLI.
## Supported authentication methods
diff --git a/docs/sidebar.json b/docs/sidebar.json
index c2c6295bfa..7c201e0071 100644
--- a/docs/sidebar.json
+++ b/docs/sidebar.json
@@ -94,11 +94,23 @@
{ "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" },
{ "label": "Model selection", "slug": "docs/cli/model" },
+ {
+ "label": "Notifications",
+ "badge": "🔬",
+ "slug": "docs/cli/notifications"
+ },
{ "label": "Plan mode", "badge": "🔬", "slug": "docs/cli/plan-mode" },
{
"label": "Subagents",
@@ -181,7 +193,7 @@
"slug": "docs/reference/memport"
},
{ "label": "Policy engine", "slug": "docs/reference/policy-engine" },
- { "label": "Tools API", "slug": "docs/reference/tools-api" }
+ { "label": "Tools reference", "slug": "docs/reference/tools" }
]
}
]
diff --git a/docs/tools/file-system.md b/docs/tools/file-system.md
index 09c792f84d..a6beb1d76d 100644
--- a/docs/tools/file-system.md
+++ b/docs/tools/file-system.md
@@ -67,7 +67,7 @@ Finds files matching specific glob patterns across the workspace.
`Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...`
- **Confirmation:** No.
-## 5. `grep_search` (SearchText)
+### `grep_search` (SearchText)
`grep_search` searches for a regular expression pattern within the content of
files in a specified directory. Can filter files by a glob pattern. Returns the
@@ -103,7 +103,7 @@ lines containing matches, along with their file paths and line numbers.
```
- **Confirmation:** No.
-## 6. `replace` (Edit)
+### `replace` (Edit)
`replace` replaces text within a file. By default, the tool expects to find and
replace exactly ONE occurrence of `old_string`. If you want to replace multiple
diff --git a/docs/tools/index.md b/docs/tools/index.md
deleted file mode 100644
index 6bdf298fea..0000000000
--- a/docs/tools/index.md
+++ /dev/null
@@ -1,105 +0,0 @@
-# Gemini CLI tools
-
-Gemini CLI uses tools to interact with your local environment, access
-information, and perform actions on your behalf. These tools extend the model's
-capabilities beyond text generation, letting it read files, execute commands,
-and search the web.
-
-## User-triggered tools
-
-You can directly trigger these tools using special syntax in your prompts.
-
-- **[File access](./file-system.md#read_many_files) (`@`):** Use the `@` symbol
- followed by a file or directory path to include its content in your prompt.
- This triggers the `read_many_files` tool.
-- **[Shell commands](./shell.md) (`!`):** Use the `!` symbol followed by a
- system command to execute it directly. This triggers the `run_shell_command`
- tool.
-
-## Model-triggered tools
-
-The Gemini model automatically requests these tools when it needs to perform
-specific actions or gather information to fulfill your requests. You do not call
-these tools manually.
-
-### File management
-
-These tools let the model explore and modify your local codebase.
-
-- **[Directory listing](./file-system.md#list_directory) (`list_directory`):**
- Lists files and subdirectories.
-- **[File reading](./file-system.md#read_file) (`read_file`):** Reads the
- content of a specific file.
-- **[File writing](./file-system.md#write_file) (`write_file`):** Creates or
- overwrites a file with new content.
-- **[File search](./file-system.md#glob) (`glob`):** Finds files matching a glob
- pattern.
-- **[Text search](./file-system.md#search_file_content)
- (`search_file_content`):** Searches for text within files using grep or
- ripgrep.
-- **[Text replacement](./file-system.md#replace) (`replace`):** Performs precise
- edits within a file.
-
-### Agent coordination
-
-These tools help the model manage its plan and interact with you.
-
-- **Ask user (`ask_user`):** Requests clarification or missing information from
- you via an interactive dialog.
-- **[Memory](./memory.md) (`save_memory`):** Saves important facts to your
- long-term memory (`GEMINI.md`).
-- **[Todos](./todos.md) (`write_todos`):** Manages a list of subtasks for
- complex plans.
-- **[Agent Skills](../cli/skills.md) (`activate_skill`):** Loads specialized
- procedural expertise when needed.
-- **[Browser agent](../core/subagents.md#browser-agent-experimental)
- (`browser_agent`):** Automates web browser tasks through the accessibility
- tree.
-- **Internal docs (`get_internal_docs`):** Accesses Gemini CLI's own
- documentation to help answer your questions.
-
-### Information gathering
-
-These tools provide the model with access to external data.
-
-- **[Web fetch](./web-fetch.md) (`web_fetch`):** Retrieves and processes content
- from specific URLs.
-- **[Web search](./web-search.md) (`google_web_search`):** Performs a Google
- Search to find up-to-date information.
-
-## How to use tools
-
-You use tools indirectly by providing natural language prompts to Gemini CLI.
-
-1. **Prompt:** You enter a request or use syntax like `@` or `!`.
-2. **Request:** The model analyzes your request and identifies if a tool is
- required.
-3. **Validation:** If a tool is needed, the CLI validates the parameters and
- checks your security settings.
-4. **Confirmation:** For sensitive operations (like writing files), the CLI
- prompts you for approval.
-5. **Execution:** The tool runs, and its output is sent back to the model.
-6. **Response:** The model uses the results to generate a final, grounded
- answer.
-
-## Security and confirmation
-
-Safety is a core part of the tool system. To protect your system, Gemini CLI
-implements several safeguards.
-
-- **User confirmation:** You must manually approve tools that modify files or
- execute shell commands. The CLI shows you a diff or the exact command before
- you confirm.
-- **Sandboxing:** You can run tool executions in secure, containerized
- environments to isolate changes from your host system. For more details, see
- the [Sandboxing](../cli/sandbox.md) guide.
-- **Trusted folders:** You can configure which directories allow the model to
- use system tools.
-
-Always review confirmation prompts carefully before allowing a tool to execute.
-
-## Next steps
-
-- Learn how to [Provide context](../cli/gemini-md.md) to guide tool use.
-- Explore the [Command reference](../reference/commands.md) for tool-related
- slash commands.
diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md
index 202325a83d..bbb5c62aba 100644
--- a/docs/tools/mcp-server.md
+++ b/docs/tools/mcp-server.md
@@ -372,7 +372,7 @@ To authenticate with a server using Service Account Impersonation, you must set
the `authProviderType` to `service_account_impersonation` and provide the
following properties:
-- **`targetAudience`** (string): The OAuth Client ID allowslisted on the
+- **`targetAudience`** (string): The OAuth Client ID allowlisted on the
IAP-protected application you are trying to access.
- **`targetServiceAccount`** (string): The email address of the Google Cloud
Service Account to impersonate.
diff --git a/docs/tools/planning.md b/docs/tools/planning.md
index 458b172510..9e9ab3d044 100644
--- a/docs/tools/planning.md
+++ b/docs/tools/planning.md
@@ -1,8 +1,8 @@
# Gemini CLI planning tools
-Planning tools allow the Gemini model to switch into a safe, read-only "Plan
-Mode" for researching and planning complex changes, and to signal the
-finalization of a plan to the user.
+Planning tools let Gemini CLI switch into a safe, read-only "Plan Mode" for
+researching and planning complex changes, and to signal the finalization of a
+plan to the user.
## 1. `enter_plan_mode` (EnterPlanMode)
@@ -18,11 +18,12 @@ and planning.
- **File:** `enter-plan-mode.ts`
- **Parameters:**
- `reason` (string, optional): A short reason explaining why the agent is
- entering plan mode (e.g., "Starting a complex feature implementation").
+ entering plan mode (for example, "Starting a complex feature
+ implementation").
- **Behavior:**
- Switches the CLI's approval mode to `PLAN`.
- Notifies the user that the agent has entered Plan Mode.
-- **Output (`llmContent`):** A message indicating the switch, e.g.,
+- **Output (`llmContent`):** A message indicating the switch, for example,
`Switching to Plan mode.`
- **Confirmation:** Yes. The user is prompted to confirm entering Plan Mode.
@@ -37,7 +38,7 @@ finalized plan to the user and requests approval to start the implementation.
- **Parameters:**
- `plan_path` (string, required): The path to the finalized Markdown plan
file. This file MUST be located within the project's temporary plans
- directory (e.g., `~/.gemini/tmp//plans/`).
+ directory (for example, `~/.gemini/tmp//plans/`).
- **Behavior:**
- Validates that the `plan_path` is within the allowed directory and that the
file exists and has content.
diff --git a/esbuild.config.js b/esbuild.config.js
index 3ecf678088..49d158ec36 100644
--- a/esbuild.config.js
+++ b/esbuild.config.js
@@ -88,6 +88,9 @@ const cliConfig = {
outfile: 'bundle/gemini.js',
define: {
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
+ 'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify(
+ pkg.config?.sandboxImageUri,
+ ),
},
plugins: createWasmPlugins(),
alias: {
diff --git a/eslint.config.js b/eslint.config.js
index 5cb8b7fcfa..a0a0429119 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -25,6 +25,23 @@ 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.',
+ },
+ {
+ selector: 'CallExpression[callee.name="fetch"]',
+ message:
+ 'Use safeFetch() from "@/utils/fetch" instead of the global fetch() to ensure SSRF protection. If you are implementing a custom security layer, use an eslint-disable comment and explain why.',
+ },
+];
+
export default tseslint.config(
{
// Global ignores
@@ -122,14 +139,12 @@ export default tseslint.config(
'no-duplicate-case': 'error',
'no-restricted-syntax': [
'error',
+ ...commonRestrictedSyntaxRules,
{
- selector: 'CallExpression[callee.name="require"]',
- message: 'Avoid using require(). Use ES6 imports instead.',
- },
- {
- selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])',
+ selector:
+ 'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
message:
- 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
+ 'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
},
],
'no-unsafe-finally': 'error',
@@ -171,6 +186,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}'],
@@ -240,6 +277,7 @@ export default tseslint.config(
...vitest.configs.recommended.rules,
'vitest/expect-expect': 'off',
'vitest/no-commented-out-tests': 'off',
+ 'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
},
},
{
diff --git a/evals/README.md b/evals/README.md
index 41ce3440b8..6cfecbad07 100644
--- a/evals/README.md
+++ b/evals/README.md
@@ -3,7 +3,8 @@
Behavioral evaluations (evals) are tests designed to validate the agent's
behavior in response to specific prompts. They serve as a critical feedback loop
for changes to system prompts, tool definitions, and other model-steering
-mechanisms.
+mechanisms, and as a tool for assessing feature reliability by model, and
+preventing regressions.
## Why Behavioral Evals?
@@ -30,6 +31,48 @@ CLI's features.
those that are generally reliable but might occasionally vary
(`USUALLY_PASSES`).
+## Best Practices
+
+When designing behavioral evals, aim for scenarios that accurately reflect
+real-world usage while remaining small and maintainable.
+
+- **Realistic Complexity**: Evals should be complicated enough to be
+ "realistic." They should operate on actual files and a source directory,
+ mirroring how a real agent interacts with a workspace. Remember that the agent
+ may behave differently in a larger codebase, so we want to avoid scenarios
+ that are too simple to be realistic.
+ - _Good_: An eval that provides a small, functional React component and asks
+ the agent to add a specific feature, requiring it to read the file,
+ understand the context, and write the correct changes.
+ - _Bad_: An eval that simply asks the agent a trivia question or asks it to
+ write a generic script without providing any local workspace context.
+- **Maintainable Size**: Evals should be small enough to reason about and
+ maintain. We probably can't check in an entire repo as a test case, though
+ over time we will want these evals to mature into more and more realistic
+ scenarios.
+ - _Good_: A test setup with 2-3 files (e.g., a source file, a config file, and
+ a test file) that isolates the specific behavior being evaluated.
+ - _Bad_: A test setup containing dozens of files from a complex framework
+ where the setup logic itself is prone to breaking.
+- **Unambiguous and Reliable Assertions**: Assertions must be clear and specific
+ to ensure the test passes for the right reason.
+ - _Good_: Checking that a modified file contains a specific AST node or exact
+ string, or verifying that a tool was called with with the right parameters.
+ - _Bad_: Only checking for a tool call, which could happen for an unrelated
+ reason. Expecting specific LLM output.
+- **Fail First**: Have tests that failed before your prompt or tool change. We
+ want to be sure the test fails before your "fix". It's pretty easy to
+ accidentally create a passing test that asserts behaviors we get for free. In
+ general, every eval should be accompanied by prompt change, and most prompt
+ changes should be accompanied by an eval.
+ - _Good_: Observing a failure, writing an eval that reliably reproduces the
+ failure, modifying the prompt/tool, and then verifying the eval passes.
+ - _Bad_: Writing an eval that passes on the first run and assuming your new
+ prompt change was responsible.
+- **Less is More**: Prefer fewer, more realistic tests that assert the major
+ paths vs. more tests that are more unit-test like. These are evals, so the
+ value is in testing how the agent works in a semi-realistic scenario.
+
## Creating an Evaluation
Evaluations are located in the `evals` directory. Each evaluation is a Vitest
diff --git a/evals/ask_user.eval.ts b/evals/ask_user.eval.ts
new file mode 100644
index 0000000000..c67f995168
--- /dev/null
+++ b/evals/ask_user.eval.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect } from 'vitest';
+import { evalTest } from './test-helper.js';
+
+describe('ask_user', () => {
+ evalTest('USUALLY_PASSES', {
+ name: 'Agent uses AskUser tool to present multiple choice options',
+ prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`,
+ assert: async (rig) => {
+ const wasToolCalled = await rig.waitForToolCall('ask_user');
+ expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
+ },
+ });
+
+ evalTest('USUALLY_PASSES', {
+ name: 'Agent uses AskUser tool to clarify ambiguous requirements',
+ files: {
+ 'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }),
+ },
+ prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`,
+ assert: async (rig) => {
+ const wasToolCalled = await rig.waitForToolCall('ask_user');
+ expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
+ },
+ });
+
+ evalTest('USUALLY_PASSES', {
+ name: 'Agent uses AskUser tool before performing significant ambiguous rework',
+ files: {
+ 'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";',
+ 'packages/core/src/util.ts': '// util\nexport function help() {}',
+ 'packages/core/package.json': JSON.stringify({
+ name: '@google/gemini-cli-core',
+ }),
+ 'README.md': '# Gemini CLI',
+ },
+ prompt: `Refactor the entire core package to be better.`,
+ assert: async (rig) => {
+ const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode');
+ expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe(
+ true,
+ );
+
+ const wasAskUserCalled = await rig.waitForToolCall('ask_user');
+ expect(
+ wasAskUserCalled,
+ 'Expected ask_user tool to be called to clarify the significant rework',
+ ).toBe(true);
+ },
+ });
+
+ // --- Regression Tests for Recent Fixes ---
+
+ // Regression test for issue #20177: Ensure the agent does not use `ask_user` to
+ // confirm shell commands. Fixed via prompt refinements and tool definition
+ // updates to clarify that shell command confirmation is handled by the UI.
+ // See fix: https://github.com/google-gemini/gemini-cli/pull/20504
+ evalTest('USUALLY_PASSES', {
+ name: 'Agent does NOT use AskUser to confirm shell commands',
+ files: {
+ 'package.json': JSON.stringify({
+ scripts: { build: 'echo building' },
+ }),
+ },
+ prompt: `Run 'npm run build' in the current directory.`,
+ assert: async (rig) => {
+ await rig.waitForTelemetryReady();
+
+ const toolLogs = rig.readToolLogs();
+ const wasShellCalled = toolLogs.some(
+ (log) => log.toolRequest.name === 'run_shell_command',
+ );
+ const wasAskUserCalled = toolLogs.some(
+ (log) => log.toolRequest.name === 'ask_user',
+ );
+
+ expect(
+ wasShellCalled,
+ 'Expected run_shell_command tool to be called',
+ ).toBe(true);
+ expect(
+ wasAskUserCalled,
+ 'ask_user should not be called to confirm shell commands',
+ ).toBe(false);
+ },
+ });
+});
diff --git a/evals/concurrency-safety.eval.ts b/evals/concurrency-safety.eval.ts
new file mode 100644
index 0000000000..f2f9e24be9
--- /dev/null
+++ b/evals/concurrency-safety.eval.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { expect } from 'vitest';
+import { evalTest } from './test-helper.js';
+
+const MUTATION_AGENT_DEFINITION = `---
+name: mutation-agent
+description: An agent that modifies the workspace (writes, deletes, git operations, etc).
+max_turns: 1
+tools:
+ - write_file
+---
+
+You are the mutation agent. Do the mutation requested.
+`;
+
+describe('concurrency safety eval test cases', () => {
+ evalTest('USUALLY_PASSES', {
+ name: 'mutation agents are run in parallel when explicitly requested',
+ params: {
+ settings: {
+ experimental: {
+ enableAgents: true,
+ },
+ },
+ },
+ prompt:
+ 'Update A.txt to say "A" and update B.txt to say "B". Delegate these tasks to two separate mutation-agent subagents. You MUST run these subagents in parallel at the same time.',
+ files: {
+ '.gemini/agents/mutation-agent.md': MUTATION_AGENT_DEFINITION,
+ },
+ assert: async (rig) => {
+ const logs = rig.readToolLogs();
+ const mutationCalls = logs.filter(
+ (log) => log.toolRequest?.name === 'mutation-agent',
+ );
+
+ expect(
+ mutationCalls.length,
+ 'Agent should have called the mutation-agent at least twice',
+ ).toBeGreaterThanOrEqual(2);
+
+ const firstPromptId = mutationCalls[0].toolRequest.prompt_id;
+ const secondPromptId = mutationCalls[1].toolRequest.prompt_id;
+
+ expect(
+ firstPromptId,
+ 'mutation agents should be called in parallel (same turn / prompt_ids) when explicitly requested',
+ ).toEqual(secondPromptId);
+ },
+ });
+});
diff --git a/evals/test-helper.ts b/evals/test-helper.ts
index 44c538c197..786ec0e418 100644
--- a/evals/test-helper.ts
+++ b/evals/test-helper.ts
@@ -112,6 +112,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
// commands.
execSync('git config core.editor "true"', execOptions);
execSync('git config core.pager "cat"', execOptions);
+ execSync('git config commit.gpgsign false', execOptions);
execSync('git add .', execOptions);
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
}
diff --git a/integration-tests/acp-env-auth.test.ts b/integration-tests/acp-env-auth.test.ts
index c83dbafce5..65f8adbf22 100644
--- a/integration-tests/acp-env-auth.test.ts
+++ b/integration-tests/acp-env-auth.test.ts
@@ -55,7 +55,7 @@ describe.skip('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
- child = spawn('node', [bundlePath, '--experimental-acp'], {
+ child = spawn('node', [bundlePath, '--acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
@@ -120,7 +120,7 @@ describe.skip('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
- child = spawn('node', [bundlePath, '--experimental-acp'], {
+ child = spawn('node', [bundlePath, '--acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
diff --git a/integration-tests/acp-telemetry.test.ts b/integration-tests/acp-telemetry.test.ts
index 393156df3e..f883b977bf 100644
--- a/integration-tests/acp-telemetry.test.ts
+++ b/integration-tests/acp-telemetry.test.ts
@@ -58,7 +58,7 @@ describe('ACP telemetry', () => {
'node',
[
bundlePath,
- '--experimental-acp',
+ '--acp',
'--fake-responses',
join(rig.testDir!, 'fake-responses.json'),
],
diff --git a/integration-tests/api-resilience.responses b/integration-tests/api-resilience.responses
new file mode 100644
index 0000000000..d30d29906e
--- /dev/null
+++ b/integration-tests/api-resilience.responses
@@ -0,0 +1 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0}],"finishReason":"STOP"}]}
diff --git a/integration-tests/api-resilience.test.ts b/integration-tests/api-resilience.test.ts
new file mode 100644
index 0000000000..870adf701a
--- /dev/null
+++ b/integration-tests/api-resilience.test.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { TestRig } from './test-helper.js';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+describe('API Resilience E2E', () => {
+ let rig: TestRig;
+
+ beforeEach(() => {
+ rig = new TestRig();
+ });
+
+ afterEach(async () => {
+ await rig.cleanup();
+ });
+
+ it('should not crash when receiving metadata-only chunks in a stream', async () => {
+ await rig.setup('api-resilience-metadata-only', {
+ fakeResponsesPath: join(
+ dirname(fileURLToPath(import.meta.url)),
+ 'api-resilience.responses',
+ ),
+ settings: {
+ planSettings: { modelRouting: false },
+ },
+ });
+
+ // Run the CLI with a simple prompt.
+ // The fake responses will provide a stream with a metadata-only chunk in the middle.
+ // We use gemini-3-pro-preview to minimize internal service calls.
+ const result = await rig.run({
+ args: ['hi', '--model', 'gemini-3-pro-preview'],
+ });
+
+ // Verify the output contains text from the normal chunks.
+ // If the CLI crashed on the metadata chunk, rig.run would throw.
+ expect(result).toContain('Part 1.');
+ expect(result).toContain('Part 2.');
+
+ // Verify telemetry event for the prompt was still generated
+ const hasUserPromptEvent = await rig.waitForTelemetryEvent('user_prompt');
+ expect(hasUserPromptEvent).toBe(true);
+ });
+});
diff --git a/integration-tests/browser-agent.cleanup.responses b/integration-tests/browser-agent.cleanup.responses
new file mode 100644
index 0000000000..988f2fa456
--- /dev/null
+++ b/integration-tests/browser-agent.cleanup.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title of https://example.com is \"Example Domain\". The browser session has been completed and cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
diff --git a/integration-tests/browser-agent.interaction.responses b/integration-tests/browser-agent.interaction.responses
new file mode 100644
index 0000000000..98474d6b59
--- /dev/null
+++ b/integration-tests/browser-agent.interaction.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and analyze the links on the page."},{"functionCall":{"name":"browser_agent","args":{"task":"Go to https://example.com and find all links on the page, then describe them"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"After analyzing https://example.com, I found the following links:\n\n1. **\"More information...\"** - This is the main link on the page that points to the IANA (Internet Assigned Numbers Authority) website for more details about reserved domains.\n\nThe page is quite minimal with just this single informational link, which is typical for example domains used in documentation."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":70,"totalTokenCount":270}}]}
diff --git a/integration-tests/browser-agent.navigate-snapshot.responses b/integration-tests/browser-agent.navigate-snapshot.responses
new file mode 100644
index 0000000000..481520234d
--- /dev/null
+++ b/integration-tests/browser-agent.navigate-snapshot.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you open https://example.com and analyze the page. Let me use the browser agent to navigate and capture the page information."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and capture the accessibility tree to get the page title and main content"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Based on the browser analysis of https://example.com:\n\n**Page Title**: Example Domain\n\n**Main Content**: The page contains a simple heading \"Example Domain\" and explanatory text stating \"This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.\"\n\nThe page has a clean, minimal layout typical of placeholder domains used for documentation and examples."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]}
diff --git a/integration-tests/browser-agent.screenshot.responses b/integration-tests/browser-agent.screenshot.responses
new file mode 100644
index 0000000000..c9abaa4268
--- /dev/null
+++ b/integration-tests/browser-agent.screenshot.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll navigate to https://example.com and take a screenshot for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and take a screenshot of the page"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":40,"totalTokenCount":140}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully navigated to https://example.com and captured a screenshot. The page shows the familiar \"Example Domain\" header with explanatory text below it. The screenshot captures the clean, minimal layout of this demonstration website."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]}
diff --git a/integration-tests/browser-agent.sequential.responses b/integration-tests/browser-agent.sequential.responses
new file mode 100644
index 0000000000..74732998aa
--- /dev/null
+++ b/integration-tests/browser-agent.sequential.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you navigate to https://example.com and perform both operations - capturing the accessibility tree and taking a screenshot."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":45,"totalTokenCount":145}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've successfully completed both operations on https://example.com:\n\n**Accessibility Tree Snapshot**: The page has a clear structure with the main heading \"Example Domain\" and descriptive text about the domain's purpose for documentation examples.\n\n**Screenshot**: Captured a visual representation of the page showing the clean, minimal layout with the heading and explanatory text.\n\nBoth the accessibility data and visual screenshot confirm this is the standard example domain page used for documentation purposes."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":80,"totalTokenCount":280}}]}
diff --git a/integration-tests/browser-agent.test.ts b/integration-tests/browser-agent.test.ts
new file mode 100644
index 0000000000..0fdb3e717b
--- /dev/null
+++ b/integration-tests/browser-agent.test.ts
@@ -0,0 +1,206 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Integration tests for the browser agent.
+ *
+ * These tests verify the complete end-to-end flow from CLI prompt through
+ * browser_agent delegation to MCP/Chrome DevTools and back. Unlike the unit
+ * tests in packages/core/src/agents/browser/ which mock all MCP components,
+ * these tests launch real Chrome instances in headless mode.
+ *
+ * Tests are skipped on systems without Chrome/Chromium installed.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { TestRig, assertModelHasOutput } from './test-helper.js';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execSync } from 'node:child_process';
+import { existsSync } from 'node:fs';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const chromeAvailable = (() => {
+ try {
+ if (process.platform === 'darwin') {
+ execSync(
+ 'test -d "/Applications/Google Chrome.app" || test -d "/Applications/Chromium.app"',
+ {
+ stdio: 'ignore',
+ },
+ );
+ } else if (process.platform === 'linux') {
+ execSync(
+ 'which google-chrome || which chromium-browser || which chromium',
+ { stdio: 'ignore' },
+ );
+ } else if (process.platform === 'win32') {
+ // Check standard Windows installation paths using Node.js fs
+ const chromePaths = [
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
+ `${process.env['LOCALAPPDATA'] ?? ''}\\Google\\Chrome\\Application\\chrome.exe`,
+ ];
+ const found = chromePaths.some((p) => existsSync(p));
+ if (!found) {
+ // Fall back to PATH check
+ execSync('where chrome || where chromium', { stdio: 'ignore' });
+ }
+ } else {
+ return false;
+ }
+ return true;
+ } catch {
+ return false;
+ }
+})();
+
+describe.skipIf(!chromeAvailable)('browser-agent', () => {
+ let rig: TestRig;
+
+ beforeEach(() => {
+ rig = new TestRig();
+ });
+
+ afterEach(async () => await rig.cleanup());
+
+ it('should navigate to a page and capture accessibility tree', async () => {
+ rig.setup('browser-navigate-and-snapshot', {
+ fakeResponsesPath: join(
+ __dirname,
+ 'browser-agent.navigate-snapshot.responses',
+ ),
+ settings: {
+ agents: {
+ browser_agent: {
+ headless: true,
+ sessionMode: 'isolated',
+ },
+ },
+ },
+ });
+
+ const result = await rig.run({
+ args: 'Open https://example.com in the browser and tell me the page title and main content.',
+ });
+
+ assertModelHasOutput(result);
+
+ const toolLogs = rig.readToolLogs();
+ const browserAgentCall = toolLogs.find(
+ (t) => t.toolRequest.name === 'browser_agent',
+ );
+ expect(
+ browserAgentCall,
+ 'Expected browser_agent to be called',
+ ).toBeDefined();
+ });
+
+ it('should take screenshots of web pages', async () => {
+ rig.setup('browser-screenshot', {
+ fakeResponsesPath: join(__dirname, 'browser-agent.screenshot.responses'),
+ settings: {
+ agents: {
+ browser_agent: {
+ headless: true,
+ sessionMode: 'isolated',
+ },
+ },
+ },
+ });
+
+ const result = await rig.run({
+ args: 'Navigate to https://example.com and take a screenshot.',
+ });
+
+ const toolLogs = rig.readToolLogs();
+ const browserCalls = toolLogs.filter(
+ (t) => t.toolRequest.name === 'browser_agent',
+ );
+ expect(browserCalls.length).toBeGreaterThan(0);
+
+ assertModelHasOutput(result);
+ });
+
+ it('should interact with page elements', async () => {
+ rig.setup('browser-interaction', {
+ fakeResponsesPath: join(__dirname, 'browser-agent.interaction.responses'),
+ settings: {
+ agents: {
+ browser_agent: {
+ headless: true,
+ sessionMode: 'isolated',
+ },
+ },
+ },
+ });
+
+ const result = await rig.run({
+ args: 'Go to https://example.com, find any links on the page, and describe them.',
+ });
+
+ const toolLogs = rig.readToolLogs();
+ const browserAgentCall = toolLogs.find(
+ (t) => t.toolRequest.name === 'browser_agent',
+ );
+ expect(
+ browserAgentCall,
+ 'Expected browser_agent to be called',
+ ).toBeDefined();
+
+ assertModelHasOutput(result);
+ });
+
+ it('should clean up browser processes after completion', async () => {
+ rig.setup('browser-cleanup', {
+ fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
+ settings: {
+ agents: {
+ browser_agent: {
+ headless: true,
+ sessionMode: 'isolated',
+ },
+ },
+ },
+ });
+
+ await rig.run({
+ args: 'Open https://example.com in the browser and check the page title.',
+ });
+
+ // Test passes if we reach here, relying on Vitest's timeout mechanism
+ // to detect hanging browser processes.
+ });
+
+ it('should handle multiple browser operations in sequence', async () => {
+ rig.setup('browser-sequential', {
+ fakeResponsesPath: join(__dirname, 'browser-agent.sequential.responses'),
+ settings: {
+ agents: {
+ browser_agent: {
+ headless: true,
+ sessionMode: 'isolated',
+ },
+ },
+ },
+ });
+
+ const result = await rig.run({
+ args: 'Navigate to https://example.com, take a snapshot of the accessibility tree, then take a screenshot.',
+ });
+
+ const toolLogs = rig.readToolLogs();
+ const browserCalls = toolLogs.filter(
+ (t) => t.toolRequest.name === 'browser_agent',
+ );
+ expect(browserCalls.length).toBeGreaterThan(0);
+
+ // Should successfully complete all operations
+ assertModelHasOutput(result);
+ });
+});
diff --git a/integration-tests/extensions-reload.test.ts b/integration-tests/extensions-reload.test.ts
index 520076d7c6..9d451cedcf 100644
--- a/integration-tests/extensions-reload.test.ts
+++ b/integration-tests/extensions-reload.test.ts
@@ -104,7 +104,7 @@ describe('extension reloading', () => {
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
- ) && output.includes('- hello')
+ ) && output.includes('- mcp_test-server_hello')
);
},
30000, // 30s timeout
@@ -148,7 +148,7 @@ describe('extension reloading', () => {
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
- ) && output.includes('- goodbye')
+ ) && output.includes('- mcp_test-server_goodbye')
);
},
30000,
diff --git a/integration-tests/hooks-agent-flow.test.ts b/integration-tests/hooks-agent-flow.test.ts
index 757c692366..b602737a39 100644
--- a/integration-tests/hooks-agent-flow.test.ts
+++ b/integration-tests/hooks-agent-flow.test.ts
@@ -165,14 +165,15 @@ describe('Hooks Agent Flow', () => {
// BeforeModel hook to track message counts across LLM calls
const messageCountFile = join(rig.testDir!, 'message-counts.json');
+ const escapedPath = JSON.stringify(messageCountFile);
const beforeModelScript = `
const fs = require('fs');
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
const messageCount = input.llm_request?.contents?.length || 0;
let counts = [];
- try { counts = JSON.parse(fs.readFileSync(${JSON.stringify(messageCountFile)}, 'utf-8')); } catch (e) {}
+ try { counts = JSON.parse(fs.readFileSync(${escapedPath}, 'utf-8')); } catch (e) {}
counts.push(messageCount);
- fs.writeFileSync(${JSON.stringify(messageCountFile)}, JSON.stringify(counts));
+ fs.writeFileSync(${escapedPath}, JSON.stringify(counts));
console.log(JSON.stringify({ decision: 'allow' }));
`;
const beforeModelScriptPath = rig.createScript(
@@ -181,14 +182,22 @@ describe('Hooks Agent Flow', () => {
);
const afterAgentScript = `
- console.log(JSON.stringify({
- decision: 'block',
- reason: 'Security policy triggered',
- hookSpecificOutput: {
- hookEventName: 'AfterAgent',
- clearContext: true
- }
- }));
+ const fs = require('fs');
+ const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
+ if (input.stop_hook_active) {
+ // Retry turn: allow execution to proceed (breaks the loop)
+ console.log(JSON.stringify({ decision: 'allow' }));
+ } else {
+ // First call: block and clear context to trigger the retry
+ console.log(JSON.stringify({
+ decision: 'block',
+ reason: 'Security policy triggered',
+ hookSpecificOutput: {
+ hookEventName: 'AfterAgent',
+ clearContext: true
+ }
+ }));
+ }
`;
const afterAgentScriptPath = rig.createScript(
'after_agent_clear.cjs',
@@ -197,8 +206,10 @@ describe('Hooks Agent Flow', () => {
rig.setup('should process clearContext in AfterAgent hook output', {
settings: {
- hooks: {
+ hooksConfig: {
enabled: true,
+ },
+ hooks: {
BeforeModel: [
{
hooks: [
diff --git a/integration-tests/hooks-system.after-agent.responses b/integration-tests/hooks-system.after-agent.responses
index 1475070c3d..526c59362d 100644
--- a/integration-tests/hooks-system.after-agent.responses
+++ b/integration-tests/hooks-system.after-agent.responses
@@ -1,2 +1,3 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hi there!"}],"role":"model"},"finishReason":"STOP","index":0}]}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Clarification: I am a bot."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Security policy triggered"}],"role":"model"},"finishReason":"STOP","index":0}]}]}
diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts
index 215cf21226..473b966d5a 100644
--- a/integration-tests/json-output.test.ts
+++ b/integration-tests/json-output.test.ts
@@ -81,7 +81,9 @@ describe('JSON output', () => {
const message = (thrown as Error).message;
// Use a regex to find the first complete JSON object in the string
- const jsonMatch = message.match(/{[\s\S]*}/);
+ // We expect the JSON to start with a quote (e.g. {"error": ...}) to avoid
+ // matching random error objects printed to stderr (like ENOENT).
+ const jsonMatch = message.match(/{\s*"[\s\S]*}/);
// Fail if no JSON-like text was found
expect(
diff --git a/integration-tests/plan-mode.test.ts b/integration-tests/plan-mode.test.ts
index f71006a36c..8709aac189 100644
--- a/integration-tests/plan-mode.test.ts
+++ b/integration-tests/plan-mode.test.ts
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import { writeFileSync } from 'node:fs';
+import { join } from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { TestRig, checkModelOutputContent } from './test-helper.js';
+import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
@@ -62,50 +64,98 @@ describe('Plan Mode', () => {
});
});
- it.skip('should allow write_file only in the plans directory in plan mode', async () => {
- await rig.setup(
- 'should allow write_file only in the plans directory in plan mode',
- {
- settings: {
- experimental: { plan: true },
- tools: {
- core: ['write_file', 'read_file', 'list_directory'],
- allowed: ['write_file'],
+ it('should allow write_file to the plans directory in plan mode', async () => {
+ const plansDir = '.gemini/tmp/foo/123/plans';
+ const testName =
+ 'should allow write_file to the plans directory in plan mode';
+
+ await rig.setup(testName, {
+ settings: {
+ experimental: { plan: true },
+ tools: {
+ core: ['write_file', 'read_file', 'list_directory'],
+ },
+ general: {
+ defaultApprovalMode: 'plan',
+ plan: {
+ directory: plansDir,
},
- general: { defaultApprovalMode: 'plan' },
},
},
- );
-
- // We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory.
- // Verify that write_file outside of plan directory fails
- await rig.run({
- approvalMode: 'plan',
- stdin:
- 'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory',
});
- const toolLogs = rig.readToolLogs();
- const writeLogs = toolLogs.filter(
- (l) => l.toolRequest.name === 'write_file',
+ // Disable the interactive terminal setup prompt in tests
+ writeFileSync(
+ join(rig.homeDir!, GEMINI_DIR, 'state.json'),
+ JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
- const planWrite = writeLogs.find(
+ const run = await rig.runInteractive({
+ approvalMode: 'plan',
+ });
+
+ await run.type('Create a file called plan.md in the plans directory.');
+ await run.type('\r');
+
+ await rig.expectToolCallSuccess(['write_file'], 30000, (args) =>
+ args.includes('plan.md'),
+ );
+
+ const toolLogs = rig.readToolLogs();
+ const planWrite = toolLogs.find(
(l) =>
+ l.toolRequest.name === 'write_file' &&
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan.md'),
);
+ expect(planWrite?.toolRequest.success).toBe(true);
+ });
- const blockedWrite = writeLogs.find((l) =>
- l.toolRequest.args.includes('hello.txt'),
+ it('should deny write_file to non-plans directory in plan mode', async () => {
+ const plansDir = '.gemini/tmp/foo/123/plans';
+ const testName =
+ 'should deny write_file to non-plans directory in plan mode';
+
+ await rig.setup(testName, {
+ settings: {
+ experimental: { plan: true },
+ tools: {
+ core: ['write_file', 'read_file', 'list_directory'],
+ },
+ general: {
+ defaultApprovalMode: 'plan',
+ plan: {
+ directory: plansDir,
+ },
+ },
+ },
+ });
+
+ // Disable the interactive terminal setup prompt in tests
+ writeFileSync(
+ join(rig.homeDir!, GEMINI_DIR, 'state.json'),
+ JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
- // Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't
- if (blockedWrite) {
- expect(blockedWrite?.toolRequest.success).toBe(false);
- }
+ const run = await rig.runInteractive({
+ approvalMode: 'plan',
+ });
- expect(planWrite?.toolRequest.success).toBe(true);
+ await run.type('Create a file called hello.txt in the current directory.');
+ await run.type('\r');
+
+ const toolLogs = rig.readToolLogs();
+ const writeLog = toolLogs.find(
+ (l) =>
+ l.toolRequest.name === 'write_file' &&
+ l.toolRequest.args.includes('hello.txt'),
+ );
+
+ // In Plan Mode, writes outside the plans directory should be blocked.
+ // Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail.
+ if (writeLog) {
+ expect(writeLog.toolRequest.success).toBe(false);
+ }
});
it('should be able to enter plan mode from default mode', async () => {
@@ -119,6 +169,12 @@ describe('Plan Mode', () => {
},
});
+ // Disable the interactive terminal setup prompt in tests
+ writeFileSync(
+ join(rig.homeDir!, GEMINI_DIR, 'state.json'),
+ JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
+ );
+
// Start in default mode and ask to enter plan mode.
await rig.run({
approvalMode: 'default',
@@ -126,10 +182,7 @@ describe('Plan Mode', () => {
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
});
- const enterPlanCallFound = await rig.waitForToolCall(
- 'enter_plan_mode',
- 10000,
- );
+ const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode');
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
true,
);
diff --git a/integration-tests/policy-headless-readonly.responses b/integration-tests/policy-headless-readonly.responses
new file mode 100644
index 0000000000..35ba546bae
--- /dev/null
+++ b/integration-tests/policy-headless-readonly.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I will read the content of the file to identify its"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":11,"totalTokenCount":8061,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"text":" language.\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":14,"totalTokenCount":8064,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"test.txt"}},"thoughtSignature":"EvkCCvYCAb4+9vt8mJ/o45uuuAJtfjaZ3YzkJzqXHZBttRE+Om0ahcr1S5RDFp50KpgHtJtbAH1pwEXampOnDV3WKiWwA+e3Jnyk4CNQegz7ZMKsl55Nem2XDViP8BZKnJVqGmSFuMoKJLFmbVIxKejtWcblfn3httbGsrUUNbHwdPjPHo1qY043lF63g0kWx4v68gPSsJpNhxLrSugKKjiyRFN+J0rOIBHI2S9MdZoHEKhJxvGMtXiJquxmhPmKcNEsn+hMdXAZB39hmrRrGRHDQPVYVPhfJthVc73ufzbn+5KGJpaMQyKY5hqrc2ea8MHz+z6BSx+tFz4NZBff1tJQOiUp09/QndxQRZHSQZr1ALGy0O1Qw4JqsX94x81IxtXqYkSRo3zgm2vl/xPMC5lKlnK5xoKJmoWaHkUNeXs/sopu3/Waf1a5Csoh9ImnKQsW0rJ6GRyDQvky1FwR6Aa98bgfNdcXOPHml/BtghaqRMXTiG6vaPJ8UFs="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":64,"totalTokenCount":8114,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"thoughtsTokenCount":81}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7969,"candidatesTokenCount":64,"totalTokenCount":8114,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7969}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":81}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The language of the file is Latin."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8054,"candidatesTokenCount":8,"totalTokenCount":8078,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8054}],"thoughtsTokenCount":16}},{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"EnIKcAG+Pvb7vnRBJVz3khx1oArQQqTNvXOXkliNQS7NvYw94dq5m+wGKRmSj3egO3GVp7pacnAtLn9NT1ABKBGpa7MpRhiAe3bbPZfkqOuveeyC19LKQ9fzasCywiYqg5k5qSxfjs5okk+O0NLOvTjN/tg="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8135,"candidatesTokenCount":8,"totalTokenCount":8159,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8135}],"thoughtsTokenCount":16}}]}
diff --git a/integration-tests/policy-headless-shell-allowed.responses b/integration-tests/policy-headless-shell-allowed.responses
new file mode 100644
index 0000000000..7c98e60db0
--- /dev/null
+++ b/integration-tests/policy-headless-shell-allowed.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I will run the requested"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":5,"totalTokenCount":8092,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"text":" shell command to verify the policy configuration.\n"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":14,"totalTokenCount":8101,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"command":"echo POLICY_TEST_ECHO_COMMAND","description":"Echo the test string to verify policy settings."}},"thoughtSignature":"EpwFCpkFAb4+9vulXgVj96CAm2eMFbDEGHz9B37GwI8N1KOvu9AHwdYWiita7yS4RKAdeBui22B5320XBaxOtZGnMo2E9pG0Pcus2WsBiecRaHUTxTmhx1BvURevrs+5m4UJeLRGMfP94+ncha4DeIQod3PKBnK8xeIJTyZBFB7+hmHbHvem2VwZh/v14e4fXlpEkkdntJbzrA1nUdctIGdEmdm0sL8PaFnMqWLUnkZvGdfq7ctFt9EYk2HW2SrHVhk3HdsyWhoxNz2MU0sRWzAgiSQY/heSSAbU7Jdgg0RjwB9o3SkCIHxqnVpkH8PQsARwnah5I5s7pW6EHr3D4f1/UVl0n26hyI2xBqF/n4aZKhtX55U4h/DIhxooZa2znstt6BS8vRcdzflFrX7OV86WQxHE4JHjQecP2ciBRimm8pL3Od3pXnRcx32L8JbrWm6dPyWlo5h5uCRy0qXye2+3SuHs5wtxOjD9NETR4TwzqFe+m0zThpxsR1ZKQeKlO7lN/s3pWih/TjbZQEQs9xr72UnlE8ZtJ4bOKj8GNbemvsrbYAO98NzJwvdil0FhblaXmReP1uYjucmLC0jCJHShqNz2KzAkDTvKs4tmio13IuCRjTZ3E5owqCUn7djDqOSDwrg235RIVJkiDIaPlHemOR15lbVQD1VOzytzT8TZLEzTV750oyHq/IhLMQHYixO8jJ2GkVvUp7bxz9oQ4UeTqT5lTF4s40H2Rlkb6trF4hKXoFhzILy1aOJTC9W3fCoop7VJLIMNulgHLWxiq65Uas6sIep87yiD4xLfbGfMm6HS4JTRhPlfxeckn/SzUfu1afg1nAvW3vBlR/YNREf0N28/PnRC08VYqA3mqCRiyPqPWsf3a0jyio0dD9A="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":54,"totalTokenCount":8141,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":138}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":54,"totalTokenCount":8141,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":138}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"POLICY_TEST_"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8042,"candidatesTokenCount":4,"totalTokenCount":8046,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8042}]}},{"candidates":[{"content":{"parts":[{"text":"ECHO_COMMAND"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8042,"candidatesTokenCount":8,"totalTokenCount":8050,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8042}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8180,"candidatesTokenCount":8,"totalTokenCount":8188,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8180}]}}]}
diff --git a/integration-tests/policy-headless-shell-denied.responses b/integration-tests/policy-headless-shell-denied.responses
new file mode 100644
index 0000000000..4278543b7e
--- /dev/null
+++ b/integration-tests/policy-headless-shell-denied.responses
@@ -0,0 +1,2 @@
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"**Assessing Command Execution**\n\nOkay, I'm currently assessing the feasibility of executing `echo POLICY_TEST_ECHO_COMMAND` using the `run_shell_command` function. Restrictions are being evaluated; the prompt is specifically geared towards a successful command output: \"POLICY_TEST_ECHO_COMMAND\".\n\n\n","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"totalTokenCount":7949,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}]}},{"candidates":[{"content":{"parts":[{"text":"I will execute the requested echo"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":6,"totalTokenCount":8161,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"text":" command to verify the policy."}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":12,"totalTokenCount":8167,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"description":"Execute the echo command as requested.","command":"echo POLICY_TEST_ECHO_COMMAND"}},"thoughtSignature":"EvkGCvYGAb4+9vucYbmJ8DrNCca9c0C8o4qKQ6V2WnzmT4mbCw8V7s0+2I/PoxrgnsxZJIIRM8y5E4bW7Jbs46GjbJ2cefY9Q3iC45eiGS5Gqvq0eAG04N3GZRwizyDOp+wJlBsaPu1cNB1t6CnMk/ZHDAHEIQUpYfYWmPudbHOQMspGMu3bX23YSI1+Q5vPVdOtM16J3EFbk3dCp+RnPa/8tVC+5AqFlLveuDbJXtrLN9wAyf4SjnPhn9BPfD0bgas3+gF03qRJvWoNcnnJiYxL3DNQtjsAYJ7IWRzciYYZSTm99blD730bn3NzvSObhlHDtb3hFpApYvG396+3prsgJg0Yjef54B4KxHfZaQbE2ndSP5zGrwLtVD5y7XJAYskvhiUqwPFHNVykqroEMzPn8wWQSGvonNR6ezcMIsUV5xwnxZDaPhvrDdIwF4NR1F5DeriJRu27+fwtCApeYkx9mPx4LqnyxOuVsILjzdSPHE6Bqf690VJSXpo67lCN4F3DRRYIuCD4UOlf8V3dvUO6BKjvChDDWnIq7KPoByDQT9VhVlZvS3/nYlkeDuhi0rk2jpByN1NdgD2YSvOlpJcka8JqKQ+lnO/7Swunij2ISUfpL2hkx6TEHjebPU2dBQkub5nSl9J1EhZn4sUGG5r6Zdv1lYcpIcO4ZYeMqZZ4uNvTvSpGdT4Jj1+qS88taKgYq7uN1RgQSTsT5wcpmlubIpgIycNwAIRFvN+DjkQjiUC6hSqdeOx3dc7LWgC/O/+PRog7kuFrD2nzih+oIP0YxXrLA9CMVPlzeAgPUi9b75HAJQ92GRHxfQ163tjZY+4bWmJtcU4NBqGH0x/jLEU9xCojTeh+mZoUDGsb3N+bVcGJftRIet7IBYveD29Z+XHtKhf7s/YIkFW8lgsG8Q0EtNchCxqIQxf9UjYEO52RhCx7i7zScB1knovt2HAotACKqDdPqg18PmpDv8Frw6Y66XeCCJzBCmNcSUTETq3K05gwkU8nyANQtjbJT0wF4LS9h5vPE+Vc7/dGH6pi1TgxWB/n4q1IXfNqilo/h2Pyw01VPsHKthNtKKq1/nSW/WuEU0rimqu7wHplMqU2nwRDCTNE9pPO59RtTHMfUxxd8yEgKBj9L8MiQGM5isIYl/lJtvucee4HD9iLpbYADlrQAlUCd0rg/z+5sQ=="}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":50,"totalTokenCount":8205,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"thoughtsTokenCount":206}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7949,"candidatesTokenCount":50,"totalTokenCount":8205,"cachedContentTokenCount":6082,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7949}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":6082}],"thoughtsTokenCount":206}}]}
+{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"AR NAR"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":8020,"candidatesTokenCount":2,"totalTokenCount":8049,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8020}],"thoughtsTokenCount":27}},{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"Er8BCrwBAb4+9vv6KGeMf6yopmPBE/az7Kjdp+Pe5a/R6wgXcyCZzGNwkwKFW3i3ro0j26bRrVeHD1zRfWFTIGdOSZKV6OMPWLqFC/RU6CNJ88B1xY7hbCVwA7EchYPzgd3YZRVNwmFu52j86/9qXf/zaqTFN+WQ0mUESJXh2O2YX8E7imAvxhmRdobVkxvEt4ZX3dW5skDhXHMDZOxbLpX0nkK7cWWS7iEc+qBFP0yinlA/eiG2ZdKpuTiDl76a9ik="}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":8226,"candidatesTokenCount":2,"totalTokenCount":8255,"promptTokensDetails":[{"modality":"TEXT","tokenCount":8226}],"thoughtsTokenCount":27}}]}
diff --git a/integration-tests/policy-headless.test.ts b/integration-tests/policy-headless.test.ts
new file mode 100644
index 0000000000..b6cc14f61c
--- /dev/null
+++ b/integration-tests/policy-headless.test.ts
@@ -0,0 +1,205 @@
+/**
+ * @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,
+ expectedDenialString?: string,
+) {
+ 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).not.toContain(`Tool "${promptCommand.tool}" not found`);
+ expect(result).toContain(promptCommand.expectedSuccessResult);
+ } else {
+ expect(log!.toolRequest.success).toBe(false);
+ expect(result).toContain(
+ expectedDenialString || 'Tool execution denied by policy',
+ );
+ expect(result).toContain(promptCommand.expectedFailureResult);
+ }
+}
+
+interface TestCase {
+ name: string;
+ responsesFile: string;
+ promptCommand: PromptCommand;
+ policyContent?: string;
+ expectAllowed: boolean;
+ expectedDenialString?: string;
+}
+
+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,
+ tc.expectedDenialString,
+ );
+ };
+
+ const testCases = [
+ {
+ name: 'should deny ASK_USER tools by default in headless mode',
+ responsesFile: 'policy-headless-shell-denied.responses',
+ promptCommand: ECHO_PROMPT,
+ expectAllowed: false,
+ expectedDenialString: 'Tool "run_shell_command" not found',
+ },
+ {
+ 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,
+ expectedDenialString: 'Tool execution denied by policy',
+ },
+ ];
+
+ it.each(testCases)(
+ '$name',
+ async (tc) => {
+ await runTestCase(tc);
+ },
+ // Large timeout for regeneration
+ process.env['REGENERATE_MODEL_GOLDENS'] === 'true' ? 120000 : undefined,
+ );
+});
diff --git a/integration-tests/run_shell_command.test.ts b/integration-tests/run_shell_command.test.ts
index 0587bb30df..8ae72fed84 100644
--- a/integration-tests/run_shell_command.test.ts
+++ b/integration-tests/run_shell_command.test.ts
@@ -18,6 +18,7 @@ const { shell } = getShellConfiguration();
function getLineCountCommand(): { command: string; tool: string } {
switch (shell) {
case 'powershell':
+ return { command: `Measure-Object -Line`, tool: 'Measure-Object' };
case 'cmd':
return { command: `find /c /v`, tool: 'find' };
case 'bash':
@@ -238,8 +239,12 @@ describe('run_shell_command', () => {
});
it('should succeed in yolo mode', async () => {
+ const isWindows = process.platform === 'win32';
await rig.setup('should succeed in yolo mode', {
- settings: { tools: { core: ['run_shell_command'] } },
+ settings: {
+ tools: { core: ['run_shell_command'] },
+ shell: isWindows ? { enableInteractiveShell: false } : undefined,
+ },
});
const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n');
diff --git a/package-lock.json b/package-lock.json
index a87134e897..8963837258 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"workspaces": [
"packages/*"
],
@@ -84,9 +84,9 @@
}
},
"node_modules/@a2a-js/sdk": {
- "version": "0.3.8",
- "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.8.tgz",
- "integrity": "sha512-vAg6JQbhOnHTzApsB7nGzCQ9r7PuY4GMr8gt88dIR8Wc8G8RSqVTyTmFeMurgzcYrtHYXS3ru2rnDoGj9UDeSw==",
+ "version": "0.3.10",
+ "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.10.tgz",
+ "integrity": "sha512-t6w5ctnwJkSOMRl6M9rn95C1FTHCPqixxMR0yWXtzhZXEnF6mF1NAK0CfKlG3cz+tcwTxkmn287QZC3t9XPgrA==",
"license": "Apache-2.0",
"dependencies": {
"uuid": "^11.1.0"
@@ -95,9 +95,17 @@
"node": ">=18"
},
"peerDependencies": {
+ "@bufbuild/protobuf": "^2.10.2",
+ "@grpc/grpc-js": "^1.11.0",
"express": "^4.21.2 || ^5.1.0"
},
"peerDependenciesMeta": {
+ "@bufbuild/protobuf": {
+ "optional": true
+ },
+ "@grpc/grpc-js": {
+ "optional": true
+ },
"express": {
"optional": true
}
@@ -515,6 +523,12 @@
"node": ">=18"
}
},
+ "node_modules/@bufbuild/protobuf": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
+ "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
+ "license": "(Apache-2.0 AND BSD-3-Clause)"
+ },
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz",
@@ -1582,18 +1596,36 @@
}
},
"node_modules/@grpc/grpc-js": {
- "version": "1.13.4",
- "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
- "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
+ "version": "1.14.3",
+ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
+ "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"dependencies": {
- "@grpc/proto-loader": "^0.7.13",
+ "@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
},
"engines": {
"node": ">=12.10.0"
}
},
+ "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
+ "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "lodash.camelcase": "^4.3.0",
+ "long": "^5.0.0",
+ "protobufjs": "^7.5.3",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/@grpc/proto-loader": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
@@ -6298,16 +6330,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"
@@ -6563,6 +6615,9 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
"engines": {
"node": ">=18"
},
@@ -6729,6 +6784,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",
@@ -8419,9 +8501,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",
@@ -8539,6 +8621,15 @@
"express": ">= 4.11"
}
},
+ "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",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -8790,13 +8881,24 @@
"statuses": "^2.0.1"
},
"engines": {
- "node": ">= 18.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
+ "node": ">= 0.8"
}
},
+ "node_modules/finalhandler/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",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/finalhandler/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -11671,6 +11773,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",
@@ -11806,6 +11923,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",
@@ -13356,6 +13479,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",
@@ -14238,6 +14373,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",
@@ -15174,6 +15410,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",
@@ -16156,6 +16420,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",
@@ -17056,7 +17335,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",
@@ -17114,7 +17393,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",
@@ -17126,7 +17405,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",
@@ -17197,14 +17476,16 @@
},
"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",
+ "@a2a-js/sdk": "^0.3.10",
+ "@bufbuild/protobuf": "^2.11.0",
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google/genai": "1.41.0",
+ "@grpc/grpc-js": "^1.14.3",
"@iarna/toml": "^2.2.5",
"@joshua.litt/get-ripgrep": "^0.0.3",
"@modelcontextprotocol/sdk": "^1.23.0",
@@ -17242,6 +17523,7 @@
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
+ "ipaddr.js": "^1.9.1",
"js-yaml": "^4.1.1",
"marked": "^15.0.12",
"mime": "4.0.7",
@@ -17462,7 +17744,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"
@@ -17477,7 +17759,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",
@@ -17494,7 +17776,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",
@@ -17511,7 +17793,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
diff --git a/package.json b/package.json
index 8940b193ad..8d931c1462 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
- "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0"
+ "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260304.28af4e127"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -37,10 +37,12 @@
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js",
+ "build:binary": "node scripts/build_binary.js",
"bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js",
- "test": "npm run test --workspaces --if-present",
- "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
+ "test": "npm run test --workspaces --if-present && npm run test:sea-launch",
+ "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch",
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
+ "test:sea-launch": "vitest run sea/sea-launch.test.js",
"test:always_passing_evals": "vitest run --config evals/vitest.config.ts",
"test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts",
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json
index 0428a84311..b70ea8986a 100644
--- a/packages/a2a-server/package.json
+++ b/packages/a2a-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts
index 1defbdd36c..ef15a907e6 100644
--- a/packages/a2a-server/src/agent/task.ts
+++ b/packages/a2a-server/src/agent/task.ts
@@ -27,7 +27,11 @@ import {
type ToolCallConfirmationDetails,
type Config,
type UserTierId,
+ type ToolLiveOutput,
+ type AnsiLine,
type AnsiOutput,
+ type AnsiToken,
+ isSubagentProgress,
EDIT_TOOL_NAMES,
processRestorableToolCalls,
} from '@google/gemini-cli-core';
@@ -336,15 +340,22 @@ export class Task {
private _schedulerOutputUpdate(
toolCallId: string,
- outputChunk: string | AnsiOutput,
+ outputChunk: ToolLiveOutput,
): void {
let outputAsText: string;
if (typeof outputChunk === 'string') {
outputAsText = outputChunk;
- } else {
- outputAsText = outputChunk
- .map((line) => line.map((token) => token.text).join(''))
+ } else if (isSubagentProgress(outputChunk)) {
+ outputAsText = JSON.stringify(outputChunk);
+ } 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(
@@ -821,7 +832,9 @@ export class Task {
if (
part.kind !== 'data' ||
!part.data ||
+ // eslint-disable-next-line no-restricted-syntax
typeof part.data['callId'] !== 'string' ||
+ // eslint-disable-next-line no-restricted-syntax
typeof part.data['outcome'] !== 'string'
) {
return false;
diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts
index c676e46289..ee63df36f7 100644
--- a/packages/a2a-server/src/config/config.test.ts
+++ b/packages/a2a-server/src/config/config.test.ts
@@ -16,6 +16,9 @@ import {
ExperimentFlags,
fetchAdminControlsOnce,
type FetchAdminControlsResponse,
+ AuthType,
+ isHeadlessMode,
+ FatalAuthenticationError,
} from '@google/gemini-cli-core';
// Mock dependencies
@@ -50,6 +53,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
startupProfiler: {
flush: vi.fn(),
},
+ isHeadlessMode: vi.fn().mockReturnValue(false),
FileDiscoveryService: vi.fn(),
getCodeAssistServer: vi.fn(),
fetchAdminControlsOnce: vi.fn(),
@@ -62,6 +66,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
vi.mock('../utils/logger.js', () => ({
logger: {
info: vi.fn(),
+ warn: vi.fn(),
error: vi.fn(),
},
}));
@@ -73,12 +78,11 @@ describe('loadConfig', () => {
beforeEach(() => {
vi.clearAllMocks();
- process.env['GEMINI_API_KEY'] = 'test-key';
+ vi.stubEnv('GEMINI_API_KEY', 'test-key');
});
afterEach(() => {
- delete process.env['CUSTOM_IGNORE_FILE_PATHS'];
- delete process.env['GEMINI_API_KEY'];
+ vi.unstubAllEnvs();
});
describe('admin settings overrides', () => {
@@ -199,7 +203,7 @@ describe('loadConfig', () => {
it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
const testPath = '/tmp/ignore';
- process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
@@ -224,7 +228,7 @@ describe('loadConfig', () => {
it('should merge customIgnoreFilePaths from settings and env var', async () => {
const envPath = '/env/ignore';
const settingsPath = '/settings/ignore';
- process.env['CUSTOM_IGNORE_FILE_PATHS'] = envPath;
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', envPath);
const settings: Settings = {
fileFiltering: {
customIgnoreFilePaths: [settingsPath],
@@ -240,7 +244,7 @@ describe('loadConfig', () => {
it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => {
const paths = ['/path/one', '/path/two'];
- process.env['CUSTOM_IGNORE_FILE_PATHS'] = paths.join(path.delimiter);
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', paths.join(path.delimiter));
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths);
@@ -254,7 +258,7 @@ describe('loadConfig', () => {
it('should initialize FileDiscoveryService with correct options', async () => {
const testPath = '/tmp/ignore';
- process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
+ vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
const settings: Settings = {
fileFiltering: {
respectGitIgnore: false,
@@ -311,5 +315,219 @@ describe('loadConfig', () => {
}),
);
});
+
+ describe('interactivity', () => {
+ it('should set interactive true when not headless', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ interactive: true,
+ enableInteractiveShell: true,
+ }),
+ );
+ });
+
+ it('should set interactive false when headless', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(true);
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+ expect(Config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ interactive: false,
+ enableInteractiveShell: false,
+ }),
+ );
+ });
+ });
+
+ describe('authentication fallback', () => {
+ beforeEach(() => {
+ vi.stubEnv('USE_CCPA', 'true');
+ vi.stubEnv('GEMINI_API_KEY', '');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => {
+ vi.stubEnv('CLOUD_SHELL', 'true');
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
+ const refreshAuthMock = vi.fn().mockImplementation((authType) => {
+ if (authType === AuthType.LOGIN_WITH_GOOGLE) {
+ throw new FatalAuthenticationError('Non-interactive session');
+ }
+ return Promise.resolve();
+ });
+
+ // Update the mock implementation for this test
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+
+ expect(refreshAuthMock).toHaveBeenCalledWith(
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
+ });
+
+ it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
+ const refreshAuthMock = vi.fn().mockImplementation((authType) => {
+ if (authType === AuthType.LOGIN_WITH_GOOGLE) {
+ throw new FatalAuthenticationError('Non-interactive session');
+ }
+ return Promise.resolve();
+ });
+
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+
+ await expect(
+ loadConfig(mockSettings, mockExtensionLoader, taskId),
+ ).rejects.toThrow('Non-interactive session');
+
+ expect(refreshAuthMock).toHaveBeenCalledWith(
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
+ });
+
+ it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => {
+ vi.stubEnv('CLOUD_SHELL', 'true');
+ vi.mocked(isHeadlessMode).mockReturnValue(true);
+
+ const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
+
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+
+ expect(refreshAuthMock).not.toHaveBeenCalledWith(
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
+ });
+
+ it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => {
+ vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
+ vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless
+
+ const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
+
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+
+ await loadConfig(mockSettings, mockExtensionLoader, taskId);
+
+ expect(refreshAuthMock).not.toHaveBeenCalledWith(
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
+ });
+
+ it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => {
+ vi.mocked(isHeadlessMode).mockReturnValue(true);
+
+ const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
+
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+
+ await expect(
+ loadConfig(mockSettings, mockExtensionLoader, taskId),
+ ).rejects.toThrow(
+ 'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.',
+ );
+
+ expect(refreshAuthMock).not.toHaveBeenCalled();
+ });
+
+ it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => {
+ vi.stubEnv('CLOUD_SHELL', 'true');
+ vi.mocked(isHeadlessMode).mockReturnValue(false);
+
+ const refreshAuthMock = vi.fn().mockImplementation((authType) => {
+ if (authType === AuthType.LOGIN_WITH_GOOGLE) {
+ throw new FatalAuthenticationError('OAuth failed');
+ }
+ if (authType === AuthType.COMPUTE_ADC) {
+ throw new Error('ADC failed');
+ }
+ return Promise.resolve();
+ });
+
+ vi.mocked(Config).mockImplementation(
+ (params: unknown) =>
+ ({
+ ...(params as object),
+ initialize: vi.fn(),
+ waitForMcpInit: vi.fn(),
+ refreshAuth: refreshAuthMock,
+ getExperiments: vi.fn().mockReturnValue({ flags: {} }),
+ getRemoteAdminSettings: vi.fn(),
+ setRemoteAdminSettings: vi.fn(),
+ }) as unknown as Config,
+ );
+
+ await expect(
+ loadConfig(mockSettings, mockExtensionLoader, taskId),
+ ).rejects.toThrow(
+ 'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
+ );
+ });
+ });
});
});
diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts
index f3100bce4d..5b6757701d 100644
--- a/packages/a2a-server/src/config/config.ts
+++ b/packages/a2a-server/src/config/config.ts
@@ -23,6 +23,9 @@ import {
fetchAdminControlsOnce,
getCodeAssistServer,
ExperimentFlags,
+ isHeadlessMode,
+ FatalAuthenticationError,
+ isCloudShell,
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
@@ -103,8 +106,8 @@ export async function loadConfig(
trustedFolder: true,
extensionLoader,
checkpointing,
- interactive: true,
- enableInteractiveShell: true,
+ interactive: !isHeadlessMode(),
+ enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
};
@@ -117,7 +120,6 @@ export async function loadConfig(
await loadServerHierarchicalMemory(
workspaceDir,
[workspaceDir],
- false,
fileService,
extensionLoader,
folderTrust,
@@ -255,7 +257,61 @@ async function refreshAuthentication(
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
);
}
- await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
+
+ const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
+ const isHeadless = isHeadlessMode();
+ const shouldSkipOauth = isHeadless || useComputeAdc;
+
+ if (shouldSkipOauth) {
+ if (isCloudShell() || useComputeAdc) {
+ logger.info(
+ `[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`,
+ );
+ try {
+ await config.refreshAuth(AuthType.COMPUTE_ADC);
+ logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
+ } catch (adcError) {
+ const adcMessage =
+ adcError instanceof Error ? adcError.message : String(adcError);
+ throw new FatalAuthenticationError(
+ `COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`,
+ );
+ }
+ } else {
+ throw new FatalAuthenticationError(
+ `Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`,
+ );
+ }
+ } else {
+ try {
+ await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
+ } catch (e) {
+ if (
+ e instanceof FatalAuthenticationError &&
+ (isCloudShell() || useComputeAdc)
+ ) {
+ logger.warn(
+ `[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`,
+ );
+ try {
+ await config.refreshAuth(AuthType.COMPUTE_ADC);
+ logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`);
+ } catch (adcError) {
+ logger.error(
+ `[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`,
+ );
+ const originalMessage = e instanceof Error ? e.message : String(e);
+ const adcMessage =
+ adcError instanceof Error ? adcError.message : String(adcError);
+ throw new FatalAuthenticationError(
+ `${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`,
+ );
+ }
+ } else {
+ throw e;
+ }
+ }
+ }
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts
index 161139279b..35ca48949f 100644
--- a/packages/a2a-server/src/http/app.ts
+++ b/packages/a2a-server/src/http/app.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import express from 'express';
+import express, { type Request } from 'express';
import type { AgentCard, Message } from '@a2a-js/sdk';
import {
@@ -13,8 +13,9 @@ import {
InMemoryTaskStore,
DefaultExecutionEventBus,
type AgentExecutionEvent,
+ UnauthenticatedUser,
} from '@a2a-js/sdk/server';
-import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components
+import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import type { AgentSettings } from '../types.js';
@@ -55,8 +56,17 @@ const coderAgentCard: AgentCard = {
pushNotifications: false,
stateTransitionHistory: true,
},
- securitySchemes: undefined,
- security: undefined,
+ securitySchemes: {
+ bearerAuth: {
+ type: 'http',
+ scheme: 'bearer',
+ },
+ basicAuth: {
+ type: 'http',
+ scheme: 'basic',
+ },
+ },
+ security: [{ bearerAuth: [] }, { basicAuth: [] }],
defaultInputModes: ['text'],
defaultOutputModes: ['text'],
skills: [
@@ -81,6 +91,35 @@ export function updateCoderAgentCardUrl(port: number) {
coderAgentCard.url = `http://localhost:${port}/`;
}
+const customUserBuilder: UserBuilder = async (req: Request) => {
+ const auth = req.headers['authorization'];
+ if (auth) {
+ const scheme = auth.split(' ')[0];
+ logger.info(
+ `[customUserBuilder] Received Authorization header with scheme: ${scheme}`,
+ );
+ }
+ if (!auth) return new UnauthenticatedUser();
+
+ // 1. Bearer Auth
+ if (auth.startsWith('Bearer ')) {
+ const token = auth.substring(7);
+ if (token === 'valid-token') {
+ return { userName: 'bearer-user', isAuthenticated: true };
+ }
+ }
+
+ // 2. Basic Auth
+ if (auth.startsWith('Basic ')) {
+ const credentials = Buffer.from(auth.substring(6), 'base64').toString();
+ if (credentials === 'admin:password') {
+ return { userName: 'basic-user', isAuthenticated: true };
+ }
+ }
+
+ return new UnauthenticatedUser();
+};
+
async function handleExecuteCommand(
req: express.Request,
res: express.Response,
@@ -204,7 +243,7 @@ export async function createApp() {
requestStorage.run({ req }, next);
});
- const appBuilder = new A2AExpressApp(requestHandler);
+ const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder);
expressApp = appBuilder.setupRoutes(expressApp, '');
expressApp.use(express.json());
diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts
index 977daedf16..7d77d8dc9a 100644
--- a/packages/a2a-server/src/utils/testing_utils.ts
+++ b/packages/a2a-server/src/utils/testing_utils.ts
@@ -75,6 +75,14 @@ export function createMockConfig(
validatePathAccess: vi.fn().mockReturnValue(undefined),
...overrides,
} as unknown as Config;
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ (mockConfig as unknown as { config: Config; promptId: string }).config =
+ mockConfig;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ (mockConfig as unknown as { config: Config; promptId: string }).promptId =
+ 'test-prompt-id';
+
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
mockConfig.getHookSystem = vi
.fn()
diff --git a/packages/cli/package.json b/packages/cli/package.json
index f4fd2f7bd1..cc561eeb8c 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
- "version": "0.33.0-nightly.20260228.1ca5c05d0",
+ "version": "0.34.0-nightly.20260304.28af4e127",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
- "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0"
+ "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.34.0-nightly.20260304.28af4e127"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -38,7 +38,7 @@
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
- "clipboardy": "^5.0.0",
+ "clipboardy": "~5.2.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
diff --git a/packages/cli/src/zed-integration/zedIntegration.test.ts b/packages/cli/src/acp/acpClient.test.ts
similarity index 78%
rename from packages/cli/src/zed-integration/zedIntegration.test.ts
rename to packages/cli/src/acp/acpClient.test.ts
index e8e5355dc0..e2fc0f0d33 100644
--- a/packages/cli/src/zed-integration/zedIntegration.test.ts
+++ b/packages/cli/src/acp/acpClient.test.ts
@@ -14,7 +14,8 @@ import {
type Mock,
type Mocked,
} from 'vitest';
-import { GeminiAgent, Session } from './zedIntegration.js';
+import { GeminiAgent, Session } from './acpClient.js';
+import type { CommandHandler } from './commandHandler.js';
import * as acp from '@agentclientprotocol/sdk';
import {
AuthType,
@@ -26,6 +27,7 @@ import {
type Config,
type MessageBus,
LlmRole,
+ type GitService,
} from '@google/gemini-cli-core';
import {
SettingScope,
@@ -62,7 +64,33 @@ vi.mock('node:path', async (importOriginal) => {
};
});
-// Mock ReadManyFilesTool
+vi.mock('../ui/commands/memoryCommand.js', () => ({
+ memoryCommand: {
+ name: 'memory',
+ action: vi.fn(),
+ },
+}));
+
+vi.mock('../ui/commands/extensionsCommand.js', () => ({
+ extensionsCommand: vi.fn().mockReturnValue({
+ name: 'extensions',
+ action: vi.fn(),
+ }),
+}));
+
+vi.mock('../ui/commands/restoreCommand.js', () => ({
+ restoreCommand: vi.fn().mockReturnValue({
+ name: 'restore',
+ action: vi.fn(),
+ }),
+}));
+
+vi.mock('../ui/commands/initCommand.js', () => ({
+ initCommand: {
+ name: 'init',
+ action: vi.fn(),
+ },
+}));
vi.mock(
'@google/gemini-cli-core',
async (
@@ -144,7 +172,10 @@ describe('GeminiAgent', () => {
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
- isPlanEnabled: vi.fn().mockReturnValue(false),
+ isPlanEnabled: vi.fn().mockReturnValue(true),
+ getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
+ getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
+ getCheckpointingEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked>>;
mockSettings = {
merged: {
@@ -177,7 +208,16 @@ describe('GeminiAgent', () => {
});
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
- expect(response.authMethods).toHaveLength(3);
+ expect(response.authMethods).toHaveLength(4);
+ const gatewayAuth = response.authMethods?.find(
+ (m) => m.id === AuthType.GATEWAY,
+ );
+ expect(gatewayAuth?._meta).toEqual({
+ gateway: {
+ protocol: 'google',
+ restartRequired: 'false',
+ },
+ });
const geminiAuth = response.authMethods?.find(
(m) => m.id === AuthType.USE_GEMINI,
);
@@ -197,6 +237,8 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
undefined,
+ undefined,
+ undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -216,6 +258,8 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
'test-api-key',
+ undefined,
+ undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -224,7 +268,47 @@ describe('GeminiAgent', () => {
);
});
+ it('should authenticate correctly with gateway method', async () => {
+ await agent.authenticate({
+ methodId: AuthType.GATEWAY,
+ _meta: {
+ gateway: {
+ baseUrl: 'https://example.com',
+ headers: { Authorization: 'Bearer token' },
+ },
+ },
+ } as unknown as acp.AuthenticateRequest);
+
+ expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
+ AuthType.GATEWAY,
+ undefined,
+ 'https://example.com',
+ { Authorization: 'Bearer token' },
+ );
+ expect(mockSettings.setValue).toHaveBeenCalledWith(
+ SettingScope.User,
+ 'security.auth.selectedType',
+ AuthType.GATEWAY,
+ );
+ });
+
+ it('should throw acp.RequestError when gateway payload is malformed', async () => {
+ await expect(
+ agent.authenticate({
+ methodId: AuthType.GATEWAY,
+ _meta: {
+ gateway: {
+ // Invalid baseUrl
+ baseUrl: 123,
+ headers: { Authorization: 'Bearer token' },
+ },
+ },
+ } as unknown as acp.AuthenticateRequest),
+ ).rejects.toThrow(/Malformed gateway payload/);
+ });
+
it('should create a new session', async () => {
+ vi.useFakeTimers();
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
@@ -237,6 +321,17 @@ describe('GeminiAgent', () => {
expect(loadCliConfig).toHaveBeenCalled();
expect(mockConfig.initialize).toHaveBeenCalled();
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
+
+ // Verify deferred call
+ await vi.runAllTimersAsync();
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ update: expect.objectContaining({
+ sessionUpdate: 'available_commands_update',
+ }),
+ }),
+ );
+ vi.useRealTimers();
});
it('should return modes without plan mode when plan is disabled', async () => {
@@ -263,6 +358,38 @@ describe('GeminiAgent', () => {
],
currentModeId: 'default',
});
+ expect(response.models).toEqual({
+ availableModels: expect.arrayContaining([
+ expect.objectContaining({
+ modelId: 'auto-gemini-2.5',
+ name: 'Auto (Gemini 2.5)',
+ }),
+ ]),
+ currentModelId: 'gemini-pro',
+ });
+ });
+
+ it('should include preview models when user has access', async () => {
+ mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
+ mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
+
+ const response = await agent.newSession({
+ cwd: '/tmp',
+ mcpServers: [],
+ });
+
+ expect(response.models?.availableModels).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ modelId: 'auto-gemini-3',
+ name: expect.stringContaining('Auto'),
+ }),
+ expect.objectContaining({
+ modelId: 'gemini-3.1-pro-preview',
+ name: 'gemini-3.1-pro-preview',
+ }),
+ ]),
+ );
});
it('should return modes with plan mode when plan is enabled', async () => {
@@ -290,6 +417,15 @@ describe('GeminiAgent', () => {
],
currentModeId: 'plan',
});
+ expect(response.models).toEqual({
+ availableModels: expect.arrayContaining([
+ expect.objectContaining({
+ modelId: 'auto-gemini-2.5',
+ name: 'Auto (Gemini 2.5)',
+ }),
+ ]),
+ currentModelId: 'gemini-pro',
+ });
});
it('should fail session creation if Gemini API key is missing', async () => {
@@ -439,6 +575,32 @@ describe('GeminiAgent', () => {
}),
).rejects.toThrow('Session not found: unknown');
});
+
+ it('should delegate setModel to session (unstable)', async () => {
+ await agent.newSession({ cwd: '/tmp', mcpServers: [] });
+ const session = (
+ agent as unknown as { sessions: Map }
+ ).sessions.get('test-session-id');
+ if (!session) throw new Error('Session not found');
+ session.setModel = vi.fn().mockReturnValue({});
+
+ const result = await agent.unstable_setSessionModel({
+ sessionId: 'test-session-id',
+ modelId: 'gemini-2.0-pro-exp',
+ });
+
+ expect(session.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
+ expect(result).toEqual({});
+ });
+
+ it('should throw error when setting model on non-existent session (unstable)', async () => {
+ await expect(
+ agent.unstable_setSessionModel({
+ sessionId: 'unknown',
+ modelId: 'gemini-2.0-pro-exp',
+ }),
+ ).rejects.toThrow('Session not found: unknown');
+ });
});
describe('Session', () => {
@@ -477,6 +639,7 @@ describe('Session', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
+ getMcpServers: vi.fn(),
getFileService: vi.fn().mockReturnValue({
shouldIgnoreFile: vi.fn().mockReturnValue(false),
}),
@@ -486,7 +649,10 @@ describe('Session', () => {
getDebugMode: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
setApprovalMode: vi.fn(),
- isPlanEnabled: vi.fn().mockReturnValue(false),
+ setModel: vi.fn(),
+ isPlanEnabled: vi.fn().mockReturnValue(true),
+ getCheckpointingEnabled: vi.fn().mockReturnValue(false),
+ getGitService: vi.fn().mockResolvedValue({} as GitService),
waitForMcpInit: vi.fn(),
} as unknown as Mocked;
mockConnection = {
@@ -495,13 +661,38 @@ describe('Session', () => {
sendNotification: vi.fn(),
} as unknown as Mocked;
- session = new Session('session-1', mockChat, mockConfig, mockConnection);
+ session = new Session('session-1', mockChat, mockConfig, mockConnection, {
+ system: { settings: {} },
+ systemDefaults: { settings: {} },
+ user: { settings: {} },
+ workspace: { settings: {} },
+ merged: { settings: {} },
+ errors: [],
+ } as unknown as LoadedSettings);
});
afterEach(() => {
vi.clearAllMocks();
});
+ it('should send available commands', async () => {
+ await session.sendAvailableCommands();
+
+ expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ update: expect.objectContaining({
+ sessionUpdate: 'available_commands_update',
+ availableCommands: expect.arrayContaining([
+ expect.objectContaining({ name: 'memory' }),
+ expect.objectContaining({ name: 'extensions' }),
+ expect.objectContaining({ name: 'restore' }),
+ expect.objectContaining({ name: 'init' }),
+ ]),
+ }),
+ }),
+ );
+ });
+
it('should await MCP initialization before processing a prompt', async () => {
const stream = createMockStream([
{
@@ -551,6 +742,113 @@ describe('Session', () => {
expect(result).toEqual({ stopReason: 'end_turn' });
});
+ it('should handle /memory command', async () => {
+ const handleCommandSpy = vi
+ .spyOn(
+ (session as unknown as { commandHandler: CommandHandler })
+ .commandHandler,
+ 'handleCommand',
+ )
+ .mockResolvedValue(true);
+
+ const result = await session.prompt({
+ sessionId: 'session-1',
+ prompt: [{ type: 'text', text: '/memory view' }],
+ });
+
+ expect(result).toEqual({ stopReason: 'end_turn' });
+ expect(handleCommandSpy).toHaveBeenCalledWith(
+ '/memory view',
+ expect.any(Object),
+ );
+ expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
+ });
+
+ it('should handle /extensions command', async () => {
+ const handleCommandSpy = vi
+ .spyOn(
+ (session as unknown as { commandHandler: CommandHandler })
+ .commandHandler,
+ 'handleCommand',
+ )
+ .mockResolvedValue(true);
+
+ const result = await session.prompt({
+ sessionId: 'session-1',
+ prompt: [{ type: 'text', text: '/extensions list' }],
+ });
+
+ expect(result).toEqual({ stopReason: 'end_turn' });
+ expect(handleCommandSpy).toHaveBeenCalledWith(
+ '/extensions list',
+ expect.any(Object),
+ );
+ expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
+ });
+
+ it('should handle /extensions explore command', async () => {
+ const handleCommandSpy = vi
+ .spyOn(
+ (session as unknown as { commandHandler: CommandHandler })
+ .commandHandler,
+ 'handleCommand',
+ )
+ .mockResolvedValue(true);
+
+ const result = await session.prompt({
+ sessionId: 'session-1',
+ prompt: [{ type: 'text', text: '/extensions explore' }],
+ });
+
+ expect(result).toEqual({ stopReason: 'end_turn' });
+ expect(handleCommandSpy).toHaveBeenCalledWith(
+ '/extensions explore',
+ expect.any(Object),
+ );
+ expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
+ });
+
+ it('should handle /restore command', async () => {
+ const handleCommandSpy = vi
+ .spyOn(
+ (session as unknown as { commandHandler: CommandHandler })
+ .commandHandler,
+ 'handleCommand',
+ )
+ .mockResolvedValue(true);
+
+ const result = await session.prompt({
+ sessionId: 'session-1',
+ prompt: [{ type: 'text', text: '/restore' }],
+ });
+
+ expect(result).toEqual({ stopReason: 'end_turn' });
+ expect(handleCommandSpy).toHaveBeenCalledWith(
+ '/restore',
+ expect.any(Object),
+ );
+ expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
+ });
+
+ it('should handle /init command', async () => {
+ const handleCommandSpy = vi
+ .spyOn(
+ (session as unknown as { commandHandler: CommandHandler })
+ .commandHandler,
+ 'handleCommand',
+ )
+ .mockResolvedValue(true);
+
+ const result = await session.prompt({
+ sessionId: 'session-1',
+ prompt: [{ type: 'text', text: '/init' }],
+ });
+
+ expect(result).toEqual({ stopReason: 'end_turn' });
+ expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object));
+ expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
+ });
+
it('should handle tool calls', async () => {
const stream1 = createMockStream([
{
@@ -1207,4 +1505,30 @@ describe('Session', () => {
'Invalid or unavailable mode: invalid-mode',
);
});
+
+ it('should set model on config', () => {
+ session.setModel('gemini-2.0-flash-exp');
+ expect(mockConfig.setModel).toHaveBeenCalledWith('gemini-2.0-flash-exp');
+ });
+
+ it('should handle unquoted commands from autocomplete (with empty leading parts)', async () => {
+ // Mock handleCommand to verify it gets called
+ const handleCommandSpy = vi
+ .spyOn(
+ (session as unknown as { commandHandler: CommandHandler })
+ .commandHandler,
+ 'handleCommand',
+ )
+ .mockResolvedValue(true);
+
+ await session.prompt({
+ sessionId: 'session-1',
+ prompt: [
+ { type: 'text', text: '' },
+ { type: 'text', text: '/memory' },
+ ],
+ });
+
+ expect(handleCommandSpy).toHaveBeenCalledWith('/memory', expect.anything());
+ });
});
diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/acp/acpClient.ts
similarity index 83%
rename from packages/cli/src/zed-integration/zedIntegration.ts
rename to packages/cli/src/acp/acpClient.ts
index 8f61a24358..ca0b8f805f 100644
--- a/packages/cli/src/zed-integration/zedIntegration.ts
+++ b/packages/cli/src/acp/acpClient.ts
@@ -4,15 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type {
- Config,
- GeminiChat,
- ToolResult,
- ToolCallConfirmationDetails,
- FilterFilesOptions,
- ConversationRecord,
-} from '@google/gemini-cli-core';
import {
+ type Config,
+ type GeminiChat,
+ type ToolResult,
+ type ToolCallConfirmationDetails,
+ type FilterFilesOptions,
+ type ConversationRecord,
CoreToolCallStatus,
AuthType,
logToolCall,
@@ -39,6 +37,16 @@ import {
ApprovalMode,
getVersion,
convertSessionToClientHistory,
+ DEFAULT_GEMINI_MODEL,
+ DEFAULT_GEMINI_FLASH_MODEL,
+ DEFAULT_GEMINI_FLASH_LITE_MODEL,
+ PREVIEW_GEMINI_MODEL,
+ PREVIEW_GEMINI_3_1_MODEL,
+ PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
+ PREVIEW_GEMINI_FLASH_MODEL,
+ DEFAULT_GEMINI_MODEL_AUTO,
+ PREVIEW_GEMINI_MODEL_AUTO,
+ getDisplayString,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -61,11 +69,14 @@ import { loadCliConfig } from '../config/config.js';
import { runExitCleanup } from '../utils/cleanup.js';
import { SessionSelector } from '../utils/sessionUtils.js';
-export async function runZedIntegration(
+import { CommandHandler } from './commandHandler.js';
+export async function runAcpClient(
config: Config,
settings: LoadedSettings,
argv: CliArgs,
) {
+ // ... (skip unchanged lines) ...
+
const { stdout: workingStdout } = createWorkingStdio();
const stdout = Writable.toWeb(workingStdout) as WritableStream;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -87,6 +98,8 @@ export class GeminiAgent {
private sessions: Map = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
private apiKey: string | undefined;
+ private baseUrl: string | undefined;
+ private customHeaders: Record | undefined;
constructor(
private config: Config,
@@ -120,6 +133,17 @@ export class GeminiAgent {
name: 'Vertex AI',
description: 'Use an API key with Vertex AI GenAI API',
},
+ {
+ id: AuthType.GATEWAY,
+ name: 'AI API Gateway',
+ description: 'Use a custom AI API Gateway',
+ _meta: {
+ gateway: {
+ protocol: 'google',
+ restartRequired: 'false',
+ },
+ },
+ },
];
await this.config.initialize();
@@ -168,7 +192,38 @@ export class GeminiAgent {
if (apiKey) {
this.apiKey = apiKey;
}
- await this.config.refreshAuth(method, apiKey ?? this.apiKey);
+
+ // Extract gateway details if present
+ const gatewaySchema = z.object({
+ baseUrl: z.string().optional(),
+ headers: z.record(z.string()).optional(),
+ });
+
+ let baseUrl: string | undefined;
+ let headers: Record | undefined;
+
+ if (meta?.['gateway']) {
+ const result = gatewaySchema.safeParse(meta['gateway']);
+ if (result.success) {
+ baseUrl = result.data.baseUrl;
+ headers = result.data.headers;
+ } else {
+ throw new acp.RequestError(
+ -32602,
+ `Malformed gateway payload: ${result.error.message}`,
+ );
+ }
+ }
+
+ this.baseUrl = baseUrl;
+ this.customHeaders = headers;
+
+ await this.config.refreshAuth(
+ method,
+ apiKey ?? this.apiKey,
+ baseUrl,
+ headers,
+ );
} catch (e) {
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
}
@@ -198,7 +253,12 @@ export class GeminiAgent {
let isAuthenticated = false;
let authErrorMessage = '';
try {
- await config.refreshAuth(authType, this.apiKey);
+ await config.refreshAuth(
+ authType,
+ this.apiKey,
+ this.baseUrl,
+ this.customHeaders,
+ );
isAuthenticated = true;
// Extra validation for Gemini API key
@@ -240,16 +300,37 @@ export class GeminiAgent {
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
- const session = new Session(sessionId, chat, config, this.connection);
+ const session = new Session(
+ sessionId,
+ chat,
+ config,
+ this.connection,
+ this.settings,
+ );
this.sessions.set(sessionId, session);
- return {
+ setTimeout(() => {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ session.sendAvailableCommands();
+ }, 0);
+
+ const { availableModels, currentModelId } = buildAvailableModels(
+ config,
+ loadedSettings,
+ );
+
+ const response = {
sessionId,
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
+ models: {
+ availableModels,
+ currentModelId,
+ },
};
+ return response;
}
async loadSession({
@@ -291,6 +372,7 @@ export class GeminiAgent {
geminiClient.getChat(),
config,
this.connection,
+ this.settings,
);
this.sessions.set(sessionId, session);
@@ -298,12 +380,27 @@ export class GeminiAgent {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.streamHistory(sessionData.messages);
- return {
+ setTimeout(() => {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ session.sendAvailableCommands();
+ }, 0);
+
+ const { availableModels, currentModelId } = buildAvailableModels(
+ config,
+ this.settings,
+ );
+
+ const response = {
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
+ models: {
+ availableModels,
+ currentModelId,
+ },
};
+ return response;
}
private async initializeSessionConfig(
@@ -323,7 +420,12 @@ export class GeminiAgent {
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
- await config.refreshAuth(selectedAuthType, this.apiKey);
+ await config.refreshAuth(
+ selectedAuthType,
+ this.apiKey,
+ this.baseUrl,
+ this.customHeaders,
+ );
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
@@ -414,16 +516,28 @@ export class GeminiAgent {
}
return session.setMode(params.modeId);
}
+
+ async unstable_setSessionModel(
+ params: acp.SetSessionModelRequest,
+ ): Promise {
+ const session = this.sessions.get(params.sessionId);
+ if (!session) {
+ throw new Error(`Session not found: ${params.sessionId}`);
+ }
+ return session.setModel(params.modelId);
+ }
}
export class Session {
private pendingPrompt: AbortController | null = null;
+ private commandHandler = new CommandHandler();
constructor(
private readonly id: string,
private readonly chat: GeminiChat,
private readonly config: Config,
private readonly connection: acp.AgentSideConnection,
+ private readonly settings: LoadedSettings,
) {}
async cancelPendingPrompt(): Promise {
@@ -446,6 +560,27 @@ export class Session {
return {};
}
+ private getAvailableCommands() {
+ return this.commandHandler.getAvailableCommands();
+ }
+
+ async sendAvailableCommands(): Promise {
+ const availableCommands = this.getAvailableCommands().map((command) => ({
+ name: command.name,
+ description: command.description,
+ }));
+
+ await this.sendUpdate({
+ sessionUpdate: 'available_commands_update',
+ availableCommands,
+ });
+ }
+
+ setModel(modelId: acp.ModelId): acp.SetSessionModelResponse {
+ this.config.setModel(modelId);
+ return {};
+ }
+
async streamHistory(messages: ConversationRecord['messages']): Promise {
for (const msg of messages) {
const contentString = partListUnionToString(msg.content);
@@ -528,6 +663,41 @@ export class Session {
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
+ // Command interception
+ let commandText = '';
+
+ for (const part of parts) {
+ if (typeof part === 'object' && part !== null) {
+ if ('text' in part) {
+ // It is a text part
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion
+ const text = (part as any).text;
+ if (typeof text === 'string') {
+ commandText += text;
+ }
+ } else {
+ // Non-text part (image, embedded resource)
+ // Stop looking for command
+ break;
+ }
+ }
+ }
+
+ commandText = commandText.trim();
+
+ if (
+ commandText &&
+ (commandText.startsWith('/') || commandText.startsWith('$'))
+ ) {
+ // If we found a command, pass it to handleCommand
+ // Note: handleCommand currently expects `commandText` to be the command string
+ // It uses `parts` argument but effectively ignores it in current implementation
+ const handled = await this.handleCommand(commandText, parts);
+ if (handled) {
+ return { stopReason: 'end_turn' };
+ }
+ }
+
let nextMessage: Content | null = { role: 'user', parts };
while (nextMessage !== null) {
@@ -627,9 +797,28 @@ export class Session {
return { stopReason: 'end_turn' };
}
- private async sendUpdate(
- update: acp.SessionNotification['update'],
- ): Promise {
+ private async handleCommand(
+ commandText: string,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ parts: Part[],
+ ): Promise {
+ const gitService = await this.config.getGitService();
+ const commandContext = {
+ config: this.config,
+ settings: this.settings,
+ git: gitService,
+ sendMessage: async (text: string) => {
+ await this.sendUpdate({
+ sessionUpdate: 'agent_message_chunk',
+ content: { type: 'text', text },
+ });
+ },
+ };
+
+ return this.commandHandler.handleCommand(commandText, commandContext);
+ }
+
+ private async sendUpdate(update: acp.SessionUpdate): Promise {
const params: acp.SessionNotification = {
sessionId: this.id,
update,
@@ -1377,3 +1566,94 @@ function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
return modes;
}
+
+function buildAvailableModels(
+ config: Config,
+ settings: LoadedSettings,
+): {
+ availableModels: Array<{
+ modelId: string;
+ name: string;
+ description?: string;
+ }>;
+ currentModelId: string;
+} {
+ const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
+ const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
+ const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
+ const selectedAuthType = settings.merged.security.auth.selectedType;
+ const useCustomToolModel =
+ useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
+
+ const mainOptions = [
+ {
+ value: DEFAULT_GEMINI_MODEL_AUTO,
+ title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
+ description:
+ 'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
+ },
+ ];
+
+ if (shouldShowPreviewModels) {
+ mainOptions.unshift({
+ value: PREVIEW_GEMINI_MODEL_AUTO,
+ title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
+ description: useGemini31
+ ? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
+ : 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
+ });
+ }
+
+ const manualOptions = [
+ {
+ value: DEFAULT_GEMINI_MODEL,
+ title: getDisplayString(DEFAULT_GEMINI_MODEL),
+ },
+ {
+ value: DEFAULT_GEMINI_FLASH_MODEL,
+ title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
+ },
+ {
+ value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
+ title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
+ },
+ ];
+
+ if (shouldShowPreviewModels) {
+ const previewProModel = useGemini31
+ ? PREVIEW_GEMINI_3_1_MODEL
+ : PREVIEW_GEMINI_MODEL;
+
+ const previewProValue = useCustomToolModel
+ ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
+ : previewProModel;
+
+ manualOptions.unshift(
+ {
+ value: previewProValue,
+ title: getDisplayString(previewProModel),
+ },
+ {
+ value: PREVIEW_GEMINI_FLASH_MODEL,
+ title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
+ },
+ );
+ }
+
+ const scaleOptions = (
+ options: Array<{ value: string; title: string; description?: string }>,
+ ) =>
+ options.map((o) => ({
+ modelId: o.value,
+ name: o.title,
+ description: o.description,
+ }));
+
+ return {
+ availableModels: [
+ ...scaleOptions(mainOptions),
+ ...scaleOptions(manualOptions),
+ ],
+ currentModelId: preferredModel,
+ };
+}
diff --git a/packages/cli/src/zed-integration/acpErrors.test.ts b/packages/cli/src/acp/acpErrors.test.ts
similarity index 100%
rename from packages/cli/src/zed-integration/acpErrors.test.ts
rename to packages/cli/src/acp/acpErrors.test.ts
diff --git a/packages/cli/src/zed-integration/acpErrors.ts b/packages/cli/src/acp/acpErrors.ts
similarity index 100%
rename from packages/cli/src/zed-integration/acpErrors.ts
rename to packages/cli/src/acp/acpErrors.ts
diff --git a/packages/cli/src/zed-integration/acpResume.test.ts b/packages/cli/src/acp/acpResume.test.ts
similarity index 93%
rename from packages/cli/src/zed-integration/acpResume.test.ts
rename to packages/cli/src/acp/acpResume.test.ts
index 8ad043fa56..090e088db1 100644
--- a/packages/cli/src/zed-integration/acpResume.test.ts
+++ b/packages/cli/src/acp/acpResume.test.ts
@@ -13,7 +13,7 @@ import {
type Mocked,
type Mock,
} from 'vitest';
-import { GeminiAgent } from './zedIntegration.js';
+import { GeminiAgent } from './acpClient.js';
import * as acp from '@agentclientprotocol/sdk';
import {
ApprovalMode,
@@ -92,7 +92,11 @@ describe('GeminiAgent Session Resume', () => {
getWorkspaceTempDir: vi.fn().mockReturnValue('/tmp/workspace'),
},
getApprovalMode: vi.fn().mockReturnValue('default'),
- isPlanEnabled: vi.fn().mockReturnValue(false),
+ isPlanEnabled: vi.fn().mockReturnValue(true),
+ getModel: vi.fn().mockReturnValue('gemini-pro'),
+ getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
+ getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
+ getCheckpointingEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked;
mockSettings = {
merged: {
@@ -200,9 +204,18 @@ describe('GeminiAgent Session Resume', () => {
name: 'YOLO',
description: 'Auto-approves all tools',
},
+ {
+ id: ApprovalMode.PLAN,
+ name: 'Plan',
+ description: 'Read-only mode',
+ },
],
currentModeId: ApprovalMode.DEFAULT,
},
+ models: {
+ availableModels: expect.any(Array) as unknown,
+ currentModelId: 'gemini-pro',
+ },
});
// Verify resumeChat received the correct arguments
diff --git a/packages/cli/src/acp/commandHandler.test.ts b/packages/cli/src/acp/commandHandler.test.ts
new file mode 100644
index 0000000000..8e04f014f3
--- /dev/null
+++ b/packages/cli/src/acp/commandHandler.test.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { CommandHandler } from './commandHandler.js';
+import { describe, it, expect } from 'vitest';
+
+describe('CommandHandler', () => {
+ it('parses commands correctly', () => {
+ const handler = new CommandHandler();
+ // @ts-expect-error - testing private method
+ const parse = (query: string) => handler.parseSlashCommand(query);
+
+ const memShow = parse('/memory show');
+ expect(memShow.commandToExecute?.name).toBe('memory show');
+ expect(memShow.args).toBe('');
+
+ const memAdd = parse('/memory add hello world');
+ expect(memAdd.commandToExecute?.name).toBe('memory add');
+ expect(memAdd.args).toBe('hello world');
+
+ const extList = parse('/extensions list');
+ expect(extList.commandToExecute?.name).toBe('extensions list');
+
+ const init = parse('/init');
+ expect(init.commandToExecute?.name).toBe('init');
+ });
+});
diff --git a/packages/cli/src/acp/commandHandler.ts b/packages/cli/src/acp/commandHandler.ts
new file mode 100644
index 0000000000..836cdf7736
--- /dev/null
+++ b/packages/cli/src/acp/commandHandler.ts
@@ -0,0 +1,134 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Command, CommandContext } from './commands/types.js';
+import { CommandRegistry } from './commands/commandRegistry.js';
+import { MemoryCommand } from './commands/memory.js';
+import { ExtensionsCommand } from './commands/extensions.js';
+import { InitCommand } from './commands/init.js';
+import { RestoreCommand } from './commands/restore.js';
+
+export class CommandHandler {
+ private registry: CommandRegistry;
+
+ constructor() {
+ this.registry = CommandHandler.createRegistry();
+ }
+
+ private static createRegistry(): CommandRegistry {
+ const registry = new CommandRegistry();
+ registry.register(new MemoryCommand());
+ registry.register(new ExtensionsCommand());
+ registry.register(new InitCommand());
+ registry.register(new RestoreCommand());
+ return registry;
+ }
+
+ getAvailableCommands(): Array<{ name: string; description: string }> {
+ return this.registry.getAllCommands().map((cmd) => ({
+ name: cmd.name,
+ description: cmd.description,
+ }));
+ }
+
+ /**
+ * Parses and executes a command string if it matches a registered command.
+ * Returns true if a command was handled, false otherwise.
+ */
+ async handleCommand(
+ commandText: string,
+ context: CommandContext,
+ ): Promise {
+ const { commandToExecute, args } = this.parseSlashCommand(commandText);
+
+ if (commandToExecute) {
+ await this.runCommand(commandToExecute, args, context);
+ return true;
+ }
+
+ return false;
+ }
+
+ private async runCommand(
+ commandToExecute: Command,
+ args: string,
+ context: CommandContext,
+ ): Promise {
+ try {
+ const result = await commandToExecute.execute(
+ context,
+ args ? args.split(/\s+/) : [],
+ );
+
+ let messageContent = '';
+ if (typeof result.data === 'string') {
+ messageContent = result.data;
+ } else if (
+ typeof result.data === 'object' &&
+ result.data !== null &&
+ 'content' in result.data
+ ) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
+ messageContent = (result.data as Record)[
+ 'content'
+ ] as string;
+ } else {
+ messageContent = JSON.stringify(result.data, null, 2);
+ }
+
+ await context.sendMessage(messageContent);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ await context.sendMessage(`Error: ${errorMessage}`);
+ }
+ }
+
+ /**
+ * Parses a raw slash command string into its matching headless command and arguments.
+ * Mirrors `packages/cli/src/utils/commands.ts` logic.
+ */
+ private parseSlashCommand(query: string): {
+ commandToExecute: Command | undefined;
+ args: string;
+ } {
+ const trimmed = query.trim();
+ const parts = trimmed.substring(1).trim().split(/\s+/);
+ const commandPath = parts.filter((p) => p);
+
+ let currentCommands = this.registry.getAllCommands();
+ let commandToExecute: Command | undefined;
+ let pathIndex = 0;
+
+ for (const part of commandPath) {
+ const foundCommand = currentCommands.find((cmd) => {
+ const expectedName = commandPath.slice(0, pathIndex + 1).join(' ');
+ return (
+ cmd.name === part ||
+ cmd.name === expectedName ||
+ cmd.aliases?.includes(part) ||
+ cmd.aliases?.includes(expectedName)
+ );
+ });
+
+ if (foundCommand) {
+ commandToExecute = foundCommand;
+ pathIndex++;
+ if (foundCommand.subCommands) {
+ currentCommands = foundCommand.subCommands;
+ } else {
+ break;
+ }
+ } else {
+ break;
+ }
+ }
+
+ const args = parts.slice(pathIndex).join(' ');
+
+ return { commandToExecute, args };
+ }
+}
diff --git a/packages/cli/src/acp/commands/commandRegistry.ts b/packages/cli/src/acp/commands/commandRegistry.ts
new file mode 100644
index 0000000000..b689d5d602
--- /dev/null
+++ b/packages/cli/src/acp/commands/commandRegistry.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { debugLogger } from '@google/gemini-cli-core';
+import type { Command } from './types.js';
+
+export class CommandRegistry {
+ private readonly commands = new Map();
+
+ register(command: Command) {
+ if (this.commands.has(command.name)) {
+ debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
+ return;
+ }
+
+ this.commands.set(command.name, command);
+
+ for (const subCommand of command.subCommands ?? []) {
+ this.register(subCommand);
+ }
+ }
+
+ get(commandName: string): Command | undefined {
+ return this.commands.get(commandName);
+ }
+
+ getAllCommands(): Command[] {
+ return [...this.commands.values()];
+ }
+}
diff --git a/packages/cli/src/acp/commands/extensions.ts b/packages/cli/src/acp/commands/extensions.ts
new file mode 100644
index 0000000000..d2946e64a6
--- /dev/null
+++ b/packages/cli/src/acp/commands/extensions.ts
@@ -0,0 +1,445 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { listExtensions } from '@google/gemini-cli-core';
+import { SettingScope } from '../../config/settings.js';
+import {
+ ExtensionManager,
+ inferInstallMetadata,
+} from '../../config/extension-manager.js';
+import { getErrorMessage } from '../../utils/errors.js';
+import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
+import { stat } from 'node:fs/promises';
+import type {
+ Command,
+ CommandContext,
+ CommandExecutionResponse,
+} from './types.js';
+import type { Config } from '@google/gemini-cli-core';
+
+export class ExtensionsCommand implements Command {
+ readonly name = 'extensions';
+ readonly description = 'Manage extensions.';
+ readonly subCommands = [
+ new ListExtensionsCommand(),
+ new ExploreExtensionsCommand(),
+ new EnableExtensionCommand(),
+ new DisableExtensionCommand(),
+ new InstallExtensionCommand(),
+ new LinkExtensionCommand(),
+ new UninstallExtensionCommand(),
+ new RestartExtensionCommand(),
+ new UpdateExtensionCommand(),
+ ];
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ return new ListExtensionsCommand().execute(context, _);
+ }
+}
+
+export class ListExtensionsCommand implements Command {
+ readonly name = 'extensions list';
+ readonly description = 'Lists all installed extensions.';
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ const extensions = listExtensions(context.config);
+ const data = extensions.length ? extensions : 'No extensions installed.';
+
+ return { name: this.name, data };
+ }
+}
+
+export class ExploreExtensionsCommand implements Command {
+ readonly name = 'extensions explore';
+ readonly description = 'Explore available extensions.';
+
+ async execute(
+ _context: CommandContext,
+ _: string[],
+ ): Promise {
+ const extensionsUrl = 'https://geminicli.com/extensions/';
+ return {
+ name: this.name,
+ data: `View or install available extensions at ${extensionsUrl}`,
+ };
+ }
+}
+
+function getEnableDisableContext(
+ config: Config,
+ args: string[],
+ invocationName: string,
+) {
+ const extensionManager = config.getExtensionLoader();
+ if (!(extensionManager instanceof ExtensionManager)) {
+ return {
+ error: `Cannot ${invocationName} extensions in this environment.`,
+ };
+ }
+
+ if (args.length === 0) {
+ return {
+ error: `Usage: /extensions ${invocationName} [--scope=]`,
+ };
+ }
+
+ let scope = SettingScope.User;
+ if (args.includes('--scope=workspace') || args.includes('workspace')) {
+ scope = SettingScope.Workspace;
+ } else if (args.includes('--scope=session') || args.includes('session')) {
+ scope = SettingScope.Session;
+ }
+
+ const name = args.filter(
+ (a) =>
+ !a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a),
+ )[0];
+
+ let names: string[] = [];
+ if (name === '--all') {
+ let extensions = extensionManager.getExtensions();
+ if (invocationName === 'enable') {
+ extensions = extensions.filter((ext) => !ext.isActive);
+ }
+ if (invocationName === 'disable') {
+ extensions = extensions.filter((ext) => ext.isActive);
+ }
+ names = extensions.map((ext) => ext.name);
+ } else if (name) {
+ names = [name];
+ } else {
+ return { error: 'No extension name provided.' };
+ }
+
+ return { extensionManager, names, scope };
+}
+
+export class EnableExtensionCommand implements Command {
+ readonly name = 'extensions enable';
+ readonly description = 'Enable an extension.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const enableContext = getEnableDisableContext(
+ context.config,
+ args,
+ 'enable',
+ );
+ if ('error' in enableContext) {
+ return { name: this.name, data: enableContext.error };
+ }
+
+ const { names, scope, extensionManager } = enableContext;
+ const output: string[] = [];
+
+ for (const name of names) {
+ try {
+ await extensionManager.enableExtension(name, scope);
+ output.push(`Extension "${name}" enabled for scope "${scope}".`);
+
+ const extension = extensionManager
+ .getExtensions()
+ .find((e) => e.name === name);
+
+ if (extension?.mcpServers) {
+ const mcpEnablementManager = McpServerEnablementManager.getInstance();
+ const mcpClientManager = context.config.getMcpClientManager();
+ const enabledServers = await mcpEnablementManager.autoEnableServers(
+ Object.keys(extension.mcpServers),
+ );
+
+ if (mcpClientManager && enabledServers.length > 0) {
+ const restartPromises = enabledServers.map((serverName) =>
+ mcpClientManager.restartServer(serverName).catch((error) => {
+ output.push(
+ `Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`,
+ );
+ }),
+ );
+ await Promise.all(restartPromises);
+ output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`);
+ }
+ }
+ } catch (e) {
+ output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`);
+ }
+ }
+
+ return { name: this.name, data: output.join('\n') || 'No action taken.' };
+ }
+}
+
+export class DisableExtensionCommand implements Command {
+ readonly name = 'extensions disable';
+ readonly description = 'Disable an extension.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const enableContext = getEnableDisableContext(
+ context.config,
+ args,
+ 'disable',
+ );
+ if ('error' in enableContext) {
+ return { name: this.name, data: enableContext.error };
+ }
+
+ const { names, scope, extensionManager } = enableContext;
+ const output: string[] = [];
+
+ for (const name of names) {
+ try {
+ await extensionManager.disableExtension(name, scope);
+ output.push(`Extension "${name}" disabled for scope "${scope}".`);
+ } catch (e) {
+ output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`);
+ }
+ }
+
+ return { name: this.name, data: output.join('\n') || 'No action taken.' };
+ }
+}
+
+export class InstallExtensionCommand implements Command {
+ readonly name = 'extensions install';
+ readonly description = 'Install an extension from a git repo or local path.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const extensionLoader = context.config.getExtensionLoader();
+ if (!(extensionLoader instanceof ExtensionManager)) {
+ return {
+ name: this.name,
+ data: 'Cannot install extensions in this environment.',
+ };
+ }
+
+ const source = args.join(' ').trim();
+ if (!source) {
+ return { name: this.name, data: `Usage: /extensions install ` };
+ }
+
+ if (/[;&|`'"]/.test(source)) {
+ return {
+ name: this.name,
+ data: `Invalid source: contains disallowed characters.`,
+ };
+ }
+
+ try {
+ const installMetadata = await inferInstallMetadata(source);
+ const extension =
+ await extensionLoader.installOrUpdateExtension(installMetadata);
+ return {
+ name: this.name,
+ data: `Extension "${extension.name}" installed successfully.`,
+ };
+ } catch (error) {
+ return {
+ name: this.name,
+ data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`,
+ };
+ }
+ }
+}
+
+export class LinkExtensionCommand implements Command {
+ readonly name = 'extensions link';
+ readonly description = 'Link an extension from a local path.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const extensionLoader = context.config.getExtensionLoader();
+ if (!(extensionLoader instanceof ExtensionManager)) {
+ return {
+ name: this.name,
+ data: 'Cannot link extensions in this environment.',
+ };
+ }
+
+ const sourceFilepath = args.join(' ').trim();
+ if (!sourceFilepath) {
+ return { name: this.name, data: `Usage: /extensions link ` };
+ }
+
+ try {
+ await stat(sourceFilepath);
+ } catch (_error) {
+ return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
+ }
+
+ try {
+ const extension = await extensionLoader.installOrUpdateExtension({
+ source: sourceFilepath,
+ type: 'link',
+ });
+ return {
+ name: this.name,
+ data: `Extension "${extension.name}" linked successfully.`,
+ };
+ } catch (error) {
+ return {
+ name: this.name,
+ data: `Failed to link extension: ${getErrorMessage(error)}`,
+ };
+ }
+ }
+}
+
+export class UninstallExtensionCommand implements Command {
+ readonly name = 'extensions uninstall';
+ readonly description = 'Uninstall an extension.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const extensionLoader = context.config.getExtensionLoader();
+ if (!(extensionLoader instanceof ExtensionManager)) {
+ return {
+ name: this.name,
+ data: 'Cannot uninstall extensions in this environment.',
+ };
+ }
+
+ const all = args.includes('--all');
+ const names = args.filter((a) => !a.startsWith('--')).map((a) => a.trim());
+
+ if (!all && names.length === 0) {
+ return {
+ name: this.name,
+ data: `Usage: /extensions uninstall |--all`,
+ };
+ }
+
+ let namesToUninstall: string[] = [];
+ if (all) {
+ namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name);
+ } else {
+ namesToUninstall = names;
+ }
+
+ if (namesToUninstall.length === 0) {
+ return {
+ name: this.name,
+ data: all ? 'No extensions installed.' : 'No extension name provided.',
+ };
+ }
+
+ const output: string[] = [];
+ for (const extensionName of namesToUninstall) {
+ try {
+ await extensionLoader.uninstallExtension(extensionName, false);
+ output.push(`Extension "${extensionName}" uninstalled successfully.`);
+ } catch (error) {
+ output.push(
+ `Failed to uninstall extension "${extensionName}": ${getErrorMessage(error)}`,
+ );
+ }
+ }
+
+ return { name: this.name, data: output.join('\n') };
+ }
+}
+
+export class RestartExtensionCommand implements Command {
+ readonly name = 'extensions restart';
+ readonly description = 'Restart an extension.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const extensionLoader = context.config.getExtensionLoader();
+ if (!(extensionLoader instanceof ExtensionManager)) {
+ return { name: this.name, data: 'Cannot restart extensions.' };
+ }
+
+ const all = args.includes('--all');
+ const names = all ? null : args.filter((a) => !!a);
+
+ if (!all && names?.length === 0) {
+ return {
+ name: this.name,
+ data: 'Usage: /extensions restart |--all',
+ };
+ }
+
+ let extensionsToRestart = extensionLoader
+ .getExtensions()
+ .filter((e) => e.isActive);
+ if (names) {
+ extensionsToRestart = extensionsToRestart.filter((e) =>
+ names.includes(e.name),
+ );
+ }
+
+ if (extensionsToRestart.length === 0) {
+ return {
+ name: this.name,
+ data: 'No active extensions matched the request.',
+ };
+ }
+
+ const output: string[] = [];
+ for (const extension of extensionsToRestart) {
+ try {
+ await extensionLoader.restartExtension(extension);
+ output.push(`Restarted "${extension.name}".`);
+ } catch (e) {
+ output.push(
+ `Failed to restart "${extension.name}": ${getErrorMessage(e)}`,
+ );
+ }
+ }
+
+ return { name: this.name, data: output.join('\n') };
+ }
+}
+
+export class UpdateExtensionCommand implements Command {
+ readonly name = 'extensions update';
+ readonly description = 'Update an extension.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const extensionLoader = context.config.getExtensionLoader();
+ if (!(extensionLoader instanceof ExtensionManager)) {
+ return { name: this.name, data: 'Cannot update extensions.' };
+ }
+
+ const all = args.includes('--all');
+ const names = all ? null : args.filter((a) => !!a);
+
+ if (!all && names?.length === 0) {
+ return {
+ name: this.name,
+ data: 'Usage: /extensions update |--all',
+ };
+ }
+
+ return {
+ name: this.name,
+ data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.',
+ };
+ }
+}
diff --git a/packages/cli/src/acp/commands/init.ts b/packages/cli/src/acp/commands/init.ts
new file mode 100644
index 0000000000..5c4197f84c
--- /dev/null
+++ b/packages/cli/src/acp/commands/init.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { performInit } from '@google/gemini-cli-core';
+import type {
+ Command,
+ CommandContext,
+ CommandExecutionResponse,
+} from './types.js';
+
+export class InitCommand implements Command {
+ name = 'init';
+ description = 'Analyzes the project and creates a tailored GEMINI.md file';
+ requiresWorkspace = true;
+
+ async execute(
+ context: CommandContext,
+ _args: string[] = [],
+ ): Promise {
+ const targetDir = context.config.getTargetDir();
+ if (!targetDir) {
+ throw new Error('Command requires a workspace.');
+ }
+
+ const geminiMdPath = path.join(targetDir, 'GEMINI.md');
+ const result = performInit(fs.existsSync(geminiMdPath));
+
+ switch (result.type) {
+ case 'message':
+ return {
+ name: this.name,
+ data: result,
+ };
+ case 'submit_prompt':
+ fs.writeFileSync(geminiMdPath, '', 'utf8');
+
+ if (typeof result.content !== 'string') {
+ throw new Error('Init command content must be a string.');
+ }
+
+ // Inform the user since we can't trigger the UI-based interactive agent loop here directly.
+ // We output the prompt text they can use to re-trigger the generation manually,
+ // or just seed the GEMINI.md file as we've done above.
+ return {
+ name: this.name,
+ data: {
+ type: 'message',
+ messageType: 'info',
+ content: `A template GEMINI.md has been created at ${geminiMdPath}.\n\nTo populate it with project context, you can run the following prompt in a new chat:\n\n${result.content}`,
+ },
+ };
+
+ default:
+ throw new Error('Unknown result type from performInit');
+ }
+ }
+}
diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts
new file mode 100644
index 0000000000..9460af7ad1
--- /dev/null
+++ b/packages/cli/src/acp/commands/memory.ts
@@ -0,0 +1,121 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ addMemory,
+ listMemoryFiles,
+ refreshMemory,
+ showMemory,
+} from '@google/gemini-cli-core';
+import type {
+ Command,
+ CommandContext,
+ CommandExecutionResponse,
+} from './types.js';
+
+const DEFAULT_SANITIZATION_CONFIG = {
+ allowedEnvironmentVariables: [],
+ blockedEnvironmentVariables: [],
+ enableEnvironmentVariableRedaction: false,
+};
+
+export class MemoryCommand implements Command {
+ readonly name = 'memory';
+ readonly description = 'Manage memory.';
+ readonly subCommands = [
+ new ShowMemoryCommand(),
+ new RefreshMemoryCommand(),
+ new ListMemoryCommand(),
+ new AddMemoryCommand(),
+ ];
+ readonly requiresWorkspace = true;
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ return new ShowMemoryCommand().execute(context, _);
+ }
+}
+
+export class ShowMemoryCommand implements Command {
+ readonly name = 'memory show';
+ readonly description = 'Shows the current memory contents.';
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ const result = showMemory(context.config);
+ return { name: this.name, data: result.content };
+ }
+}
+
+export class RefreshMemoryCommand implements Command {
+ readonly name = 'memory refresh';
+ readonly aliases = ['memory reload'];
+ readonly description = 'Refreshes the memory from the source.';
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ const result = await refreshMemory(context.config);
+ return { name: this.name, data: result.content };
+ }
+}
+
+export class ListMemoryCommand implements Command {
+ readonly name = 'memory list';
+ readonly description = 'Lists the paths of the GEMINI.md files in use.';
+
+ async execute(
+ context: CommandContext,
+ _: string[],
+ ): Promise {
+ const result = listMemoryFiles(context.config);
+ return { name: this.name, data: result.content };
+ }
+}
+
+export class AddMemoryCommand implements Command {
+ readonly name = 'memory add';
+ readonly description = 'Add content to the memory.';
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const textToAdd = args.join(' ').trim();
+ const result = addMemory(textToAdd);
+ if (result.type === 'message') {
+ return { name: this.name, data: result.content };
+ }
+
+ const toolRegistry = context.config.getToolRegistry();
+ const tool = toolRegistry.getTool(result.toolName);
+ if (tool) {
+ const abortController = new AbortController();
+ const signal = abortController.signal;
+
+ await context.sendMessage(`Saving memory via ${result.toolName}...`);
+
+ await tool.buildAndExecute(result.toolArgs, signal, undefined, {
+ sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
+ });
+ await refreshMemory(context.config);
+ return {
+ name: this.name,
+ data: `Added memory: "${textToAdd}"`,
+ };
+ } else {
+ return {
+ name: this.name,
+ data: `Error: Tool ${result.toolName} not found.`,
+ };
+ }
+ }
+}
diff --git a/packages/cli/src/acp/commands/restore.ts b/packages/cli/src/acp/commands/restore.ts
new file mode 100644
index 0000000000..ec9166ed84
--- /dev/null
+++ b/packages/cli/src/acp/commands/restore.ts
@@ -0,0 +1,178 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ getCheckpointInfoList,
+ getToolCallDataSchema,
+ isNodeError,
+ performRestore,
+} from '@google/gemini-cli-core';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import type {
+ Command,
+ CommandContext,
+ CommandExecutionResponse,
+} from './types.js';
+
+export class RestoreCommand implements Command {
+ readonly name = 'restore';
+ readonly description =
+ 'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created';
+ readonly requiresWorkspace = true;
+ readonly subCommands = [new ListCheckpointsCommand()];
+
+ async execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise {
+ const { config, git: gitService } = context;
+ const argsStr = args.join(' ');
+
+ try {
+ if (!argsStr) {
+ return await new ListCheckpointsCommand().execute(context);
+ }
+
+ if (!config.getCheckpointingEnabled()) {
+ return {
+ name: this.name,
+ data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
+ };
+ }
+
+ const selectedFile = argsStr.endsWith('.json')
+ ? argsStr
+ : `${argsStr}.json`;
+
+ const checkpointDir = config.storage.getProjectTempCheckpointsDir();
+ const filePath = path.join(checkpointDir, selectedFile);
+
+ let data: string;
+ try {
+ data = await fs.readFile(filePath, 'utf-8');
+ } catch (error) {
+ if (isNodeError(error) && error.code === 'ENOENT') {
+ return {
+ name: this.name,
+ data: `File not found: ${selectedFile}`,
+ };
+ }
+ throw error;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const toolCallData = JSON.parse(data);
+ const ToolCallDataSchema = getToolCallDataSchema();
+ const parseResult = ToolCallDataSchema.safeParse(toolCallData);
+
+ if (!parseResult.success) {
+ return {
+ name: this.name,
+ data: 'Checkpoint file is invalid or corrupted.',
+ };
+ }
+
+ const restoreResultGenerator = performRestore(
+ parseResult.data,
+ gitService,
+ );
+
+ const restoreResult = [];
+ for await (const result of restoreResultGenerator) {
+ restoreResult.push(result);
+ }
+
+ // Format the result nicely since Zed just dumps data
+ const formattedResult = restoreResult
+ .map((r) => {
+ if (r.type === 'message') {
+ return `[${r.messageType.toUpperCase()}] ${r.content}`;
+ } else if (r.type === 'load_history') {
+ return `Loaded history with ${r.clientHistory.length} messages.`;
+ }
+ return `Restored: ${JSON.stringify(r)}`;
+ })
+ .join('\n');
+
+ return {
+ name: this.name,
+ data: formattedResult,
+ };
+ } catch (error) {
+ return {
+ name: this.name,
+ data: `An unexpected error occurred during restore: ${error}`,
+ };
+ }
+ }
+}
+
+export class ListCheckpointsCommand implements Command {
+ readonly name = 'restore list';
+ readonly description = 'Lists all available checkpoints.';
+
+ async execute(context: CommandContext): Promise {
+ const { config } = context;
+
+ try {
+ if (!config.getCheckpointingEnabled()) {
+ return {
+ name: this.name,
+ data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
+ };
+ }
+
+ const checkpointDir = config.storage.getProjectTempCheckpointsDir();
+ try {
+ await fs.mkdir(checkpointDir, { recursive: true });
+ } catch (_e) {
+ // Ignore
+ }
+
+ const files = await fs.readdir(checkpointDir);
+ const jsonFiles = files.filter((file) => file.endsWith('.json'));
+
+ if (jsonFiles.length === 0) {
+ return { name: this.name, data: 'No checkpoints found.' };
+ }
+
+ const checkpointFiles = new Map();
+ for (const file of jsonFiles) {
+ const filePath = path.join(checkpointDir, file);
+ const data = await fs.readFile(filePath, 'utf-8');
+ checkpointFiles.set(file, data);
+ }
+
+ const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
+
+ const formatted = checkpointInfoList
+ .map((info) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const i = info as Record;
+ const fileName = String(i['fileName'] || 'Unknown');
+ const toolName = String(i['toolName'] || 'Unknown');
+ const status = String(i['status'] || 'Unknown');
+ const timestamp = new Date(
+ Number(i['timestamp']) || 0,
+ ).toLocaleString();
+
+ return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`;
+ })
+ .join('\n');
+
+ return {
+ name: this.name,
+ data: `Available Checkpoints:\n${formatted}`,
+ };
+ } catch (_error) {
+ return {
+ name: this.name,
+ data: 'An unexpected error occurred while listing checkpoints.',
+ };
+ }
+ }
+}
diff --git a/packages/cli/src/acp/commands/types.ts b/packages/cli/src/acp/commands/types.ts
new file mode 100644
index 0000000000..099f0c923f
--- /dev/null
+++ b/packages/cli/src/acp/commands/types.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Config, GitService } from '@google/gemini-cli-core';
+import type { LoadedSettings } from '../../config/settings.js';
+
+export interface CommandContext {
+ config: Config;
+ settings: LoadedSettings;
+ git?: GitService;
+ sendMessage: (text: string) => Promise;
+}
+
+export interface CommandArgument {
+ readonly name: string;
+ readonly description: string;
+ readonly isRequired?: boolean;
+}
+
+export interface Command {
+ readonly name: string;
+ readonly aliases?: string[];
+ readonly description: string;
+ readonly arguments?: CommandArgument[];
+ readonly subCommands?: Command[];
+ readonly requiresWorkspace?: boolean;
+
+ execute(
+ context: CommandContext,
+ args: string[],
+ ): Promise;
+}
+
+export interface CommandExecutionResponse {
+ readonly name: string;
+ readonly data: unknown;
+}
diff --git a/packages/cli/src/zed-integration/fileSystemService.test.ts b/packages/cli/src/acp/fileSystemService.test.ts
similarity index 100%
rename from packages/cli/src/zed-integration/fileSystemService.test.ts
rename to packages/cli/src/acp/fileSystemService.test.ts
diff --git a/packages/cli/src/zed-integration/fileSystemService.ts b/packages/cli/src/acp/fileSystemService.ts
similarity index 100%
rename from packages/cli/src/zed-integration/fileSystemService.ts
rename to packages/cli/src/acp/fileSystemService.ts
diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts
index 5255dfeb83..1886444b88 100644
--- a/packages/cli/src/commands/extensions/install.ts
+++ b/packages/cli/src/commands/extensions/install.ts
@@ -5,6 +5,7 @@
*/
import type { CommandModule } from 'yargs';
+import * as path from 'node:path';
import chalk from 'chalk';
import {
debugLogger,
@@ -51,12 +52,13 @@ export async function handleInstall(args: InstallArgs) {
const settings = loadSettings(workspaceDir).merged;
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
- const resolvedPath = getRealPath(source);
- installMetadata.source = resolvedPath;
- const trustResult = isWorkspaceTrusted(settings, resolvedPath);
+ const absolutePath = path.resolve(source);
+ const realPath = getRealPath(absolutePath);
+ installMetadata.source = absolutePath;
+ const trustResult = isWorkspaceTrusted(settings, absolutePath);
if (trustResult.isTrusted !== true) {
const discoveryResults =
- await FolderTrustDiscoveryService.discover(resolvedPath);
+ await FolderTrustDiscoveryService.discover(realPath);
const hasDiscovery =
discoveryResults.commands.length > 0 ||
@@ -69,7 +71,7 @@ export async function handleInstall(args: InstallArgs) {
'',
chalk.bold('Do you trust the files in this folder?'),
'',
- `The extension source at "${resolvedPath}" is not trusted.`,
+ `The extension source at "${absolutePath}" is not trusted.`,
'',
'Trusting a folder allows Gemini CLI to load its local configurations,',
'including custom commands, hooks, MCP servers, agent skills, and',
@@ -127,10 +129,10 @@ export async function handleInstall(args: InstallArgs) {
);
if (confirmed) {
const trustedFolders = loadTrustedFolders();
- await trustedFolders.setValue(resolvedPath, TrustLevel.TRUST_FOLDER);
+ await trustedFolders.setValue(realPath, TrustLevel.TRUST_FOLDER);
} else {
throw new Error(
- `Installation aborted: Folder "${resolvedPath}" is not trusted.`,
+ `Installation aborted: Folder "${absolutePath}" is not trusted.`,
);
}
}
diff --git a/packages/cli/src/commands/extensions/uninstall.test.ts b/packages/cli/src/commands/extensions/uninstall.test.ts
index 8ae9f6d376..65aed446c5 100644
--- a/packages/cli/src/commands/extensions/uninstall.test.ts
+++ b/packages/cli/src/commands/extensions/uninstall.test.ts
@@ -28,6 +28,7 @@ import { getErrorMessage } from '../../utils/errors.js';
// Hoisted mocks - these survive vi.clearAllMocks()
const mockUninstallExtension = vi.hoisted(() => vi.fn());
const mockLoadExtensions = vi.hoisted(() => vi.fn());
+const mockGetExtensions = vi.hoisted(() => vi.fn());
// Mock dependencies with hoisted functions
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
@@ -38,6 +39,7 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => {
ExtensionManager: vi.fn().mockImplementation(() => ({
uninstallExtension: mockUninstallExtension,
loadExtensions: mockLoadExtensions,
+ getExtensions: mockGetExtensions,
setRequestConsent: vi.fn(),
setRequestSetting: vi.fn(),
})),
@@ -93,6 +95,7 @@ describe('extensions uninstall command', () => {
afterEach(() => {
mockLoadExtensions.mockClear();
mockUninstallExtension.mockClear();
+ mockGetExtensions.mockClear();
vi.clearAllMocks();
});
@@ -145,6 +148,41 @@ describe('extensions uninstall command', () => {
mockCwd.mockRestore();
});
+ it('should uninstall all extensions when --all flag is used', async () => {
+ mockLoadExtensions.mockResolvedValue(undefined);
+ mockUninstallExtension.mockResolvedValue(undefined);
+ mockGetExtensions.mockReturnValue([{ name: 'ext1' }, { name: 'ext2' }]);
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
+ await handleUninstall({ all: true });
+
+ expect(mockUninstallExtension).toHaveBeenCalledTimes(2);
+ expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false);
+ expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false);
+ expect(emitConsoleLog).toHaveBeenCalledWith(
+ 'log',
+ 'Extension "ext1" successfully uninstalled.',
+ );
+ expect(emitConsoleLog).toHaveBeenCalledWith(
+ 'log',
+ 'Extension "ext2" successfully uninstalled.',
+ );
+ mockCwd.mockRestore();
+ });
+
+ it('should log a message if no extensions are installed and --all flag is used', async () => {
+ mockLoadExtensions.mockResolvedValue(undefined);
+ mockGetExtensions.mockReturnValue([]);
+ const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
+ await handleUninstall({ all: true });
+
+ expect(mockUninstallExtension).not.toHaveBeenCalled();
+ expect(emitConsoleLog).toHaveBeenCalledWith(
+ 'log',
+ 'No extensions currently installed.',
+ );
+ mockCwd.mockRestore();
+ });
+
it('should report errors for failed uninstalls but continue with others', async () => {
mockLoadExtensions.mockResolvedValue(undefined);
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
@@ -236,13 +274,14 @@ describe('extensions uninstall command', () => {
const command = uninstallCommand;
it('should have correct command and describe', () => {
- expect(command.command).toBe('uninstall ');
+ expect(command.command).toBe('uninstall [names..]');
expect(command.describe).toBe('Uninstalls one or more extensions.');
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
+ option: Mock;
check: Mock;
}
@@ -250,11 +289,12 @@ describe('extensions uninstall command', () => {
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
+ option: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
- it('should configure positional argument', () => {
+ it('should configure arguments and options', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
@@ -264,18 +304,31 @@ describe('extensions uninstall command', () => {
type: 'string',
array: true,
});
+ expect(yargsMock.option).toHaveBeenCalledWith('all', {
+ type: 'boolean',
+ describe: 'Uninstall all installed extensions.',
+ default: false,
+ });
expect(yargsMock.check).toHaveBeenCalled();
});
- it('check function should throw for missing names', () => {
+ it('check function should throw for missing names and no --all flag', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
- expect(() => checkCallback({ names: [] })).toThrow(
- 'Please include at least one extension name to uninstall as a positional argument.',
+ expect(() => checkCallback({ names: [], all: false })).toThrow(
+ 'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.',
);
});
+
+ it('check function should pass if --all flag is used even without names', () => {
+ (command.builder as (yargs: Argv) => Argv)(
+ yargsMock as unknown as Argv,
+ );
+ const checkCallback = yargsMock.check.mock.calls[0][0];
+ expect(() => checkCallback({ names: [], all: true })).not.toThrow();
+ });
});
it('handler should call handleUninstall', async () => {
@@ -283,10 +336,17 @@ describe('extensions uninstall command', () => {
mockUninstallExtension.mockResolvedValue(undefined);
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
interface TestArgv {
- names: string[];
- [key: string]: unknown;
+ names?: string[];
+ all?: boolean;
+ _: string[];
+ $0: string;
}
- const argv: TestArgv = { names: ['my-extension'], _: [], $0: '' };
+ const argv: TestArgv = {
+ names: ['my-extension'],
+ all: false,
+ _: [],
+ $0: '',
+ };
await (command.handler as unknown as (args: TestArgv) => Promise)(
argv,
);
diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts
index a67a4d3abe..b78b9510df 100644
--- a/packages/cli/src/commands/extensions/uninstall.ts
+++ b/packages/cli/src/commands/extensions/uninstall.ts
@@ -14,7 +14,8 @@ import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface UninstallArgs {
- names: string[]; // can be extension names or source URLs.
+ names?: string[]; // can be extension names or source URLs.
+ all?: boolean;
}
export async function handleUninstall(args: UninstallArgs) {
@@ -28,8 +29,24 @@ export async function handleUninstall(args: UninstallArgs) {
});
await extensionManager.loadExtensions();
+ let namesToUninstall: string[] = [];
+ if (args.all) {
+ namesToUninstall = extensionManager
+ .getExtensions()
+ .map((ext) => ext.name);
+ } else if (args.names) {
+ namesToUninstall = [...new Set(args.names)];
+ }
+
+ if (namesToUninstall.length === 0) {
+ if (args.all) {
+ debugLogger.log('No extensions currently installed.');
+ }
+ return;
+ }
+
const errors: Array<{ name: string; error: string }> = [];
- for (const name of [...new Set(args.names)]) {
+ for (const name of namesToUninstall) {
try {
await extensionManager.uninstallExtension(name, false);
debugLogger.log(`Extension "${name}" successfully uninstalled.`);
@@ -51,7 +68,7 @@ export async function handleUninstall(args: UninstallArgs) {
}
export const uninstallCommand: CommandModule = {
- command: 'uninstall ',
+ command: 'uninstall [names..]',
describe: 'Uninstalls one or more extensions.',
builder: (yargs) =>
yargs
@@ -61,10 +78,15 @@ export const uninstallCommand: CommandModule = {
type: 'string',
array: true,
})
+ .option('all', {
+ type: 'boolean',
+ describe: 'Uninstall all installed extensions.',
+ default: false,
+ })
.check((argv) => {
- if (!argv.names || argv.names.length === 0) {
+ if (!argv.all && (!argv.names || argv.names.length === 0)) {
throw new Error(
- 'Please include at least one extension name to uninstall as a positional argument.',
+ 'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.',
);
}
return true;
@@ -72,7 +94,9 @@ export const uninstallCommand: CommandModule = {
handler: async (argv) => {
await handleUninstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- names: argv['names'] as string[],
+ names: argv['names'] as string[] | undefined,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ all: argv['all'] as boolean,
});
await exitCli();
},
diff --git a/packages/cli/src/commands/hooks/migrate.ts b/packages/cli/src/commands/hooks/migrate.ts
index 47cc8660d7..36bb2cf9aa 100644
--- a/packages/cli/src/commands/hooks/migrate.ts
+++ b/packages/cli/src/commands/hooks/migrate.ts
@@ -79,6 +79,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown {
migrated['command'] = hook['command'];
// Replace CLAUDE_PROJECT_DIR with GEMINI_PROJECT_DIR in command
+ // eslint-disable-next-line no-restricted-syntax
if (typeof migrated['command'] === 'string') {
migrated['command'] = migrated['command'].replace(
/\$CLAUDE_PROJECT_DIR/g,
@@ -93,6 +94,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown {
}
// Map timeout field (Claude uses seconds, Gemini uses seconds)
+ // eslint-disable-next-line no-restricted-syntax
if ('timeout' in hook && typeof hook['timeout'] === 'number') {
migrated['timeout'] = hook['timeout'];
}
@@ -140,6 +142,7 @@ function migrateClaudeHooks(claudeConfig: unknown): Record {
// Transform matcher
if (
'matcher' in definition &&
+ // eslint-disable-next-line no-restricted-syntax
typeof definition['matcher'] === 'string'
) {
migratedDef['matcher'] = transformMatcher(definition['matcher']);
diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts
index aaaf667815..54534961dd 100644
--- a/packages/cli/src/commands/mcp/list.test.ts
+++ b/packages/cli/src/commands/mcp/list.test.ts
@@ -14,11 +14,16 @@ import {
type Mock,
} from 'vitest';
import { listMcpServers } from './list.js';
-import { loadSettings, mergeSettings } from '../../config/settings.js';
+import {
+ loadSettings,
+ mergeSettings,
+ type LoadedSettings,
+} from '../../config/settings.js';
import { createTransport, debugLogger } from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ExtensionStorage } from '../../config/extensions/storage.js';
import { ExtensionManager } from '../../config/extension-manager.js';
+import { McpServerEnablementManager } from '../../config/mcp/index.js';
vi.mock('../../config/settings.js', async (importOriginal) => {
const actual =
@@ -45,6 +50,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
CONNECTED: 'CONNECTED',
CONNECTING: 'CONNECTING',
DISCONNECTED: 'DISCONNECTED',
+ BLOCKED: 'BLOCKED',
+ DISABLED: 'DISABLED',
},
Storage: Object.assign(
vi.fn().mockImplementation((_cwd: string) => ({
@@ -54,6 +61,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
})),
{
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
+ getGlobalGeminiDir: () => '/tmp/gemini',
},
),
GEMINI_DIR: '.gemini',
@@ -96,6 +104,12 @@ describe('mcp list command', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
+ McpServerEnablementManager.resetInstance();
+ // Use a mock for isFileEnabled to avoid reading real files
+ vi.spyOn(
+ McpServerEnablementManager.prototype,
+ 'isFileEnabled',
+ ).mockResolvedValue(true);
mockTransport = { close: vi.fn() };
mockClient = {
@@ -265,7 +279,10 @@ describe('mcp list command', () => {
mockClient.connect.mockResolvedValue(undefined);
mockClient.ping.mockResolvedValue(undefined);
- await listMcpServers(settingsWithAllowlist);
+ await listMcpServers({
+ merged: settingsWithAllowlist,
+ isTrusted: true,
+ } as unknown as LoadedSettings);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('allowed-server'),
@@ -304,4 +321,56 @@ describe('mcp list command', () => {
),
);
});
+
+ it('should display blocked status for servers in excluded list', async () => {
+ const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
+ mockedLoadSettings.mockReturnValue({
+ merged: {
+ ...defaultMergedSettings,
+ mcp: {
+ excluded: ['blocked-server'],
+ },
+ mcpServers: {
+ 'blocked-server': { command: '/test/server' },
+ },
+ },
+ isTrusted: true,
+ });
+
+ await listMcpServers();
+
+ expect(debugLogger.log).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'blocked-server: /test/server (stdio) - Blocked',
+ ),
+ );
+ expect(mockedCreateTransport).not.toHaveBeenCalled();
+ });
+
+ it('should display disabled status for servers disabled via enablement manager', async () => {
+ const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
+ mockedLoadSettings.mockReturnValue({
+ merged: {
+ ...defaultMergedSettings,
+ mcpServers: {
+ 'disabled-server': { command: '/test/server' },
+ },
+ },
+ isTrusted: true,
+ });
+
+ vi.spyOn(
+ McpServerEnablementManager.prototype,
+ 'isFileEnabled',
+ ).mockResolvedValue(false);
+
+ await listMcpServers();
+
+ expect(debugLogger.log).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'disabled-server: /test/server (stdio) - Disabled',
+ ),
+ );
+ expect(mockedCreateTransport).not.toHaveBeenCalled();
+ });
});
diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts
index 421c822a55..a1df1a8027 100644
--- a/packages/cli/src/commands/mcp/list.ts
+++ b/packages/cli/src/commands/mcp/list.ts
@@ -6,8 +6,11 @@
// File for 'gemini mcp list' command
import type { CommandModule } from 'yargs';
-import { type MergedSettings, loadSettings } from '../../config/settings.js';
-import type { MCPServerConfig } from '@google/gemini-cli-core';
+import {
+ type MergedSettings,
+ loadSettings,
+ type LoadedSettings,
+} from '../../config/settings.js';
import {
MCPServerStatus,
createTransport,
@@ -15,8 +18,13 @@ import {
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
} from '@google/gemini-cli-core';
+import type { MCPServerConfig } from '@google/gemini-cli-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ExtensionManager } from '../../config/extension-manager.js';
+import {
+ canLoadServer,
+ McpServerEnablementManager,
+} from '../../config/mcp/index.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
@@ -61,13 +69,13 @@ export async function getMcpServersFromConfig(
async function testMCPConnection(
serverName: string,
config: MCPServerConfig,
+ isTrusted: boolean,
+ activeSettings: MergedSettings,
): Promise {
- const settings = loadSettings();
-
// SECURITY: Only test connection if workspace is trusted or if it's a remote server.
// stdio servers execute local commands and must never run in untrusted workspaces.
const isStdio = !!config.command;
- if (isStdio && !settings.isTrusted) {
+ if (isStdio && !isTrusted) {
return MCPServerStatus.DISCONNECTED;
}
@@ -80,7 +88,7 @@ async function testMCPConnection(
sanitizationConfig: {
enableEnvironmentVariableRedaction: true,
allowedEnvironmentVariables: [],
- blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars,
+ blockedEnvironmentVariables: activeSettings.advanced.excludedEnvVars,
},
emitMcpDiagnostic: (
severity: 'info' | 'warning' | 'error',
@@ -105,7 +113,7 @@ async function testMCPConnection(
debugLogger.log(message, error);
}
},
- isTrustedFolder: () => settings.isTrusted,
+ isTrustedFolder: () => isTrusted,
};
let transport;
@@ -135,14 +143,40 @@ async function testMCPConnection(
async function getServerStatus(
serverName: string,
server: MCPServerConfig,
+ isTrusted: boolean,
+ activeSettings: MergedSettings,
): Promise {
+ const mcpEnablementManager = McpServerEnablementManager.getInstance();
+ const loadResult = await canLoadServer(serverName, {
+ adminMcpEnabled: activeSettings.admin?.mcp?.enabled ?? true,
+ allowedList: activeSettings.mcp?.allowed,
+ excludedList: activeSettings.mcp?.excluded,
+ enablement: mcpEnablementManager.getEnablementCallbacks(),
+ });
+
+ if (!loadResult.allowed) {
+ if (
+ loadResult.blockType === 'admin' ||
+ loadResult.blockType === 'allowlist' ||
+ loadResult.blockType === 'excludelist'
+ ) {
+ return MCPServerStatus.BLOCKED;
+ }
+ return MCPServerStatus.DISABLED;
+ }
+
// Test all server types by attempting actual connection
- return testMCPConnection(serverName, server);
+ return testMCPConnection(serverName, server, isTrusted, activeSettings);
}
-export async function listMcpServers(settings?: MergedSettings): Promise {
+export async function listMcpServers(
+ loadedSettingsArg?: LoadedSettings,
+): Promise {
+ const loadedSettings = loadedSettingsArg ?? loadSettings();
+ const activeSettings = loadedSettings.merged;
+
const { mcpServers, blockedServerNames } =
- await getMcpServersFromConfig(settings);
+ await getMcpServersFromConfig(activeSettings);
const serverNames = Object.keys(mcpServers);
if (blockedServerNames.length > 0) {
@@ -165,7 +199,12 @@ export async function listMcpServers(settings?: MergedSettings): Promise {
for (const serverName of serverNames) {
const server = mcpServers[serverName];
- const status = await getServerStatus(serverName, server);
+ const status = await getServerStatus(
+ serverName,
+ server,
+ loadedSettings.isTrusted,
+ activeSettings,
+ );
let statusIndicator = '';
let statusText = '';
@@ -178,6 +217,14 @@ export async function listMcpServers(settings?: MergedSettings): Promise {
statusIndicator = chalk.yellow('…');
statusText = 'Connecting';
break;
+ case MCPServerStatus.BLOCKED:
+ statusIndicator = chalk.red('⛔');
+ statusText = 'Blocked';
+ break;
+ case MCPServerStatus.DISABLED:
+ statusIndicator = chalk.gray('○');
+ statusText = 'Disabled';
+ break;
case MCPServerStatus.DISCONNECTED:
default:
statusIndicator = chalk.red('✗');
@@ -203,14 +250,14 @@ export async function listMcpServers(settings?: MergedSettings): Promise {
}
interface ListArgs {
- settings?: MergedSettings;
+ loadedSettings?: LoadedSettings;
}
export const listCommand: CommandModule
-
-
- {row.cachedTokens}
-
-
-
+ {row.inputTokens}
+
+
+
- {row.outputTokens}
-
-
- >
- )}
-
- {row.bucket &&
- row.bucket.remainingFraction != null &&
- row.bucket.resetTime && (
-
- {(row.bucket.remainingFraction * 100).toFixed(1)}%{' '}
- {formatResetTime(row.bucket.resetTime)}
-
- )}
+ {row.cachedTokens}
+
+
+
+ {row.outputTokens}
+
+
+ >
+ )}
+ {showQuotaColumn && (
+ <>
+
+ {row.bucket && row.bucket.remainingFraction != null && (
+
+ {renderProgressBar(
+ effectiveUsedFraction,
+ statusColor,
+ progressBarWidth,
+ )}
+
+ )}
+
+
+ {row.bucket && row.bucket.remainingFraction != null && (
+
+ {row.bucket.remainingFraction === 0 ? (
+
+ Limit
+
+ ) : (
+
+ {percentageText}
+
+ )}
+
+ )}
+
+
+
+ {row.bucket?.resetTime &&
+ formatResetTime(row.bucket.resetTime, 'column')
+ ? formatResetTime(row.bucket.resetTime, 'column')
+ : ''}
+
+
+ >
+ )}
-
- ))}
+ );
+ })}
{cacheEfficiency > 0 && !showQuotaColumn && (
diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx
index ce5f094428..fcb66ea0b2 100644
--- a/packages/cli/src/ui/components/StatusDisplay.test.tsx
+++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect, vi, afterEach } from 'vitest';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render } from '../../test-utils/render.js';
import { Text } from 'ink';
import { StatusDisplay } from './StatusDisplay.js';
@@ -89,11 +89,12 @@ const renderStatusDisplay = async (
};
describe('StatusDisplay', () => {
- const originalEnv = process.env;
+ beforeEach(() => {
+ vi.stubEnv('GEMINI_SYSTEM_MD', '');
+ });
afterEach(() => {
- process.env = { ...originalEnv };
- delete process.env['GEMINI_SYSTEM_MD'];
+ vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -112,7 +113,7 @@ describe('StatusDisplay', () => {
});
it('renders system md indicator if env var is set', async () => {
- process.env['GEMINI_SYSTEM_MD'] = 'true';
+ vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
unmount();
diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx
index c4a6149126..dbd5281bc6 100644
--- a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx
+++ b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx
@@ -127,4 +127,44 @@ describe('SuggestionsDisplay', () => {
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
+
+ it('renders command section separators for slash mode', async () => {
+ const groupedSuggestions = [
+ {
+ label: 'list',
+ value: 'list',
+ description: 'Browse auto-saved chats',
+ sectionTitle: 'auto',
+ },
+ {
+ label: 'list',
+ value: 'list',
+ description: 'List checkpoints',
+ sectionTitle: 'checkpoints',
+ },
+ {
+ label: 'save',
+ value: 'save',
+ description: 'Save checkpoint',
+ sectionTitle: 'checkpoints',
+ },
+ ];
+
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+
+ await waitUntilReady();
+ const frame = lastFrame();
+ expect(frame).toContain('-- auto --');
+ expect(frame).toContain('-- checkpoints --');
+ });
});
diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx
index d9498e7a6b..c17341faae 100644
--- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx
+++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx
@@ -14,9 +14,12 @@ import { sanitizeForDisplay } from '../utils/textUtils.js';
export interface Suggestion {
label: string;
value: string;
+ insertValue?: string;
description?: string;
matchedIndex?: number;
commandKind?: CommandKind;
+ sectionTitle?: string;
+ submitValue?: string;
}
interface SuggestionsDisplayProps {
suggestions: Suggestion[];
@@ -84,8 +87,14 @@ export function SuggestionsDisplay({
const originalIndex = startIndex + index;
const isActive = originalIndex === activeIndex;
const isExpanded = originalIndex === expandedIndex;
- const textColor = isActive ? theme.text.accent : theme.text.secondary;
+ const textColor = isActive ? theme.ui.focus : theme.text.secondary;
const isLong = suggestion.value.length >= MAX_WIDTH;
+ const previousSectionTitle =
+ suggestions[originalIndex - 1]?.sectionTitle;
+ const shouldRenderSectionHeader =
+ mode === 'slash' &&
+ !!suggestion.sectionTitle &&
+ suggestion.sectionTitle !== previousSectionTitle;
const labelElement = (
-
-
- {labelElement}
- {suggestion.commandKind &&
- COMMAND_KIND_SUFFIX[suggestion.commandKind] && (
-
- {COMMAND_KIND_SUFFIX[suggestion.commandKind]}
-
- )}
-
-
+
+ {shouldRenderSectionHeader && (
+
+ -- {suggestion.sectionTitle} --
+
+ )}
- {suggestion.description && (
-
-
- {sanitizeForDisplay(suggestion.description, 100)}
-
+
+
+
+ {labelElement}
+ {suggestion.commandKind &&
+ COMMAND_KIND_SUFFIX[suggestion.commandKind] && (
+
+ {COMMAND_KIND_SUFFIX[suggestion.commandKind]}
+
+ )}
+
- )}
- {isActive && isLong && (
-
- {isExpanded ? ' ← ' : ' → '}
-
- )}
+
+ {suggestion.description && (
+
+
+ {sanitizeForDisplay(suggestion.description, 100)}
+
+
+ )}
+
+ {isActive && isLong && (
+
+ {isExpanded ? ' ← ' : ' → '}
+
+ )}
+
);
})}
diff --git a/packages/cli/src/ui/components/ThemeDialog.test.tsx b/packages/cli/src/ui/components/ThemeDialog.test.tsx
index d606773903..77ab1d5fd9 100644
--- a/packages/cli/src/ui/components/ThemeDialog.test.tsx
+++ b/packages/cli/src/ui/components/ThemeDialog.test.tsx
@@ -8,6 +8,22 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ThemeDialog } from './ThemeDialog.js';
+
+const { mockIsDevelopment } = vi.hoisted(() => ({
+ mockIsDevelopment: { value: false },
+}));
+
+vi.mock('../../utils/installationInfo.js', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ get isDevelopment() {
+ return mockIsDevelopment.value;
+ },
+ };
+});
+
import { createMockSettings } from '../../test-utils/settings.js';
import { DEFAULT_THEME, themeManager } from '../themes/theme-manager.js';
import { act } from 'react';
@@ -30,17 +46,21 @@ describe('ThemeDialog Snapshots', () => {
vi.restoreAllMocks();
});
- it('should render correctly in theme selection mode', async () => {
- const settings = createMockSettings();
- const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
- ,
- { settings },
- );
- await waitUntilReady();
+ it.each([true, false])(
+ 'should render correctly in theme selection mode (isDevelopment: %s)',
+ async (isDev) => {
+ mockIsDevelopment.value = isDev;
+ const settings = createMockSettings();
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ { settings },
+ );
+ await waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
- unmount();
- });
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ },
+ );
it('should render correctly in scope selector mode', async () => {
const settings = createMockSettings();
@@ -191,7 +211,7 @@ describe('Hint Visibility', () => {
,
{
settings,
- uiState: { terminalBackgroundColor: '#1E1E2E' },
+ uiState: { terminalBackgroundColor: '#000000' },
},
);
await waitUntilReady();
diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx
index c4bfe66897..4bfb623db7 100644
--- a/packages/cli/src/ui/components/ThemeDialog.tsx
+++ b/packages/cli/src/ui/components/ThemeDialog.tsx
@@ -23,6 +23,8 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { ScopeSelector } from './shared/ScopeSelector.js';
import { useUIState } from '../contexts/UIStateContext.js';
+import { ColorsDisplay } from './ColorsDisplay.js';
+import { isDevelopment } from '../../utils/installationInfo.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
@@ -245,6 +247,11 @@ export function ThemeDialog({
// The code block is slightly longer than the diff, so give it more space.
const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6);
const diffHeight = Math.floor(availableHeightForPanes * 0.4);
+
+ const previewTheme =
+ themeManager.getTheme(highlightedThemeName || DEFAULT_THEME.name) ||
+ DEFAULT_THEME;
+
return (
Preview
- {/* Get the Theme object for the highlighted theme, fall back to default if not found */}
- {(() => {
- const previewTheme =
- themeManager.getTheme(
- highlightedThemeName || DEFAULT_THEME.name,
- ) || DEFAULT_THEME;
-
- return (
-
- {colorizeCode({
- code: `# function
+
+ {colorizeCode({
+ code: `# function
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a`,
- language: 'python',
- availableHeight:
- isAlternateBuffer === false ? codeBlockHeight : undefined,
- maxWidth: colorizeCodeWidth,
- settings,
- })}
-
-
+
-
- );
- })()}
+ availableTerminalHeight={
+ isAlternateBuffer === false ? diffHeight : undefined
+ }
+ terminalWidth={colorizeCodeWidth}
+ theme={previewTheme}
+ />
+
+ {isDevelopment && (
+
+
+
+ )}
) : (
diff --git a/packages/cli/src/ui/components/ThemedGradient.test.tsx b/packages/cli/src/ui/components/ThemedGradient.test.tsx
index 60507015b5..6632a63300 100644
--- a/packages/cli/src/ui/components/ThemedGradient.test.tsx
+++ b/packages/cli/src/ui/components/ThemedGradient.test.tsx
@@ -13,6 +13,10 @@ vi.mock('../semantic-colors.js', () => ({
theme: {
ui: {
gradient: ['red', 'blue'],
+ focus: 'green',
+ },
+ background: {
+ focus: 'darkgreen',
},
text: {
accent: 'cyan',
diff --git a/packages/cli/src/ui/components/Tips.test.tsx b/packages/cli/src/ui/components/Tips.test.tsx
index 06b4760834..873230fb87 100644
--- a/packages/cli/src/ui/components/Tips.test.tsx
+++ b/packages/cli/src/ui/components/Tips.test.tsx
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -11,22 +11,18 @@ import type { Config } from '@google/gemini-cli-core';
describe('Tips', () => {
it.each([
- [0, '3. Create GEMINI.md files'],
- [5, '3. /help for more information'],
- ])(
- 'renders correct tips when file count is %i',
- async (count, expectedText) => {
- const config = {
- getGeminiMdFileCount: vi.fn().mockReturnValue(count),
- } as unknown as Config;
+ { fileCount: 0, description: 'renders all tips including GEMINI.md tip' },
+ { fileCount: 5, description: 'renders fewer tips when GEMINI.md exists' },
+ ])('$description', async ({ fileCount }) => {
+ const config = {
+ getGeminiMdFileCount: vi.fn().mockReturnValue(fileCount),
+ } as unknown as Config;
- const { lastFrame, waitUntilReady, unmount } = render(
- ,
- );
- await waitUntilReady();
- const output = lastFrame();
- expect(output).toContain(expectedText);
- unmount();
- },
- );
+ const { lastFrame, waitUntilReady, unmount } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
});
diff --git a/packages/cli/src/ui/components/Tips.tsx b/packages/cli/src/ui/components/Tips.tsx
index 576b8494c5..8ac6f33bf8 100644
--- a/packages/cli/src/ui/components/Tips.tsx
+++ b/packages/cli/src/ui/components/Tips.tsx
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -15,30 +15,26 @@ interface TipsProps {
export const Tips: React.FC = ({ config }) => {
const geminiMdFileCount = config.getGeminiMdFileCount();
+
return (
-
+ Tips for getting started:
-
- 1. Ask questions, edit files, or run commands.
-
-
- 2. Be specific for the best results.
-
{geminiMdFileCount === 0 && (
- 3. Create{' '}
-
- GEMINI.md
- {' '}
- files to customize your interactions with Gemini.
+ 1. Create GEMINI.md files to customize your
+ interactions
)}
- {geminiMdFileCount === 0 ? '4.' : '3.'}{' '}
-
- /help
- {' '}
- for more information.
+ {geminiMdFileCount === 0 ? '2.' : '1.'}{' '}
+ /help for more information
+
+
+ {geminiMdFileCount === 0 ? '3.' : '2.'} Ask coding questions, edit code
+ or run commands
+
+
+ {geminiMdFileCount === 0 ? '4.' : '3.'} Be specific for the best results
);
diff --git a/packages/cli/src/ui/components/ToastDisplay.test.tsx b/packages/cli/src/ui/components/ToastDisplay.test.tsx
index 668f91c8d9..b1432caa9d 100644
--- a/packages/cli/src/ui/components/ToastDisplay.test.tsx
+++ b/packages/cli/src/ui/components/ToastDisplay.test.tsx
@@ -188,7 +188,7 @@ describe('ToastDisplay', () => {
});
await waitUntilReady();
expect(lastFrame()).toContain(
- 'Ctrl+O to show more lines of the last response',
+ 'Press Ctrl+O to show more lines of the last response',
);
});
diff --git a/packages/cli/src/ui/components/ToastDisplay.tsx b/packages/cli/src/ui/components/ToastDisplay.tsx
index 6fcef1667c..869139cb39 100644
--- a/packages/cli/src/ui/components/ToastDisplay.tsx
+++ b/packages/cli/src/ui/components/ToastDisplay.tsx
@@ -78,7 +78,7 @@ export const ToastDisplay: React.FC = () => {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
return (
- Ctrl+O to {action} lines of the last response
+ Press Ctrl+O to {action} lines of the last response
);
}
diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx
index 3fb1cc8c6f..b976bb3755 100644
--- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx
+++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx
@@ -15,7 +15,6 @@ import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js';
-import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
import { useUIActions } from '../contexts/UIActionsContext.js';
@@ -43,7 +42,6 @@ export const ToolConfirmationQueue: React.FC = ({
}) => {
const config = useConfig();
const { getPreferredEditor } = useUIActions();
- const isAlternateBuffer = useAlternateBuffer();
const {
mainAreaWidth,
terminalHeight,
@@ -157,10 +155,5 @@ export const ToolConfirmationQueue: React.FC = ({
>
);
- return isAlternateBuffer ? (
- /* Shadow the global provider to maintain isolation in ASB mode. */
- {content}
- ) : (
- content
- );
+ return {content};
};
diff --git a/packages/cli/src/ui/components/UserIdentity.test.tsx b/packages/cli/src/ui/components/UserIdentity.test.tsx
index a5b41f4b61..aa7f4d3da2 100644
--- a/packages/cli/src/ui/components/UserIdentity.test.tsx
+++ b/packages/cli/src/ui/components/UserIdentity.test.tsx
@@ -45,12 +45,31 @@ describe('', () => {
await waitUntilReady();
const output = lastFrame();
- expect(output).toContain('Logged in with Google: test@example.com');
+ expect(output).toContain('test@example.com');
expect(output).toContain('/auth');
+ expect(output).not.toContain('/upgrade');
unmount();
});
- it('should render login message without colon if email is missing', async () => {
+ it('should render the user email on the very first frame (regression test)', () => {
+ const mockConfig = makeFakeConfig();
+ vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
+ authType: AuthType.LOGIN_WITH_GOOGLE,
+ model: 'gemini-pro',
+ } as unknown as ContentGeneratorConfig);
+ vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue(undefined);
+
+ const { lastFrameRaw, unmount } = renderWithProviders(
+ ,
+ );
+
+ // Assert immediately on the first available frame before any async ticks happen
+ const output = lastFrameRaw();
+ expect(output).toContain('test@example.com');
+ unmount();
+ });
+
+ it('should render login message if email is missing', async () => {
// Modify the mock for this specific test
vi.mocked(UserAccountManager).mockImplementationOnce(
() =>
@@ -73,12 +92,12 @@ describe('', () => {
const output = lastFrame();
expect(output).toContain('Logged in with Google');
- expect(output).not.toContain('Logged in with Google:');
expect(output).toContain('/auth');
+ expect(output).not.toContain('/upgrade');
unmount();
});
- it('should render plan name on a separate line if provided', async () => {
+ it('should render plan name and upgrade indicator', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: AuthType.LOGIN_WITH_GOOGLE,
@@ -92,18 +111,10 @@ describe('', () => {
await waitUntilReady();
const output = lastFrame();
- expect(output).toContain('Logged in with Google: test@example.com');
+ expect(output).toContain('test@example.com');
expect(output).toContain('/auth');
- expect(output).toContain('Plan: Premium Plan');
-
- // Check for two lines (or more if wrapped, but here it should be separate)
- const lines = output?.split('\n').filter((line) => line.trim().length > 0);
- expect(lines?.some((line) => line.includes('Logged in with Google'))).toBe(
- true,
- );
- expect(lines?.some((line) => line.includes('Plan: Premium Plan'))).toBe(
- true,
- );
+ expect(output).toContain('Premium Plan');
+ expect(output).toContain('/upgrade');
unmount();
});
@@ -139,6 +150,26 @@ describe('', () => {
const output = lastFrame();
expect(output).toContain(`Authenticated with ${AuthType.USE_GEMINI}`);
expect(output).toContain('/auth');
+ expect(output).not.toContain('/upgrade');
+ unmount();
+ });
+
+ it('should render specific tier name when provided', async () => {
+ const mockConfig = makeFakeConfig();
+ vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
+ authType: AuthType.LOGIN_WITH_GOOGLE,
+ model: 'gemini-pro',
+ } as unknown as ContentGeneratorConfig);
+ vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue('Enterprise Tier');
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toContain('Enterprise Tier');
+ expect(output).toContain('/upgrade');
unmount();
});
});
diff --git a/packages/cli/src/ui/components/UserIdentity.tsx b/packages/cli/src/ui/components/UserIdentity.tsx
index e506bfb052..7b07a4f91c 100644
--- a/packages/cli/src/ui/components/UserIdentity.tsx
+++ b/packages/cli/src/ui/components/UserIdentity.tsx
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -20,41 +20,45 @@ interface UserIdentityProps {
export const UserIdentity: React.FC = ({ config }) => {
const authType = config.getContentGeneratorConfig()?.authType;
-
- const { email, tierName } = useMemo(() => {
- if (!authType) {
- return { email: undefined, tierName: undefined };
+ const email = useMemo(() => {
+ if (authType) {
+ const userAccountManager = new UserAccountManager();
+ return userAccountManager.getCachedGoogleAccount() ?? undefined;
}
- const userAccountManager = new UserAccountManager();
- return {
- email: userAccountManager.getCachedGoogleAccount(),
- tierName: config.getUserTierName(),
- };
- }, [config, authType]);
+ return undefined;
+ }, [authType]);
+
+ const tierName = useMemo(
+ () => (authType ? config.getUserTierName() : undefined),
+ [config, authType],
+ );
if (!authType) {
return null;
}
return (
-
+
+ {/* User Email /auth */}
-
+
{authType === AuthType.LOGIN_WITH_GOOGLE ? (
-
- Logged in with Google{email ? ':' : ''}
- {email ? ` ${email}` : ''}
-
+ {email ?? 'Logged in with Google'}
) : (
`Authenticated with ${authType}`
)}
/auth
+
+ {/* Tier Name /upgrade */}
{tierName && (
-
- Plan: {tierName}
-
+
+
+ {tierName}
+
+ /upgrade
+
)}
);
diff --git a/packages/cli/src/ui/components/ValidationDialog.tsx b/packages/cli/src/ui/components/ValidationDialog.tsx
index 6e126ea4ef..f94de6b86d 100644
--- a/packages/cli/src/ui/components/ValidationDialog.tsx
+++ b/packages/cli/src/ui/components/ValidationDialog.tsx
@@ -16,7 +16,8 @@ import {
type ValidationIntent,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface ValidationDialogProps {
validationLink?: string;
@@ -32,6 +33,7 @@ export function ValidationDialog({
learnMoreUrl,
onChoice,
}: ValidationDialogProps): React.JSX.Element {
+ const keyMatchers = useKeyMatchers();
const [state, setState] = useState('choosing');
const [errorMessage, setErrorMessage] = useState('');
diff --git a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap
index 18e75b75e2..ec8712ebc1 100644
--- a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap
@@ -2,20 +2,17 @@
exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = `
"
- ███ █████████
-░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
- ███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ▝▜▄ Gemini CLI v0.10.0
+ ▝▜▄
+ ▗▟▀
+ ▝▀
+
Tips for getting started:
-1. Ask questions, edit files, or run commands.
-2. Be specific for the best results.
-3. Create GEMINI.md files to customize your interactions with Gemini.
-4. /help for more information.
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results
Action Required (was prompted):
@@ -25,20 +22,17 @@ Action Required (was prompted):
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = `
"
- ███ █████████
-░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
- ███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ▝▜▄ Gemini CLI v0.10.0
+ ▝▜▄
+ ▗▟▀
+ ▝▀
+
Tips for getting started:
-1. Ask questions, edit files, or run commands.
-2. Be specific for the best results.
-3. Create GEMINI.md files to customize your interactions with Gemini.
-4. /help for more information.
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool1 Description for tool 1 │
│ │
@@ -52,39 +46,33 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
"
- ███ █████████
-░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
- ███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ▝▜▄ Gemini CLI v0.10.0
+ ▝▜▄
+ ▗▟▀
+ ▝▀
+
Tips for getting started:
-1. Ask questions, edit files, or run commands.
-2. Be specific for the best results.
-3. Create GEMINI.md files to customize your interactions with Gemini.
-4. /help for more information.
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results
"
`;
exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = `
"
- ███ █████████
-░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
- ███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ▝▜▄ Gemini CLI v0.10.0
+ ▝▜▄
+ ▗▟▀
+ ▝▀
+
Tips for getting started:
-1. Ask questions, edit files, or run commands.
-2. Be specific for the best results.
-3. Create GEMINI.md files to customize your interactions with Gemini.
-4. /help for more information.
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool1 Description for tool 1 │
│ │
@@ -98,39 +86,33 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
"
- ███ █████████
-░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
- ███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ▝▜▄ Gemini CLI v0.10.0
+ ▝▜▄
+ ▗▟▀
+ ▝▀
+
Tips for getting started:
-1. Ask questions, edit files, or run commands.
-2. Be specific for the best results.
-3. Create GEMINI.md files to customize your interactions with Gemini.
-4. /help for more information.
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results
"
`;
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `
"
- ███ █████████
-░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
- ███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ▝▜▄ Gemini CLI v0.10.0
+ ▝▜▄
+ ▗▟▀
+ ▝▀
+
Tips for getting started:
-1. Ask questions, edit files, or run commands.
-2. Be specific for the best results.
-3. Create GEMINI.md files to customize your interactions with Gemini.
-4. /help for more information.
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Hello Gemini
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap
index 324274fddd..4411f766de 100644
--- a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap
@@ -2,82 +2,70 @@
exports[` > should not render the banner when no flags are set 1`] = `
"
- ███ █████████
-░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
- ███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ▝▜▄ Gemini CLI v1.0.0
+ ▝▜▄
+ ▗▟▀
+ ▝▀
+
Tips for getting started:
-1. Ask questions, edit files, or run commands.
-2. Be specific for the best results.
-3. Create GEMINI.md files to customize your interactions with Gemini.
-4. /help for more information.
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results
"
`;
exports[` > should not render the default banner if shown count is 5 or more 1`] = `
"
- ███ █████████
-░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
- ███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ▝▜▄ Gemini CLI v1.0.0
+ ▝▜▄
+ ▗▟▀
+ ▝▀
+
Tips for getting started:
-1. Ask questions, edit files, or run commands.
-2. Be specific for the best results.
-3. Create GEMINI.md files to customize your interactions with Gemini.
-4. /help for more information.
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results
"
`;
exports[` > should render the banner with default text 1`] = `
"
- ███ █████████
-░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
- ███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ▝▜▄ Gemini CLI v1.0.0
+ ▝▜▄
+ ▗▟▀
+ ▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+
Tips for getting started:
-1. Ask questions, edit files, or run commands.
-2. Be specific for the best results.
-3. Create GEMINI.md files to customize your interactions with Gemini.
-4. /help for more information.
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results
"
`;
exports[` > should render the banner with warning text 1`] = `
"
- ███ █████████
-░░░███ ███░░░░░███
- ░░░███ ███ ░░░
- ░░░███░███
- ███░ ░███ █████
- ███░ ░░███ ░░███
- ███░ ░░█████████
-░░░ ░░░░░░░░░
+ ▝▜▄ Gemini CLI v1.0.0
+ ▝▜▄
+ ▗▟▀
+ ▝▀
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ There are capacity issues │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+
Tips for getting started:
-1. Ask questions, edit files, or run commands.
-2. Be specific for the best results.
-3. Create GEMINI.md files to customize your interactions with Gemini.
-4. /help for more information.
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg
new file mode 100644
index 0000000000..4e9d0e67a5
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg
@@ -0,0 +1,30 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg
new file mode 100644
index 0000000000..fa8373acc7
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg
@@ -0,0 +1,31 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap
new file mode 100644
index 0000000000..2bb5276ee8
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap
@@ -0,0 +1,31 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`AppHeader Icon Rendering > renders the default icon in standard terminals 1`] = `
+"
+ ▝▜▄ Gemini CLI v1.0.0
+ ▝▜▄
+ ▗▟▀
+ ▝▀
+
+
+Tips for getting started:
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results"
+`;
+
+exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = `
+"
+ ▝▜▄ Gemini CLI v1.0.0
+ ▝▜▄
+ ▗▟▀
+ ▗▟▀
+
+
+Tips for getting started:
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap
index 2544f7322e..8ddb141478 100644
--- a/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap
@@ -1,31 +1,31 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode 1`] = `
-"auto-accept edits shift+tab to manual
+"auto-accept edits Shift+Tab to manual
"
`;
exports[`ApprovalModeIndicator > renders correctly for AUTO_EDIT mode with plan enabled 1`] = `
-"auto-accept edits shift+tab to plan
+"auto-accept edits Shift+Tab to plan
"
`;
exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode 1`] = `
-"shift+tab to accept edits
+"Shift+Tab to accept edits
"
`;
exports[`ApprovalModeIndicator > renders correctly for DEFAULT mode with plan enabled 1`] = `
-"shift+tab to accept edits
+"Shift+Tab to accept edits
"
`;
exports[`ApprovalModeIndicator > renders correctly for PLAN mode 1`] = `
-"plan shift+tab to manual
+"plan Shift+Tab to manual
"
`;
exports[`ApprovalModeIndicator > renders correctly for YOLO mode 1`] = `
-"YOLO ctrl+y
+"YOLO Ctrl+Y
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap
index 29a7683d06..06f509f1f6 100644
--- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap
@@ -5,7 +5,7 @@ exports[`AskUserDialog > Choice question placeholder > uses default placeholder
1. TypeScript
2. JavaScript
-● 3. Enter a custom value
+● 3. Enter a custom value
Enter to submit · Esc to cancel
"
@@ -16,7 +16,7 @@ exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Oth
1. TypeScript
2. JavaScript
-● 3. Type another language...
+● 3. Type another language...
Enter to submit · Esc to cancel
"
@@ -26,8 +26,8 @@ exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scrol
"Choose an option
▲
-● 1. Option 1
- Description 1
+● 1. Option 1
+ Description 1
2. Option 2
Description 2
▼
@@ -39,8 +39,8 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
"Choose an option
-● 1. Option 1
- Description 1
+● 1. Option 1
+ Description 1
2. Option 2
Description 2
3. Option 3
@@ -122,8 +122,8 @@ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
exports[`AskUserDialog > hides progress header for single question 1`] = `
"Which authentication method should we use?
-● 1. OAuth 2.0
- Industry standard, supports SSO
+● 1. OAuth 2.0
+ Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
@@ -135,8 +135,8 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
exports[`AskUserDialog > renders question and options 1`] = `
"Which authentication method should we use?
-● 1. OAuth 2.0
- Industry standard, supports SSO
+● 1. OAuth 2.0
+ Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
@@ -150,8 +150,8 @@ exports[`AskUserDialog > shows Review tab in progress header for multiple questi
Which framework?
-● 1. React
- Component library
+● 1. React
+ Component library
2. Vue
Progressive framework
3. Enter a custom value
@@ -163,8 +163,8 @@ Enter to select · ←/→ to switch questions · Esc to cancel
exports[`AskUserDialog > shows keyboard hints 1`] = `
"Which authentication method should we use?
-● 1. OAuth 2.0
- Industry standard, supports SSO
+● 1. OAuth 2.0
+ Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
@@ -178,8 +178,8 @@ exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
Which database should we use?
-● 1. PostgreSQL
- Relational database
+● 1. PostgreSQL
+ Relational database
2. MongoDB
Document database
3. Enter a custom value
diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap
index db1b6d1ba5..073c106ceb 100644
--- a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap
@@ -19,8 +19,8 @@ Files to Modify
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
-● 2. Yes, manually accept edits
- Approves plan but requires confirmation for each tool
+● 2. Yes, manually accept edits
+ Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
@@ -44,8 +44,8 @@ Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
-● 1. Yes, automatically accept edits
- Approves plan and allows tools to run automatically
+● 1. Yes, automatically accept edits
+ Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
@@ -76,8 +76,8 @@ Implementation Steps
8. Add multi-factor authentication in src/auth/MFAService.ts
... last 22 lines hidden (Ctrl+O to show) ...
-● 1. Yes, automatically accept edits
- Approves plan and allows tools to run automatically
+● 1. Yes, automatically accept edits
+ Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
@@ -103,8 +103,8 @@ Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
-● 1. Yes, automatically accept edits
- Approves plan and allows tools to run automatically
+● 1. Yes, automatically accept edits
+ Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
@@ -132,8 +132,8 @@ Files to Modify
1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
-● 2. Yes, manually accept edits
- Approves plan but requires confirmation for each tool
+● 2. Yes, manually accept edits
+ Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
@@ -157,8 +157,8 @@ Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
-● 1. Yes, automatically accept edits
- Approves plan and allows tools to run automatically
+● 1. Yes, automatically accept edits
+ Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
@@ -210,8 +210,8 @@ Testing Strategy
- Security penetration testing
- Load testing for session management
-● 1. Yes, automatically accept edits
- Approves plan and allows tools to run automatically
+● 1. Yes, automatically accept edits
+ Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
@@ -237,8 +237,8 @@ Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
-● 1. Yes, automatically accept edits
- Approves plan and allows tools to run automatically
+● 1. Yes, automatically accept edits
+ Approves plan and allows tools to run automatically
2. Yes, manually accept edits
Approves plan but requires confirmation for each tool
3. Type your feedback...
diff --git a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
index 414e8cfa8f..2d98d66f03 100644
--- a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap
@@ -1,38 +1,45 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[` > displays "Limit reached" message when remaining is 0 1`] = `
-" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro Limit reached
+" workspace (/directory) sandbox /model /stats
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
"
`;
exports[` > displays the usage indicator when usage is low 1`] = `
-" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 15%
+" workspace (/directory) sandbox /model /stats
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
"
`;
exports[` > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
-" ...s/to/make/it/long no sandbox /model gemini-pro 100%
+" workspace (/directory) sandbox /model context
+ ...me/more/directories/to/make/it/long no sandbox gemini-pro 14%
"
`;
exports[` > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
-" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 100% context left
+" workspace (/directory) sandbox /model context
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used
"
`;
exports[` > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `
-" no sandbox (see /docs)
+" sandbox
+ no sandbox
"
`;
exports[` > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[` > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `
-" ...directories/to/make/it/long no sandbox (see /docs)
+" workspace (/directory) sandbox
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
"
`;
exports[` > hides the usage indicator when usage is not near limit 1`] = `
-" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro
+" workspace (/directory) sandbox /model /stats
+ ~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-highlights-the-active-item-in-the-preview.snap.svg b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-highlights-the-active-item-in-the-preview.snap.svg
new file mode 100644
index 0000000000..7cec49200d
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-highlights-the-active-item-in-the-preview.snap.svg
@@ -0,0 +1,159 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-renders-correctly-with-default-settings.snap.svg b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-renders-correctly-with-default-settings.snap.svg
new file mode 100644
index 0000000000..ae9b8eecea
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog--FooterConfigDialog-renders-correctly-with-default-settings.snap.svg
@@ -0,0 +1,154 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap
new file mode 100644
index 0000000000..f2fee0a8c3
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap
@@ -0,0 +1,131 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > highlights the active item in the preview 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Configure Footer │
+│ │
+│ Select which items to display in the footer. │
+│ │
+│ [✓] workspace │
+│ Current working directory │
+│ [✓] git-branch │
+│ Current git branch name (not shown when unavailable) │
+│ [✓] sandbox │
+│ Sandbox type and trust indicator │
+│ [✓] model-name │
+│ Current model identifier │
+│ [✓] quota │
+│ Remaining usage on daily limit (not shown when unavailable) │
+│ [ ] context-used │
+│ Percentage of context window used │
+│ [ ] memory-usage │
+│ Memory used by the application │
+│ [ ] session-id │
+│ Unique identifier for the current session │
+│ > [✓] code-changes │
+│ Lines added/removed in the session (not shown when zero) │
+│ [ ] token-count │
+│ Total tokens used in the session (not shown when zero) │
+│ [✓] Show footer labels │
+│ │
+│ Reset to default footer │
+│ │
+│ │
+│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
+│ │
+│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
+│ │ Preview: │ │
+│ │ workspace (/directory) branch sandbox /model /stats diff │ │
+│ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │
+│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[` > renders correctly with default settings 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Configure Footer │
+│ │
+│ Select which items to display in the footer. │
+│ │
+│ > [✓] workspace │
+│ Current working directory │
+│ [✓] git-branch │
+│ Current git branch name (not shown when unavailable) │
+│ [✓] sandbox │
+│ Sandbox type and trust indicator │
+│ [✓] model-name │
+│ Current model identifier │
+│ [✓] quota │
+│ Remaining usage on daily limit (not shown when unavailable) │
+│ [ ] context-used │
+│ Percentage of context window used │
+│ [ ] memory-usage │
+│ Memory used by the application │
+│ [ ] session-id │
+│ Unique identifier for the current session │
+│ [ ] code-changes │
+│ Lines added/removed in the session (not shown when zero) │
+│ [ ] token-count │
+│ Total tokens used in the session (not shown when zero) │
+│ [✓] Show footer labels │
+│ │
+│ Reset to default footer │
+│ │
+│ │
+│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
+│ │
+│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
+│ │ Preview: │ │
+│ │ workspace (/directory) branch sandbox /model /stats │ │
+│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
+│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[` > renders correctly with default settings 2`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Configure Footer │
+│ │
+│ Select which items to display in the footer. │
+│ │
+│ > [✓] workspace │
+│ Current working directory │
+│ [✓] git-branch │
+│ Current git branch name (not shown when unavailable) │
+│ [✓] sandbox │
+│ Sandbox type and trust indicator │
+│ [✓] model-name │
+│ Current model identifier │
+│ [✓] quota │
+│ Remaining usage on daily limit (not shown when unavailable) │
+│ [ ] context-used │
+│ Percentage of context window used │
+│ [ ] memory-usage │
+│ Memory used by the application │
+│ [ ] session-id │
+│ Unique identifier for the current session │
+│ [ ] code-changes │
+│ Lines added/removed in the session (not shown when zero) │
+│ [ ] token-count │
+│ Total tokens used in the session (not shown when zero) │
+│ [✓] Show footer labels │
+│ │
+│ Reset to default footer │
+│ │
+│ │
+│ Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close │
+│ │
+│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
+│ │ Preview: │ │
+│ │ workspace (/directory) branch sandbox /model /stats │ │
+│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
+│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap
index b1784dc10d..d237b30f99 100644
--- a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap
@@ -388,8 +388,17 @@ exports[` > renders InfoMessage for "info" type with multi
"
`;
-exports[` > thinking items > renders thinking item when enabled 1`] = `
-" Thinking
+exports[` > thinking items > renders "Thinking..." header when isFirstThinking is true 1`] = `
+" Thinking...
+ │
+ │ Thinking
+ │ test
+"
+`;
+
+exports[` > thinking items > renders thinking item when enabled 1`] = `
+" │
+ │ Thinking
│ test
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/HooksDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HooksDialog.test.tsx.snap
new file mode 100644
index 0000000000..1a2271cc45
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/HooksDialog.test.tsx.snap
@@ -0,0 +1,124 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`HooksDialog > snapshots > renders empty hooks dialog 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ No hooks configured. │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`HooksDialog > snapshots > renders hook using command as name when name is not provided 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Security Warning: │
+│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
+│ Review hook scripts carefully. │
+│ │
+│ Learn more: https://geminicli.com/docs/hooks │
+│ │
+│ Configured Hooks │
+│ │
+│ before-tool │
+│ │
+│ echo hello [enabled] │
+│ Source: /mock/path │
+│ │
+│ │
+│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │
+│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`HooksDialog > snapshots > renders hook with all metadata (matcher, sequential, timeout) 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Security Warning: │
+│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
+│ Review hook scripts carefully. │
+│ │
+│ Learn more: https://geminicli.com/docs/hooks │
+│ │
+│ Configured Hooks │
+│ │
+│ before-tool │
+│ │
+│ my-hook [enabled] │
+│ A hook with all metadata fields │
+│ Source: /mock/path/GEMINI.md | Matcher: shell_exec | Sequential | Timeout: 30s │
+│ │
+│ │
+│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │
+│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`HooksDialog > snapshots > renders hooks grouped by event name with enabled and disabled status 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Security Warning: │
+│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
+│ Review hook scripts carefully. │
+│ │
+│ Learn more: https://geminicli.com/docs/hooks │
+│ │
+│ Configured Hooks │
+│ │
+│ before-tool │
+│ │
+│ hook1 [enabled] │
+│ Test hook: hook1 │
+│ Source: /mock/path/GEMINI.md | Command: run-hook1 │
+│ │
+│ hook2 [disabled] │
+│ Test hook: hook2 │
+│ Source: /mock/path/GEMINI.md | Command: run-hook2 │
+│ │
+│ after-agent │
+│ │
+│ hook3 [enabled] │
+│ Test hook: hook3 │
+│ Source: /mock/path/GEMINI.md | Command: run-hook3 │
+│ │
+│ │
+│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │
+│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
+
+exports[`HooksDialog > snapshots > renders single hook with security warning, source, and tips 1`] = `
+"
+╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Security Warning: │
+│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │
+│ Review hook scripts carefully. │
+│ │
+│ Learn more: https://geminicli.com/docs/hooks │
+│ │
+│ Configured Hooks │
+│ │
+│ before-tool │
+│ │
+│ test-hook [enabled] │
+│ Test hook: test-hook │
+│ Source: /mock/path/GEMINI.md | Command: run-test-hook │
+│ │
+│ │
+│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │
+│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
index 88a1b0486f..5a2819702e 100644
--- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
@@ -12,8 +12,8 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
(r:) Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
- lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
- ...
+ lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
+ ...
"
`;
@@ -22,8 +22,8 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
(r:) Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
- lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
- llllllllllllllllllllllllllllllllllllllllllllllllll
+ lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
+ llllllllllllllllllllllllllllllllllllllllllllllllll
"
`;
@@ -31,7 +31,7 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
- git commit -m "feat: add search" in src/app
+ git commit -m "feat: add search" in src/app
"
`;
@@ -39,7 +39,7 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
- git commit -m "feat: add search" in src/app
+ git commit -m "feat: add search" in src/app
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap
index d70a278827..666525e720 100644
--- a/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[` > should truncate long primary text instead of wrapping 1`] = `
-"MockRespondin This is an extremely long loading phrase that shoul… (esc to
+"MockRespondin This is an extremely long loading phrase that shoul…(esc to
gSpinner cancel, 5s)
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg
new file mode 100644
index 0000000000..558118cdfb
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg
@@ -0,0 +1,42 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
index 0599e82f7c..c0043bf6f9 100644
--- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
@@ -4,7 +4,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Focuse
"ScrollableList
AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command Running a long command... │
+│ ⊶ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
@@ -18,7 +18,6 @@ AppHeader(full)
│ Line 19 █ │
│ Line 20 █ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
-ShowMoreLines
"
`;
@@ -26,7 +25,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocu
"ScrollableList
AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command Running a long command... │
+│ ⊶ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
@@ -40,14 +39,13 @@ AppHeader(full)
│ Line 19 █ │
│ Line 20 █ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
-ShowMoreLines
"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
"AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command Running a long command... │
+│ ⊶ Shell Command Running a long command... │
│ │
│ ... first 11 lines hidden (Ctrl+O to show) ... │
│ Line 12 │
@@ -60,14 +58,13 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
│ Line 19 │
│ Line 20 │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
-ShowMoreLines
"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
"AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command Running a long command... │
+│ ⊶ Shell Command Running a long command... │
│ │
│ Line 1 │
│ Line 2 │
@@ -90,7 +87,6 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
│ Line 19 │
│ Line 20 │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
-ShowMoreLines
"
`;
@@ -105,6 +101,94 @@ exports[`MainContent > renders a split tool group without a gap between static a
│ │
│ Part 2 │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
-ShowMoreLines
"
`;
+
+exports[`MainContent > renders mixed history items (user + gemini) with single line padding between them 1`] = `
+"ScrollableList
+AppHeader(full)
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ > User message
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+✦ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+ Gemini response
+"
+`;
+
+exports[`MainContent > renders multiple history items with single line padding between them 1`] = `
+"ScrollableList
+AppHeader(full)
+✦ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+ Gemini message 1
+
+✦ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+ Gemini message 2
+"
+`;
+
+exports[`MainContent > renders multiple thinking messages sequentially correctly 1`] = `
+"ScrollableList
+AppHeader(full)
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ > Plan a solution
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ Thinking...
+ │
+ │ Initial analysis
+ │ This is a multiple line paragraph for the first thinking message of how the model analyzes the
+ │ problem.
+ │
+ │ Planning execution
+ │ This a second multiple line paragraph for the second thinking message explaining the plan in
+ │ detail so that it wraps around the terminal display.
+ │
+ │ Refining approach
+ │ And finally a third multiple line paragraph for the third thinking message to refine the
+ │ solution.
+"
+`;
+
+exports[`MainContent > renders multiple thinking messages sequentially correctly 2`] = `
+"ScrollableList
+AppHeader(full)
+▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
+ > Plan a solution
+▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
+ Thinking...
+ │
+ │ Initial analysis
+ │ This is a multiple line paragraph for the first thinking message of how the model analyzes the
+ │ problem.
+ │
+ │ Planning execution
+ │ This a second multiple line paragraph for the second thinking message explaining the plan in
+ │ detail so that it wraps around the terminal display.
+ │
+ │ Refining approach
+ │ And finally a third multiple line paragraph for the third thinking message to refine the
+ │ solution."
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap
index b631f4e8ad..822777ad67 100644
--- a/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/QuotaDisplay.test.tsx.snap
@@ -1,12 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`QuotaDisplay > should NOT render reset time when terse is true 1`] = `
-"15%
+"85%
"
`;
-exports[`QuotaDisplay > should render red when usage < 5% 1`] = `
-"/stats 4% usage remaining
+exports[`QuotaDisplay > should render critical when used >= 95% 1`] = `
+"96% used
"
`;
@@ -15,12 +15,12 @@ exports[`QuotaDisplay > should render terse limit reached message 1`] = `
"
`;
-exports[`QuotaDisplay > should render with reset time when provided 1`] = `
-"/stats 15% usage remaining, resets in 1h
+exports[`QuotaDisplay > should render warning when used >= 80% 1`] = `
+"85% used
"
`;
-exports[`QuotaDisplay > should render yellow when usage < 20% 1`] = `
-"/stats 15% usage remaining
+exports[`QuotaDisplay > should render with reset time when provided 1`] = `
+"85% used (Limit resets in 1h)
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap
index 583d75d281..15cd8748ae 100644
--- a/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap
@@ -6,7 +6,7 @@ exports[`SessionBrowser component > enters search mode, filters sessions, and re
Search: query (Esc to cancel)
Index │ Msgs │ Age │ Match
- ❯ #1 │ 1 │ 10mo │ You: Query is here a… (+1 more)
+ ❯ #1 │ 1 │ 10mo │ You: Query is here a… (+1 more)
▼
"
`;
@@ -17,7 +17,7 @@ exports[`SessionBrowser component > renders a list of sessions and marks current
Sort: s Reverse: r First/Last: g/G
Index │ Msgs │ Age │ Name
- ❯ #1 │ 5 │ 10mo │ Second conversation about dogs (current)
+ ❯ #1 │ 5 │ 10mo │ Second conversation about dogs (current)
#2 │ 2 │ 10mo │ First conversation about cats
▼
"
diff --git a/packages/cli/src/ui/components/__snapshots__/SessionRetentionWarningDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionRetentionWarningDialog.test.tsx.snap
deleted file mode 100644
index 95f1b4760c..0000000000
--- a/packages/cli/src/ui/components/__snapshots__/SessionRetentionWarningDialog.test.tsx.snap
+++ /dev/null
@@ -1,21 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`SessionRetentionWarningDialog > should match snapshot 1`] = `
-"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
-│ │
-│ Keep chat history │
-│ │
-│ To keep your workspace clean, we are introducing a limit on how long chat sessions are stored. │
-│ Please choose a retention period for your existing chats: │
-│ │
-│ │
-│ 1. Keep for 30 days (Recommended) │
-│ 123 sessions will be deleted │
-│ ● 2. Keep for 120 days │
-│ No sessions will be deleted at this time │
-│ │
-│ Set a custom limit /settings and change "Keep chat history". │
-│ │
-╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
-"
-`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
index eb0fada885..e6d61e64e5 100644
--- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
@@ -17,14 +17,13 @@ exports[` > renders the summary display with a title 1`
│ » API Time: 50.2s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
-│ Model Usage │
-│ Model Reqs Input Tokens Cache Reads Output Tokens │
-│ ──────────────────────────────────────────────────────────────────────────── │
-│ gemini-2.5-pro 10 500 500 2,000 │
+│ Model Reqs Input Tokens Cache Reads Output Tokens │
+│ ──────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 10 500 500 2,000 │
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
-│ Tip: Resume a previous session using gemini --resume or /resume │
+│ To resume this session: gemini --resume test-session │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg
index b7ad1d10db..96ac9e7621 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg
@@ -4,130 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- false
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update
- true
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ false
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg
index c088c69139..7a35e051b2 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg
@@ -4,130 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- true*
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update
- true
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ true*
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg
index 0b981a31c8..9c01031ebe 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg
@@ -4,128 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- false*
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update true*
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging false*
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ false*
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true*
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false*
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg
index b7ad1d10db..96ac9e7621 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg
@@ -4,130 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- false
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update
- true
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ false
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg
index b7ad1d10db..96ac9e7621 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg
@@ -4,130 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- false
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update
- true
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ false
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg
index 81d4868518..f9cf782f72 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg
@@ -4,128 +4,136 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │ Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- Search to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- Vim Mode
- false
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update
- true
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
- > Apply To
- │
- │
- ●
- 1.
- User Settings
- │
- │
- 2. Workspace Settings
- │
- │
- 3. System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ Search to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+ Vim Mode
+ false
+ │
+ │
+ Enable Vim keybindings
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │
+ > Apply To
+ │
+ │
+
+ ●
+
+
+ 1.
+
+
+ User Settings
+
+ │
+ │
+ 2.
+ Workspace Settings
+ │
+ │
+ 3.
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg
index 324ed5c2cb..1866d1ab67 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg
@@ -4,129 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- false*
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update false*
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ false*
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ false*
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg
index b7ad1d10db..96ac9e7621 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg
@@ -4,130 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- false
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update
- true
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging
- false
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ false
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ true
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ false
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg
index e99a5b4cdd..739a96cf09 100644
--- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg
@@ -4,128 +4,142 @@
- ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
- > Settings
- │
- │
- │
- │
- ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
- │
- │
- │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │
+ > Settings
+ │
+ │
+ │
+ │
+ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+ │
+ │
+ │S
- earch to filter
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
- │
- │
- │
- │
- ▲
- │
- │
- ●
- Vim Mode
- true*
- │
- │
- Enable Vim keybindings
- │
- │
- │
- │
- Default Approval Mode
- Default
- │
- │
- The default approval mode for tool execution. 'default' prompts for approval, 'au…
- │
- │
- │
- │
- Enable Auto Update false*
- │
- │
- Enable automatic updates.
- │
- │
- │
- │
- Enable Notifications
- false
- │
- │
- Enable run-event notifications for action-required prompts and session completion. …
- │
- │
- │
- │
- Plan Directory
- undefined
- │
- │
- The directory where planning artifacts are stored. If not specified, defaults t…
- │
- │
- │
- │
- Plan Model Routing
- true
- │
- │
- Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
- │
- │
- │
- │
- Max Chat Model Attempts
- 10
- │
- │
- Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
- │
- │
- │
- │
- Debug Keystroke Logging true*
- │
- │
- Enable debug logging of keystrokes to the console.
- │
- │
- │
- │
- ▼
- │
- │
- │
- │
+ earch to filter
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+ │
+ │
+ ▲
+ │
+ │
+
+ ●
+
+
+ Vim Mode
+
+
+ true*
+ │
+ │
+
+
+ Enable Vim keybindings
+
+ │
+ │
+ │
+ │
+ Default Approval Mode
+ Default
+ │
+ │
+ The default approval mode for tool execution. 'default' prompts for approval, 'au…
+ │
+ │
+ │
+ │
+ Enable Auto Update
+ false*
+ │
+ │
+ Enable automatic updates.
+ │
+ │
+ │
+ │
+ Enable Notifications
+ false
+ │
+ │
+ Enable run-event notifications for action-required prompts and session completion. …
+ │
+ │
+ │
+ │
+ Plan Directory
+ undefined
+ │
+ │
+ The directory where planning artifacts are stored. If not specified, defaults t…
+ │
+ │
+ │
+ │
+ Plan Model Routing
+ true
+ │
+ │
+ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…
+ │
+ │
+ │
+ │
+ Max Chat Model Attempts
+ 10
+ │
+ │
+ Maximum number of attempts for requests to the main chat model. Cannot exceed 10.
+ │
+ │
+ │
+ │
+ Debug Keystroke Logging
+ true*
+ │
+ │
+ Enable debug logging of keystrokes to the console.
+ │
+ │
+ │
+ │
+ ▼
+ │
+ │
+ │
+ │ Apply To
- │
- │
- ●
- User Settings
- │
- │
- Workspace Settings
- │
- │
- System Settings
- │
- │
- │
- │
- (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
- │
- │
- │
- ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+ │
+ │
+
+ ●
+
+
+ User Settings
+
+ │
+ │
+ Workspace Settings
+ │
+ │
+ System Settings
+ │
+ │
+ │
+ │
+ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)
+ │
+ │
+ │
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap
index 70d2cba48d..9e65c72f69 100644
--- a/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap
@@ -5,8 +5,8 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
Shortcuts See /help for more
! shell mode
@ select file or folder
- Esc Esc clear & rewind
- Tab Tab focus UI
+ Double Esc clear & rewind
+ Tab focus UI
Ctrl+Y YOLO mode
Shift+Tab cycle mode
Ctrl+V paste images
@@ -21,8 +21,8 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
Shortcuts See /help for more
! shell mode
@ select file or folder
- Esc Esc clear & rewind
- Tab Tab focus UI
+ Double Esc clear & rewind
+ Tab focus UI
Ctrl+Y YOLO mode
Shift+Tab cycle mode
Ctrl+V paste images
@@ -37,8 +37,8 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
Shortcuts See /help for more
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Alt+M raw markdown mode
- Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
- Tab Tab focus UI
+ Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
+ Tab focus UI
"
`;
@@ -47,7 +47,7 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
Shortcuts See /help for more
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
- Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
- Tab Tab focus UI
+ Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
+ Tab focus UI
"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
index cc31c301ba..8f876cc44b 100644
--- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
@@ -117,10 +117,9 @@ exports[` > Conditional Rendering Tests > hides Efficiency secti
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
-│ Model Usage │
-│ Model Reqs Input Tokens Cache Reads Output Tokens │
-│ ──────────────────────────────────────────────────────────────────────────── │
-│ gemini-2.5-pro 1 100 0 100 │
+│ Model Reqs Input Tokens Cache Reads Output Tokens │
+│ ──────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 1 100 0 100 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -162,16 +161,15 @@ exports[` > Quota Display > renders pooled quota information for
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
-│ auto Usage │
-│ 65% usage remaining │
+│ 35% used │
│ Usage limit: 1,100 │
│ Usage limits span all sessions and reset daily. │
│ For a full token breakdown, run \`/stats model\`. │
│ │
-│ Model Reqs Usage remaining │
-│ ──────────────────────────────────────────────────────────── │
-│ gemini-2.5-pro - │
-│ gemini-2.5-flash - │
+│ Model Reqs Model usage Usage resets │
+│ ──────────────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 90% │
+│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 30% │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -193,10 +191,9 @@ exports[` > Quota Display > renders quota information for unused
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
-│ Model Usage │
-│ Model Reqs Usage remaining │
-│ ──────────────────────────────────────────────────────────── │
-│ gemini-2.5-flash - 50.0% resets in 2h │
+│ Model Reqs Model usage Usage resets │
+│ ──────────────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 50% 2:00 PM (2h) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -218,10 +215,9 @@ exports[` > Quota Display > renders quota information when quota
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
-│ Model Usage │
-│ Model Reqs Usage remaining │
-│ ──────────────────────────────────────────────────────────── │
-│ gemini-2.5-pro 1 75.0% resets in 1h 30m │
+│ Model Reqs Model usage Usage resets │
+│ ──────────────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 1 ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% 1:30 PM (1h 30m) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -283,11 +279,10 @@ exports[` > renders a table with two models correctly 1`] = `
│ » API Time: 19.5s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
-│ Model Usage │
-│ Model Reqs Input Tokens Cache Reads Output Tokens │
-│ ──────────────────────────────────────────────────────────────────────────── │
-│ gemini-2.5-pro 3 500 500 2,000 │
-│ gemini-2.5-flash 5 15,000 10,000 15,000 │
+│ Model Reqs Input Tokens Cache Reads Output Tokens │
+│ ──────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 3 500 500 2,000 │
+│ gemini-2.5-flash 5 15,000 10,000 15,000 │
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
@@ -312,10 +307,9 @@ exports[` > renders all sections when all data is present 1`] =
│ » API Time: 100ms (44.8%) │
│ » Tool Time: 123ms (55.2%) │
│ │
-│ Model Usage │
-│ Model Reqs Input Tokens Cache Reads Output Tokens │
-│ ──────────────────────────────────────────────────────────────────────────── │
-│ gemini-2.5-pro 1 50 50 100 │
+│ Model Reqs Input Tokens Cache Reads Output Tokens │
+│ ──────────────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 1 50 50 100 │
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
diff --git a/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap
index 775233f30e..3c79a534a2 100644
--- a/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap
@@ -7,7 +7,7 @@ exports[`SuggestionsDisplay > handles scrolling 1`] = `
Cmd 7 Description 7
Cmd 8 Description 8
Cmd 9 Description 9
- Cmd 10 Description 10
+ Cmd 10 Description 10
Cmd 11 Description 11
Cmd 12 Description 12
▼
@@ -17,13 +17,13 @@ exports[`SuggestionsDisplay > handles scrolling 1`] = `
exports[`SuggestionsDisplay > highlights active item 1`] = `
" command1 Description 1
- command2 Description 2
+ command2 Description 2
command3 Description 3
"
`;
exports[`SuggestionsDisplay > renders MCP tag for MCP prompts 1`] = `
-" mcp-tool [MCP]
+" mcp-tool [MCP]
"
`;
@@ -33,7 +33,7 @@ exports[`SuggestionsDisplay > renders loading state 1`] = `
`;
exports[`SuggestionsDisplay > renders suggestions list 1`] = `
-" command1 Description 1
+" command1 Description 1
command2 Description 2
command3 Description 3
"
diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg
index 6042642abd..fca715c952 100644
--- a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg
@@ -4,9 +4,12 @@
- ID Name
- ────────────────────────────────────────────────────────────────────────────────────────────────────
- 1 Alice
- 2 Bob
+ ID
+ Name
+ ────────────────────────────────────────────────────────────────────────────────────────────────────
+ 1
+ Alice
+ 2
+ Bob
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg
index 359b4ee76d..870e292d66 100644
--- a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg
@@ -4,8 +4,8 @@
- Value
- ────────────────────────────────────────────────────────────────────────────────────────────────────
+ Value
+ ────────────────────────────────────────────────────────────────────────────────────────────────────20
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg
index 4473a2e810..508eca9a5b 100644
--- a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg
+++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg
@@ -4,8 +4,8 @@
- Status
- ────────────────────────────────────────────────────────────────────────────────────────────────────
+ Status
+ ────────────────────────────────────────────────────────────────────────────────────────────────────Active
diff --git a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
index 11f2af0a5c..4a5b30fc5c 100644
--- a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap
@@ -8,7 +8,7 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal
│ 1. ANSI Dark │ │ │
│ 2. Atom One Dark │ 1 # function │ │
│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
-│ ● 4. Default Dark │ 3 a, b = 0, 1 │ │
+│ ● 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
@@ -58,7 +58,7 @@ exports[`Initial Theme Selection > should use the theme from settings even if te
│ ● 1. ANSI Dark │ │ │
│ 2. Atom One Dark │ 1 # function │ │
│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
-│ 4. Default Dark │ 3 a, b = 0, 1 │ │
+│ 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
@@ -89,7 +89,7 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
"
`;
-exports[`ThemeDialog Snapshots > should render correctly in theme selection mode 1`] = `
+exports[`ThemeDialog Snapshots > should render correctly in theme selection mode (isDevelopment: false) 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Select Theme Preview │
@@ -113,3 +113,90 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
+
+exports[`ThemeDialog Snapshots > should render correctly in theme selection mode (isDevelopment: true) 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ > Select Theme Preview │
+│ ▲ ┌─────────────────────────────────────────────────┐ │
+│ ● 1. ANSI Dark (Matches terminal) │ │ │
+│ 2. Atom One Dark │ 1 # function │ │
+│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
+│ 4. Default Dark │ 3 a, b = 0, 1 │ │
+│ 5. Dracula Dark │ 4 for _ in range(n): │ │
+│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
+│ 7. Holiday Dark │ 6 return a │ │
+│ 8. Shades Of Purple Dark │ │ │
+│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
+│ 10. ANSI Light │ 1 + print(f"Hello, {name}!") │ │
+│ 11. Ayu Light │ │ │
+│ 12. Default Light └─────────────────────────────────────────────────┘ │
+│ ▼ │
+│ ╭─────────────────────────────────────────────────╮ │
+│ │ DEVELOPER TOOLS (Not visible to users) │ │
+│ │ │ │
+│ │ How do colors get applied? │ │
+│ │ • Hex: Rendered exactly by modern terminals. │ │
+│ │ Not overridden by app themes. │ │
+│ │ • Blank: Uses your terminal's default │ │
+│ │ foreground/background. │ │
+│ │ • Compatibility: On older terminals, hex is │ │
+│ │ approximated to the nearest ANSI color. │ │
+│ │ • ANSI Names: 'red', 'green', etc. are mapped │ │
+│ │ to your terminal app's palette. │ │
+│ │ │ │
+│ │ Value Name │ │
+│ │ #0000… backgroun Main terminal background │ │
+│ │ d.primary color │ │
+│ │ #5F5… backgroun Subtle background for │ │
+│ │ d.message message blocks │ │
+│ │ #5F5… backgroun Background for the input │ │
+│ │ d.input prompt │ │
+│ │ #00… background. Background highlight for │ │
+│ │ focus selected/focused items │ │
+│ │ #005… backgrou Background for added lines │ │
+│ │ nd.diff. in diffs │ │
+│ │ added │ │
+│ │ #5F0… backgroun Background for removed │ │
+│ │ d.diff.re lines in diffs │ │
+│ │ moved │ │
+│ │ #FFFFF text.prim Primary text color (uses │ │
+│ │ F ary terminal default if blank) │ │
+│ │ #AFAFAF text.secon Secondary/dimmed text │ │
+│ │ dary color │ │
+│ │ #87AFFF text.link Hyperlink and highlighting │ │
+│ │ color │ │
+│ │ #D7AFFF text.accen Accent color for │ │
+│ │ t emphasis │ │
+│ │ #FFFFFF text.res Color for model response │ │
+│ │ ponse text (uses terminal default │ │
+│ │ if blank) │ │
+│ │ #878787 border.def Standard border color │ │
+│ │ ault │ │
+│ │ #AFAFAFui.comme Color for code comments and │ │
+│ │ nt metadata │ │
+│ │ #AFAFA ui.symbol Color for technical symbols │ │
+│ │ F and UI icons │ │
+│ │ #87AFF ui.active Border color for active or │ │
+│ │ F running elements │ │
+│ │ #87878 ui.dark Deeply dimmed color for │ │
+│ │ 7 subtle UI elements │ │
+│ │ #D7FFD ui.focus Color for focused elements │ │
+│ │ 7 (e.g. selected menu items, │ │
+│ │ focused borders) │ │
+│ │ #FF87AFstatus.err Color for error messages │ │
+│ │ or and critical status │ │
+│ │ #D7FFD7status.suc Color for success messages │ │
+│ │ cess and positive status │ │
+│ │ #FFFFA status.wa Color for warnings and │ │
+│ │ F rning cautionary status │ │
+│ │ #4796E4 ui.gradien │ │
+│ │ #847ACE t │ │
+│ │ #C3677F │ │
+│ ╰─────────────────────────────────────────────────╯ │
+│ │
+│ (Use Enter to select, Tab to configure scope, Esc to close) │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/Tips.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Tips.test.tsx.snap
new file mode 100644
index 0000000000..dbc60fcf4d
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Tips.test.tsx.snap
@@ -0,0 +1,20 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Tips > 'renders all tips including GEMINI.md …' 1`] = `
+"
+Tips for getting started:
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results
+"
+`;
+
+exports[`Tips > 'renders fewer tips when GEMINI.md exi…' 1`] = `
+"
+Tips for getting started:
+1. /help for more information
+2. Ask coding questions, edit code or run commands
+3. Be specific for the best results
+"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-renders-a-multiline-shell-command-with-syntax-highlighting-and-redirection-warning-SVG-snapshot-.snap.svg b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-renders-a-multiline-shell-command-with-syntax-highlighting-and-redirection-warning-SVG-snapshot-.snap.svg
new file mode 100644
index 0000000000..32ece1f90f
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-renders-a-multiline-shell-command-with-syntax-highlighting-and-redirection-warning-SVG-snapshot-.snap.svg
@@ -0,0 +1,88 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap
index a39d668825..6d9baba94f 100644
--- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap
@@ -16,6 +16,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
+ Press Ctrl+O to show more lines
"
`;
diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx
index 0bdf9b65e9..bc2246d23f 100644
--- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx
+++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx
@@ -7,12 +7,9 @@
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
-import { ShowMoreLines } from '../ShowMoreLines.js';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
import { useUIState } from '../../contexts/UIStateContext.js';
-import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
-import { OverflowProvider } from '../../contexts/OverflowContext.js';
interface GeminiMessageProps {
text: string;
@@ -31,8 +28,7 @@ export const GeminiMessage: React.FC = ({
const prefix = '✦ ';
const prefixWidth = prefix.length;
- const isAlternateBuffer = useAlternateBuffer();
- const content = (
+ return (
@@ -44,29 +40,14 @@ export const GeminiMessage: React.FC = ({
text={text}
isPending={isPending}
availableTerminalHeight={
- isAlternateBuffer || availableTerminalHeight === undefined
+ availableTerminalHeight === undefined
? undefined
: Math.max(availableTerminalHeight - 1, 1)
}
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
renderMarkdown={renderMarkdown}
/>
-
-
-
);
-
- return isAlternateBuffer ? (
- /* Shadow the global provider to maintain isolation in ASB mode. */
- {content}
- ) : (
- content
- );
};
diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx
index 259a0016f3..1aed5e1950 100644
--- a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx
+++ b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx
@@ -7,9 +7,7 @@
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
-import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
-import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
interface GeminiMessageContentProps {
text: string;
@@ -31,7 +29,6 @@ export const GeminiMessageContent: React.FC = ({
terminalWidth,
}) => {
const { renderMarkdown } = useUIState();
- const isAlternateBuffer = useAlternateBuffer();
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
@@ -41,21 +38,13 @@ export const GeminiMessageContent: React.FC = ({
text={text}
isPending={isPending}
availableTerminalHeight={
- isAlternateBuffer || availableTerminalHeight === undefined
+ availableTerminalHeight === undefined
? undefined
: Math.max(availableTerminalHeight - 1, 1)
}
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
renderMarkdown={renderMarkdown}
/>
-
-
-
);
};
diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx
index e725a23993..bea86e3834 100644
--- a/packages/cli/src/ui/components/messages/InfoMessage.tsx
+++ b/packages/cli/src/ui/components/messages/InfoMessage.tsx
@@ -11,6 +11,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
+ secondaryText?: string;
icon?: string;
color?: string;
marginBottom?: number;
@@ -18,6 +19,7 @@ interface InfoMessageProps {
export const InfoMessage: React.FC = ({
text,
+ secondaryText,
icon,
color,
marginBottom,
@@ -35,6 +37,9 @@ export const InfoMessage: React.FC = ({
{text.split('\n').map((line, index) => (
+ {index === text.split('\n').length - 1 && secondaryText && (
+ {secondaryText}
+ )}
))}
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
index 72ce8cec5f..b650ee4d9d 100644
--- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
@@ -65,7 +65,7 @@ describe('', () => {
['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
])('clicks inside the shell area sets focus for %s', async (_, name) => {
- const { lastFrame, simulateClick } = renderShell(
+ const { lastFrame, simulateClick, unmount } = renderShell(
{ name },
{ mouseEventsEnabled: true },
);
@@ -79,6 +79,7 @@ describe('', () => {
await waitFor(() => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true);
});
+ unmount();
});
it('resets focus when shell finishes', async () => {
let updateStatus: (s: CoreToolCallStatus) => void = () => {};
@@ -91,7 +92,7 @@ describe('', () => {
return ;
};
- const { lastFrame } = renderWithProviders(, {
+ const { lastFrame, unmount } = renderWithProviders(, {
uiActions,
uiState: {
streamingState: StreamingState.Idle,
@@ -115,6 +116,7 @@ describe('', () => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
expect(lastFrame()).not.toContain('(Shift+Tab to unfocus)');
});
+ unmount();
});
});
@@ -135,6 +137,14 @@ describe('', () => {
{ status: CoreToolCallStatus.Error, resultDisplay: 'Error output' },
undefined,
],
+ [
+ 'renders in Cancelled state with partial output',
+ {
+ status: CoreToolCallStatus.Cancelled,
+ resultDisplay: 'Partial output before cancellation',
+ },
+ undefined,
+ ],
[
'renders in Alternate Buffer mode while focused',
{
@@ -164,9 +174,13 @@ describe('', () => {
},
],
])('%s', async (_, props, options) => {
- const { lastFrame, waitUntilReady } = renderShell(props, options);
+ const { lastFrame, waitUntilReady, unmount } = renderShell(
+ props,
+ options,
+ );
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
+ unmount();
});
});
@@ -177,51 +191,66 @@ describe('', () => {
10,
8,
false,
+ true,
],
[
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
100,
- ACTIVE_SHELL_MAX_LINES,
+ ACTIVE_SHELL_MAX_LINES - 3,
false,
+ true,
],
[
'uses full availableTerminalHeight when focused in alternate buffer mode',
100,
98, // 100 - 2
true,
+ false,
],
[
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
undefined,
- ACTIVE_SHELL_MAX_LINES,
+ ACTIVE_SHELL_MAX_LINES - 3,
+ false,
false,
],
- ])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => {
- const { lastFrame, waitUntilReady } = renderShell(
- {
- resultDisplay: LONG_OUTPUT,
- renderOutputAsMarkdown: false,
- availableTerminalHeight,
- ptyId: 1,
- status: CoreToolCallStatus.Executing,
- },
- {
- useAlternateBuffer: true,
- uiState: {
- activePtyId: focused ? 1 : 2,
- embeddedShellFocused: focused,
+ ])(
+ '%s',
+ async (
+ _,
+ availableTerminalHeight,
+ expectedMaxLines,
+ focused,
+ constrainHeight,
+ ) => {
+ const { lastFrame, waitUntilReady, unmount } = renderShell(
+ {
+ resultDisplay: LONG_OUTPUT,
+ renderOutputAsMarkdown: false,
+ availableTerminalHeight,
+ ptyId: 1,
+ status: CoreToolCallStatus.Executing,
},
- },
- );
+ {
+ useAlternateBuffer: true,
+ uiState: {
+ activePtyId: focused ? 1 : 2,
+ embeddedShellFocused: focused,
+ constrainHeight,
+ },
+ },
+ );
- await waitUntilReady();
- const frame = lastFrame();
- expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
- expect(frame).toMatchSnapshot();
- });
+ await waitUntilReady();
+ const frame = lastFrame();
+ expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
+ expect(frame).toMatchSnapshot();
+ unmount();
+ },
+ );
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
- const { lastFrame } = renderShell(
+ const { lastFrame, unmount } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
@@ -236,10 +265,11 @@ describe('', () => {
// Should show all 100 lines
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
+ unmount();
});
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
- const { lastFrame, waitUntilReady } = renderShell(
+ const { lastFrame, waitUntilReady, unmount } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
@@ -262,10 +292,11 @@ describe('', () => {
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
expect(lastFrame()).toMatchSnapshot();
+ unmount();
});
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
- const { lastFrame, waitUntilReady } = renderShell(
+ const { lastFrame, waitUntilReady, unmount } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
@@ -284,10 +315,11 @@ describe('', () => {
await waitUntilReady();
await waitFor(() => {
const frame = lastFrame();
- // Should still be constrained to ACTIVE_SHELL_MAX_LINES (15) because isExpandable is false
- expect(frame.match(/Line \d+/g)?.length).toBe(15);
+ // Should still be constrained to 12 (15 - 3) because isExpandable is false
+ expect(frame.match(/Line \d+/g)?.length).toBe(12);
});
expect(lastFrame()).toMatchSnapshot();
+ unmount();
});
});
});
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
index 8e760b28e7..f34aa08bfb 100644
--- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
@@ -24,8 +24,16 @@ import type { ToolMessageProps } from './ToolMessage.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { useUIState } from '../../contexts/UIStateContext.js';
-import { type Config } from '@google/gemini-cli-core';
-import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js';
+import {
+ type Config,
+ ShellExecutionService,
+ CoreToolCallStatus,
+} from '@google/gemini-cli-core';
+import {
+ calculateShellMaxLines,
+ calculateToolContentMaxLines,
+ SHELL_CONTENT_OVERHEAD,
+} from '../../utils/toolLayoutUtils.js';
export interface ShellToolMessageProps extends ToolMessageProps {
config?: Config;
@@ -78,6 +86,47 @@ export const ShellToolMessage: React.FC = ({
embeddedShellFocused,
);
+ const maxLines = calculateShellMaxLines({
+ status,
+ isAlternateBuffer,
+ isThisShellFocused,
+ availableTerminalHeight,
+ constrainHeight,
+ isExpandable,
+ });
+
+ const availableHeight = calculateToolContentMaxLines({
+ availableTerminalHeight,
+ isAlternateBuffer,
+ maxLinesLimit: maxLines,
+ });
+
+ React.useEffect(() => {
+ const isExecuting = status === CoreToolCallStatus.Executing;
+ if (isExecuting && ptyId) {
+ try {
+ const childWidth = terminalWidth - 4; // account for padding and borders
+ const finalHeight =
+ availableHeight ?? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD;
+
+ ShellExecutionService.resizePty(
+ ptyId,
+ Math.max(1, childWidth),
+ Math.max(1, finalHeight),
+ );
+ } catch (e) {
+ if (
+ !(
+ e instanceof Error &&
+ e.message.includes('Cannot resize a pty that has already exited')
+ )
+ ) {
+ throw e;
+ }
+ }
+ }
+ }, [ptyId, status, terminalWidth, availableHeight]);
+
const { setEmbeddedShellFocused } = useUIActions();
const wasFocusedRef = React.useRef(false);
@@ -125,7 +174,11 @@ export const ShellToolMessage: React.FC = ({
borderDimColor={borderDimColor}
containerRef={headerRef}
>
-
+ = ({
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
- maxLines={calculateShellMaxLines({
- status,
- isAlternateBuffer,
- isThisShellFocused,
- availableTerminalHeight,
- constrainHeight,
- isExpandable,
- })}
+ maxLines={maxLines}
/>
{isThisShellFocused && config && (
({
+ default: () => ⠋,
+}));
+
+describe('', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ cleanup();
+ });
+
+ it('renders correctly with description in args', async () => {
+ const progress: SubagentProgress = {
+ isSubagentProgress: true,
+ agentName: 'TestAgent',
+ recentActivity: [
+ {
+ id: '1',
+ type: 'tool_call',
+ content: 'run_shell_command',
+ args: '{"command": "echo hello", "description": "Say hello"}',
+ status: 'running',
+ },
+ ],
+ };
+
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders correctly with displayName and description from item', async () => {
+ const progress: SubagentProgress = {
+ isSubagentProgress: true,
+ agentName: 'TestAgent',
+ recentActivity: [
+ {
+ id: '1',
+ type: 'tool_call',
+ content: 'run_shell_command',
+ displayName: 'RunShellCommand',
+ description: 'Executing echo hello',
+ args: '{"command": "echo hello"}',
+ status: 'running',
+ },
+ ],
+ };
+
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders correctly with command fallback', async () => {
+ const progress: SubagentProgress = {
+ isSubagentProgress: true,
+ agentName: 'TestAgent',
+ recentActivity: [
+ {
+ id: '2',
+ type: 'tool_call',
+ content: 'run_shell_command',
+ args: '{"command": "echo hello"}',
+ status: 'running',
+ },
+ ],
+ };
+
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders correctly with file_path', async () => {
+ const progress: SubagentProgress = {
+ isSubagentProgress: true,
+ agentName: 'TestAgent',
+ recentActivity: [
+ {
+ id: '3',
+ type: 'tool_call',
+ content: 'write_file',
+ args: '{"file_path": "/tmp/test.txt", "content": "foo"}',
+ status: 'completed',
+ },
+ ],
+ };
+
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('truncates long args', async () => {
+ const longDesc =
+ 'This is a very long description that should definitely be truncated because it exceeds the limit of sixty characters.';
+ const progress: SubagentProgress = {
+ isSubagentProgress: true,
+ agentName: 'TestAgent',
+ recentActivity: [
+ {
+ id: '4',
+ type: 'tool_call',
+ content: 'run_shell_command',
+ args: JSON.stringify({ description: longDesc }),
+ status: 'running',
+ },
+ ],
+ };
+
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders thought bubbles correctly', async () => {
+ const progress: SubagentProgress = {
+ isSubagentProgress: true,
+ agentName: 'TestAgent',
+ recentActivity: [
+ {
+ id: '5',
+ type: 'thought',
+ content: 'Thinking about life',
+ status: 'running',
+ },
+ ],
+ };
+
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders cancelled state correctly', async () => {
+ const progress: SubagentProgress = {
+ isSubagentProgress: true,
+ agentName: 'TestAgent',
+ recentActivity: [],
+ state: 'cancelled',
+ };
+
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders "Request cancelled." with the info icon', async () => {
+ const progress: SubagentProgress = {
+ isSubagentProgress: true,
+ agentName: 'TestAgent',
+ recentActivity: [
+ {
+ id: '6',
+ type: 'thought',
+ content: 'Request cancelled.',
+ status: 'error',
+ },
+ ],
+ };
+
+ const { lastFrame, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx
new file mode 100644
index 0000000000..b34a904b3e
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx
@@ -0,0 +1,151 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../../semantic-colors.js';
+import Spinner from 'ink-spinner';
+import type {
+ SubagentProgress,
+ SubagentActivityItem,
+} from '@google/gemini-cli-core';
+import { TOOL_STATUS } from '../../constants.js';
+import { STATUS_INDICATOR_WIDTH } from './ToolShared.js';
+
+export interface SubagentProgressDisplayProps {
+ progress: SubagentProgress;
+}
+
+const formatToolArgs = (args?: string): string => {
+ if (!args) return '';
+ try {
+ const parsed: unknown = JSON.parse(args);
+ if (typeof parsed !== 'object' || parsed === null) {
+ return args;
+ }
+
+ if (
+ 'description' in parsed &&
+ typeof parsed.description === 'string' &&
+ parsed.description
+ ) {
+ return parsed.description;
+ }
+ if ('command' in parsed && typeof parsed.command === 'string')
+ return parsed.command;
+ if ('file_path' in parsed && typeof parsed.file_path === 'string')
+ return parsed.file_path;
+ if ('dir_path' in parsed && typeof parsed.dir_path === 'string')
+ return parsed.dir_path;
+ if ('query' in parsed && typeof parsed.query === 'string')
+ return parsed.query;
+ if ('url' in parsed && typeof parsed.url === 'string') return parsed.url;
+ if ('target' in parsed && typeof parsed.target === 'string')
+ return parsed.target;
+
+ return args;
+ } catch {
+ return args;
+ }
+};
+
+export const SubagentProgressDisplay: React.FC<
+ SubagentProgressDisplayProps
+> = ({ progress }) => {
+ let headerText: string | undefined;
+ let headerColor = theme.text.secondary;
+
+ if (progress.state === 'cancelled') {
+ headerText = `Subagent ${progress.agentName} was cancelled.`;
+ headerColor = theme.status.warning;
+ } else if (progress.state === 'error') {
+ headerText = `Subagent ${progress.agentName} failed.`;
+ headerColor = theme.status.error;
+ } else if (progress.state === 'completed') {
+ headerText = `Subagent ${progress.agentName} completed.`;
+ headerColor = theme.status.success;
+ }
+
+ return (
+
+ {headerText && (
+
+
+ {headerText}
+
+
+ )}
+
+ {progress.recentActivity.map((item: SubagentActivityItem) => {
+ if (item.type === 'thought') {
+ const isCancellation = item.content === 'Request cancelled.';
+ const icon = isCancellation ? 'ℹ ' : '💭';
+ const color = isCancellation
+ ? theme.status.warning
+ : theme.text.secondary;
+
+ return (
+
+
+ {icon}
+
+
+ {item.content}
+
+
+ );
+ } else if (item.type === 'tool_call') {
+ const statusSymbol =
+ item.status === 'running' ? (
+
+ ) : item.status === 'completed' ? (
+ {TOOL_STATUS.SUCCESS}
+ ) : item.status === 'cancelled' ? (
+
+ {TOOL_STATUS.CANCELED}
+
+ ) : (
+ {TOOL_STATUS.ERROR}
+ );
+
+ const formattedArgs = item.description || formatToolArgs(item.args);
+ const displayArgs =
+ formattedArgs.length > 60
+ ? formattedArgs.slice(0, 60) + '...'
+ : formattedArgs;
+
+ return (
+
+ {statusSymbol}
+
+
+ {item.displayName || item.content}
+
+ {displayArgs && (
+
+
+ {displayArgs}
+
+
+ )}
+
+
+ );
+ }
+ return null;
+ })}
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx
index a27923c014..1499d285f7 100644
--- a/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ThinkingMessage.test.tsx
@@ -7,84 +7,156 @@
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { ThinkingMessage } from './ThinkingMessage.js';
+import React from 'react';
describe('ThinkingMessage', () => {
- it('renders subject line', async () => {
- const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ it('renders subject line with vertical rule and "Thinking..." header', async () => {
+ const renderResult = renderWithProviders(
,
);
- await waitUntilReady();
+ await renderResult.waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
- unmount();
+ const output = renderResult.lastFrame();
+ expect(output).toContain(' Thinking...');
+ expect(output).toContain('│');
+ expect(output).toContain('Planning');
+ expect(output).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
});
it('uses description when subject is empty', async () => {
- const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ const renderResult = renderWithProviders(
,
);
- await waitUntilReady();
+ await renderResult.waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
- unmount();
+ const output = renderResult.lastFrame();
+ expect(output).toContain('Processing details');
+ expect(output).toContain('│');
+ expect(output).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
});
it('renders full mode with left border and full text', async () => {
- const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ const renderResult = renderWithProviders(
,
);
- await waitUntilReady();
+ await renderResult.waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
- unmount();
+ const output = renderResult.lastFrame();
+ expect(output).toContain('│');
+ expect(output).toContain('Planning');
+ expect(output).toContain('I am planning the solution.');
+ expect(output).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
});
- it('indents summary line correctly', async () => {
- const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ it('renders "Thinking..." header when isFirstThinking is true', async () => {
+ const renderResult = renderWithProviders(
,
);
- await waitUntilReady();
+ await renderResult.waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
- unmount();
+ const output = renderResult.lastFrame();
+ expect(output).toContain(' Thinking...');
+ expect(output).toContain('Summary line');
+ expect(output).toContain('│');
+ expect(output).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
});
it('normalizes escaped newline tokens', async () => {
- const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ const renderResult = renderWithProviders(
,
);
- await waitUntilReady();
+ await renderResult.waitUntilReady();
- expect(lastFrame()).toMatchSnapshot();
- unmount();
+ expect(renderResult.lastFrame()).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
});
it('renders empty state gracefully', async () => {
- const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
- ,
+ const renderResult = renderWithProviders(
+ ,
);
- await waitUntilReady();
+ await renderResult.waitUntilReady();
- expect(lastFrame({ allowEmpty: true })).toBe('');
- unmount();
+ expect(renderResult.lastFrame({ allowEmpty: true })).toBe('');
+ renderResult.unmount();
+ });
+
+ it('renders multiple thinking messages sequentially correctly', async () => {
+ const renderResult = renderWithProviders(
+
+
+
+
+ ,
+ );
+ await renderResult.waitUntilReady();
+
+ expect(renderResult.lastFrame()).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
});
});
diff --git a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx
index 86882307e7..9591989774 100644
--- a/packages/cli/src/ui/components/messages/ThinkingMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ThinkingMessage.tsx
@@ -13,6 +13,30 @@ import { normalizeEscapedNewlines } from '../../utils/textUtils.js';
interface ThinkingMessageProps {
thought: ThoughtSummary;
+ terminalWidth: number;
+ isFirstThinking?: boolean;
+}
+
+const THINKING_LEFT_PADDING = 1;
+
+function normalizeThoughtLines(thought: ThoughtSummary): string[] {
+ const subject = normalizeEscapedNewlines(thought.subject).trim();
+ const description = normalizeEscapedNewlines(thought.description).trim();
+
+ if (!subject && !description) {
+ return [];
+ }
+
+ if (!subject) {
+ return description.split('\n');
+ }
+
+ if (!description) {
+ return [subject];
+ }
+
+ const bodyLines = description.split('\n');
+ return [subject, ...bodyLines];
}
/**
@@ -21,60 +45,47 @@ interface ThinkingMessageProps {
*/
export const ThinkingMessage: React.FC = ({
thought,
+ terminalWidth,
+ isFirstThinking,
}) => {
- const { summary, body } = useMemo(() => {
- const subject = normalizeEscapedNewlines(thought.subject).trim();
- const description = normalizeEscapedNewlines(thought.description).trim();
+ const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]);
- if (!subject && !description) {
- return { summary: '', body: '' };
- }
-
- if (!subject) {
- const lines = description
- .split('\n')
- .map((l) => l.trim())
- .filter(Boolean);
- return {
- summary: lines[0] || '',
- body: lines.slice(1).join('\n'),
- };
- }
-
- return {
- summary: subject,
- body: description,
- };
- }, [thought]);
-
- if (!summary && !body) {
+ if (fullLines.length === 0) {
return null;
}
return (
-
- {summary && (
-
+
+ {isFirstThinking && (
+
+ {' '}
+ Thinking...{' '}
+
+ )}
+
+
+
+ {fullLines.length > 0 && (
- {summary}
+ {fullLines[0]}
-
- )}
- {body && (
-
-
- {body}
+ )}
+ {fullLines.slice(1).map((line, index) => (
+
+ {line}
-
- )}
+ ))}
+
);
};
diff --git a/packages/cli/src/ui/components/messages/Todo.tsx b/packages/cli/src/ui/components/messages/Todo.tsx
index 4f2b95fd3c..786fe5e2f1 100644
--- a/packages/cli/src/ui/components/messages/Todo.tsx
+++ b/packages/cli/src/ui/components/messages/Todo.tsx
@@ -11,6 +11,8 @@ import { useMemo } from 'react';
import type { HistoryItemToolGroup } from '../../types.js';
import { Checklist } from '../Checklist.js';
import type { ChecklistItemData } from '../ChecklistItem.js';
+import { formatCommand } from '../../utils/keybindingUtils.js';
+import { Command } from '../../../config/keyBindings.js';
export const TodoTray: React.FC = () => {
const uiState = useUIState();
@@ -55,7 +57,7 @@ export const TodoTray: React.FC = () => {
title="Todo"
items={checklistItems}
isExpanded={uiState.showFullTodos}
- toggleHint="ctrl+t to toggle"
+ toggleHint={`${formatCommand(Command.SHOW_FULL_TODOS)} to toggle`}
/>
);
};
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
index b3b34ae0a8..fec1228c63 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
@@ -240,6 +240,37 @@ describe('ToolConfirmationMessage', () => {
unmount();
});
+ it('should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot)', async () => {
+ const confirmationDetails: SerializableConfirmationDetails = {
+ type: 'exec',
+ title: 'Confirm Multiline Script',
+ command: 'echo "hello"\nfor i in 1 2 3; do\n echo $i\ndone',
+ rootCommand: 'echo',
+ rootCommands: ['echo'],
+ };
+
+ const result = renderWithProviders(
+ ,
+ );
+ await result.waitUntilReady();
+
+ const output = result.lastFrame();
+ expect(output).toContain('echo "hello"');
+ expect(output).toContain('for i in 1 2 3; do');
+ expect(output).toContain('echo $i');
+ expect(output).toContain('done');
+
+ await expect(result).toMatchSvgSnapshot();
+ result.unmount();
+ });
+
describe('with folder trust', () => {
const editConfirmationDetails: SerializableConfirmationDetails = {
type: 'edit',
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index 022a68e953..1ace75633c 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -29,22 +29,18 @@ import {
import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
-import {
- REDIRECTION_WARNING_NOTE_LABEL,
- REDIRECTION_WARNING_NOTE_TEXT,
- REDIRECTION_WARNING_TIP_LABEL,
- REDIRECTION_WARNING_TIP_TEXT,
-} from '../../textConstants.js';
import { AskUserDialog } from '../AskUserDialog.js';
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
import { WarningMessage } from './WarningMessage.js';
+import { colorizeCode } from '../../utils/CodeColorizer.js';
import {
getDeceptiveUrlDetails,
toUnicodeUrl,
type DeceptiveUrlDetails,
} from '../../utils/urlSecurityUtils.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export interface ToolConfirmationMessageProps {
callId: string;
@@ -56,6 +52,11 @@ export interface ToolConfirmationMessageProps {
terminalWidth: number;
}
+const REDIRECTION_WARNING_NOTE_LABEL = 'Note: ';
+const REDIRECTION_WARNING_NOTE_TEXT =
+ 'Command contains redirection which can be undesirable.';
+const REDIRECTION_WARNING_TIP_LABEL = 'Tip: '; // Padded to align with "Note: "
+
export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps
> = ({
@@ -67,6 +68,7 @@ export const ToolConfirmationMessage: React.FC<
availableTerminalHeight,
terminalWidth,
}) => {
+ const keyMatchers = useKeyMatchers();
const { confirm, isDiffingEnabled } = useToolActions();
const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
callId: string;
@@ -502,12 +504,12 @@ export const ToolConfirmationMessage: React.FC<
if (containsRedirection) {
// Calculate lines needed for Note and Tip
const safeWidth = Math.max(terminalWidth, 1);
+ const tipText = `Toggle auto-edit (${formatCommand(Command.CYCLE_APPROVAL_MODE)}) to allow redirection in the future.`;
+
const noteLength =
REDIRECTION_WARNING_NOTE_LABEL.length +
REDIRECTION_WARNING_NOTE_TEXT.length;
- const tipLength =
- REDIRECTION_WARNING_TIP_LABEL.length +
- REDIRECTION_WARNING_TIP_TEXT.length;
+ const tipLength = REDIRECTION_WARNING_TIP_LABEL.length + tipText.length;
const noteLines = Math.ceil(noteLength / safeWidth);
const tipLines = Math.ceil(tipLength / safeWidth);
@@ -533,7 +535,7 @@ export const ToolConfirmationMessage: React.FC<
{REDIRECTION_WARNING_TIP_LABEL}
- {REDIRECTION_WARNING_TIP_TEXT}
+ {tipText}
>
@@ -548,9 +550,19 @@ export const ToolConfirmationMessage: React.FC<
>
{commandsToDisplay.map((cmd, idx) => (
-
- {sanitizeForDisplay(cmd)}
-
+
+ {colorizeCode({
+ code: cmd,
+ language: 'bash',
+ maxWidth: Math.max(terminalWidth, 1),
+ settings,
+ hideLineNumbers: true,
+ })}
+
))}
@@ -634,6 +646,7 @@ export const ToolConfirmationMessage: React.FC<
mcpToolDetailsText,
expandDetailsHintKey,
getPreferredEditor,
+ settings,
]);
const bodyOverflowDirection: 'top' | 'bottom' =
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
index 056e6a54b4..8971d488d3 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
@@ -6,7 +6,6 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
-import { act } from 'react';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type {
HistoryItem,
@@ -767,200 +766,4 @@ describe('', () => {
},
);
});
-
- describe('Manual Overflow Detection', () => {
- it('detects overflow for string results exceeding available height', async () => {
- const toolCalls = [
- createToolCall({
- resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
- }),
- ];
- const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
- ,
- {
- config: baseMockConfig,
- settings: fullVerbositySettings,
- useAlternateBuffer: true,
- uiState: {
- constrainHeight: true,
- },
- },
- );
- await waitUntilReady();
- expect(lastFrame()?.toLowerCase()).toContain(
- 'press ctrl+o to show more lines',
- );
- unmount();
- });
-
- it('detects overflow for array results exceeding available height', async () => {
- // resultDisplay when array is expected to be AnsiLine[]
- // AnsiLine is AnsiToken[]
- const toolCalls = [
- createToolCall({
- resultDisplay: Array(5).fill([{ text: 'line', fg: 'default' }]),
- }),
- ];
- const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
- ,
- {
- config: baseMockConfig,
- settings: fullVerbositySettings,
- useAlternateBuffer: true,
- uiState: {
- constrainHeight: true,
- },
- },
- );
- await waitUntilReady();
- expect(lastFrame()?.toLowerCase()).toContain(
- 'press ctrl+o to show more lines',
- );
- unmount();
- });
-
- it('respects ACTIVE_SHELL_MAX_LINES for focused shell tools', async () => {
- const toolCalls = [
- createToolCall({
- name: 'run_shell_command',
- status: CoreToolCallStatus.Executing,
- ptyId: 1,
- resultDisplay: Array(20).fill('line').join('\n'), // 20 lines > 15 (limit)
- }),
- ];
- const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
- ,
- {
- config: baseMockConfig,
- settings: fullVerbositySettings,
- useAlternateBuffer: true,
- uiState: {
- constrainHeight: true,
- activePtyId: 1,
- embeddedShellFocused: true,
- },
- },
- );
- await waitUntilReady();
- expect(lastFrame()?.toLowerCase()).toContain(
- 'press ctrl+o to show more lines',
- );
- unmount();
- });
-
- it('does not show expansion hint when content is within limits', async () => {
- const toolCalls = [
- createToolCall({
- resultDisplay: 'small result',
- }),
- ];
- const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
- ,
- {
- config: baseMockConfig,
- settings: fullVerbositySettings,
- useAlternateBuffer: true,
- uiState: {
- constrainHeight: true,
- },
- },
- );
- await waitUntilReady();
- expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
- unmount();
- });
-
- it('hides expansion hint when constrainHeight is false', async () => {
- const toolCalls = [
- createToolCall({
- resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
- }),
- ];
- const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
- ,
- {
- config: baseMockConfig,
- settings: fullVerbositySettings,
- useAlternateBuffer: true,
- uiState: {
- constrainHeight: false,
- },
- },
- );
- await waitUntilReady();
- expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
- unmount();
- });
-
- it('isolates overflow hint in ASB mode (ignores global overflow state)', async () => {
- // In this test, the tool output is SHORT (no local overflow).
- // We will inject a dummy ID into the global overflow state.
- // ToolGroupMessage should still NOT show the hint because it calculates
- // overflow locally and passes it as a prop.
- const toolCalls = [
- createToolCall({
- resultDisplay: 'short result',
- }),
- ];
- const { lastFrame, unmount, waitUntilReady, capturedOverflowActions } =
- renderWithProviders(
- ,
- {
- config: baseMockConfig,
- settings: fullVerbositySettings,
- useAlternateBuffer: true,
- uiState: {
- constrainHeight: true,
- },
- },
- );
- await waitUntilReady();
-
- // Manually trigger a global overflow
- act(() => {
- expect(capturedOverflowActions).toBeDefined();
- capturedOverflowActions!.addOverflowingId('unrelated-global-id');
- });
-
- // The hint should NOT appear because ToolGroupMessage is isolated by its prop logic
- expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
- unmount();
- });
- });
});
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
index 29e485a27c..05f9984d69 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
@@ -17,18 +17,12 @@ import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
-import { isShellTool, isThisShellFocused } from './ToolShared.js';
+import { isShellTool } from './ToolShared.js';
import {
shouldHideToolCall,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
-import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
-import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
-import {
- calculateShellMaxLines,
- calculateToolContentMaxLines,
-} from '../../utils/toolLayoutUtils.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
import { useSettings } from '../../contexts/SettingsContext.js';
@@ -75,6 +69,7 @@ export const ToolGroupMessage: React.FC = ({
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
+ parentCallId: t.parentCallId,
});
}),
[allToolCalls, isLowErrorVerbosity],
@@ -82,13 +77,11 @@ export const ToolGroupMessage: React.FC = ({
const config = useConfig();
const {
- constrainHeight,
activePtyId,
embeddedShellFocused,
backgroundShells,
pendingHistoryItems,
} = useUIState();
- const isAlternateBuffer = useAlternateBuffer();
const { borderColor, borderDimColor } = useMemo(
() =>
@@ -148,72 +141,6 @@ export const ToolGroupMessage: React.FC = ({
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
- /*
- * ToolGroupMessage calculates its own overflow state locally and passes
- * it as a prop to ShowMoreLines. This isolates it from global overflow
- * reports in ASB mode, while allowing it to contribute to the global
- * 'Toast' hint in Standard mode.
- *
- * Because of this prop-based isolation and the explicit mode-checks in
- * AppContainer, we do not need to shadow the OverflowProvider here.
- */
- const hasOverflow = useMemo(() => {
- if (!availableTerminalHeightPerToolMessage) return false;
- return visibleToolCalls.some((tool) => {
- const isShellToolCall = isShellTool(tool.name);
- const isFocused = isThisShellFocused(
- tool.name,
- tool.status,
- tool.ptyId,
- activePtyId,
- embeddedShellFocused,
- );
-
- let maxLines: number | undefined;
-
- if (isShellToolCall) {
- maxLines = calculateShellMaxLines({
- status: tool.status,
- isAlternateBuffer,
- isThisShellFocused: isFocused,
- availableTerminalHeight: availableTerminalHeightPerToolMessage,
- constrainHeight,
- isExpandable,
- });
- }
-
- // Standard tools and Shell tools both eventually use ToolResultDisplay's logic.
- // ToolResultDisplay uses calculateToolContentMaxLines to find the final line budget.
- const contentMaxLines = calculateToolContentMaxLines({
- availableTerminalHeight: availableTerminalHeightPerToolMessage,
- isAlternateBuffer,
- maxLinesLimit: maxLines,
- });
-
- if (!contentMaxLines) return false;
-
- if (typeof tool.resultDisplay === 'string') {
- const text = tool.resultDisplay;
- const hasTrailingNewline = text.endsWith('\n');
- const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
- const lineCount = contentText.split('\n').length;
- return lineCount > contentMaxLines;
- }
- if (Array.isArray(tool.resultDisplay)) {
- return tool.resultDisplay.length > contentMaxLines;
- }
- return false;
- });
- }, [
- visibleToolCalls,
- availableTerminalHeightPerToolMessage,
- activePtyId,
- embeddedShellFocused,
- isAlternateBuffer,
- constrainHeight,
- isExpandable,
- ]);
-
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
// tool groups. borderBottomOverride=true means we must render the closing border;
@@ -306,12 +233,6 @@ export const ToolGroupMessage: React.FC = ({
/>
)
}
- {(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
-
- )}
);
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
index 3877c039c6..b9405860f4 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx
@@ -9,7 +9,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ToolMessage, type ToolMessageProps } from './ToolMessage.js';
import { StreamingState } from '../../types.js';
import { Text } from 'ink';
-import { type AnsiOutput, CoreToolCallStatus } from '@google/gemini-cli-core';
+import {
+ type AnsiOutput,
+ CoreToolCallStatus,
+ Kind,
+} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
@@ -435,4 +439,99 @@ describe('', () => {
expect(output).toMatchSnapshot();
unmount();
});
+
+ describe('Truncation', () => {
+ it('applies truncation for Kind.Agent when availableTerminalHeight is provided', async () => {
+ const multilineString = Array.from(
+ { length: 30 },
+ (_, i) => `Line ${i + 1}`,
+ ).join('\n');
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ uiActions,
+ uiState: {
+ streamingState: StreamingState.Idle,
+ constrainHeight: true,
+ },
+ width: 80,
+ useAlternateBuffer: false,
+ },
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+
+ // Since kind=Kind.Agent and availableTerminalHeight is provided, it should truncate to SUBAGENT_MAX_LINES (15)
+ // and show the FIRST lines (overflowDirection='bottom')
+ expect(output).toContain('Line 1');
+ expect(output).toContain('Line 14');
+ expect(output).not.toContain('Line 16');
+ expect(output).not.toContain('Line 30');
+ unmount();
+ });
+
+ it('does NOT apply truncation for Kind.Agent when availableTerminalHeight is undefined', async () => {
+ const multilineString = Array.from(
+ { length: 30 },
+ (_, i) => `Line ${i + 1}`,
+ ).join('\n');
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ uiActions,
+ uiState: { streamingState: StreamingState.Idle },
+ width: 80,
+ useAlternateBuffer: false,
+ },
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+
+ expect(output).toContain('Line 1');
+ expect(output).toContain('Line 30');
+ unmount();
+ });
+
+ it('does NOT apply truncation for Kind.Read', async () => {
+ const multilineString = Array.from(
+ { length: 30 },
+ (_, i) => `Line ${i + 1}`,
+ ).join('\n');
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ uiActions,
+ uiState: { streamingState: StreamingState.Idle },
+ width: 80,
+ useAlternateBuffer: false,
+ },
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+
+ expect(output).toContain('Line 1');
+ expect(output).toContain('Line 30');
+ unmount();
+ });
+ });
});
diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx
index 8a3e2e2c09..5747f7677f 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx
@@ -21,8 +21,9 @@ import {
useFocusHint,
FocusHint,
} from './ToolShared.js';
-import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
+import { type Config, CoreToolCallStatus, Kind } from '@google/gemini-cli-core';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
+import { SUBAGENT_MAX_LINES } from '../../constants.js';
export type { TextEmphasis };
@@ -45,6 +46,7 @@ export const ToolMessage: React.FC = ({
description,
resultDisplay,
status,
+ kind,
availableTerminalHeight,
terminalWidth,
emphasis = 'medium',
@@ -88,12 +90,17 @@ export const ToolMessage: React.FC = ({
borderColor={borderColor}
borderDimColor={borderDimColor}
>
-
+ = ({
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
+ maxLines={
+ kind === Kind.Agent && availableTerminalHeight !== undefined
+ ? SUBAGENT_MAX_LINES
+ : undefined
+ }
+ overflowDirection={kind === Kind.Agent ? 'bottom' : 'top'}
/>
{isThisShellFocused && config && (
diff --git a/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx b/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx
index a82132d0d8..20b8d13459 100644
--- a/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx
@@ -8,21 +8,19 @@ import { describe, it, expect } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { StreamingState, type IndividualToolCallDisplay } from '../../types.js';
-import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { waitFor } from '../../../test-utils/async.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
+import { useOverflowState } from '../../contexts/OverflowContext.js';
describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => {
- it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => {
+ it('should ensure ToolGroupMessage correctly reports overflow to the global state in Alternate Buffer (ASB) mode', async () => {
/**
* Logic:
- * 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
- * 2. ASB mode reserves 1 + 6 = 7 lines.
- * 3. Line budget = 10 - 7 = 3 lines.
- * 4. 5 lines of output > 3 lines budget => hasOverflow should be TRUE.
+ * 1. availableTerminalHeight(13) - staticHeight(1) - ASB Reserved(6) = 6 lines per tool.
+ * 2. 10 lines of output > 6 lines budget => hasOverflow should be TRUE.
*/
- const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`);
+ const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`);
const resultDisplay = lines.join('\n');
const toolCalls: IndividualToolCallDisplay[] = [
@@ -36,8 +34,15 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
},
];
- const { lastFrame } = renderWithProviders(
-
+ let latestOverflowState: ReturnType;
+ const StateCapture = () => {
+ latestOverflowState = useOverflowState();
+ return null;
+ };
+
+ const { unmount, waitUntilReady } = renderWithProviders(
+ <>
+
- ,
+ >,
{
uiState: {
streamingState: StreamingState.Idle,
@@ -55,24 +60,26 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
},
);
- // In ASB mode, the hint should appear because hasOverflow is now correctly calculated.
- await waitFor(() =>
- expect(lastFrame()?.toLowerCase()).toContain(
- 'press ctrl+o to show more lines',
- ),
- );
+ await waitUntilReady();
+
+ // To verify that the overflow state was indeed updated by the Scrollable component.
+ await waitFor(() => {
+ expect(latestOverflowState?.overflowingIds.size).toBeGreaterThan(0);
+ });
+
+ unmount();
});
- it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => {
+ it('should ensure ToolGroupMessage correctly reports overflow in Standard mode', async () => {
/**
* Logic:
- * 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
- * 2. Standard mode reserves 1 + 2 = 3 lines.
- * 3. Line budget = 10 - 3 = 7 lines.
- * 4. 9 lines of output > 7 lines budget => hasOverflow should be TRUE.
+ * 1. availableTerminalHeight(13) passed to ToolGroupMessage.
+ * 2. ToolGroupMessage subtracts its static height (2) => 11 lines available for tools.
+ * 3. ToolResultDisplay gets 11 lines, subtracts static height (1) and Standard Reserved (2) => 8 lines.
+ * 4. 15 lines of output > 8 lines budget => hasOverflow should be TRUE.
*/
- const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`);
+ const lines = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`);
const resultDisplay = lines.join('\n');
const toolCalls: IndividualToolCallDisplay[] = [
@@ -86,16 +93,14 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
},
];
- const { lastFrame } = renderWithProviders(
-
-
- ,
+ const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
+ ,
{
uiState: {
streamingState: StreamingState.Idle,
@@ -105,11 +110,11 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
},
);
+ await waitUntilReady();
+
// Verify truncation is occurring (standard mode uses MaxSizedBox)
await waitFor(() => expect(lastFrame()).toContain('hidden (Ctrl+O'));
- // In Standard mode, ToolGroupMessage calculates hasOverflow correctly now.
- // While Standard mode doesn't render the inline hint (ShowMoreLines returns null),
- // the logic inside ToolGroupMessage is now synchronized.
+ unmount();
});
});
diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx
index f7d158d68c..02f466e72f 100644
--- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx
@@ -6,35 +6,15 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi } from 'vitest';
import type { AnsiOutput } from '@google/gemini-cli-core';
-// Mock UIStateContext partially
-const mockUseUIState = vi.fn();
-vi.mock('../../contexts/UIStateContext.js', async (importOriginal) => {
- const actual =
- await importOriginal();
- return {
- ...actual,
- useUIState: () => mockUseUIState(),
- };
-});
-
-// Mock useAlternateBuffer
-const mockUseAlternateBuffer = vi.fn();
-vi.mock('../../hooks/useAlternateBuffer.js', () => ({
- useAlternateBuffer: () => mockUseAlternateBuffer(),
-}));
-
describe('ToolResultDisplay', () => {
beforeEach(() => {
vi.clearAllMocks();
- mockUseUIState.mockReturnValue({ renderMarkdown: true });
- mockUseAlternateBuffer.mockReturnValue(false);
});
it('uses ScrollableList for ANSI output in alternate buffer mode', async () => {
- mockUseAlternateBuffer.mockReturnValue(true);
const content = 'ansi content';
const ansiResult: AnsiOutput = [
[
@@ -56,6 +36,7 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
maxLines={10}
/>,
+ { useAlternateBuffer: true },
);
await waitUntilReady();
const output = lastFrame();
@@ -65,13 +46,13 @@ describe('ToolResultDisplay', () => {
});
it('uses Scrollable for non-ANSI output in alternate buffer mode', async () => {
- mockUseAlternateBuffer.mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
,
+ { useAlternateBuffer: true },
);
await waitUntilReady();
const output = lastFrame();
@@ -82,13 +63,13 @@ describe('ToolResultDisplay', () => {
});
it('passes hasFocus prop to scrollable components', async () => {
- mockUseAlternateBuffer.mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
,
+ { useAlternateBuffer: true },
);
await waitUntilReady();
@@ -99,6 +80,7 @@ describe('ToolResultDisplay', () => {
it('renders string result as markdown by default', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
,
+ { useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
@@ -115,6 +97,10 @@ describe('ToolResultDisplay', () => {
availableTerminalHeight={20}
renderOutputAsMarkdown={false}
/>,
+ {
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
+ },
);
await waitUntilReady();
const output = lastFrame();
@@ -131,6 +117,10 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
availableTerminalHeight={20}
/>,
+ {
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
+ },
);
await waitUntilReady();
const output = lastFrame();
@@ -150,6 +140,7 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
availableTerminalHeight={20}
/>,
+ { useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
@@ -179,6 +170,7 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
availableTerminalHeight={20}
/>,
+ { useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame();
@@ -197,6 +189,7 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
availableTerminalHeight={20}
/>,
+ { useAlternateBuffer: false },
);
await waitUntilReady();
const output = lastFrame({ allowEmpty: true });
@@ -206,7 +199,6 @@ describe('ToolResultDisplay', () => {
});
it('does not fall back to plain text if availableHeight is set and not in alternate buffer', async () => {
- mockUseAlternateBuffer.mockReturnValue(false);
// availableHeight calculation: 20 - 1 - 5 = 14 > 3
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
{
availableTerminalHeight={20}
renderOutputAsMarkdown={true}
/>,
+ {
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
+ },
);
await waitUntilReady();
const output = lastFrame();
@@ -223,7 +219,6 @@ describe('ToolResultDisplay', () => {
});
it('keeps markdown if in alternate buffer even with availableHeight', async () => {
- mockUseAlternateBuffer.mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
{
availableTerminalHeight={20}
renderOutputAsMarkdown={true}
/>,
+ { useAlternateBuffer: true },
);
await waitUntilReady();
const output = lastFrame();
@@ -309,6 +305,10 @@ describe('ToolResultDisplay', () => {
availableTerminalHeight={20}
maxLines={3}
/>,
+ {
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
+ },
);
await waitUntilReady();
const output = lastFrame();
@@ -341,6 +341,10 @@ describe('ToolResultDisplay', () => {
maxLines={25}
availableTerminalHeight={undefined}
/>,
+ {
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
+ },
);
await waitUntilReady();
const output = lastFrame();
diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
index 8e0fc4442a..0bbe3446e0 100644
--- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
+++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
@@ -9,9 +9,13 @@ import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
-import { MaxSizedBox } from '../shared/MaxSizedBox.js';
+import { SlicingMaxSizedBox } from '../shared/SlicingMaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
-import type { AnsiOutput, AnsiLine } from '@google/gemini-cli-core';
+import {
+ type AnsiOutput,
+ type AnsiLine,
+ isSubagentProgress,
+} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
@@ -20,10 +24,7 @@ import { ScrollableList } from '../shared/ScrollableList.js';
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
-
-// Large threshold to ensure we don't cause performance issues for very large
-// outputs that will get truncated further MaxSizedBox anyway.
-const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 20000;
+import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
export interface ToolResultDisplayProps {
resultDisplay: string | object | undefined;
@@ -32,6 +33,7 @@ export interface ToolResultDisplayProps {
renderOutputAsMarkdown?: boolean;
maxLines?: number;
hasFocus?: boolean;
+ overflowDirection?: 'top' | 'bottom';
}
interface FileDiffResult {
@@ -46,6 +48,7 @@ export const ToolResultDisplay: React.FC = ({
renderOutputAsMarkdown = true,
maxLines,
hasFocus = false,
+ overflowDirection = 'top',
}) => {
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
@@ -73,177 +76,147 @@ export const ToolResultDisplay: React.FC = ({
[],
);
- const { truncatedResultDisplay, hiddenLinesCount } = React.useMemo(() => {
- let hiddenLines = 0;
- // Only truncate string output if not in alternate buffer mode to ensure
- // we can scroll through the full output.
- if (typeof resultDisplay === 'string' && !isAlternateBuffer) {
- let text = resultDisplay;
- if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
- text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
- }
- if (maxLines) {
- const hasTrailingNewline = text.endsWith('\n');
- const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
- const lines = contentText.split('\n');
- if (lines.length > maxLines) {
- // We will have a label from MaxSizedBox. Reserve space for it.
- const targetLines = Math.max(1, maxLines - 1);
- hiddenLines = lines.length - targetLines;
- text =
- lines.slice(-targetLines).join('\n') +
- (hasTrailingNewline ? '\n' : '');
- }
- }
- return { truncatedResultDisplay: text, hiddenLinesCount: hiddenLines };
- }
-
- if (Array.isArray(resultDisplay) && !isAlternateBuffer && maxLines) {
- if (resultDisplay.length > maxLines) {
- // We will have a label from MaxSizedBox. Reserve space for it.
- const targetLines = Math.max(1, maxLines - 1);
- return {
- truncatedResultDisplay: resultDisplay.slice(-targetLines),
- hiddenLinesCount: resultDisplay.length - targetLines,
- };
- }
- }
-
- return { truncatedResultDisplay: resultDisplay, hiddenLinesCount: 0 };
- }, [resultDisplay, isAlternateBuffer, maxLines]);
-
- if (!truncatedResultDisplay) return null;
+ if (!resultDisplay) return null;
// 1. Early return for background tools (Todos)
- if (
- typeof truncatedResultDisplay === 'object' &&
- 'todos' in truncatedResultDisplay
- ) {
+ if (typeof resultDisplay === 'object' && 'todos' in resultDisplay) {
// display nothing, as the TodoTray will handle rendering todos
return null;
}
- // 2. High-performance path: Virtualized ANSI in interactive mode
- if (isAlternateBuffer && Array.isArray(truncatedResultDisplay)) {
- // If availableHeight is undefined, fallback to a safe default to prevents infinite loop
- // where Container grows -> List renders more -> Container grows.
- const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
- const listHeight = Math.min(
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- (truncatedResultDisplay as AnsiOutput).length,
- limit,
- );
+ const renderContent = (contentData: string | object | undefined) => {
+ // Check if string content is valid JSON and pretty-print it
+ const prettyJSON =
+ typeof contentData === 'string' ? tryParseJSON(contentData) : null;
+ const formattedJSON = prettyJSON
+ ? JSON.stringify(prettyJSON, null, 2)
+ : null;
- return (
-
- 1}
- keyExtractor={keyExtractor}
- initialScrollIndex={SCROLL_TO_ITEM_END}
- hasFocus={hasFocus}
+ let content: React.ReactNode;
+
+ if (formattedJSON) {
+ // Render pretty-printed JSON
+ content = (
+
+ {formattedJSON}
+
+ );
+ } else if (isSubagentProgress(contentData)) {
+ content = ;
+ } else if (typeof contentData === 'string' && renderOutputAsMarkdown) {
+ content = (
+
+ );
+ } else if (typeof contentData === 'string' && !renderOutputAsMarkdown) {
+ content = (
+
+ {contentData}
+
+ );
+ } else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
+ content = (
+
+ );
+ } else {
+ const shouldDisableTruncation =
+ isAlternateBuffer ||
+ (availableTerminalHeight === undefined && maxLines === undefined);
+
+ content = (
+
+ );
+ }
+
+ // Final render based on session mode
+ if (isAlternateBuffer) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return content;
+ };
+
+ // ASB Mode Handling (Interactive/Fullscreen)
+ if (isAlternateBuffer) {
+ // Virtualized path for large ANSI arrays
+ if (Array.isArray(resultDisplay)) {
+ const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
+ const listHeight = Math.min(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ (resultDisplay as AnsiOutput).length,
+ limit,
+ );
+
+ return (
+
+ 1}
+ keyExtractor={keyExtractor}
+ initialScrollIndex={SCROLL_TO_ITEM_END}
+ hasFocus={hasFocus}
+ />
+
+ );
+ }
+
+ // Standard path for strings/diffs in ASB
+ return (
+
+ {renderContent(resultDisplay)}
);
}
- // 3. Compute content node for non-virtualized paths
- // Check if string content is valid JSON and pretty-print it
- const prettyJSON =
- typeof truncatedResultDisplay === 'string'
- ? tryParseJSON(truncatedResultDisplay)
- : null;
- const formattedJSON = prettyJSON ? JSON.stringify(prettyJSON, null, 2) : null;
-
- let content: React.ReactNode;
-
- if (formattedJSON) {
- // Render pretty-printed JSON
- content = (
-
- {formattedJSON}
-
- );
- } else if (
- typeof truncatedResultDisplay === 'string' &&
- renderOutputAsMarkdown
- ) {
- content = (
-
- );
- } else if (
- typeof truncatedResultDisplay === 'string' &&
- !renderOutputAsMarkdown
- ) {
- content = (
-
- {truncatedResultDisplay}
-
- );
- } else if (
- typeof truncatedResultDisplay === 'object' &&
- 'fileDiff' in truncatedResultDisplay
- ) {
- content = (
-
- );
- } else {
- const shouldDisableTruncation =
- isAlternateBuffer ||
- (availableTerminalHeight === undefined && maxLines === undefined);
-
- content = (
-
- );
- }
-
- // 4. Final render based on session mode
- if (isAlternateBuffer) {
- return (
-
- {content}
-
- );
- }
-
+ // Standard Mode Handling (History/Scrollback)
+ // We use SlicingMaxSizedBox which includes MaxSizedBox for precision truncation + hidden labels
return (
-
- {content}
-
+ {(truncatedResultDisplay) => renderContent(truncatedResultDisplay)}
+
);
};
diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx
index 2dff7d25e7..b809e89748 100644
--- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx
@@ -4,76 +4,98 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect } from 'vitest';
-import { ToolGroupMessage } from './ToolGroupMessage.js';
import { renderWithProviders } from '../../../test-utils/render.js';
-import { StreamingState, type IndividualToolCallDisplay } from '../../types.js';
-import { waitFor } from '../../../test-utils/async.js';
-import { CoreToolCallStatus } from '@google/gemini-cli-core';
+import { ToolResultDisplay } from './ToolResultDisplay.js';
+import { describe, it, expect } from 'vitest';
+import { type AnsiOutput } from '@google/gemini-cli-core';
describe('ToolResultDisplay Overflow', () => {
- it('should display "press ctrl-o" hint when content overflows in ToolGroupMessage', async () => {
- // Large output that will definitely overflow
- const lines = [];
- for (let i = 0; i < 50; i++) {
- lines.push(`line ${i + 1}`);
- }
- const resultDisplay = lines.join('\n');
-
- const toolCalls: IndividualToolCallDisplay[] = [
- {
- callId: 'call-1',
- name: 'test-tool',
- description: 'a test tool',
- status: CoreToolCallStatus.Success,
- resultDisplay,
- confirmationDetails: undefined,
- },
- ];
-
- const { lastFrame, waitUntilReady } = renderWithProviders(
- {
+ const content = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
{
- uiState: {
- streamingState: StreamingState.Idle,
- constrainHeight: true,
- },
- useAlternateBuffer: true,
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
},
);
-
await waitUntilReady();
+ const output = lastFrame();
- // In ASB mode the overflow hint can render before the scroll position
- // settles. Wait for both the hint and the tail of the content so this
- // snapshot is deterministic across slower CI runners.
- await waitFor(() => {
- const frame = lastFrame();
- expect(frame).toBeDefined();
- expect(frame?.toLowerCase()).toContain('press ctrl+o to show more lines');
- expect(frame).toContain('line 50');
- });
+ expect(output).toContain('Line 1');
+ expect(output).toContain('Line 2');
+ expect(output).not.toContain('Line 3'); // Line 3 is replaced by the "hidden" label
+ expect(output).not.toContain('Line 4');
+ expect(output).not.toContain('Line 5');
+ expect(output).toContain('hidden');
+ unmount();
+ });
- const frame = lastFrame();
- expect(frame).toBeDefined();
- if (frame) {
- expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines');
- // Ensure it's AFTER the bottom border
- const linesOfOutput = frame.split('\n');
- const bottomBorderIndex = linesOfOutput.findLastIndex((l) =>
- l.includes('╰─'),
- );
- const hintIndex = linesOfOutput.findIndex((l) =>
- l.toLowerCase().includes('press ctrl+o to show more lines'),
- );
- expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
- expect(frame).toMatchSnapshot();
- }
+ it('shows the tail of the content when overflowDirection is top (string default)', async () => {
+ const content = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
+ },
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+
+ expect(output).not.toContain('Line 1');
+ expect(output).not.toContain('Line 2');
+ expect(output).not.toContain('Line 3');
+ expect(output).toContain('Line 4');
+ expect(output).toContain('Line 5');
+ expect(output).toContain('hidden');
+ unmount();
+ });
+
+ it('shows the head of the content when overflowDirection is bottom (ANSI)', async () => {
+ const ansiResult: AnsiOutput = Array.from({ length: 5 }, (_, i) => [
+ {
+ text: `Line ${i + 1}`,
+ fg: '',
+ bg: '',
+ bold: false,
+ italic: false,
+ underline: false,
+ dim: false,
+ inverse: false,
+ },
+ ]);
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ {
+ useAlternateBuffer: false,
+ uiState: { constrainHeight: true },
+ },
+ );
+ await waitUntilReady();
+ const output = lastFrame();
+
+ expect(output).toContain('Line 1');
+ expect(output).toContain('Line 2');
+ expect(output).not.toContain('Line 3');
+ expect(output).not.toContain('Line 4');
+ expect(output).not.toContain('Line 5');
+ expect(output).toContain('hidden');
+ unmount();
});
});
diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx
index 4831e07279..0e072cfd13 100644
--- a/packages/cli/src/ui/components/messages/ToolShared.tsx
+++ b/packages/cli/src/ui/components/messages/ToolShared.tsx
@@ -7,7 +7,7 @@
import React, { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
-import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
+import { CliSpinner } from '../CliSpinner.js';
import {
SHELL_COMMAND_NAME,
SHELL_NAME,
@@ -123,7 +123,7 @@ export const FocusHint: React.FC<{
return (
-
+
{isThisShellFocused
? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)`
: `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`}
@@ -137,15 +137,21 @@ export type TextEmphasis = 'high' | 'medium' | 'low';
type ToolStatusIndicatorProps = {
status: CoreToolCallStatus;
name: string;
+ isFocused?: boolean;
};
export const ToolStatusIndicator: React.FC = ({
status: coreStatus,
name,
+ isFocused,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
const isShell = isShellTool(name);
- const statusColor = isShell ? theme.ui.symbol : theme.status.warning;
+ const statusColor = isFocused
+ ? theme.ui.focus
+ : isShell
+ ? theme.ui.active
+ : theme.status.warning;
return (
@@ -153,10 +159,9 @@ export const ToolStatusIndicator: React.FC = ({
{TOOL_STATUS.PENDING}
)}
{status === ToolCallStatus.Executing && (
-
+
+
+
)}
{status === ToolCallStatus.Success && (
@@ -187,6 +192,7 @@ type ToolInfoProps = {
description: string;
status: CoreToolCallStatus;
emphasis: TextEmphasis;
+ progressMessage?: string;
originalRequestName?: string;
};
@@ -195,6 +201,7 @@ export const ToolInfo: React.FC = ({
description,
status: coreStatus,
emphasis,
+ progressMessage: _progressMessage,
originalRequestName,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx
index 6453ab94c1..6609a7d1c4 100644
--- a/packages/cli/src/ui/components/messages/UserMessage.tsx
+++ b/packages/cli/src/ui/components/messages/UserMessage.tsx
@@ -29,7 +29,7 @@ export const UserMessage: React.FC = ({ text, width }) => {
const config = useConfig();
const useBackgroundColor = config.getUseBackgroundColor();
- const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary;
+ const textColor = isSlashCommand ? theme.text.accent : theme.text.primary;
const displayText = useMemo(() => {
if (!text) return text;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap
index 4f89811121..f584e7f483 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap
@@ -7,7 +7,7 @@ Note: Command contains redirection which can be undesirable.
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
Allow execution of: 'echo, redirection (>)'?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap
index 0d34c7e49d..1847b8ce67 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap
@@ -2,11 +2,8 @@
exports[` > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command │
+│ ⊶ Shell Command A shell command │
│ │
-│ Line 86 │
-│ Line 87 │
-│ Line 88 │
│ Line 89 │
│ Line 90 │
│ Line 91 │
@@ -16,8 +13,8 @@ exports[` > Height Constraints > defaults to ACTIVE_SHELL_MA
│ Line 95 │
│ Line 96 │
│ Line 97 │
-│ Line 98 ▄ │
-│ Line 99 █ │
+│ Line 98 │
+│ Line 99 ▄ │
│ Line 100 █ │
"
`;
@@ -131,7 +128,7 @@ exports[` > Height Constraints > fully expands in alternate
exports[` > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command │
+│ ⊶ Shell Command A shell command │
│ │
│ Line 93 │
│ Line 94 │
@@ -148,9 +145,6 @@ exports[` > Height Constraints > stays constrained in altern
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
-│ Line 86 │
-│ Line 87 │
-│ Line 88 │
│ Line 89 │
│ Line 90 │
│ Line 91 │
@@ -160,19 +154,16 @@ exports[` > Height Constraints > stays constrained in altern
│ Line 95 │
│ Line 96 │
│ Line 97 │
-│ Line 98 ▄ │
-│ Line 99 █ │
+│ Line 98 │
+│ Line 99 ▄ │
│ Line 100 █ │
"
`;
exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command │
+│ ⊶ Shell Command A shell command │
│ │
-│ Line 86 │
-│ Line 87 │
-│ Line 88 │
│ Line 89 │
│ Line 90 │
│ Line 91 │
@@ -182,15 +173,15 @@ exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
│ Line 95 │
│ Line 96 │
│ Line 97 │
-│ Line 98 ▄ │
-│ Line 99 █ │
+│ Line 98 │
+│ Line 99 ▄ │
│ Line 100 █ │
"
`;
exports[` > Height Constraints > uses full availableTerminalHeight when focused in alternate buffer mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
+│ ⊶ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Line 3 │
│ Line 4 │
@@ -295,7 +286,7 @@ exports[` > Height Constraints > uses full availableTerminal
exports[` > Snapshots > renders in Alternate Buffer mode while focused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
+│ ⊶ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Test result │
"
@@ -303,12 +294,20 @@ exports[` > Snapshots > renders in Alternate Buffer mode whi
exports[` > Snapshots > renders in Alternate Buffer mode while unfocused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command │
+│ ⊶ Shell Command A shell command │
│ │
│ Test result │
"
`;
+exports[` > Snapshots > renders in Cancelled state with partial output 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────╮
+│ - Shell Command A shell command │
+│ │
+│ Partial output before cancellation │
+"
+`;
+
exports[` > Snapshots > renders in Error state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ x Shell Command A shell command │
@@ -319,7 +318,7 @@ exports[` > Snapshots > renders in Error state 1`] = `
exports[` > Snapshots > renders in Executing state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ ⊷ Shell Command A shell command │
+│ ⊶ Shell Command A shell command │
│ │
│ Test result │
"
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap
new file mode 100644
index 0000000000..8a4c5bd4c4
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap
@@ -0,0 +1,41 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > renders "Request cancelled." with the info icon 1`] = `
+"ℹ Request cancelled.
+"
+`;
+
+exports[` > renders cancelled state correctly 1`] = `
+"Subagent TestAgent was cancelled.
+"
+`;
+
+exports[` > renders correctly with command fallback 1`] = `
+"⠋ run_shell_command echo hello
+"
+`;
+
+exports[` > renders correctly with description in args 1`] = `
+"⠋ run_shell_command Say hello
+"
+`;
+
+exports[` > renders correctly with displayName and description from item 1`] = `
+"⠋ RunShellCommand Executing echo hello
+"
+`;
+
+exports[` > renders correctly with file_path 1`] = `
+"✓ write_file /tmp/test.txt
+"
+`;
+
+exports[` > renders thought bubbles correctly 1`] = `
+"💭 Thinking about life
+"
+`;
+
+exports[` > truncates long args 1`] = `
+"⠋ run_shell_command This is a very long description that should definitely be tr...
+"
+`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-normalizes-escaped-newline-tokens.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-normalizes-escaped-newline-tokens.snap.svg
new file mode 100644
index 0000000000..660d8b4fa1
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-normalizes-escaped-newline-tokens.snap.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-Thinking-header-when-isFirstThinking-is-true.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-Thinking-header-when-isFirstThinking-is-true.snap.svg
new file mode 100644
index 0000000000..38647281df
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-Thinking-header-when-isFirstThinking-is-true.snap.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-full-mode-with-left-border-and-full-text.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-full-mode-with-left-border-and-full-text.snap.svg
new file mode 100644
index 0000000000..0294b63f30
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-full-mode-with-left-border-and-full-text.snap.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-multiple-thinking-messages-sequentially-correctly.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-multiple-thinking-messages-sequentially-correctly.snap.svg
new file mode 100644
index 0000000000..b7f8a52358
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-multiple-thinking-messages-sequentially-correctly.snap.svg
@@ -0,0 +1,30 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-subject-line-with-vertical-rule-and-Thinking-header.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-subject-line-with-vertical-rule-and-Thinking-header.snap.svg
new file mode 100644
index 0000000000..350a0cc61f
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-renders-subject-line-with-vertical-rule-and-Thinking-header.snap.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-uses-description-when-subject-is-empty.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-uses-description-when-subject-is-empty.snap.svg
new file mode 100644
index 0000000000..ce2b2a4686
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage-ThinkingMessage-uses-description-when-subject-is-empty.snap.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap
index 365f655d7d..da33a2a14c 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap
@@ -1,30 +1,107 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`ThinkingMessage > indents summary line correctly 1`] = `
-" Summary line
- │ First body line
-"
-`;
-
exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = `
-" Matching the Blocks
+" Thinking...
+ │
+ │ Matching the Blocks
│ Some more text
"
`;
+exports[`ThinkingMessage > normalizes escaped newline tokens 2`] = `
+" Thinking...
+ │
+ │ Matching the Blocks
+ │ Some more text"
+`;
+
+exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is true 1`] = `
+" Thinking...
+ │
+ │ Summary line
+ │ First body line
+"
+`;
+
+exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is true 2`] = `
+" Thinking...
+ │
+ │ Summary line
+ │ First body line"
+`;
+
exports[`ThinkingMessage > renders full mode with left border and full text 1`] = `
-" Planning
+" Thinking...
+ │
+ │ Planning
│ I am planning the solution.
"
`;
-exports[`ThinkingMessage > renders subject line 1`] = `
-" Planning
+exports[`ThinkingMessage > renders full mode with left border and full text 2`] = `
+" Thinking...
+ │
+ │ Planning
+ │ I am planning the solution."
+`;
+
+exports[`ThinkingMessage > renders multiple thinking messages sequentially correctly 1`] = `
+" Thinking...
+ │
+ │ Initial analysis
+ │ This is a multiple line paragraph for the first thinking message of how the
+ │ model analyzes the problem.
+ │
+ │ Planning execution
+ │ This a second multiple line paragraph for the second thinking message
+ │ explaining the plan in detail so that it wraps around the terminal display.
+ │
+ │ Refining approach
+ │ And finally a third multiple line paragraph for the third thinking message to
+ │ refine the solution.
+"
+`;
+
+exports[`ThinkingMessage > renders multiple thinking messages sequentially correctly 2`] = `
+" Thinking...
+ │
+ │ Initial analysis
+ │ This is a multiple line paragraph for the first thinking message of how the
+ │ model analyzes the problem.
+ │
+ │ Planning execution
+ │ This a second multiple line paragraph for the second thinking message
+ │ explaining the plan in detail so that it wraps around the terminal display.
+ │
+ │ Refining approach
+ │ And finally a third multiple line paragraph for the third thinking message to
+ │ refine the solution."
+`;
+
+exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking..." header 1`] = `
+" Thinking...
+ │
+ │ Planning
│ test
"
`;
+exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking..." header 2`] = `
+" Thinking...
+ │
+ │ Planning
+ │ test"
+`;
+
exports[`ThinkingMessage > uses description when subject is empty 1`] = `
-" Processing details
+" Thinking...
+ │
+ │ Processing details
"
`;
+
+exports[`ThinkingMessage > uses description when subject is empty 2`] = `
+" Thinking...
+ │
+ │ Processing details"
+`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap
index 86ba095192..554808e830 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap
@@ -2,7 +2,7 @@
exports[` (showFullTodos: false) > renders a todo list with long descriptions that wrap when full view is on 1`] = `
"──────────────────────────────────────────────────
- Todo 1/2 completed (ctrl+t to toggle) » This i…
+ Todo 1/2 completed (Ctrl+T to toggle) » This i…
"
`;
@@ -14,25 +14,25 @@ exports[` (showFullTodos: false) > renders null when todo list is em
exports[` (showFullTodos: false) > renders the most recent todo list when multiple write_todos calls are in history 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
- Todo 0/2 completed (ctrl+t to toggle) » Newer Task 2
+ Todo 0/2 completed (Ctrl+T to toggle) » Newer Task 2
"
`;
exports[` (showFullTodos: false) > renders when todos exist and one is in progress 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
- Todo 1/3 completed (ctrl+t to toggle) » Task 2
+ Todo 1/3 completed (Ctrl+T to toggle) » Task 2
"
`;
exports[` (showFullTodos: false) > renders when todos exist but none are in progress 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
- Todo 1/2 completed (ctrl+t to toggle)
+ Todo 1/2 completed (Ctrl+T to toggle)
"
`;
exports[` (showFullTodos: true) > renders a todo list with long descriptions that wrap when full view is on 1`] = `
"──────────────────────────────────────────────────
- Todo 1/2 completed (ctrl+t to toggle)
+ Todo 1/2 completed (Ctrl+T to toggle)
» This is a very long description for a pending
task that should wrap around multiple lines
@@ -44,7 +44,7 @@ exports[` (showFullTodos: true) > renders a todo list with long desc
exports[` (showFullTodos: true) > renders full list when all todos are inactive 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
- Todo 1/1 completed (ctrl+t to toggle)
+ Todo 1/1 completed (Ctrl+T to toggle)
✓ Task 1
✗ Task 2
@@ -57,7 +57,7 @@ exports[` (showFullTodos: true) > renders null when todo list is emp
exports[` (showFullTodos: true) > renders the most recent todo list when multiple write_todos calls are in history 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
- Todo 0/2 completed (ctrl+t to toggle)
+ Todo 0/2 completed (Ctrl+T to toggle)
☐ Newer Task 1
» Newer Task 2
@@ -66,7 +66,7 @@ exports[` (showFullTodos: true) > renders the most recent todo list
exports[` (showFullTodos: true) > renders when todos exist and one is in progress 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
- Todo 1/3 completed (ctrl+t to toggle)
+ Todo 1/3 completed (Ctrl+T to toggle)
☐ Pending Task
» Task 2
@@ -77,7 +77,7 @@ exports[` (showFullTodos: true) > renders when todos exist and one i
exports[` (showFullTodos: true) > renders when todos exist but none are in progress 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
- Todo 1/2 completed (ctrl+t to toggle)
+ Todo 1/2 completed (Ctrl+T to toggle)
☐ Pending Task
✗ In Progress Task
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg
new file mode 100644
index 0000000000..d1396e2335
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg
@@ -0,0 +1,32 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap
index 72eda055d5..3f207df881 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap
@@ -2,11 +2,13 @@
exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = `
"echo "hello"
+
ls -la
+
whoami
Allow execution of 3 commands?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -19,7 +21,7 @@ URLs to fetch:
- https://raw.githubusercontent.com/google/gemini-react/main/README.md
Do you want to proceed?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -29,7 +31,20 @@ exports[`ToolConfirmationMessage > should not display urls if prompt and url are
"https://example.com
Do you want to proceed?
-● 1. Allow once
+● 1. Allow once
+ 2. Allow for this session
+ 3. No, suggest changes (esc)
+"
+`;
+
+exports[`ToolConfirmationMessage > should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot) 1`] = `
+"echo "hello"
+for i in 1 2 3; do
+ echo $i
+done
+Allow execution of: 'echo'?
+
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -40,7 +55,7 @@ exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool an
Tool: testtool
Allow execution of MCP tool "testtool" from server "testserver"?
-● 1. Allow once
+● 1. Allow once
2. Allow tool for this session
3. Allow all server tools for this session
4. No, suggest changes (esc)
@@ -55,7 +70,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
-● 1. Allow once
+● 1. Allow once
2. Modify with external editor
3. No, suggest changes (esc)
"
@@ -69,7 +84,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. Modify with external editor
4. No, suggest changes (esc)
@@ -80,7 +95,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
"echo "hello"
Allow execution of: 'echo'?
-● 1. Allow once
+● 1. Allow once
2. No, suggest changes (esc)
"
`;
@@ -89,7 +104,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
"echo "hello"
Allow execution of: 'echo'?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -99,7 +114,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
"https://example.com
Do you want to proceed?
-● 1. Allow once
+● 1. Allow once
2. No, suggest changes (esc)
"
`;
@@ -108,7 +123,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
"https://example.com
Do you want to proceed?
-● 1. Allow once
+● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -119,7 +134,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' >
Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
-● 1. Allow once
+● 1. Allow once
2. No, suggest changes (esc)
"
`;
@@ -129,7 +144,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' >
Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
-● 1. Allow once
+● 1. Allow once
2. Allow tool for this session
3. Allow all server tools for this session
4. No, suggest changes (esc)
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap
index 6adcb80a5c..29da4d5860 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap
@@ -71,7 +71,7 @@ exports[` > Golden Snapshots > renders mixed tool calls incl
│ │
│ Test result │
│ │
-│ ⊷ run_shell_command Run command │
+│ ⊶ run_shell_command Run command │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap
index f31865874d..ec5643e773 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap
@@ -29,7 +29,7 @@ exports[` > ToolStatusIndicator rendering > shows - for Canceled
exports[` > ToolStatusIndicator rendering > shows MockRespondingSpinner for Executing status when streamingState is Responding 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ MockRespondingSpinnertest-tool A tool for testing │
+│ ⊶ test-tool A tool for testing │
│ │
│ Test result │
"
@@ -45,7 +45,7 @@ exports[` > ToolStatusIndicator rendering > shows o for Pending s
exports[` > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is Idle 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ MockRespondingSpinnertest-tool A tool for testing │
+│ ⊶ test-tool A tool for testing │
│ │
│ Test result │
"
@@ -53,7 +53,7 @@ exports[` > ToolStatusIndicator rendering > shows paused spinner
exports[` > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is WaitingForConfirmation 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ MockRespondingSpinnertest-tool A tool for testing │
+│ ⊶ test-tool A tool for testing │
│ │
│ Test result │
"
@@ -94,7 +94,7 @@ exports[` > renders DiffRenderer for diff results 1`] = `
exports[` > renders McpProgressIndicator with percentage and message for executing tools 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ MockRespondingSpinnertest-tool A tool for testing │
+│ ⊶ test-tool A tool for testing │
│ │
│ ████████░░░░░░░░░░░░ 42% │
│ Working on it... │
@@ -128,7 +128,7 @@ exports[` > renders emphasis correctly 2`] = `
exports[` > renders indeterminate progress when total is missing 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ MockRespondingSpinnertest-tool A tool for testing │
+│ ⊶ test-tool A tool for testing │
│ │
│ ███████░░░░░░░░░░░░░ 7 │
│ Test result │
@@ -137,7 +137,7 @@ exports[` > renders indeterminate progress when total is missing
exports[` > renders only percentage when progressMessage is missing 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ MockRespondingSpinnertest-tool A tool for testing │
+│ ⊶ test-tool A tool for testing │
│ │
│ ███████████████░░░░░ 75% │
│ Test result │
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap
index fb4f1ec722..8da15d7fdb 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap
@@ -2,63 +2,63 @@
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing (Tab to focus) │
+│ ⊶ Shell Command A tool for testing (Tab to focus) │
│ │
"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > initial-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing │
+│ ⊶ Shell Command A tool for testing │
│ │
"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing (Tab to focus) │
+│ ⊶ Shell Command A tool for testing (Tab to focus) │
│ │
"
`;
exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > initial-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing │
+│ ⊶ Shell Command A tool for testing │
│ │
"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing (Tab to focus) │
+│ ⊶ Shell Command A tool for testing (Tab to focus) │
│ │
"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > initial-no-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing │
+│ ⊶ Shell Command A tool for testing │
│ │
"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing (Tab to focus) │
+│ ⊶ Shell Command A tool for testing (Tab to focus) │
│ │
"
`;
exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > initial-with-output 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command A tool for testing │
+│ ⊶ Shell Command A tool for testing │
│ │
"
`;
exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
-│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (Tab to focus) │
+│ ⊶ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (Tab to focus) │
│ │
"
`;
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap
deleted file mode 100644
index aab4b690a1..0000000000
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap
+++ /dev/null
@@ -1,16 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = `
-"╭──────────────────────────────────────────────────────────────────────────╮
-│ ✓ test-tool a test tool │
-│ │
-│ line 45 │
-│ line 46 │
-│ line 47 │
-│ line 48 │
-│ line 49 │
-│ line 50 █ │
-╰──────────────────────────────────────────────────────────────────────────╯
- Press Ctrl+O to show more lines
-"
-`;
diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx
index 2444374c3e..8fffd4c5fc 100644
--- a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx
@@ -19,13 +19,15 @@ vi.mock('../../hooks/useSelectionList.js');
const mockTheme = {
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
- status: { success: 'COLOR_SUCCESS' },
+ ui: { focus: 'COLOR_FOCUS' },
+ background: { focus: 'COLOR_FOCUS_BG' },
} as typeof theme;
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
- status: { success: 'COLOR_SUCCESS' },
+ ui: { focus: 'COLOR_FOCUS' },
+ background: { focus: 'COLOR_FOCUS_BG' },
},
}));
@@ -161,8 +163,8 @@ describe('BaseSelectionList', () => {
expect(mockRenderItem).toHaveBeenCalledWith(
items[0],
expect.objectContaining({
- titleColor: mockTheme.status.success,
- numberColor: mockTheme.status.success,
+ titleColor: mockTheme.ui.focus,
+ numberColor: mockTheme.ui.focus,
isSelected: true,
}),
);
@@ -207,8 +209,8 @@ describe('BaseSelectionList', () => {
expect(mockRenderItem).toHaveBeenCalledWith(
items[1],
expect.objectContaining({
- titleColor: mockTheme.status.success,
- numberColor: mockTheme.status.success,
+ titleColor: mockTheme.ui.focus,
+ numberColor: mockTheme.ui.focus,
isSelected: true,
}),
);
@@ -267,7 +269,7 @@ describe('BaseSelectionList', () => {
items[0],
expect.objectContaining({
isSelected: true,
- titleColor: mockTheme.status.success,
+ titleColor: mockTheme.ui.focus,
numberColor: mockTheme.text.secondary,
}),
);
diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
index db0d624a74..7efb40b3ae 100644
--- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
@@ -33,6 +33,7 @@ export interface BaseSelectionListProps<
wrapAround?: boolean;
focusKey?: string;
priority?: boolean;
+ selectedIndicator?: string;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
@@ -65,6 +66,7 @@ export function BaseSelectionList<
wrapAround = true,
focusKey,
priority,
+ selectedIndicator = '●',
renderItem,
}: BaseSelectionListProps): React.JSX.Element {
const { activeIndex } = useSelectionList({
@@ -117,8 +119,8 @@ export function BaseSelectionList<
let numberColor = theme.text.primary;
if (isSelected) {
- titleColor = theme.status.success;
- numberColor = theme.status.success;
+ titleColor = theme.ui.focus;
+ numberColor = theme.ui.focus;
} else if (item.disabled) {
titleColor = theme.text.secondary;
numberColor = theme.text.secondary;
@@ -137,14 +139,18 @@ export function BaseSelectionList<
)}.`;
return (
-
+
{/* Radio button indicator */}
- {isSelected ? '●' : ' '}
+ {isSelected ? selectedIndicator : ' '}
diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx
index fbbc6ff517..5cc731e3f7 100644
--- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx
@@ -174,7 +174,10 @@ describe('BaseSettingsDialog', () => {
it('should render footer content when provided', async () => {
const { lastFrame, unmount } = await renderDialog({
- footerContent: Custom Footer,
+ footer: {
+ content: Custom Footer,
+ height: 1,
+ },
});
expect(lastFrame()).toContain('Custom Footer');
@@ -531,6 +534,37 @@ describe('BaseSettingsDialog', () => {
});
describe('edit mode', () => {
+ it('should prioritize editValue over rawValue stringification', async () => {
+ const objectItem: SettingsDialogItem = {
+ key: 'object-setting',
+ label: 'Object Setting',
+ description: 'A complex object setting',
+ displayValue: '{"foo":"bar"}',
+ type: 'object',
+ rawValue: { foo: 'bar' },
+ editValue: '{"foo":"bar"}',
+ };
+ const { stdin } = await renderDialog({
+ items: [objectItem],
+ });
+
+ // Enter edit mode and immediately commit
+ await act(async () => {
+ stdin.write(TerminalKeys.ENTER);
+ });
+ await act(async () => {
+ stdin.write(TerminalKeys.ENTER);
+ });
+
+ await waitFor(() => {
+ expect(mockOnEditCommit).toHaveBeenCalledWith(
+ 'object-setting',
+ '{"foo":"bar"}',
+ expect.objectContaining({ type: 'object' }),
+ );
+ });
+ });
+
it('should commit edit on Enter', async () => {
const items = createMockItems(4);
const stringItem = items.find((i) => i.type === 'string')!;
@@ -770,4 +804,57 @@ describe('BaseSettingsDialog', () => {
unmount();
});
});
+
+ describe('responsiveness', () => {
+ it('should show the scope selector when availableHeight is sufficient (25)', async () => {
+ const { lastFrame, unmount } = await renderDialog({
+ availableHeight: 25,
+ showScopeSelector: true,
+ });
+
+ const frame = lastFrame();
+ expect(frame).toContain('Apply To');
+ unmount();
+ });
+
+ it('should hide the scope selector when availableHeight is small (24) to show more items', async () => {
+ const { lastFrame, unmount } = await renderDialog({
+ availableHeight: 24,
+ showScopeSelector: true,
+ });
+
+ const frame = lastFrame();
+ expect(frame).not.toContain('Apply To');
+ unmount();
+ });
+
+ it('should reduce the number of visible items based on height', async () => {
+ // At height 25, it should show 2 items (math: (25-4 - (10+5))/3 = 2)
+ const { lastFrame, unmount } = await renderDialog({
+ availableHeight: 25,
+ items: createMockItems(10),
+ });
+
+ const frame = lastFrame();
+ // Items 0 and 1 should be there
+ expect(frame).toContain('Boolean Setting');
+ expect(frame).toContain('String Setting');
+ // Item 2 should NOT be there
+ expect(frame).not.toContain('Number Setting');
+ unmount();
+ });
+
+ it('should show scroll indicators when list is truncated by height', async () => {
+ const { lastFrame, unmount } = await renderDialog({
+ availableHeight: 25,
+ items: createMockItems(10),
+ });
+
+ const frame = lastFrame();
+ // Shows both scroll indicators when the list is truncated by height
+ expect(frame).toContain('▼');
+ expect(frame).toContain('▲');
+ unmount();
+ });
+ });
});
diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
index 29592b479b..45dda8b38c 100644
--- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
@@ -4,23 +4,26 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import React, { useState, useEffect, useCallback, useRef } from 'react';
+import React, { useMemo, useState, useCallback } from 'react';
import { Box, Text } from 'ink';
import chalk from 'chalk';
import { theme } from '../../semantic-colors.js';
import type { LoadableSettingScope } from '../../../config/settings.js';
+import type {
+ SettingsType,
+ SettingsValue,
+} from '../../../config/settingsSchema.js';
import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
import { RadioButtonSelect } from './RadioButtonSelect.js';
import { TextInput } from './TextInput.js';
import type { TextBuffer } from './text-buffer.js';
-import {
- cpSlice,
- cpLen,
- stripUnsafeCharacters,
- cpIndexToOffset,
-} from '../../utils/textUtils.js';
+import { cpSlice, cpLen, cpIndexToOffset } from '../../utils/textUtils.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
+import { useSettingsNavigation } from '../../hooks/useSettingsNavigation.js';
+import { useInlineEditBuffer } from '../../hooks/useInlineEditBuffer.js';
+import { formatCommand } from '../../utils/keybindingUtils.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
/**
* Represents a single item in the settings dialog.
@@ -33,7 +36,7 @@ export interface SettingsDialogItem {
/** Optional description below label */
description?: string;
/** Item type for determining interaction behavior */
- type: 'boolean' | 'number' | 'string' | 'enum';
+ type: SettingsType;
/** Pre-formatted display value (with * if modified) */
displayValue: string;
/** Grey out value (at default) */
@@ -41,7 +44,9 @@ export interface SettingsDialogItem {
/** Scope message e.g., "(Modified in Workspace)" */
scopeMessage?: string;
/** Raw value for edit mode initialization */
- rawValue?: string | number | boolean;
+ rawValue?: SettingsValue;
+ /** Optional pre-formatted edit buffer value for complex types */
+ editValue?: string;
}
/**
@@ -53,7 +58,6 @@ export interface BaseSettingsDialogProps {
title: string;
/** Optional border color for the dialog */
borderColor?: string;
-
// Search (optional feature)
/** Whether to show the search input. Default: true */
searchEnabled?: boolean;
@@ -99,9 +103,14 @@ export interface BaseSettingsDialogProps {
currentItem: SettingsDialogItem | undefined,
) => boolean;
- // Optional extra content below help text (for restart prompt, etc.)
- /** Optional footer content (e.g., restart prompt) */
- footerContent?: React.ReactNode;
+ /** Available terminal height for dynamic windowing */
+ availableHeight?: number;
+
+ /** Optional footer configuration */
+ footer?: {
+ content: React.ReactNode;
+ height: number;
+ };
}
/**
@@ -125,68 +134,114 @@ export function BaseSettingsDialog({
onItemClear,
onClose,
onKeyPress,
- footerContent,
+ availableHeight,
+ footer,
}: BaseSettingsDialogProps): React.JSX.Element {
+ const keyMatchers = useKeyMatchers();
+ // Calculate effective max items and scope visibility based on terminal height
+ const { effectiveMaxItemsToShow, finalShowScopeSelector } = useMemo(() => {
+ const initialShowScope = showScopeSelector;
+ const initialMaxItems = maxItemsToShow;
+
+ if (!availableHeight) {
+ return {
+ effectiveMaxItemsToShow: initialMaxItems,
+ finalShowScopeSelector: initialShowScope,
+ };
+ }
+
+ // Layout constants based on BaseSettingsDialog structure:
+ const DIALOG_PADDING = 4;
+ const SETTINGS_TITLE_HEIGHT = 1;
+ // Account for the unconditional spacer below search/title section
+ const SEARCH_SECTION_HEIGHT = searchEnabled ? 5 : 1;
+ const SCROLL_ARROWS_HEIGHT = 2;
+ const ITEMS_SPACING_AFTER = 1;
+ const SCOPE_SECTION_HEIGHT = 5;
+ const HELP_TEXT_HEIGHT = 1;
+ const FOOTER_CONTENT_HEIGHT = footer?.height ?? 0;
+ const ITEM_HEIGHT = 3;
+
+ const currentAvailableHeight = availableHeight - DIALOG_PADDING;
+
+ const baseFixedHeight =
+ SETTINGS_TITLE_HEIGHT +
+ SEARCH_SECTION_HEIGHT +
+ SCROLL_ARROWS_HEIGHT +
+ ITEMS_SPACING_AFTER +
+ HELP_TEXT_HEIGHT +
+ FOOTER_CONTENT_HEIGHT;
+
+ // Calculate max items with scope selector
+ const heightWithScope = baseFixedHeight + SCOPE_SECTION_HEIGHT;
+ const availableForItemsWithScope = currentAvailableHeight - heightWithScope;
+ const maxItemsWithScope = Math.max(
+ 1,
+ Math.floor(availableForItemsWithScope / ITEM_HEIGHT),
+ );
+
+ // Calculate max items without scope selector
+ const availableForItemsWithoutScope =
+ currentAvailableHeight - baseFixedHeight;
+ const maxItemsWithoutScope = Math.max(
+ 1,
+ Math.floor(availableForItemsWithoutScope / ITEM_HEIGHT),
+ );
+
+ // In small terminals, hide scope selector if it would allow more items to show
+ let shouldShowScope = initialShowScope;
+ let maxItems = initialShowScope ? maxItemsWithScope : maxItemsWithoutScope;
+
+ if (initialShowScope && availableHeight < 25) {
+ // Hide scope selector if it gains us more than 1 extra item
+ if (maxItemsWithoutScope > maxItemsWithScope + 1) {
+ shouldShowScope = false;
+ maxItems = maxItemsWithoutScope;
+ }
+ }
+
+ return {
+ effectiveMaxItemsToShow: Math.min(maxItems, items.length),
+ finalShowScopeSelector: shouldShowScope,
+ };
+ }, [
+ availableHeight,
+ maxItemsToShow,
+ items.length,
+ searchEnabled,
+ showScopeSelector,
+ footer,
+ ]);
+
// Internal state
- const [activeIndex, setActiveIndex] = useState(0);
- const [scrollOffset, setScrollOffset] = useState(0);
+ const { activeIndex, windowStart, moveUp, moveDown } = useSettingsNavigation({
+ items,
+ maxItemsToShow: effectiveMaxItemsToShow,
+ });
+
+ const { editState, editDispatch, startEditing, commitEdit, cursorVisible } =
+ useInlineEditBuffer({
+ onCommit: (key, value) => {
+ const itemToCommit = items.find((i) => i.key === key);
+ if (itemToCommit) {
+ onEditCommit(key, value, itemToCommit);
+ }
+ },
+ });
+
+ const {
+ editingKey,
+ buffer: editBuffer,
+ cursorPos: editCursorPos,
+ } = editState;
+
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
'settings',
);
- const [editingKey, setEditingKey] = useState(null);
- const [editBuffer, setEditBuffer] = useState('');
- const [editCursorPos, setEditCursorPos] = useState(0);
- const [cursorVisible, setCursorVisible] = useState(true);
-
- const prevItemsRef = useRef(items);
-
- // Preserve focus when items change (e.g., search filter)
- useEffect(() => {
- const prevItems = prevItemsRef.current;
- if (prevItems !== items) {
- const prevActiveItem = prevItems[activeIndex];
- if (prevActiveItem) {
- const newIndex = items.findIndex((i) => i.key === prevActiveItem.key);
- if (newIndex !== -1) {
- // Item still exists in the filtered list, keep focus on it
- setActiveIndex(newIndex);
- // Adjust scroll offset to ensure the item is visible
- let newScroll = scrollOffset;
- if (newIndex < scrollOffset) newScroll = newIndex;
- else if (newIndex >= scrollOffset + maxItemsToShow)
- newScroll = newIndex - maxItemsToShow + 1;
-
- const maxScroll = Math.max(0, items.length - maxItemsToShow);
- setScrollOffset(Math.min(newScroll, maxScroll));
- } else {
- // Item was filtered out, reset to the top
- setActiveIndex(0);
- setScrollOffset(0);
- }
- } else {
- setActiveIndex(0);
- setScrollOffset(0);
- }
- prevItemsRef.current = items;
- }
- }, [items, activeIndex, scrollOffset, maxItemsToShow]);
-
- // Cursor blink effect
- useEffect(() => {
- if (!editingKey) return;
- setCursorVisible(true);
- const interval = setInterval(() => {
- setCursorVisible((v) => !v);
- }, 500);
- return () => clearInterval(interval);
- }, [editingKey]);
-
- // Ensure focus stays on settings when scope selection is hidden
- useEffect(() => {
- if (!showScopeSelector && focusSection === 'scope') {
- setFocusSection('settings');
- }
- }, [showScopeSelector, focusSection]);
+ const effectiveFocusSection =
+ !finalShowScopeSelector && focusSection === 'scope'
+ ? 'settings'
+ : focusSection;
// Scope selector items
const scopeItems = getScopeItems().map((item) => ({
@@ -195,43 +250,20 @@ export function BaseSettingsDialog({
}));
// Calculate visible items based on scroll offset
- const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
+ const visibleItems = items.slice(
+ windowStart,
+ windowStart + effectiveMaxItemsToShow,
+ );
// Show scroll indicators if there are more items than can be displayed
- const showScrollUp = items.length > maxItemsToShow;
- const showScrollDown = items.length > maxItemsToShow;
+ const showScrollUp = items.length > effectiveMaxItemsToShow;
+ const showScrollDown = items.length > effectiveMaxItemsToShow;
// Get current item
const currentItem = items[activeIndex];
- // Start editing a field
- const startEditing = useCallback((key: string, initialValue: string) => {
- setEditingKey(key);
- setEditBuffer(initialValue);
- setEditCursorPos(cpLen(initialValue));
- setCursorVisible(true);
- }, []);
-
- // Commit edit and exit edit mode
- const commitEdit = useCallback(() => {
- if (editingKey && currentItem) {
- onEditCommit(editingKey, editBuffer, currentItem);
- }
- setEditingKey(null);
- setEditBuffer('');
- setEditCursorPos(0);
- }, [editingKey, editBuffer, currentItem, onEditCommit]);
-
- // Handle scope highlight (for RadioButtonSelect)
- const handleScopeHighlight = useCallback(
- (scope: LoadableSettingScope) => {
- onScopeChange?.(scope);
- },
- [onScopeChange],
- );
-
- // Handle scope select (for RadioButtonSelect)
- const handleScopeSelect = useCallback(
+ // Handle scope changes (for RadioButtonSelect)
+ const handleScopeChange = useCallback(
(scope: LoadableSettingScope) => {
onScopeChange?.(scope);
},
@@ -241,8 +273,8 @@ export function BaseSettingsDialog({
// Keyboard handling
useKeypress(
(key: Key) => {
- // Let parent handle custom keys first
- if (onKeyPress?.(key, currentItem)) {
+ // Let parent handle custom keys first (only if not editing)
+ if (!editingKey && onKeyPress?.(key, currentItem)) {
return;
}
@@ -253,44 +285,31 @@ export function BaseSettingsDialog({
// Navigation within edit buffer
if (keyMatchers[Command.MOVE_LEFT](key)) {
- setEditCursorPos((p) => Math.max(0, p - 1));
+ editDispatch({ type: 'MOVE_LEFT' });
return;
}
if (keyMatchers[Command.MOVE_RIGHT](key)) {
- setEditCursorPos((p) => Math.min(cpLen(editBuffer), p + 1));
+ editDispatch({ type: 'MOVE_RIGHT' });
return;
}
if (keyMatchers[Command.HOME](key)) {
- setEditCursorPos(0);
+ editDispatch({ type: 'HOME' });
return;
}
if (keyMatchers[Command.END](key)) {
- setEditCursorPos(cpLen(editBuffer));
+ editDispatch({ type: 'END' });
return;
}
// Backspace
if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) {
- if (editCursorPos > 0) {
- setEditBuffer((b) => {
- const before = cpSlice(b, 0, editCursorPos - 1);
- const after = cpSlice(b, editCursorPos);
- return before + after;
- });
- setEditCursorPos((p) => p - 1);
- }
+ editDispatch({ type: 'DELETE_LEFT' });
return;
}
// Delete
if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) {
- if (editCursorPos < cpLen(editBuffer)) {
- setEditBuffer((b) => {
- const before = cpSlice(b, 0, editCursorPos);
- const after = cpSlice(b, editCursorPos + 1);
- return before + after;
- });
- }
+ editDispatch({ type: 'DELETE_RIGHT' });
return;
}
@@ -309,70 +328,35 @@ export function BaseSettingsDialog({
// Up/Down in edit mode - commit and navigate
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
commitEdit();
- const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
- setActiveIndex(newIndex);
- if (newIndex === items.length - 1) {
- setScrollOffset(Math.max(0, items.length - maxItemsToShow));
- } else if (newIndex < scrollOffset) {
- setScrollOffset(newIndex);
- }
+ moveUp();
return;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
commitEdit();
- const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
- setActiveIndex(newIndex);
- if (newIndex === 0) {
- setScrollOffset(0);
- } else if (newIndex >= scrollOffset + maxItemsToShow) {
- setScrollOffset(newIndex - maxItemsToShow + 1);
- }
+ moveDown();
return;
}
// Character input
- let ch = key.sequence;
- let isValidChar = false;
- if (type === 'number') {
- isValidChar = /[0-9\-+.]/.test(ch);
- } else {
- isValidChar = ch.length === 1 && ch.charCodeAt(0) >= 32;
- // Sanitize string input to prevent unsafe characters
- ch = stripUnsafeCharacters(ch);
- }
-
- if (isValidChar && ch.length > 0) {
- setEditBuffer((b) => {
- const before = cpSlice(b, 0, editCursorPos);
- const after = cpSlice(b, editCursorPos);
- return before + ch + after;
+ if (key.sequence) {
+ editDispatch({
+ type: 'INSERT_CHAR',
+ char: key.sequence,
+ isNumberType: type === 'number',
});
- setEditCursorPos((p) => p + 1);
}
return;
}
// Not in edit mode - handle navigation and actions
- if (focusSection === 'settings') {
+ if (effectiveFocusSection === 'settings') {
// Up/Down navigation with wrap-around
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
- const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
- setActiveIndex(newIndex);
- if (newIndex === items.length - 1) {
- setScrollOffset(Math.max(0, items.length - maxItemsToShow));
- } else if (newIndex < scrollOffset) {
- setScrollOffset(newIndex);
- }
+ moveUp();
return true;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
- const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
- setActiveIndex(newIndex);
- if (newIndex === 0) {
- setScrollOffset(0);
- } else if (newIndex >= scrollOffset + maxItemsToShow) {
- setScrollOffset(newIndex - maxItemsToShow + 1);
- }
+ moveDown();
return true;
}
@@ -381,9 +365,11 @@ export function BaseSettingsDialog({
if (currentItem.type === 'boolean' || currentItem.type === 'enum') {
onItemToggle(currentItem.key, currentItem);
} else {
- // Start editing for string/number
+ // Start editing for string/number/array/object
const rawVal = currentItem.rawValue;
- const initialValue = rawVal !== undefined ? String(rawVal) : '';
+ const initialValue =
+ currentItem.editValue ??
+ (rawVal !== undefined ? String(rawVal) : '');
startEditing(currentItem.key, initialValue);
}
return true;
@@ -403,7 +389,7 @@ export function BaseSettingsDialog({
}
// Tab - switch focus section
- if (key.name === 'tab' && showScopeSelector) {
+ if (key.name === 'tab' && finalShowScopeSelector) {
setFocusSection((s) => (s === 'settings' ? 'scope' : 'settings'));
return;
}
@@ -418,7 +404,7 @@ export function BaseSettingsDialog({
},
{
isActive: true,
- priority: focusSection === 'settings' && !editingKey,
+ priority: effectiveFocusSection === 'settings',
},
);
@@ -435,10 +421,10 @@ export function BaseSettingsDialog({
{/* Title */}
- {focusSection === 'settings' ? '> ' : ' '}
+ {effectiveFocusSection === 'settings' ? '> ' : ' '}
{title}{' '}
@@ -450,8 +436,8 @@ export function BaseSettingsDialog({
borderColor={
editingKey
? theme.border.default
- : focusSection === 'settings'
- ? theme.border.focused
+ : effectiveFocusSection === 'settings'
+ ? theme.ui.focus
: theme.border.default
}
paddingX={1}
@@ -459,7 +445,7 @@ export function BaseSettingsDialog({
marginTop={1}
>
@@ -481,9 +467,10 @@ export function BaseSettingsDialog({
)}
{visibleItems.map((item, idx) => {
- const globalIndex = idx + scrollOffset;
+ const globalIndex = idx + windowStart;
const isActive =
- focusSection === 'settings' && activeIndex === globalIndex;
+ effectiveFocusSection === 'settings' &&
+ activeIndex === globalIndex;
// Compute display value with edit mode cursor
let displayValue: string;
@@ -514,12 +501,17 @@ export function BaseSettingsDialog({
return (
-
+
{isActive ? '●' : ''}
@@ -536,9 +528,7 @@ export function BaseSettingsDialog({
minWidth={0}
>
{item.label}
{item.scopeMessage && (
@@ -557,7 +547,7 @@ export function BaseSettingsDialog({
{/* Scope Selection */}
- {showScopeSelector && (
+ {finalShowScopeSelector && (
-
- {focusSection === 'scope' ? '> ' : ' '}Apply To
+
+ {effectiveFocusSection === 'scope' ? '> ' : ' '}Apply To
item.value === selectedScope,
)}
- onSelect={handleScopeSelect}
- onHighlight={handleScopeHighlight}
- isFocused={focusSection === 'scope'}
- showNumbers={focusSection === 'scope'}
- priority={focusSection === 'scope'}
+ onSelect={handleScopeChange}
+ onHighlight={handleScopeChange}
+ isFocused={effectiveFocusSection === 'scope'}
+ showNumbers={effectiveFocusSection === 'scope'}
+ priority={effectiveFocusSection === 'scope'}
/>
)}
@@ -614,13 +604,14 @@ export function BaseSettingsDialog({
{/* Help text */}
- (Use Enter to select, Ctrl+L to reset
- {showScopeSelector ? ', Tab to change focus' : ''}, Esc to close)
+ (Use Enter to select, {formatCommand(Command.CLEAR_SCREEN)} to reset
+ {finalShowScopeSelector ? ', Tab to change focus' : ''}, Esc to
+ close)
{/* Footer content (e.g., restart prompt) */}
- {footerContent && {footerContent}}
+ {footer && {footer.content}}
);
diff --git a/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.test.tsx b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.test.tsx
index 14af016b38..8fe9f66bee 100644
--- a/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.test.tsx
+++ b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.test.tsx
@@ -29,6 +29,12 @@ vi.mock('../../semantic-colors.js', () => ({
primary: 'COLOR_PRIMARY',
secondary: 'COLOR_SECONDARY',
},
+ ui: {
+ focus: 'COLOR_FOCUS',
+ },
+ background: {
+ focus: 'COLOR_FOCUS_BG',
+ },
status: {
success: 'COLOR_SUCCESS',
},
diff --git a/packages/cli/src/ui/components/shared/DialogFooter.tsx b/packages/cli/src/ui/components/shared/DialogFooter.tsx
index 7411a91611..ee16d43650 100644
--- a/packages/cli/src/ui/components/shared/DialogFooter.tsx
+++ b/packages/cli/src/ui/components/shared/DialogFooter.tsx
@@ -11,7 +11,7 @@ import { theme } from '../../semantic-colors.js';
export interface DialogFooterProps {
/** The main shortcut (e.g., "Enter to submit") */
primaryAction: string;
- /** Secondary navigation shortcuts (e.g., "Tab/Shift+Tab to switch questions") */
+ /** Secondary navigation shortcuts (e.g., "Tab to switch questions") */
navigationActions?: string;
/** Exit shortcut (defaults to "Esc to cancel") */
cancelAction?: string;
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
index c5122770c0..d21cebe971 100644
--- a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
@@ -9,9 +9,19 @@ import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { MaxSizedBox } from './MaxSizedBox.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { Box, Text } from 'ink';
-import { describe, it, expect } from 'vitest';
+import { act } from 'react';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
it('renders children without truncation when they fit', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
@@ -22,6 +32,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('Hello, World!');
expect(lastFrame()).toMatchSnapshot();
@@ -40,6 +53,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 2 lines hidden (Ctrl+O to show) ...',
@@ -60,6 +76,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... last 2 lines hidden (Ctrl+O to show) ...',
@@ -80,6 +99,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 2 lines hidden (Ctrl+O to show) ...',
@@ -98,6 +120,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 1 line hidden (Ctrl+O to show) ...',
@@ -118,6 +143,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 7 lines hidden (Ctrl+O to show) ...',
@@ -137,6 +165,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('This is a');
expect(lastFrame()).toMatchSnapshot();
@@ -154,6 +185,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('Line 1');
expect(lastFrame()).toMatchSnapshot();
@@ -166,6 +200,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).equals('');
unmount();
@@ -185,6 +222,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('Line 1 from Fragment');
expect(lastFrame()).toMatchSnapshot();
@@ -206,6 +246,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 21 lines hidden (Ctrl+O to show) ...',
@@ -229,6 +272,9 @@ describe('', () => {
,
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain(
'... last 21 lines hidden (Ctrl+O to show) ...',
@@ -253,6 +299,9 @@ describe('', () => {
{ width: 80 },
);
+ await act(async () => {
+ vi.runAllTimers();
+ });
await waitUntilReady();
expect(lastFrame()).toContain('... last');
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
index 0c2922ddfb..e88dcd4b76 100644
--- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
@@ -20,7 +20,7 @@ import { formatCommand } from '../../utils/keybindingUtils.js';
*/
export const MINIMUM_MAX_HEIGHT = 2;
-interface MaxSizedBoxProps {
+export interface MaxSizedBoxProps {
children?: React.ReactNode;
maxWidth?: number;
maxHeight?: number;
@@ -96,12 +96,15 @@ export const MaxSizedBox: React.FC = ({
} else {
removeOverflowingId?.(id);
}
-
- return () => {
- removeOverflowingId?.(id);
- };
}, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]);
+ useEffect(
+ () => () => {
+ removeOverflowingId?.(id);
+ },
+ [id, removeOverflowingId],
+ );
+
if (effectiveMaxHeight === undefined) {
return (
diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
index 33c77f1a25..00607e522a 100644
--- a/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
+++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
@@ -27,6 +27,8 @@ vi.mock('./BaseSelectionList.js', () => ({
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: { secondary: 'COLOR_SECONDARY' },
+ ui: { focus: 'COLOR_FOCUS' },
+ background: { focus: 'COLOR_FOCUS_BG' },
},
}));
diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx
index 87ec6e72d6..a1f9be0b7c 100644
--- a/packages/cli/src/ui/components/shared/Scrollable.tsx
+++ b/packages/cli/src/ui/components/shared/Scrollable.tsx
@@ -5,13 +5,23 @@
*/
import type React from 'react';
-import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
+import {
+ useState,
+ useRef,
+ useCallback,
+ useMemo,
+ useLayoutEffect,
+ useEffect,
+ useId,
+} from 'react';
import { Box, ResizeObserver, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
+import { useOverflowActions } from '../../contexts/OverflowContext.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
interface ScrollableProps {
children?: React.ReactNode;
@@ -22,6 +32,7 @@ interface ScrollableProps {
hasFocus: boolean;
scrollToBottom?: boolean;
flexGrow?: number;
+ reportOverflow?: boolean;
}
export const Scrollable: React.FC = ({
@@ -33,10 +44,14 @@ export const Scrollable: React.FC = ({
hasFocus,
scrollToBottom,
flexGrow,
+ reportOverflow = false,
}) => {
+ const keyMatchers = useKeyMatchers();
const [scrollTop, setScrollTop] = useState(0);
const viewportRef = useRef(null);
const contentRef = useRef(null);
+ const overflowActions = useOverflowActions();
+ const id = useId();
const [size, setSize] = useState({
innerHeight: typeof height === 'number' ? height : 0,
scrollHeight: 0,
@@ -52,6 +67,27 @@ export const Scrollable: React.FC = ({
scrollTopRef.current = scrollTop;
}, [scrollTop]);
+ useEffect(() => {
+ if (reportOverflow && size.scrollHeight > size.innerHeight) {
+ overflowActions?.addOverflowingId?.(id);
+ } else {
+ overflowActions?.removeOverflowingId?.(id);
+ }
+ }, [
+ reportOverflow,
+ size.scrollHeight,
+ size.innerHeight,
+ id,
+ overflowActions,
+ ]);
+
+ useEffect(
+ () => () => {
+ overflowActions?.removeOverflowingId?.(id);
+ },
+ [id, overflowActions],
+ );
+
const viewportObserverRef = useRef(null);
const contentObserverRef = useRef(null);
diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx
index b7085329a3..33a3f72310 100644
--- a/packages/cli/src/ui/components/shared/ScrollableList.tsx
+++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx
@@ -22,7 +22,8 @@ import { useScrollable } from '../../contexts/ScrollProvider.js';
import { Box, type DOMElement } from 'ink';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
const ANIMATION_FRAME_DURATION_MS = 33;
@@ -46,6 +47,7 @@ function ScrollableList(
props: ScrollableListProps,
ref: React.Ref>,
) {
+ const keyMatchers = useKeyMatchers();
const { hasFocus, width } = props;
const virtualizedListRef = useRef>(null);
const containerRef = useRef(null);
diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx
index 1611bc2842..046040af90 100644
--- a/packages/cli/src/ui/components/shared/SearchableList.tsx
+++ b/packages/cli/src/ui/components/shared/SearchableList.tsx
@@ -11,7 +11,8 @@ import { useSelectionList } from '../../hooks/useSelectionList.js';
import { TextInput } from './TextInput.js';
import type { TextBuffer } from './text-buffer.js';
import { useKeypress } from '../../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
/**
* Generic interface for items in a searchable list.
@@ -85,6 +86,7 @@ export function SearchableList({
onSearch,
resetSelectionOnItemsChange = false,
}: SearchableListProps): React.JSX.Element {
+ const keyMatchers = useKeyMatchers();
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
items,
onSearch,
diff --git a/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.test.tsx b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.test.tsx
new file mode 100644
index 0000000000..184c968836
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.test.tsx
@@ -0,0 +1,123 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../../test-utils/render.js';
+import { OverflowProvider } from '../../contexts/OverflowContext.js';
+import { SlicingMaxSizedBox } from './SlicingMaxSizedBox.js';
+import { Box, Text } from 'ink';
+import { describe, it, expect } from 'vitest';
+
+describe('', () => {
+ it('renders string data without slicing when it fits', async () => {
+ const { lastFrame, waitUntilReady, unmount } = render(
+
+
+ {(truncatedData) => {truncatedData}}
+
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toContain('Hello World');
+ unmount();
+ });
+
+ it('slices string data by characters when very long', async () => {
+ const veryLongString = 'A'.repeat(25000);
+ const { lastFrame, waitUntilReady, unmount } = render(
+
+
+ {(truncatedData) => {truncatedData.length}}
+
+ ,
+ );
+ await waitUntilReady();
+ // 20000 characters + 3 for '...'
+ expect(lastFrame()).toContain('20003');
+ unmount();
+ });
+
+ it('slices string data by lines when maxLines is provided', async () => {
+ const multilineString = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
+ const { lastFrame, waitUntilReady, unmount } = render(
+
+
+ {(truncatedData) => {truncatedData}}
+
+ ,
+ );
+ await waitUntilReady();
+ // maxLines=3, so it should keep 3-1 = 2 lines
+ expect(lastFrame()).toContain('Line 1');
+ expect(lastFrame()).toContain('Line 2');
+ expect(lastFrame()).not.toContain('Line 3');
+ expect(lastFrame()).toContain(
+ '... last 3 lines hidden (Ctrl+O to show) ...',
+ );
+ unmount();
+ });
+
+ it('slices array data when maxLines is provided', async () => {
+ const dataArray = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'];
+ const { lastFrame, waitUntilReady, unmount } = render(
+
+
+ {(truncatedData) => (
+
+ {truncatedData.map((item, i) => (
+ {item}
+ ))}
+
+ )}
+
+ ,
+ );
+ await waitUntilReady();
+ // maxLines=3, so it should keep 3-1 = 2 items
+ expect(lastFrame()).toContain('Item 1');
+ expect(lastFrame()).toContain('Item 2');
+ expect(lastFrame()).not.toContain('Item 3');
+ expect(lastFrame()).toContain(
+ '... last 3 lines hidden (Ctrl+O to show) ...',
+ );
+ unmount();
+ });
+
+ it('does not slice when isAlternateBuffer is true', async () => {
+ const multilineString = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
+ const { lastFrame, waitUntilReady, unmount } = render(
+
+
+ {(truncatedData) => {truncatedData}}
+
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toContain('Line 5');
+ expect(lastFrame()).not.toContain('hidden');
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx
new file mode 100644
index 0000000000..b756c40ee2
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useMemo } from 'react';
+import { MaxSizedBox, type MaxSizedBoxProps } from './MaxSizedBox.js';
+
+// Large threshold to ensure we don't cause performance issues for very large
+// outputs that will get truncated further MaxSizedBox anyway.
+const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 20000;
+
+export interface SlicingMaxSizedBoxProps
+ extends Omit {
+ data: T;
+ maxLines?: number;
+ isAlternateBuffer?: boolean;
+ children: (truncatedData: T) => React.ReactNode;
+}
+
+/**
+ * An extension of MaxSizedBox that performs explicit slicing of the input data
+ * (string or array) before rendering. This is useful for performance and to
+ * ensure consistent truncation behavior for large outputs.
+ */
+export function SlicingMaxSizedBox({
+ data,
+ maxLines,
+ isAlternateBuffer,
+ children,
+ ...boxProps
+}: SlicingMaxSizedBoxProps) {
+ const { truncatedData, hiddenLinesCount } = useMemo(() => {
+ let hiddenLines = 0;
+ const overflowDirection = boxProps.overflowDirection ?? 'top';
+
+ // Only truncate string output if not in alternate buffer mode to ensure
+ // we can scroll through the full output.
+ if (typeof data === 'string' && !isAlternateBuffer) {
+ let text: string = data as string;
+ if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
+ if (overflowDirection === 'bottom') {
+ text = text.slice(0, MAXIMUM_RESULT_DISPLAY_CHARACTERS) + '...';
+ } else {
+ text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
+ }
+ }
+ if (maxLines) {
+ const hasTrailingNewline = text.endsWith('\n');
+ const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
+ const lines = contentText.split('\n');
+ if (lines.length > maxLines) {
+ // We will have a label from MaxSizedBox. Reserve space for it.
+ const targetLines = Math.max(1, maxLines - 1);
+ hiddenLines = lines.length - targetLines;
+ if (overflowDirection === 'bottom') {
+ text =
+ lines.slice(0, targetLines).join('\n') +
+ (hasTrailingNewline ? '\n' : '');
+ } else {
+ text =
+ lines.slice(-targetLines).join('\n') +
+ (hasTrailingNewline ? '\n' : '');
+ }
+ }
+ }
+ return {
+ truncatedData: text,
+ hiddenLinesCount: hiddenLines,
+ };
+ }
+
+ if (Array.isArray(data) && !isAlternateBuffer && maxLines) {
+ if (data.length > maxLines) {
+ // We will have a label from MaxSizedBox. Reserve space for it.
+ const targetLines = Math.max(1, maxLines - 1);
+ const hiddenCount = data.length - targetLines;
+ return {
+ truncatedData:
+ overflowDirection === 'bottom'
+ ? data.slice(0, targetLines)
+ : data.slice(-targetLines),
+ hiddenLinesCount: hiddenCount,
+ };
+ }
+ }
+
+ return { truncatedData: data, hiddenLinesCount: 0 };
+ }, [data, isAlternateBuffer, maxLines, boxProps.overflowDirection]);
+
+ return (
+
+ {/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */}
+ {children(truncatedData as unknown as T)}
+
+ );
+}
diff --git a/packages/cli/src/ui/components/shared/TextInput.test.tsx b/packages/cli/src/ui/components/shared/TextInput.test.tsx
index f12714e288..7e802bbbe3 100644
--- a/packages/cli/src/ui/components/shared/TextInput.test.tsx
+++ b/packages/cli/src/ui/components/shared/TextInput.test.tsx
@@ -17,7 +17,8 @@ vi.mock('../../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
-vi.mock('./text-buffer.js', () => {
+vi.mock('./text-buffer.js', async (importOriginal) => {
+ const actual = await importOriginal();
const mockTextBuffer = {
text: '',
lines: [''],
@@ -60,6 +61,7 @@ vi.mock('./text-buffer.js', () => {
};
return {
+ ...actual,
useTextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer),
TextBuffer: vi.fn(() => mockTextBuffer as unknown as TextBuffer),
};
@@ -82,6 +84,7 @@ describe('TextInput', () => {
cursor: [0, 0],
visualCursor: [0, 0],
viewportVisualLines: [''],
+ pastedContent: {} as Record,
handleInput: vi.fn((key) => {
if (key.sequence) {
buffer.text += key.sequence;
@@ -298,6 +301,58 @@ describe('TextInput', () => {
unmount();
});
+ it('expands paste placeholder to real content on submit', async () => {
+ const placeholder = '[Pasted Text: 6 lines]';
+ const realContent = 'line1\nline2\nline3\nline4\nline5\nline6';
+ mockBuffer.setText(placeholder);
+ mockBuffer.pastedContent = { [placeholder]: realContent };
+ const { waitUntilReady, unmount } = render(
+ ,
+ );
+ await waitUntilReady();
+ const keypressHandler = mockedUseKeypress.mock.calls[0][0];
+
+ await act(async () => {
+ keypressHandler({
+ name: 'return',
+ shift: false,
+ alt: false,
+ ctrl: false,
+ cmd: false,
+ sequence: '',
+ });
+ });
+ await waitUntilReady();
+
+ expect(onSubmit).toHaveBeenCalledWith(realContent);
+ unmount();
+ });
+
+ it('submits text unchanged when pastedContent is empty', async () => {
+ mockBuffer.setText('normal text');
+ mockBuffer.pastedContent = {};
+ const { waitUntilReady, unmount } = render(
+ ,
+ );
+ await waitUntilReady();
+ const keypressHandler = mockedUseKeypress.mock.calls[0][0];
+
+ await act(async () => {
+ keypressHandler({
+ name: 'return',
+ shift: false,
+ alt: false,
+ ctrl: false,
+ cmd: false,
+ sequence: '',
+ });
+ });
+ await waitUntilReady();
+
+ expect(onSubmit).toHaveBeenCalledWith('normal text');
+ unmount();
+ });
+
it('calls onCancel on escape', async () => {
vi.useFakeTimers();
const { waitUntilReady, unmount } = render(
diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx
index 40f44cda53..cc3fcaeb8d 100644
--- a/packages/cli/src/ui/components/shared/TextInput.tsx
+++ b/packages/cli/src/ui/components/shared/TextInput.tsx
@@ -12,8 +12,10 @@ import { useKeypress } from '../../hooks/useKeypress.js';
import chalk from 'chalk';
import { theme } from '../../semantic-colors.js';
import type { TextBuffer } from './text-buffer.js';
+import { expandPastePlaceholders } from './text-buffer.js';
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export interface TextInputProps {
buffer: TextBuffer;
@@ -30,6 +32,7 @@ export function TextInput({
onCancel,
focus = true,
}: TextInputProps): React.JSX.Element {
+ const keyMatchers = useKeyMatchers();
const {
text,
handleInput,
@@ -47,14 +50,14 @@ export function TextInput({
}
if (keyMatchers[Command.SUBMIT](key) && onSubmit) {
- onSubmit(text);
+ onSubmit(expandPastePlaceholders(text, buffer.pastedContent));
return true;
}
const handled = handleInput(key);
return handled;
},
- [handleInput, onCancel, onSubmit, text],
+ [handleInput, onCancel, onSubmit, text, buffer.pastedContent, keyMatchers],
);
useKeypress(handleKeyPress, { isActive: focus, priority: true });
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap
index 803ec8dd98..9f256d4cb6 100644
--- a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap
+++ b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap
@@ -10,7 +10,7 @@ exports[`SearchableList > should match snapshot 1`] = `
● Item One
Description for item one
- Item Two
+ Item Two
Description for item two
Item Three
@@ -25,7 +25,7 @@ exports[`SearchableList > should reset selection to top when items change if res
│ Search... │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
- Item One
+ Item One
Description for item one
● Item Two
@@ -58,7 +58,7 @@ exports[`SearchableList > should reset selection to top when items change if res
● Item One
Description for item one
- Item Two
+ Item Two
Description for item two
Item Three
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index 71ee40b642..808fc8a554 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -25,11 +25,12 @@ import {
} from '../../utils/textUtils.js';
import { parsePastedPaths } from '../../utils/clipboardUtils.js';
import type { Key } from '../../contexts/KeypressContext.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
import { openFileInEditor } from '../../utils/editorUtils.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export const LARGE_PASTE_LINE_THRESHOLD = 5;
export const LARGE_PASTE_CHAR_THRESHOLD = 500;
@@ -38,6 +39,17 @@ export const LARGE_PASTE_CHAR_THRESHOLD = 500;
export const PASTED_TEXT_PLACEHOLDER_REGEX =
/\[Pasted Text: \d+ (?:lines|chars)(?: #\d+)?\]/g;
+// Replace paste placeholder strings with their actual pasted content.
+export function expandPastePlaceholders(
+ text: string,
+ pastedContent: Record,
+): string {
+ return text.replace(
+ PASTED_TEXT_PLACEHOLDER_REGEX,
+ (match) => pastedContent[match] || match,
+ );
+}
+
export type Direction =
| 'left'
| 'right'
@@ -2697,6 +2709,7 @@ export function useTextBuffer({
singleLine = false,
getPreferredEditor,
}: UseTextBufferProps): TextBuffer {
+ const keyMatchers = useKeyMatchers();
const initialState = useMemo((): TextBufferState => {
const lines = initialText.split('\n');
const [initialCursorRow, initialCursorCol] = calculateInitialCursorPosition(
@@ -3086,10 +3099,7 @@ export function useTextBuffer({
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
const filePath = pathMod.join(tmpDir, 'buffer.txt');
// Expand paste placeholders so user sees full content in editor
- const expandedText = text.replace(
- PASTED_TEXT_PLACEHOLDER_REGEX,
- (match) => pastedContent[match] || match,
- );
+ const expandedText = expandPastePlaceholders(text, pastedContent);
fs.writeFileSync(filePath, expandedText, 'utf8');
dispatch({ type: 'create_undo_snapshot' });
@@ -3262,6 +3272,7 @@ export function useTextBuffer({
text,
visualCursor,
visualLines,
+ keyMatchers,
],
);
diff --git a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx
index 878cacfed0..4de6568189 100644
--- a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx
+++ b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx
@@ -10,7 +10,8 @@ import Spinner from 'ink-spinner';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
interface Issue {
number: number;
@@ -106,6 +107,7 @@ export const TriageDuplicates = ({
onExit: () => void;
initialLimit?: number;
}) => {
+ const keyMatchers = useKeyMatchers();
const [state, setState] = useState({
status: 'loading',
issues: [],
diff --git a/packages/cli/src/ui/components/triage/TriageIssues.tsx b/packages/cli/src/ui/components/triage/TriageIssues.tsx
index 595384a124..e6779d6c02 100644
--- a/packages/cli/src/ui/components/triage/TriageIssues.tsx
+++ b/packages/cli/src/ui/components/triage/TriageIssues.tsx
@@ -10,9 +10,10 @@ import Spinner from 'ink-spinner';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
import { TextInput } from '../shared/TextInput.js';
import { useTextBuffer } from '../shared/text-buffer.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
interface Issue {
number: number;
@@ -69,6 +70,7 @@ export const TriageIssues = ({
initialLimit?: number;
until?: string;
}) => {
+ const keyMatchers = useKeyMatchers();
const [state, setState] = useState({
status: 'loading',
issues: [],
diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx
index 1f6fba96ea..394eba3a2a 100644
--- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx
+++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx
@@ -24,7 +24,7 @@ import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
import { useUIState } from '../../contexts/UIStateContext.js';
-interface ExtensionRegistryViewProps {
+export interface ExtensionRegistryViewProps {
onSelect?: (extension: RegistryExtension) => void;
onClose?: () => void;
extensionManager: ExtensionManager;
diff --git a/packages/cli/src/ui/components/views/HooksList.tsx b/packages/cli/src/ui/components/views/HooksList.tsx
deleted file mode 100644
index bce3fcf870..0000000000
--- a/packages/cli/src/ui/components/views/HooksList.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import type React from 'react';
-import { Box, Text } from 'ink';
-import { theme } from '../../semantic-colors.js';
-
-interface HooksListProps {
- hooks: ReadonlyArray<{
- config: {
- command?: string;
- type: string;
- name?: string;
- description?: string;
- timeout?: number;
- };
- source: string;
- eventName: string;
- matcher?: string;
- sequential?: boolean;
- enabled: boolean;
- }>;
-}
-
-export const HooksList: React.FC = ({ hooks }) => {
- if (hooks.length === 0) {
- return (
-
- No hooks configured.
-
- );
- }
-
- // Group hooks by event name for better organization
- const hooksByEvent = hooks.reduce(
- (acc, hook) => {
- if (!acc[hook.eventName]) {
- acc[hook.eventName] = [];
- }
- acc[hook.eventName].push(hook);
- return acc;
- },
- {} as Record>,
- );
-
- return (
-
-
-
- ⚠️ Security Warning:
-
-
- Hooks can execute arbitrary commands on your system. Only use hooks
- from sources you trust. Review hook scripts carefully.
-
-
-
-
-
- Learn more:{' '}
- https://geminicli.com/docs/hooks
-
-
-
-
- Configured Hooks:
-
-
- {Object.entries(hooksByEvent).map(([eventName, eventHooks]) => (
-
-
- {eventName}:
-
-
- {eventHooks.map((hook, index) => {
- const hookName =
- hook.config.name || hook.config.command || 'unknown';
- const statusColor = hook.enabled
- ? theme.status.success
- : theme.text.secondary;
- const statusText = hook.enabled ? 'enabled' : 'disabled';
-
- return (
-
-
-
- {hookName}
- {` [${statusText}]`}
-
-
-
- {hook.config.description && (
- {hook.config.description}
- )}
-
- Source: {hook.source}
- {hook.config.name &&
- hook.config.command &&
- ` | Command: ${hook.config.command}`}
- {hook.matcher && ` | Matcher: ${hook.matcher}`}
- {hook.sequential && ` | Sequential`}
- {hook.config.timeout &&
- ` | Timeout: ${hook.config.timeout}s`}
-
-
-
- );
- })}
-
-
- ))}
-
-
-
- Tip: Use /hooks enable {''} or{' '}
- /hooks disable {''} to toggle individual
- hooks. Use /hooks enable-all or{' '}
- /hooks disable-all to toggle all hooks at once.
-
-
-
- );
-};
diff --git a/packages/cli/src/ui/components/views/McpStatus.test.tsx b/packages/cli/src/ui/components/views/McpStatus.test.tsx
index 1c600069c1..e4808f31c4 100644
--- a/packages/cli/src/ui/components/views/McpStatus.test.tsx
+++ b/packages/cli/src/ui/components/views/McpStatus.test.tsx
@@ -16,7 +16,6 @@ describe('McpStatus', () => {
servers: {
'server-1': {
url: 'http://localhost:8080',
- name: 'server-1',
description: 'A test server',
},
},
@@ -200,6 +199,38 @@ describe('McpStatus', () => {
unmount();
});
+ it('renders correctly with both blocked and unblocked servers', async () => {
+ const { lastFrame, unmount, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders only blocked servers when no configured servers exist', async () => {
+ const { lastFrame, unmount, waitUntilReady } = render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
it('renders correctly with a connecting server', async () => {
const { lastFrame, unmount, waitUntilReady } = render(
,
diff --git a/packages/cli/src/ui/components/views/McpStatus.tsx b/packages/cli/src/ui/components/views/McpStatus.tsx
index 473c3de547..c007d14635 100644
--- a/packages/cli/src/ui/components/views/McpStatus.tsx
+++ b/packages/cli/src/ui/components/views/McpStatus.tsx
@@ -48,7 +48,12 @@ export const McpStatus: React.FC = ({
showDescriptions,
showSchema,
}) => {
- const serverNames = Object.keys(servers);
+ const serverNames = Object.keys(servers).filter(
+ (serverName) =>
+ !blockedServers.some(
+ (blockedServer) => blockedServer.name === serverName,
+ ),
+ );
if (serverNames.length === 0 && blockedServers.length === 0) {
return (
@@ -82,7 +87,6 @@ export const McpStatus: React.FC = ({
Configured MCP servers:
-
{serverNames.map((serverName) => {
const server = servers[serverName];
const serverTools = tools.filter(
diff --git a/packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap b/packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap
index eb1d1de83c..71a34c5026 100644
--- a/packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap
+++ b/packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap
@@ -17,12 +17,6 @@ A test server
exports[`McpStatus > renders correctly with a blocked server 1`] = `
"Configured MCP servers:
-🟢 server-1 - Ready (1 tool)
-A test server
- Tools:
- - tool-1
- A test tool
-
🔴 server-1 (from test-extension) - Blocked
"
`;
@@ -83,6 +77,19 @@ A test server
"
`;
+exports[`McpStatus > renders correctly with both blocked and unblocked servers 1`] = `
+"Configured MCP servers:
+
+🟢 server-1 - Ready (1 tool)
+A test server
+ Tools:
+ - tool-1
+ A test tool
+
+🔴 server-2 (from test-extension) - Blocked
+"
+`;
+
exports[`McpStatus > renders correctly with expired OAuth status 1`] = `
"Configured MCP servers:
@@ -172,3 +179,10 @@ A test server
A test tool
"
`;
+
+exports[`McpStatus > renders only blocked servers when no configured servers exist 1`] = `
+"Configured MCP servers:
+
+🔴 server-1 (from test-extension) - Blocked
+"
+`;
diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts
index 795db1e3a0..db52be1105 100644
--- a/packages/cli/src/ui/constants.ts
+++ b/packages/cli/src/ui/constants.ts
@@ -37,6 +37,7 @@ export const EXPAND_HINT_DURATION_MS = 5000;
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
+export const DEFAULT_SELECTION_OPACITY = 0.2;
export const DEFAULT_BORDER_OPACITY = 0.4;
export const KEYBOARD_SHORTCUTS_URL =
@@ -48,3 +49,12 @@ export const ACTIVE_SHELL_MAX_LINES = 15;
// Max lines to preserve in history for completed shell commands
export const COMPLETED_SHELL_MAX_LINES = 15;
+
+// Max lines to show for subagent results before collapsing
+export const SUBAGENT_MAX_LINES = 15;
+
+/** Minimum terminal width required to show the full context used label */
+export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100;
+
+/** Default context usage fraction at which to trigger compression */
+export const DEFAULT_COMPRESSION_THRESHOLD = 0.5;
diff --git a/packages/cli/src/ui/constants/tips.ts b/packages/cli/src/ui/constants/tips.ts
index 9abbaa12d5..a1b0351001 100644
--- a/packages/cli/src/ui/constants/tips.ts
+++ b/packages/cli/src/ui/constants/tips.ts
@@ -30,11 +30,11 @@ export const INFORMATIVE_TIPS = [
'Choose a specific Gemini model for conversations (/settings)…',
'Limit the number of turns in your session history (/settings)…',
'Automatically summarize large tool outputs to save tokens (settings.json)…',
- 'Control when chat history gets compressed based on token usage (settings.json)…',
+ 'Control when chat history gets compressed based on context compression threshold (settings.json)…',
'Define custom context file names, like CONTEXT.md (settings.json)…',
'Set max directories to scan for context files (/settings)…',
'Expand your workspace with additional directories (/workspace)…',
- 'Control how /memory refresh loads context files (/settings)…',
+ 'Control how /memory reload loads context files (/settings)…',
'Toggle respect for .gitignore files in context (/settings)…',
'Toggle respect for .geminiignore files in context (/settings)…',
'Enable recursive file search for @-file completions (/settings)…',
@@ -122,11 +122,11 @@ export const INFORMATIVE_TIPS = [
'Show version info with /about…',
'Change your authentication method with /auth…',
'File a bug report directly with /bug…',
- 'List your saved chat checkpoints with /chat list…',
- 'Save your current conversation with /chat save …',
- 'Resume a saved conversation with /chat resume …',
- 'Delete a conversation checkpoint with /chat delete …',
- 'Share your conversation to a file with /chat share …',
+ 'List your saved chat checkpoints with /resume list…',
+ 'Save your current conversation with /resume save …',
+ 'Resume a saved conversation with /resume resume …',
+ 'Delete a conversation checkpoint with /resume delete …',
+ 'Share your conversation to a file with /resume share …',
'Clear the screen and history with /clear…',
'Save tokens by summarizing the context with /compress…',
'Copy the last response to your clipboard with /copy…',
@@ -142,10 +142,10 @@ export const INFORMATIVE_TIPS = [
'Create a workspace-specific GEMINI.md file with /init…',
'List configured MCP servers and tools with /mcp list…',
'Authenticate with an OAuth-enabled MCP server with /mcp auth…',
- 'Restart MCP servers with /mcp refresh…',
+ 'Reload MCP servers with /mcp reload…',
'See the current instructional context with /memory show…',
'Add content to the instructional memory with /memory add…',
- 'Reload instructional context from GEMINI.md files with /memory refresh…',
+ 'Reload instructional context from GEMINI.md files with /memory reload…',
'List the paths of the GEMINI.md files in use with /memory list…',
'Choose your Gemini model with /model…',
'Display the privacy notice with /privacy…',
diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
index 2b946eb35f..e353d3f23c 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
@@ -297,7 +297,7 @@ describe('KeypressContext', () => {
expect.objectContaining({
name: 'escape',
shift: false,
- alt: true,
+ alt: false,
cmd: false,
}),
);
@@ -306,7 +306,7 @@ describe('KeypressContext', () => {
expect.objectContaining({
name: 'escape',
shift: false,
- alt: true,
+ alt: false,
cmd: false,
}),
);
@@ -335,7 +335,7 @@ describe('KeypressContext', () => {
expect.objectContaining({
name: 'escape',
shift: false,
- alt: true,
+ alt: false,
cmd: false,
}),
);
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
index 7d1881644d..d3f9031ffe 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -178,8 +178,7 @@ function nonKeyboardEventFilter(
}
/**
- * Converts return keys pressed quickly after other keys into plain
- * insertable return characters.
+ * Converts return keys pressed quickly after insertable keys into a shift+return
*
* This is to accommodate older terminals that paste text without bracketing.
*/
@@ -201,7 +200,7 @@ function bufferFastReturn(keypressHandler: KeypressHandler): KeypressHandler {
} else {
keypressHandler(key);
}
- lastKeyTime = now;
+ lastKeyTime = key.insertable ? now : 0;
};
}
@@ -630,7 +629,7 @@ function* emitKeys(
} else if (sequence === `${ESC}${ESC}`) {
// Double escape
name = 'escape';
- alt = true;
+ alt = false;
// Emit first escape key here, then continue processing
keypressHandler({
@@ -645,7 +644,7 @@ function* emitKeys(
} else if (escaped) {
// Escape sequence timeout
name = ch.length ? undefined : 'escape';
- alt = true;
+ alt = ch.length > 0;
} else {
// Any other character is considered printable.
insertable = true;
@@ -786,6 +785,8 @@ export function KeypressProvider({
);
useEffect(() => {
+ terminalCapabilityManager.enableSupportedModes();
+
const wasRaw = stdin.isRaw;
if (wasRaw === false) {
setRawMode(true);
diff --git a/packages/cli/src/ui/contexts/OverflowContext.tsx b/packages/cli/src/ui/contexts/OverflowContext.tsx
index cee02090b6..f27108367a 100644
--- a/packages/cli/src/ui/contexts/OverflowContext.tsx
+++ b/packages/cli/src/ui/contexts/OverflowContext.tsx
@@ -11,6 +11,8 @@ import {
useState,
useCallback,
useMemo,
+ useRef,
+ useEffect,
} from 'react';
export interface OverflowState {
@@ -42,31 +44,70 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({
}) => {
const [overflowingIds, setOverflowingIds] = useState(new Set());
- const addOverflowingId = useCallback((id: string) => {
- setOverflowingIds((prevIds) => {
- if (prevIds.has(id)) {
- return prevIds;
- }
- const newIds = new Set(prevIds);
- newIds.add(id);
- return newIds;
- });
+ /**
+ * We use a ref to track the current set of overflowing IDs and a timeout to
+ * batch updates to the next tick. This prevents infinite render loops (layout
+ * oscillation) where showing an overflow hint causes a layout shift that
+ * hides the hint, which then restores the layout and shows the hint again.
+ */
+ const idsRef = useRef(new Set());
+ const timeoutRef = useRef(null);
+
+ const syncState = useCallback(() => {
+ if (timeoutRef.current) return;
+
+ // Use a microtask to batch updates and break synchronous recursive loops.
+ // This prevents "Maximum update depth exceeded" errors during layout shifts.
+ timeoutRef.current = setTimeout(() => {
+ timeoutRef.current = null;
+ setOverflowingIds((prevIds) => {
+ // Optimization: only update state if the set has actually changed
+ if (
+ prevIds.size === idsRef.current.size &&
+ [...prevIds].every((id) => idsRef.current.has(id))
+ ) {
+ return prevIds;
+ }
+ return new Set(idsRef.current);
+ });
+ }, 0);
}, []);
- const removeOverflowingId = useCallback((id: string) => {
- setOverflowingIds((prevIds) => {
- if (!prevIds.has(id)) {
- return prevIds;
+ useEffect(
+ () => () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
}
- const newIds = new Set(prevIds);
- newIds.delete(id);
- return newIds;
- });
- }, []);
+ },
+ [],
+ );
+
+ const addOverflowingId = useCallback(
+ (id: string) => {
+ if (!idsRef.current.has(id)) {
+ idsRef.current.add(id);
+ syncState();
+ }
+ },
+ [syncState],
+ );
+
+ const removeOverflowingId = useCallback(
+ (id: string) => {
+ if (idsRef.current.has(id)) {
+ idsRef.current.delete(id);
+ syncState();
+ }
+ },
+ [syncState],
+ );
const reset = useCallback(() => {
- setOverflowingIds(new Set());
- }, []);
+ if (idsRef.current.size > 0) {
+ idsRef.current.clear();
+ syncState();
+ }
+ }, [syncState]);
const stateValue = useMemo(
() => ({
diff --git a/packages/cli/src/ui/contexts/SettingsContext.tsx b/packages/cli/src/ui/contexts/SettingsContext.tsx
index 2c5ae37dfd..259f4c21a2 100644
--- a/packages/cli/src/ui/contexts/SettingsContext.tsx
+++ b/packages/cli/src/ui/contexts/SettingsContext.tsx
@@ -12,6 +12,7 @@ import type {
SettingsFile,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
+import { checkExhaustive } from '@google/gemini-cli-core';
export const SettingsContext = React.createContext(
undefined,
@@ -66,7 +67,7 @@ export const useSettingsStore = (): SettingsStoreValue => {
case SettingScope.SystemDefaults:
return snapshot.systemDefaults;
default:
- throw new Error(`Invalid scope: ${scope}`);
+ checkExhaustive(scope);
}
},
}),
diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx
index 554cff34f9..ea9025aa6b 100644
--- a/packages/cli/src/ui/contexts/UIStateContext.tsx
+++ b/packages/cli/src/ui/contexts/UIStateContext.tsx
@@ -107,8 +107,6 @@ export interface UIState {
history: HistoryItem[];
historyManager: UseHistoryManagerReturn;
isThemeDialogOpen: boolean;
- shouldShowRetentionWarning: boolean;
- sessionsToDeleteCount: number;
themeError: string | null;
isAuthenticating: boolean;
isConfigInitialized: boolean;
diff --git a/packages/cli/src/ui/contexts/VimModeContext.tsx b/packages/cli/src/ui/contexts/VimModeContext.tsx
index d4495846d2..7f7a7ea2a3 100644
--- a/packages/cli/src/ui/contexts/VimModeContext.tsx
+++ b/packages/cli/src/ui/contexts/VimModeContext.tsx
@@ -4,15 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useState,
-} from 'react';
-import type { LoadedSettings } from '../../config/settings.js';
+import { createContext, useCallback, useContext, useState } from 'react';
import { SettingScope } from '../../config/settings.js';
+import { useSettingsStore } from './SettingsContext.js';
export type VimMode = 'NORMAL' | 'INSERT';
@@ -27,35 +21,22 @@ const VimModeContext = createContext(undefined);
export const VimModeProvider = ({
children,
- settings,
}: {
children: React.ReactNode;
- settings: LoadedSettings;
}) => {
- const initialVimEnabled = settings.merged.general.vimMode;
- const [vimEnabled, setVimEnabled] = useState(initialVimEnabled);
+ const { settings, setSetting } = useSettingsStore();
+ const vimEnabled = settings.merged.general.vimMode;
const [vimMode, setVimMode] = useState('INSERT');
- useEffect(() => {
- // Initialize vimEnabled from settings on mount
- const enabled = settings.merged.general.vimMode;
- setVimEnabled(enabled);
- // When vim mode is enabled, start in INSERT mode
- if (enabled) {
- setVimMode('INSERT');
- }
- }, [settings.merged.general.vimMode]);
-
const toggleVimEnabled = useCallback(async () => {
const newValue = !vimEnabled;
- setVimEnabled(newValue);
// When enabling vim mode, start in INSERT mode
if (newValue) {
setVimMode('INSERT');
}
- settings.setValue(SettingScope.User, 'general.vimMode', newValue);
+ setSetting(SettingScope.User, 'general.vimMode', newValue);
return newValue;
- }, [vimEnabled, settings]);
+ }, [vimEnabled, setSetting]);
const value = {
vimEnabled,
diff --git a/packages/cli/src/ui/hooks/creditsFlowHandler.ts b/packages/cli/src/ui/hooks/creditsFlowHandler.ts
index 497d4904e6..91f0997873 100644
--- a/packages/cli/src/ui/hooks/creditsFlowHandler.ts
+++ b/packages/cli/src/ui/hooks/creditsFlowHandler.ts
@@ -110,7 +110,6 @@ async function handleOverageMenu(
isDialogPending,
setOverageMenuRequest,
setModelSwitchedFromQuotaError,
- historyManager,
} = args;
logBillingEvent(
@@ -155,13 +154,6 @@ async function handleOverageMenu(
setModelSwitchedFromQuotaError(false);
config.setQuotaErrorOccurred(false);
config.setOverageStrategy('always');
- historyManager.addItem(
- {
- type: MessageType.INFO,
- text: `Using AI Credits for this request.`,
- },
- Date.now(),
- );
return 'retry_with_credits';
case 'use_fallback':
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
index 6190d163f7..f47aa30fba 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
@@ -18,14 +18,11 @@ import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import {
type GeminiClient,
- type UserFeedbackPayload,
SlashCommandStatus,
MCPDiscoveryState,
makeFakeConfig,
coreEvents,
- CoreEvent,
} from '@google/gemini-cli-core';
-import { SlashCommandConflictHandler } from '../../services/SlashCommandConflictHandler.js';
const {
logSlashCommand,
@@ -186,26 +183,6 @@ describe('useSlashCommandProcessor', () => {
mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands));
mockMcpLoadCommands.mockResolvedValue(Object.freeze(mcpCommands));
- const conflictHandler = new SlashCommandConflictHandler();
- conflictHandler.start();
-
- const handleFeedback = (payload: UserFeedbackPayload) => {
- let type = MessageType.INFO;
- if (payload.severity === 'error') {
- type = MessageType.ERROR;
- } else if (payload.severity === 'warning') {
- type = MessageType.WARNING;
- }
- mockAddItem(
- {
- type,
- text: payload.message,
- },
- Date.now(),
- );
- };
- coreEvents.on(CoreEvent.UserFeedback, handleFeedback);
-
let result!: { current: ReturnType };
let unmount!: () => void;
let rerender!: (props?: unknown) => void;
@@ -253,8 +230,6 @@ describe('useSlashCommandProcessor', () => {
});
unmountHook = async () => {
- conflictHandler.stop();
- coreEvents.off(CoreEvent.UserFeedback, handleFeedback);
unmount();
};
@@ -336,57 +311,6 @@ describe('useSlashCommandProcessor', () => {
expect(mockFileLoadCommands).toHaveBeenCalledTimes(1);
expect(mockMcpLoadCommands).toHaveBeenCalledTimes(1);
});
-
- it('should provide an immutable array of commands to consumers', async () => {
- const testCommand = createTestCommand({ name: 'test' });
- const result = await setupProcessorHook({
- builtinCommands: [testCommand],
- });
-
- await waitFor(() => {
- expect(result.current.slashCommands).toHaveLength(1);
- });
-
- const commands = result.current.slashCommands;
-
- expect(() => {
- // @ts-expect-error - We are intentionally testing a violation of the readonly type.
- commands.push(createTestCommand({ name: 'rogue' }));
- }).toThrow(TypeError);
- });
-
- it('should override built-in commands with file-based commands of the same name', async () => {
- const builtinAction = vi.fn();
- const fileAction = vi.fn();
-
- const builtinCommand = createTestCommand({
- name: 'override',
- description: 'builtin',
- action: builtinAction,
- });
- const fileCommand = createTestCommand(
- { name: 'override', description: 'file', action: fileAction },
- CommandKind.FILE,
- );
-
- const result = await setupProcessorHook({
- builtinCommands: [builtinCommand],
- fileCommands: [fileCommand],
- });
-
- await waitFor(() => {
- // The service should only return one command with the name 'override'
- expect(result.current.slashCommands).toHaveLength(1);
- });
-
- await act(async () => {
- await result.current.handleSlashCommand('/override');
- });
-
- // Only the file-based command's action should be called.
- expect(fileAction).toHaveBeenCalledTimes(1);
- expect(builtinAction).not.toHaveBeenCalled();
- });
});
describe('Command Execution Logic', () => {
@@ -731,7 +655,7 @@ describe('useSlashCommandProcessor', () => {
content: [{ text: 'The actual prompt from the TOML file.' }],
}),
},
- CommandKind.FILE,
+ CommandKind.USER_FILE,
);
const result = await setupProcessorHook({
@@ -866,42 +790,6 @@ describe('useSlashCommandProcessor', () => {
});
describe('Command Precedence', () => {
- it('should override mcp-based commands with file-based commands of the same name', async () => {
- const mcpAction = vi.fn();
- const fileAction = vi.fn();
-
- const mcpCommand = createTestCommand(
- {
- name: 'override',
- description: 'mcp',
- action: mcpAction,
- },
- CommandKind.MCP_PROMPT,
- );
- const fileCommand = createTestCommand(
- { name: 'override', description: 'file', action: fileAction },
- CommandKind.FILE,
- );
-
- const result = await setupProcessorHook({
- fileCommands: [fileCommand],
- mcpCommands: [mcpCommand],
- });
-
- await waitFor(() => {
- // The service should only return one command with the name 'override'
- expect(result.current.slashCommands).toHaveLength(1);
- });
-
- await act(async () => {
- await result.current.handleSlashCommand('/override');
- });
-
- // Only the file-based command's action should be called.
- expect(fileAction).toHaveBeenCalledTimes(1);
- expect(mcpAction).not.toHaveBeenCalled();
- });
-
it('should prioritize a command with a primary name over a command with a matching alias', async () => {
const quitAction = vi.fn();
const exitAction = vi.fn();
@@ -917,7 +805,7 @@ describe('useSlashCommandProcessor', () => {
name: 'exit',
action: exitAction,
},
- CommandKind.FILE,
+ CommandKind.USER_FILE,
);
// The order of commands in the final loaded array is not guaranteed,
@@ -949,7 +837,7 @@ describe('useSlashCommandProcessor', () => {
});
const exitCommand = createTestCommand(
{ name: 'exit', action: vi.fn() },
- CommandKind.FILE,
+ CommandKind.USER_FILE,
);
const result = await setupProcessorHook({
@@ -1106,119 +994,4 @@ describe('useSlashCommandProcessor', () => {
expect(result.current.slashCommands).toEqual([newCommand]),
);
});
-
- describe('Conflict Notifications', () => {
- it('should display a warning when a command conflict occurs', async () => {
- const builtinCommand = createTestCommand({ name: 'deploy' });
- const extensionCommand = createTestCommand(
- {
- name: 'deploy',
- extensionName: 'firebase',
- },
- CommandKind.FILE,
- );
-
- const result = await setupProcessorHook({
- builtinCommands: [builtinCommand],
- fileCommands: [extensionCommand],
- });
-
- await waitFor(() => expect(result.current.slashCommands).toHaveLength(2));
-
- expect(mockAddItem).toHaveBeenCalledWith(
- expect.objectContaining({
- type: MessageType.INFO,
- text: expect.stringContaining('Command conflicts detected'),
- }),
- expect.any(Number),
- );
-
- expect(mockAddItem).toHaveBeenCalledWith(
- expect.objectContaining({
- type: MessageType.INFO,
- text: expect.stringContaining(
- "- Command '/deploy' from extension 'firebase' was renamed",
- ),
- }),
- expect.any(Number),
- );
- });
-
- it('should deduplicate conflict warnings across re-renders', async () => {
- const builtinCommand = createTestCommand({ name: 'deploy' });
- const extensionCommand = createTestCommand(
- {
- name: 'deploy',
- extensionName: 'firebase',
- },
- CommandKind.FILE,
- );
-
- const result = await setupProcessorHook({
- builtinCommands: [builtinCommand],
- fileCommands: [extensionCommand],
- });
-
- await waitFor(() => expect(result.current.slashCommands).toHaveLength(2));
-
- // First notification
- expect(mockAddItem).toHaveBeenCalledWith(
- expect.objectContaining({
- type: MessageType.INFO,
- text: expect.stringContaining('Command conflicts detected'),
- }),
- expect.any(Number),
- );
-
- mockAddItem.mockClear();
-
- // Trigger a reload or re-render
- await act(async () => {
- result.current.commandContext.ui.reloadCommands();
- });
-
- // Wait a bit for effect to run
- await new Promise((resolve) => setTimeout(resolve, 100));
-
- // Should NOT have notified again
- expect(mockAddItem).not.toHaveBeenCalledWith(
- expect.objectContaining({
- type: MessageType.INFO,
- text: expect.stringContaining('Command conflicts detected'),
- }),
- expect.any(Number),
- );
- });
-
- it('should correctly identify the winner extension in the message', async () => {
- const ext1Command = createTestCommand(
- {
- name: 'deploy',
- extensionName: 'firebase',
- },
- CommandKind.FILE,
- );
- const ext2Command = createTestCommand(
- {
- name: 'deploy',
- extensionName: 'aws',
- },
- CommandKind.FILE,
- );
-
- const result = await setupProcessorHook({
- fileCommands: [ext1Command, ext2Command],
- });
-
- await waitFor(() => expect(result.current.slashCommands).toHaveLength(2));
-
- expect(mockAddItem).toHaveBeenCalledWith(
- expect.objectContaining({
- type: MessageType.INFO,
- text: expect.stringContaining("conflicts with extension 'firebase'"),
- }),
- expect.any(Number),
- );
- });
- });
});
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index c3f178ad1b..20a76dcf43 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -502,7 +502,9 @@ export const useSlashCommandProcessor = (
const props = result.props as Record;
if (
!props ||
+ // eslint-disable-next-line no-restricted-syntax
typeof props['name'] !== 'string' ||
+ // eslint-disable-next-line no-restricted-syntax
typeof props['displayName'] !== 'string' ||
!props['definition']
) {
diff --git a/packages/cli/src/ui/hooks/toolMapping.test.ts b/packages/cli/src/ui/hooks/toolMapping.test.ts
index 3b4e942357..e949a8575c 100644
--- a/packages/cli/src/ui/hooks/toolMapping.test.ts
+++ b/packages/cli/src/ui/hooks/toolMapping.test.ts
@@ -325,7 +325,6 @@ describe('toolMapping', () => {
const result = mapToDisplay(toolCall);
expect(result.tools[0].originalRequestName).toBe('original_tool');
});
-
it('propagates isClientInitiated from tool request', () => {
const clientInitiatedTool: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts
index db9df81566..e06ebf5bb5 100644
--- a/packages/cli/src/ui/hooks/toolMapping.ts
+++ b/packages/cli/src/ui/hooks/toolMapping.ts
@@ -48,6 +48,7 @@ export function mapToDisplay(
const baseDisplayProperties = {
callId: call.request.callId,
+ parentCallId: call.request.parentCallId,
name: displayName,
description,
renderOutputAsMarkdown,
@@ -102,6 +103,7 @@ export function mapToDisplay(
...baseDisplayProperties,
status: call.status,
isClientInitiated: !!call.request.isClientInitiated,
+ kind: call.tool?.kind,
resultDisplay,
confirmationDetails,
outputFile,
diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts
index 08ddd362f7..10d36ae01f 100644
--- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts
+++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts
@@ -86,7 +86,7 @@ describe('useApprovalModeIndicator', () => {
(value: ApprovalMode) => void
>,
isYoloModeDisabled: vi.fn().mockReturnValue(false),
- isPlanEnabled: vi.fn().mockReturnValue(false),
+ isPlanEnabled: vi.fn().mockReturnValue(true),
isTrustedFolder: vi.fn().mockReturnValue(true) as Mock<() => boolean>,
getCoreTools: vi.fn().mockReturnValue([]) as Mock<() => string[]>,
getToolDiscoveryCommand: vi.fn().mockReturnValue(undefined) as Mock<
diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts
index 1b5076027f..84e465106f 100644
--- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts
+++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts
@@ -11,7 +11,8 @@ import {
getAdminErrorMessage,
} from '@google/gemini-cli-core';
import { useKeypress } from './useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from './useKeyMatchers.js';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
@@ -30,6 +31,7 @@ export function useApprovalModeIndicator({
isActive = true,
allowPlanMode = false,
}: UseApprovalModeIndicatorArgs): ApprovalMode {
+ const keyMatchers = useKeyMatchers();
const currentConfigValue = config.getApprovalMode();
const [showApprovalMode, setApprovalMode] = useState(currentConfigValue);
diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts
index 02eb4c47f8..03e9383833 100644
--- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts
@@ -120,8 +120,8 @@ describe('useAtCompletion', () => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'src/',
- 'src/components/',
'src/index.js',
+ 'src/components/',
'src/components/Button.tsx',
]);
});
diff --git a/packages/cli/src/ui/hooks/useBanner.test.ts b/packages/cli/src/ui/hooks/useBanner.test.ts
index 1d876c078c..cb5712bec4 100644
--- a/packages/cli/src/ui/hooks/useBanner.test.ts
+++ b/packages/cli/src/ui/hooks/useBanner.test.ts
@@ -29,6 +29,9 @@ vi.mock('../semantic-colors.js', () => ({
status: {
warning: 'mock-warning-color',
},
+ ui: {
+ focus: 'mock-focus-color',
+ },
},
}));
diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx
index 8f91013070..52f3889634 100644
--- a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx
+++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx
@@ -46,6 +46,7 @@ vi.mock('./useShellCompletion', () => ({
completionStart: 0,
completionEnd: 0,
query: '',
+ activeStart: 0,
})),
}));
@@ -57,7 +58,12 @@ const setupMocks = ({
isLoading = false,
isPerfectMatch = false,
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
- shellCompletionRange = { completionStart: 0, completionEnd: 0, query: '' },
+ shellCompletionRange = {
+ completionStart: 0,
+ completionEnd: 0,
+ query: '',
+ activeStart: 0,
+ },
}: {
atSuggestions?: Suggestion[];
slashSuggestions?: Suggestion[];
@@ -69,6 +75,7 @@ const setupMocks = ({
completionStart: number;
completionEnd: number;
query: string;
+ activeStart?: number;
};
}) => {
// Mock for @-completions
@@ -116,7 +123,10 @@ const setupMocks = ({
setSuggestions(shellSuggestions);
}
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
- return shellCompletionRange;
+ return {
+ ...shellCompletionRange,
+ activeStart: shellCompletionRange.activeStart ?? 0,
+ };
},
);
};
@@ -139,38 +149,57 @@ describe('useCommandCompletion', () => {
});
}
+ let hookResult: ReturnType & {
+ textBuffer: ReturnType;
+ };
+
+ function TestComponent({
+ initialText,
+ cursorOffset,
+ shellModeActive,
+ active,
+ }: {
+ initialText: string;
+ cursorOffset?: number;
+ shellModeActive: boolean;
+ active: boolean;
+ }) {
+ const textBuffer = useTextBufferForTest(initialText, cursorOffset);
+ const completion = useCommandCompletion({
+ buffer: textBuffer,
+ cwd: testRootDir,
+ slashCommands: [],
+ commandContext: mockCommandContext,
+ reverseSearchActive: false,
+ shellModeActive,
+ config: mockConfig,
+ active,
+ });
+ hookResult = { ...completion, textBuffer };
+ return null;
+ }
+
const renderCommandCompletionHook = (
initialText: string,
cursorOffset?: number,
shellModeActive = false,
active = true,
) => {
- let hookResult: ReturnType & {
- textBuffer: ReturnType;
- };
-
- function TestComponent() {
- const textBuffer = useTextBufferForTest(initialText, cursorOffset);
- const completion = useCommandCompletion({
- buffer: textBuffer,
- cwd: testRootDir,
- slashCommands: [],
- commandContext: mockCommandContext,
- reverseSearchActive: false,
- shellModeActive,
- config: mockConfig,
- active,
- });
- hookResult = { ...completion, textBuffer };
- return null;
- }
- renderWithProviders();
+ const renderResult = renderWithProviders(
+ ,
+ );
return {
result: {
get current() {
return hookResult;
},
},
+ ...renderResult,
};
};
@@ -464,6 +493,31 @@ describe('useCommandCompletion', () => {
expect(result.current.textBuffer.text).toBe('@src/file1.txt ');
});
+ it('should insert canonical slash command text when suggestion provides insertValue', async () => {
+ setupMocks({
+ slashSuggestions: [
+ {
+ label: 'list',
+ value: 'list',
+ insertValue: 'resume list',
+ },
+ ],
+ slashCompletionRange: { completionStart: 1, completionEnd: 5 },
+ });
+
+ const { result } = renderCommandCompletionHook('/resu');
+
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBe(1);
+ });
+
+ act(() => {
+ result.current.handleAutocomplete(0);
+ });
+
+ expect(result.current.textBuffer.text).toBe('/resume list ');
+ });
+
it('should complete a file path when cursor is not at the end of the line', async () => {
const text = '@src/fi is a good file';
const cursorOffset = 7; // after "i"
@@ -524,6 +578,129 @@ describe('useCommandCompletion', () => {
expect(result.current.textBuffer.text).toBe('@src\\components\\');
});
+
+ it('should show ghost text for a single shell completion', async () => {
+ const text = 'l';
+ setupMocks({
+ shellSuggestions: [{ label: 'ls', value: 'ls' }],
+ shellCompletionRange: {
+ completionStart: 0,
+ completionEnd: 1,
+ query: 'l',
+ activeStart: 0,
+ },
+ });
+
+ const { result } = renderCommandCompletionHook(
+ text,
+ text.length,
+ true, // shellModeActive
+ );
+
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ // Should show "ls " as ghost text (including trailing space)
+ expect(result.current.promptCompletion.text).toBe('ls ');
+ });
+
+ it('should not show ghost text if there are multiple completions', async () => {
+ const text = 'l';
+ setupMocks({
+ shellSuggestions: [
+ { label: 'ls', value: 'ls' },
+ { label: 'ln', value: 'ln' },
+ ],
+ shellCompletionRange: {
+ completionStart: 0,
+ completionEnd: 1,
+ query: 'l',
+ activeStart: 0,
+ },
+ });
+
+ const { result } = renderCommandCompletionHook(
+ text,
+ text.length,
+ true, // shellModeActive
+ );
+
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ expect(result.current.promptCompletion.text).toBe('');
+ });
+
+ it('should not show ghost text if the typed text extends past the completion', async () => {
+ // "ls " is already typed.
+ const text = 'ls ';
+ const cursorOffset = text.length;
+
+ const { result } = renderCommandCompletionHook(
+ text,
+ cursorOffset,
+ true, // shellModeActive
+ );
+
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ expect(result.current.promptCompletion.text).toBe('');
+ });
+
+ it('should clear ghost text after user types a space when exact match ghost text was showing', async () => {
+ const textWithoutSpace = 'ls';
+
+ setupMocks({
+ shellSuggestions: [{ label: 'ls', value: 'ls' }],
+ shellCompletionRange: {
+ completionStart: 0,
+ completionEnd: 2,
+ query: 'ls',
+ activeStart: 0,
+ },
+ });
+
+ const { result } = renderCommandCompletionHook(
+ textWithoutSpace,
+ textWithoutSpace.length,
+ true, // shellModeActive
+ );
+
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ // Initially no ghost text because "ls" perfectly matches "ls"
+ expect(result.current.promptCompletion.text).toBe('');
+
+ // Now simulate typing a space.
+ // In the real app, shellCompletionRange.completionStart would change immediately to 3,
+ // but suggestions (and activeStart) would still be from the previous token for a few ms.
+ setupMocks({
+ shellSuggestions: [{ label: 'ls', value: 'ls' }], // Stale suggestions
+ shellCompletionRange: {
+ completionStart: 3, // New token position
+ completionEnd: 3,
+ query: '',
+ activeStart: 0, // Stale active start
+ },
+ });
+
+ act(() => {
+ result.current.textBuffer.setText('ls ', 'end');
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ // Should STILL be empty because completionStart (3) !== activeStart (0)
+ expect(result.current.promptCompletion.text).toBe('');
+ });
});
describe('prompt completion filtering', () => {
diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx
index f9b772bc93..b803f7ed98 100644
--- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx
+++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useCallback, useMemo, useEffect } from 'react';
+import { useCallback, useMemo, useEffect, useState } from 'react';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
@@ -37,6 +37,9 @@ export interface UseCommandCompletionReturn {
showSuggestions: boolean;
isLoadingSuggestions: boolean;
isPerfectMatch: boolean;
+ forceShowShellSuggestions: boolean;
+ setForceShowShellSuggestions: (value: boolean) => void;
+ isShellSuggestionsVisible: boolean;
setActiveSuggestionIndex: React.Dispatch>;
resetCompletionState: () => void;
navigateUp: () => void;
@@ -80,6 +83,9 @@ export function useCommandCompletion({
config,
active,
}: UseCommandCompletionOptions): UseCommandCompletionReturn {
+ const [forceShowShellSuggestions, setForceShowShellSuggestions] =
+ useState(false);
+
const {
suggestions,
activeSuggestionIndex,
@@ -93,11 +99,16 @@ export function useCommandCompletion({
setIsPerfectMatch,
setVisibleStartIndex,
- resetCompletionState,
+ resetCompletionState: baseResetCompletionState,
navigateUp,
navigateDown,
} = useCompletion();
+ const resetCompletionState = useCallback(() => {
+ baseResetCompletionState();
+ setForceShowShellSuggestions(false);
+ }, [baseResetCompletionState]);
+
const cursorRow = buffer.cursor[0];
const cursorCol = buffer.cursor[1];
@@ -231,10 +242,73 @@ export function useCommandCompletion({
? shellCompletionRange.query
: memoQuery;
- const promptCompletion = usePromptCompletion({
+ const basePromptCompletion = usePromptCompletion({
buffer,
});
+ const isShellSuggestionsVisible =
+ completionMode !== CompletionMode.SHELL || forceShowShellSuggestions;
+
+ const promptCompletion = useMemo(() => {
+ if (
+ completionMode === CompletionMode.SHELL &&
+ suggestions.length === 1 &&
+ query != null &&
+ shellCompletionRange.completionStart === shellCompletionRange.activeStart
+ ) {
+ const suggestion = suggestions[0];
+ const textToInsertBase = suggestion.value;
+
+ if (
+ textToInsertBase.startsWith(query) &&
+ textToInsertBase.length > query.length
+ ) {
+ const currentLine = buffer.lines[cursorRow] || '';
+ const start = shellCompletionRange.completionStart;
+ const end = shellCompletionRange.completionEnd;
+
+ let textToInsert = textToInsertBase;
+ const charAfterCompletion = currentLine[end];
+ if (
+ charAfterCompletion !== ' ' &&
+ !textToInsert.endsWith('/') &&
+ !textToInsert.endsWith('\\')
+ ) {
+ textToInsert += ' ';
+ }
+
+ const newText =
+ currentLine.substring(0, start) +
+ textToInsert +
+ currentLine.substring(end);
+
+ return {
+ text: newText,
+ isActive: true,
+ isLoading: false,
+ accept: () => {
+ buffer.replaceRangeByOffset(
+ logicalPosToOffset(buffer.lines, cursorRow, start),
+ logicalPosToOffset(buffer.lines, cursorRow, end),
+ textToInsert,
+ );
+ },
+ clear: () => {},
+ markSelected: () => {},
+ };
+ }
+ }
+ return basePromptCompletion;
+ }, [
+ completionMode,
+ suggestions,
+ query,
+ basePromptCompletion,
+ buffer,
+ cursorRow,
+ shellCompletionRange,
+ ]);
+
useEffect(() => {
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
setVisibleStartIndex(0);
@@ -271,6 +345,7 @@ export function useCommandCompletion({
active &&
completionMode !== CompletionMode.IDLE &&
!reverseSearchActive &&
+ isShellSuggestionsVisible &&
(isLoadingSuggestions || suggestions.length > 0);
/**
@@ -299,7 +374,7 @@ export function useCommandCompletion({
}
// Apply space padding for slash commands (needed for subcommands like "/chat list")
- let suggestionText = suggestion.value;
+ let suggestionText = suggestion.insertValue ?? suggestion.value;
if (completionMode === CompletionMode.SLASH) {
// Add leading space if completing a subcommand (cursor is after parent command with no space)
if (start === end && start > 1 && currentLine[start - 1] !== ' ') {
@@ -348,7 +423,7 @@ export function useCommandCompletion({
}
// Add space padding for Tab completion (auto-execute gets padding from getCompletedText)
- let suggestionText = suggestion.value;
+ let suggestionText = suggestion.insertValue ?? suggestion.value;
if (completionMode === CompletionMode.SLASH) {
if (
start === end &&
@@ -395,6 +470,9 @@ export function useCommandCompletion({
showSuggestions,
isLoadingSuggestions,
isPerfectMatch,
+ forceShowShellSuggestions,
+ setForceShowShellSuggestions,
+ isShellSuggestionsVisible,
setActiveSuggestionIndex,
resetCompletionState,
navigateUp,
diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx b/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx
index a558686bd8..95212b023c 100644
--- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx
+++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx
@@ -24,7 +24,10 @@ import {
} from '../../config/extensions/update.js';
import { ExtensionUpdateState } from '../state/extensions.js';
import { ExtensionManager } from '../../config/extension-manager.js';
-import { loadSettings } from '../../config/settings.js';
+import {
+ loadSettings,
+ resetSettingsCacheForTesting,
+} from '../../config/settings.js';
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal();
@@ -59,6 +62,7 @@ describe('useExtensionUpdates', () => {
let extensionManager: ExtensionManager;
beforeEach(() => {
+ resetSettingsCacheForTesting();
vi.mocked(loadAgentsFromDirectory).mockResolvedValue({
agents: [],
errors: [],
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index 5621ebd646..2020dc4785 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -57,6 +57,7 @@ import { MessageType, StreamingState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
+import { theme } from '../semantic-colors.js';
// --- MOCKS ---
const mockSendMessageStream = vi
@@ -277,6 +278,7 @@ describe('useGeminiStream', () => {
addHistory: vi.fn(),
getSessionId: vi.fn(() => 'test-session-id'),
setQuotaErrorOccurred: vi.fn(),
+ resetBillingTurnState: vi.fn(),
getQuotaErrorOccurred: vi.fn(() => false),
getModel: vi.fn(() => 'gemini-2.5-pro'),
getContentGeneratorConfig: vi.fn(() => ({
@@ -813,14 +815,6 @@ describe('useGeminiStream', () => {
expect(injectedHintPart.text).toContain(
'Do not cancel/skip tasks unless the user explicitly cancels them.',
);
- expect(
- mockAddItem.mock.calls.some(
- ([item]) =>
- item?.type === 'info' &&
- typeof item.text === 'string' &&
- item.text.includes('Got it. Focusing on tests only.'),
- ),
- ).toBe(true);
expect(mockRunInDevTraceSpan).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1064,9 +1058,9 @@ describe('useGeminiStream', () => {
);
expect(noteIndex).toBeGreaterThanOrEqual(0);
expect(stopIndex).toBeGreaterThanOrEqual(0);
- expect(failureHintIndex).toBeGreaterThanOrEqual(0);
+ // The failure hint should NOT be present if the suppressed error note was shown
+ expect(failureHintIndex).toBe(-1);
expect(noteIndex).toBeLessThan(stopIndex);
- expect(stopIndex).toBeLessThan(failureHintIndex);
});
it('should group multiple cancelled tool call responses into a single history entry', async () => {
@@ -2307,14 +2301,14 @@ describe('useGeminiStream', () => {
requestTokens: 20,
remainingTokens: 80,
expectedMessage:
- 'Sending this message (20 tokens) might exceed the remaining context window limit (80 tokens).',
+ 'Sending this message (20 tokens) might exceed the context window limit (80 tokens left).',
},
{
name: 'with suggestion when remaining tokens are < 75% of limit',
requestTokens: 30,
remainingTokens: 70,
expectedMessage:
- 'Sending this message (30 tokens) might exceed the remaining context window limit (70 tokens). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
+ 'Sending this message (30 tokens) might exceed the context window limit (70 tokens left). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
},
])(
'should add message $name',
@@ -2395,6 +2389,43 @@ describe('useGeminiStream', () => {
});
});
+ it('should add informational messages when ChatCompressed event is received', async () => {
+ vi.mocked(tokenLimit).mockReturnValue(10000);
+ // Setup mock to return a stream with ChatCompressed event
+ mockSendMessageStream.mockReturnValue(
+ (async function* () {
+ yield {
+ type: ServerGeminiEventType.ChatCompressed,
+ value: {
+ originalTokenCount: 1000,
+ newTokenCount: 500,
+ compressionStatus: 'compressed',
+ },
+ };
+ })(),
+ );
+
+ const { result } = renderHookWithDefaults();
+
+ // Submit a query
+ await act(async () => {
+ await result.current.submitQuery('Test compression');
+ });
+
+ // Check that the succinct info message was added
+ await waitFor(() => {
+ expect(mockAddItem).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: MessageType.INFO,
+ text: 'Context compressed from 10% to 5%.',
+ secondaryText: 'Change threshold in /settings.',
+ color: theme.status.warning,
+ }),
+ expect.any(Number),
+ );
+ });
+ });
+
it.each([
{
reason: 'STOP',
@@ -2800,7 +2831,6 @@ describe('useGeminiStream', () => {
type: 'thinking',
thought: expect.objectContaining({ subject: 'Full thought' }),
}),
- expect.any(Number),
);
});
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 36374a5e20..d254902a94 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -35,7 +35,6 @@ import {
CoreEvent,
CoreToolCallStatus,
buildUserSteeringHintPrompt,
- generateSteeringAckMessage,
GeminiCliOperation,
getPlanModeExitMessage,
} from '@google/gemini-cli-core';
@@ -108,9 +107,9 @@ enum StreamProcessingStatus {
}
const SUPPRESSED_TOOL_ERRORS_NOTE =
- 'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
+ 'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for details.';
const LOW_VERBOSITY_FAILURE_NOTE =
- 'This request failed. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
+ 'This request failed. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for full details.';
function isShellToolData(data: unknown): data is ShellToolData {
if (typeof data !== 'object' || data === null) {
@@ -597,7 +596,10 @@ export const useGeminiStream = (
if (!isLowErrorVerbosity || config.getDebugMode()) {
return;
}
- if (lowVerbosityFailureNoteShownRef.current) {
+ if (
+ lowVerbosityFailureNoteShownRef.current ||
+ suppressedToolErrorNoteShownRef.current
+ ) {
return;
}
@@ -903,17 +905,14 @@ export const useGeminiStream = (
);
const handleThoughtEvent = useCallback(
- (eventValue: ThoughtSummary, userMessageTimestamp: number) => {
+ (eventValue: ThoughtSummary, _userMessageTimestamp: number) => {
setThought(eventValue);
if (getInlineThinkingMode(settings) === 'full') {
- addItem(
- {
- type: 'thinking',
- thought: eventValue,
- } as HistoryItemThinking,
- userMessageTimestamp,
- );
+ addItem({
+ type: 'thinking',
+ thought: eventValue,
+ } as HistoryItemThinking);
}
},
[addItem, settings, setThought],
@@ -1065,16 +1064,27 @@ export const useGeminiStream = (
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
- return addItem({
- type: 'info',
- text:
- `IMPORTANT: This conversation exceeded the compress threshold. ` +
- `A compressed context will be sent for future messages (compressed from: ` +
- `${eventValue?.originalTokenCount ?? 'unknown'} to ` +
- `${eventValue?.newTokenCount ?? 'unknown'} tokens).`,
- });
+
+ const limit = tokenLimit(config.getModel());
+ const originalPercentage = Math.round(
+ ((eventValue?.originalTokenCount ?? 0) / limit) * 100,
+ );
+ const newPercentage = Math.round(
+ ((eventValue?.newTokenCount ?? 0) / limit) * 100,
+ );
+
+ addItem(
+ {
+ type: MessageType.INFO,
+ text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
+ secondaryText: `Change threshold in /settings.`,
+ color: theme.status.warning,
+ marginBottom: 1,
+ } as HistoryItemInfo,
+ userMessageTimestamp,
+ );
},
- [addItem, pendingHistoryItemRef, setPendingHistoryItem],
+ [addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
);
const handleMaxSessionTurnsEvent = useCallback(
@@ -1094,12 +1104,12 @@ export const useGeminiStream = (
const limit = tokenLimit(config.getModel());
- const isLessThan75Percent =
+ const isMoreThan25PercentUsed =
limit > 0 && remainingTokenCount < limit * 0.75;
- let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the remaining context window limit (${remainingTokenCount} tokens).`;
+ let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the context window limit (${remainingTokenCount.toLocaleString()} tokens left).`;
- if (isLessThan75Percent) {
+ if (isMoreThan25PercentUsed) {
text +=
' Please try reducing the size of your message or use the `/compress` command to compress the chat history.';
}
@@ -1363,6 +1373,9 @@ export const useGeminiStream = (
if (!options?.isContinuation) {
setModelSwitchedFromQuotaError(false);
config.setQuotaErrorOccurred(false);
+ config.resetBillingTurnState(
+ settings.merged.billing?.overageStrategy,
+ );
suppressedToolErrorCountRef.current = 0;
suppressedToolErrorNoteShownRef.current = false;
lowVerbosityFailureNoteShownRef.current = false;
@@ -1523,6 +1536,7 @@ export const useGeminiStream = (
setThought,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
+ settings.merged.billing?.overageStrategy,
],
);
@@ -1750,18 +1764,6 @@ export const useGeminiStream = (
responsesToSend.unshift({
text: buildUserSteeringHintPrompt(hintText),
});
- void generateSteeringAckMessage(
- config.getBaseLlmClient(),
- hintText,
- ).then((ackText) => {
- addItem({
- type: 'info',
- icon: '· ',
- color: theme.text.secondary,
- marginBottom: 1,
- text: ackText,
- } as HistoryItemInfo);
- });
}
}
@@ -1798,7 +1800,6 @@ export const useGeminiStream = (
addItem,
registerBackgroundShell,
consumeUserHint,
- config,
isLowErrorVerbosity,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
diff --git a/packages/cli/src/ui/hooks/useInlineEditBuffer.test.ts b/packages/cli/src/ui/hooks/useInlineEditBuffer.test.ts
new file mode 100644
index 0000000000..b22ee62c81
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useInlineEditBuffer.test.ts
@@ -0,0 +1,158 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderHook } from '../../test-utils/render.js';
+import { act } from 'react';
+import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
+import { useInlineEditBuffer } from './useInlineEditBuffer.js';
+
+describe('useEditBuffer', () => {
+ let mockOnCommit: Mock;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockOnCommit = vi.fn();
+ });
+
+ it('should initialize with empty state', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ expect(result.current.editState.editingKey).toBeNull();
+ expect(result.current.editState.buffer).toBe('');
+ expect(result.current.editState.cursorPos).toBe(0);
+ });
+
+ it('should start editing correctly', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('my-key', 'initial'));
+
+ expect(result.current.editState.editingKey).toBe('my-key');
+ expect(result.current.editState.buffer).toBe('initial');
+ expect(result.current.editState.cursorPos).toBe(7); // End of string
+ });
+
+ it('should commit edit and reset state', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+
+ act(() => result.current.startEditing('my-key', 'text'));
+ act(() => result.current.commitEdit());
+
+ expect(mockOnCommit).toHaveBeenCalledWith('my-key', 'text');
+ expect(result.current.editState.editingKey).toBeNull();
+ expect(result.current.editState.buffer).toBe('');
+ });
+
+ it('should move cursor left and right', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('key', 'ab')); // cursor at 2
+
+ act(() => result.current.editDispatch({ type: 'MOVE_LEFT' }));
+ expect(result.current.editState.cursorPos).toBe(1);
+
+ act(() => result.current.editDispatch({ type: 'MOVE_LEFT' }));
+ expect(result.current.editState.cursorPos).toBe(0);
+
+ // Shouldn't go below 0
+ act(() => result.current.editDispatch({ type: 'MOVE_LEFT' }));
+ expect(result.current.editState.cursorPos).toBe(0);
+
+ act(() => result.current.editDispatch({ type: 'MOVE_RIGHT' }));
+ expect(result.current.editState.cursorPos).toBe(1);
+ });
+
+ it('should handle home and end', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('key', 'testing')); // cursor at 7
+
+ act(() => result.current.editDispatch({ type: 'HOME' }));
+ expect(result.current.editState.cursorPos).toBe(0);
+
+ act(() => result.current.editDispatch({ type: 'END' }));
+ expect(result.current.editState.cursorPos).toBe(7);
+ });
+
+ it('should delete characters to the left (backspace)', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('key', 'abc')); // cursor at 3
+
+ act(() => result.current.editDispatch({ type: 'DELETE_LEFT' }));
+ expect(result.current.editState.buffer).toBe('ab');
+ expect(result.current.editState.cursorPos).toBe(2);
+
+ // Move to start, shouldn't delete
+ act(() => result.current.editDispatch({ type: 'HOME' }));
+ act(() => result.current.editDispatch({ type: 'DELETE_LEFT' }));
+ expect(result.current.editState.buffer).toBe('ab');
+ });
+
+ it('should delete characters to the right (delete tab)', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('key', 'abc'));
+ act(() => result.current.editDispatch({ type: 'HOME' })); // cursor at 0
+
+ act(() => result.current.editDispatch({ type: 'DELETE_RIGHT' }));
+ expect(result.current.editState.buffer).toBe('bc');
+ expect(result.current.editState.cursorPos).toBe(0);
+ });
+
+ it('should insert valid characters into string', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('key', 'ab'));
+ act(() => result.current.editDispatch({ type: 'MOVE_LEFT' })); // cursor at 1
+
+ act(() =>
+ result.current.editDispatch({
+ type: 'INSERT_CHAR',
+ char: 'x',
+ isNumberType: false,
+ }),
+ );
+ expect(result.current.editState.buffer).toBe('axb');
+ expect(result.current.editState.cursorPos).toBe(2);
+ });
+
+ it('should validate number character insertions', () => {
+ const { result } = renderHook(() =>
+ useInlineEditBuffer({ onCommit: mockOnCommit }),
+ );
+ act(() => result.current.startEditing('key', '12'));
+
+ // Valid number char
+ act(() =>
+ result.current.editDispatch({
+ type: 'INSERT_CHAR',
+ char: '.',
+ isNumberType: true,
+ }),
+ );
+ expect(result.current.editState.buffer).toBe('12.');
+
+ // Invalid number char
+ act(() =>
+ result.current.editDispatch({
+ type: 'INSERT_CHAR',
+ char: 'a',
+ isNumberType: true,
+ }),
+ );
+ expect(result.current.editState.buffer).toBe('12.'); // Unchanged
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useInlineEditBuffer.ts b/packages/cli/src/ui/hooks/useInlineEditBuffer.ts
new file mode 100644
index 0000000000..c3dbb05016
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useInlineEditBuffer.ts
@@ -0,0 +1,152 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useReducer, useCallback, useEffect, useState } from 'react';
+import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
+
+export interface EditBufferState {
+ editingKey: string | null;
+ buffer: string;
+ cursorPos: number;
+}
+
+export type EditBufferAction =
+ | { type: 'START_EDIT'; key: string; initialValue: string }
+ | { type: 'COMMIT_EDIT' }
+ | { type: 'MOVE_LEFT' }
+ | { type: 'MOVE_RIGHT' }
+ | { type: 'HOME' }
+ | { type: 'END' }
+ | { type: 'DELETE_LEFT' }
+ | { type: 'DELETE_RIGHT' }
+ | { type: 'INSERT_CHAR'; char: string; isNumberType: boolean };
+
+const initialState: EditBufferState = {
+ editingKey: null,
+ buffer: '',
+ cursorPos: 0,
+};
+
+function editBufferReducer(
+ state: EditBufferState,
+ action: EditBufferAction,
+): EditBufferState {
+ switch (action.type) {
+ case 'START_EDIT':
+ return {
+ editingKey: action.key,
+ buffer: action.initialValue,
+ cursorPos: cpLen(action.initialValue),
+ };
+
+ case 'COMMIT_EDIT':
+ return initialState;
+
+ case 'MOVE_LEFT':
+ return {
+ ...state,
+ cursorPos: Math.max(0, state.cursorPos - 1),
+ };
+
+ case 'MOVE_RIGHT':
+ return {
+ ...state,
+ cursorPos: Math.min(cpLen(state.buffer), state.cursorPos + 1),
+ };
+
+ case 'HOME':
+ return { ...state, cursorPos: 0 };
+
+ case 'END':
+ return { ...state, cursorPos: cpLen(state.buffer) };
+
+ case 'DELETE_LEFT': {
+ if (state.cursorPos === 0) return state;
+ const before = cpSlice(state.buffer, 0, state.cursorPos - 1);
+ const after = cpSlice(state.buffer, state.cursorPos);
+ return {
+ ...state,
+ buffer: before + after,
+ cursorPos: state.cursorPos - 1,
+ };
+ }
+
+ case 'DELETE_RIGHT': {
+ if (state.cursorPos === cpLen(state.buffer)) return state;
+ const before = cpSlice(state.buffer, 0, state.cursorPos);
+ const after = cpSlice(state.buffer, state.cursorPos + 1);
+ return {
+ ...state,
+ buffer: before + after,
+ };
+ }
+
+ case 'INSERT_CHAR': {
+ let ch = action.char;
+ let isValidChar = false;
+
+ if (action.isNumberType) {
+ isValidChar = /[0-9\-+.]/.test(ch);
+ } else {
+ isValidChar = ch.length === 1 && ch.charCodeAt(0) >= 32;
+ ch = stripUnsafeCharacters(ch);
+ }
+
+ if (!isValidChar || ch.length === 0) return state;
+
+ const before = cpSlice(state.buffer, 0, state.cursorPos);
+ const after = cpSlice(state.buffer, state.cursorPos);
+ return {
+ ...state,
+ buffer: before + ch + after,
+ cursorPos: state.cursorPos + 1,
+ };
+ }
+
+ default:
+ return state;
+ }
+}
+
+export interface UseEditBufferProps {
+ onCommit: (key: string, value: string) => void;
+}
+
+export function useInlineEditBuffer({ onCommit }: UseEditBufferProps) {
+ const [state, dispatch] = useReducer(editBufferReducer, initialState);
+ const [cursorVisible, setCursorVisible] = useState(true);
+
+ useEffect(() => {
+ if (!state.editingKey) {
+ setCursorVisible(true);
+ return;
+ }
+ setCursorVisible(true);
+ const interval = setInterval(() => {
+ setCursorVisible((v) => !v);
+ }, 500);
+ return () => clearInterval(interval);
+ }, [state.editingKey, state.buffer, state.cursorPos]);
+
+ const startEditing = useCallback((key: string, initialValue: string) => {
+ dispatch({ type: 'START_EDIT', key, initialValue });
+ }, []);
+
+ const commitEdit = useCallback(() => {
+ if (state.editingKey) {
+ onCommit(state.editingKey, state.buffer);
+ }
+ dispatch({ type: 'COMMIT_EDIT' });
+ }, [state.editingKey, state.buffer, onCommit]);
+
+ return {
+ editState: state,
+ editDispatch: dispatch,
+ startEditing,
+ commitEdit,
+ cursorVisible,
+ };
+}
diff --git a/packages/cli/src/ui/hooks/useKeyMatchers.ts b/packages/cli/src/ui/hooks/useKeyMatchers.ts
new file mode 100644
index 0000000000..a42a066ee0
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useKeyMatchers.ts
@@ -0,0 +1,17 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useMemo } from 'react';
+import type { KeyMatchers } from '../keyMatchers.js';
+import { defaultKeyMatchers } from '../keyMatchers.js';
+
+/**
+ * Hook to retrieve the currently active key matchers.
+ * This prepares the codebase for dynamic or custom key bindings in the future.
+ */
+export function useKeyMatchers(): KeyMatchers {
+ return useMemo(() => defaultKeyMatchers, []);
+}
diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx
index e0ae9b5f20..ae5e20e0e8 100644
--- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx
+++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx
@@ -49,7 +49,7 @@ describe('useLoadingIndicator', () => {
shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode;
- errorVerbosity?: 'low' | 'full';
+ errorVerbosity: 'low' | 'full';
}) {
hookResult = useLoadingIndicator({
streamingState,
diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts
index ee46589d12..4f7b631844 100644
--- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts
+++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts
@@ -22,7 +22,7 @@ export interface UseLoadingIndicatorProps {
retryStatus: RetryAttemptPayload | null;
loadingPhrasesMode?: LoadingPhrasesMode;
customWittyPhrases?: string[];
- errorVerbosity?: 'low' | 'full';
+ errorVerbosity: 'low' | 'full';
}
export const useLoadingIndicator = ({
@@ -31,7 +31,7 @@ export const useLoadingIndicator = ({
retryStatus,
loadingPhrasesMode,
customWittyPhrases,
- errorVerbosity = 'full',
+ errorVerbosity,
}: UseLoadingIndicatorProps) => {
const [timerResetKey, setTimerResetKey] = useState(0);
const isTimerActive = streamingState === StreamingState.Responding;
diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts
index 40b1f68926..533eefa676 100644
--- a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts
+++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts
@@ -67,14 +67,6 @@ export function useQuotaAndFallback({
const isDialogPending = useRef(false);
const isValidationPending = useRef(false);
- // Initial overage strategy from settings; runtime value read from config at call time.
- const initialOverageStrategy =
- (settings.merged.billing?.overageStrategy as
- | 'ask'
- | 'always'
- | 'never'
- | undefined) ?? 'ask';
-
// Set up Flash fallback handler
useEffect(() => {
const fallbackHandler: FallbackModelHandler = async (
@@ -109,9 +101,7 @@ export function useQuotaAndFallback({
? getResetTimeMessage(error.retryDelayMs)
: undefined;
- const overageStrategy =
- config.getBillingSettings().overageStrategy ??
- initialOverageStrategy;
+ const overageStrategy = config.getBillingSettings().overageStrategy;
const creditsResult = await handleCreditsFlow({
config,
@@ -209,7 +199,6 @@ export function useQuotaAndFallback({
userTier,
paidTier,
settings,
- initialOverageStrategy,
setModelSwitchedFromQuotaError,
onShowAuthSelection,
errorVerbosity,
diff --git a/packages/cli/src/ui/hooks/useSelectionList.test.tsx b/packages/cli/src/ui/hooks/useSelectionList.test.tsx
index 6b9342d025..4151375280 100644
--- a/packages/cli/src/ui/hooks/useSelectionList.test.tsx
+++ b/packages/cli/src/ui/hooks/useSelectionList.test.tsx
@@ -81,6 +81,8 @@ describe('useSelectionList', () => {
isFocused?: boolean;
showNumbers?: boolean;
wrapAround?: boolean;
+ focusKey?: string;
+ priority?: boolean;
}) => {
let hookResult: ReturnType;
function TestComponent(props: typeof initialProps) {
@@ -771,6 +773,67 @@ describe('useSelectionList', () => {
});
});
+ describe('Programmatic Focus (focusKey)', () => {
+ it('should change the activeIndex when a valid focusKey is provided', async () => {
+ const { result, rerender, waitUntilReady } =
+ await renderSelectionListHook({
+ items,
+ onSelect: mockOnSelect,
+ });
+ expect(result.current.activeIndex).toBe(0);
+
+ await rerender({ focusKey: 'C' });
+ await waitUntilReady();
+ expect(result.current.activeIndex).toBe(2);
+ });
+
+ it('should ignore a focusKey that does not exist', async () => {
+ const { result, rerender, waitUntilReady } =
+ await renderSelectionListHook({
+ items,
+ onSelect: mockOnSelect,
+ });
+ expect(result.current.activeIndex).toBe(0);
+
+ await rerender({ focusKey: 'UNKNOWN' });
+ await waitUntilReady();
+ expect(result.current.activeIndex).toBe(0);
+ });
+
+ it('should ignore a focusKey that points to a disabled item', async () => {
+ const { result, rerender, waitUntilReady } =
+ await renderSelectionListHook({
+ items, // B is disabled
+ onSelect: mockOnSelect,
+ });
+ expect(result.current.activeIndex).toBe(0);
+
+ await rerender({ focusKey: 'B' });
+ await waitUntilReady();
+ expect(result.current.activeIndex).toBe(0);
+ });
+
+ it('should handle clearing the focusKey', async () => {
+ const { result, rerender, waitUntilReady } =
+ await renderSelectionListHook({
+ items,
+ onSelect: mockOnSelect,
+ focusKey: 'C',
+ });
+ expect(result.current.activeIndex).toBe(2);
+
+ await rerender({ focusKey: undefined });
+ await waitUntilReady();
+ // Should remain at 2
+ expect(result.current.activeIndex).toBe(2);
+
+ // We can then change it again to something else
+ await rerender({ focusKey: 'D' });
+ await waitUntilReady();
+ expect(result.current.activeIndex).toBe(3);
+ });
+ });
+
describe('Reactivity (Dynamic Updates)', () => {
it('should update activeIndex when initialIndex prop changes', async () => {
const { result, rerender } = await renderSelectionListHook({
diff --git a/packages/cli/src/ui/hooks/useSelectionList.ts b/packages/cli/src/ui/hooks/useSelectionList.ts
index 80ca40a0ed..9f73c54da4 100644
--- a/packages/cli/src/ui/hooks/useSelectionList.ts
+++ b/packages/cli/src/ui/hooks/useSelectionList.ts
@@ -6,8 +6,9 @@
import { useReducer, useRef, useEffect, useCallback } from 'react';
import { useKeypress, type Key } from './useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
import { debugLogger } from '@google/gemini-cli-core';
+import { useKeyMatchers } from './useKeyMatchers.js';
export interface SelectionListItem {
key: string;
@@ -213,8 +214,7 @@ function selectionListReducer(
case 'INITIALIZE': {
const { initialIndex, items, wrapAround } = action.payload;
const activeKey =
- initialIndex === state.initialIndex &&
- state.activeIndex !== state.initialIndex
+ initialIndex === state.initialIndex
? state.items[state.activeIndex]?.key
: undefined;
@@ -291,6 +291,7 @@ export function useSelectionList({
focusKey,
priority,
}: UseSelectionListOptions): UseSelectionListResult {
+ const keyMatchers = useKeyMatchers();
const baseItems = toBaseItems(items);
const [state, dispatch] = useReducer(selectionListReducer, {
@@ -461,7 +462,7 @@ export function useSelectionList({
}
return false;
},
- [dispatch, itemsLength, showNumbers],
+ [dispatch, itemsLength, showNumbers, keyMatchers],
);
useKeypress(handleKeypress, {
diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts
index ceff3e9c8c..d356def6a9 100644
--- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts
+++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts
@@ -190,6 +190,37 @@ describe('convertSessionToHistoryFormats', () => {
});
});
+ it('should convert thinking tokens (thoughts) to thinking history items', () => {
+ const messages: MessageRecord[] = [
+ {
+ type: 'gemini',
+ content: 'Hi there',
+ thoughts: [
+ {
+ subject: 'Thinking...',
+ description: 'I should say hello.',
+ timestamp: new Date().toISOString(),
+ },
+ ],
+ } as MessageRecord,
+ ];
+
+ const result = convertSessionToHistoryFormats(messages);
+
+ expect(result.uiHistory).toHaveLength(2);
+ expect(result.uiHistory[0]).toMatchObject({
+ type: 'thinking',
+ thought: {
+ subject: 'Thinking...',
+ description: 'I should say hello.',
+ },
+ });
+ expect(result.uiHistory[1]).toMatchObject({
+ type: 'gemini',
+ text: 'Hi there',
+ });
+ });
+
it('should prioritize displayContent for UI history but use content for client history', () => {
const messages: MessageRecord[] = [
{
diff --git a/packages/cli/src/ui/hooks/useSessionRetentionCheck.test.ts b/packages/cli/src/ui/hooks/useSessionRetentionCheck.test.ts
deleted file mode 100644
index 67e5efbc6b..0000000000
--- a/packages/cli/src/ui/hooks/useSessionRetentionCheck.test.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-/**
- * @license
- * Copyright 2026 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { renderHook } from '../../test-utils/render.js';
-import { useSessionRetentionCheck } from './useSessionRetentionCheck.js';
-import { type Config } from '@google/gemini-cli-core';
-import type { Settings } from '../../config/settingsSchema.js';
-import { waitFor } from '../../test-utils/async.js';
-
-// Mock utils
-const mockGetAllSessionFiles = vi.fn();
-const mockIdentifySessionsToDelete = vi.fn();
-
-vi.mock('../../utils/sessionUtils.js', () => ({
- getAllSessionFiles: () => mockGetAllSessionFiles(),
-}));
-
-vi.mock('../../utils/sessionCleanup.js', () => ({
- identifySessionsToDelete: () => mockIdentifySessionsToDelete(),
- DEFAULT_MIN_RETENTION: '30d',
-}));
-
-describe('useSessionRetentionCheck', () => {
- const mockConfig = {
- storage: {
- getProjectTempDir: () => '/mock/project/temp/dir',
- },
- getSessionId: () => 'mock-session-id',
- } as unknown as Config;
-
- beforeEach(() => {
- vi.resetAllMocks();
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('should show warning if enabled is true but maxAge is undefined', async () => {
- const settings = {
- general: {
- sessionRetention: {
- enabled: true,
- maxAge: undefined,
- warningAcknowledged: false,
- },
- },
- } as unknown as Settings;
-
- mockGetAllSessionFiles.mockResolvedValue(['session1.json']);
- mockIdentifySessionsToDelete.mockResolvedValue(['session1.json']);
-
- const { result } = renderHook(() =>
- useSessionRetentionCheck(mockConfig, settings),
- );
-
- await waitFor(() => {
- expect(result.current.checkComplete).toBe(true);
- expect(result.current.shouldShowWarning).toBe(true);
- expect(mockGetAllSessionFiles).toHaveBeenCalled();
- expect(mockIdentifySessionsToDelete).toHaveBeenCalled();
- });
- });
-
- it('should not show warning if warningAcknowledged is true', async () => {
- const settings = {
- general: {
- sessionRetention: {
- warningAcknowledged: true,
- },
- },
- } as unknown as Settings;
-
- const { result } = renderHook(() =>
- useSessionRetentionCheck(mockConfig, settings),
- );
-
- await waitFor(() => {
- expect(result.current.checkComplete).toBe(true);
- expect(result.current.shouldShowWarning).toBe(false);
- expect(mockGetAllSessionFiles).not.toHaveBeenCalled();
- expect(mockIdentifySessionsToDelete).not.toHaveBeenCalled();
- });
- });
-
- it('should not show warning if retention is already enabled', async () => {
- const settings = {
- general: {
- sessionRetention: {
- enabled: true,
- maxAge: '30d', // Explicitly enabled with non-default
- },
- },
- } as unknown as Settings;
-
- const { result } = renderHook(() =>
- useSessionRetentionCheck(mockConfig, settings),
- );
-
- await waitFor(() => {
- expect(result.current.checkComplete).toBe(true);
- expect(result.current.shouldShowWarning).toBe(false);
- expect(mockGetAllSessionFiles).not.toHaveBeenCalled();
- expect(mockIdentifySessionsToDelete).not.toHaveBeenCalled();
- });
- });
-
- it('should show warning if sessions to delete exist', async () => {
- const settings = {
- general: {
- sessionRetention: {
- enabled: false,
- warningAcknowledged: false,
- },
- },
- } as unknown as Settings;
-
- mockGetAllSessionFiles.mockResolvedValue([
- 'session1.json',
- 'session2.json',
- ]);
- mockIdentifySessionsToDelete.mockResolvedValue(['session1.json']); // 1 session to delete
-
- const { result } = renderHook(() =>
- useSessionRetentionCheck(mockConfig, settings),
- );
-
- await waitFor(() => {
- expect(result.current.checkComplete).toBe(true);
- expect(result.current.shouldShowWarning).toBe(true);
- expect(result.current.sessionsToDeleteCount).toBe(1);
- expect(mockGetAllSessionFiles).toHaveBeenCalled();
- expect(mockIdentifySessionsToDelete).toHaveBeenCalled();
- });
- });
-
- it('should call onAutoEnable if no sessions to delete and currently disabled', async () => {
- const settings = {
- general: {
- sessionRetention: {
- enabled: false,
- warningAcknowledged: false,
- },
- },
- } as unknown as Settings;
-
- mockGetAllSessionFiles.mockResolvedValue(['session1.json']);
- mockIdentifySessionsToDelete.mockResolvedValue([]); // 0 sessions to delete
-
- const onAutoEnable = vi.fn();
-
- const { result } = renderHook(() =>
- useSessionRetentionCheck(mockConfig, settings, onAutoEnable),
- );
-
- await waitFor(() => {
- expect(result.current.checkComplete).toBe(true);
- expect(result.current.shouldShowWarning).toBe(false);
- expect(onAutoEnable).toHaveBeenCalled();
- });
- });
-
- it('should not show warning if no sessions to delete', async () => {
- const settings = {
- general: {
- sessionRetention: {
- enabled: false,
- warningAcknowledged: false,
- },
- },
- } as unknown as Settings;
-
- mockGetAllSessionFiles.mockResolvedValue([
- 'session1.json',
- 'session2.json',
- ]);
- mockIdentifySessionsToDelete.mockResolvedValue([]); // 0 sessions to delete
-
- const { result } = renderHook(() =>
- useSessionRetentionCheck(mockConfig, settings),
- );
-
- await waitFor(() => {
- expect(result.current.checkComplete).toBe(true);
- expect(result.current.shouldShowWarning).toBe(false);
- expect(result.current.sessionsToDeleteCount).toBe(0);
- expect(mockGetAllSessionFiles).toHaveBeenCalled();
- expect(mockIdentifySessionsToDelete).toHaveBeenCalled();
- });
- });
-
- it('should handle errors gracefully (assume no warning)', async () => {
- const settings = {
- general: {
- sessionRetention: {
- enabled: false,
- warningAcknowledged: false,
- },
- },
- } as unknown as Settings;
-
- mockGetAllSessionFiles.mockRejectedValue(new Error('FS Error'));
-
- const { result } = renderHook(() =>
- useSessionRetentionCheck(mockConfig, settings),
- );
-
- await waitFor(() => {
- expect(result.current.checkComplete).toBe(true);
- expect(result.current.shouldShowWarning).toBe(false);
- });
- });
-});
diff --git a/packages/cli/src/ui/hooks/useSessionRetentionCheck.ts b/packages/cli/src/ui/hooks/useSessionRetentionCheck.ts
deleted file mode 100644
index 99b443cffc..0000000000
--- a/packages/cli/src/ui/hooks/useSessionRetentionCheck.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * @license
- * Copyright 2026 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { useState, useEffect } from 'react';
-import { type Config } from '@google/gemini-cli-core';
-import { type Settings } from '../../config/settings.js';
-import { getAllSessionFiles } from '../../utils/sessionUtils.js';
-import { identifySessionsToDelete } from '../../utils/sessionCleanup.js';
-import path from 'node:path';
-
-export function useSessionRetentionCheck(
- config: Config,
- settings: Settings,
- onAutoEnable?: () => void,
-) {
- const [shouldShowWarning, setShouldShowWarning] = useState(false);
- const [sessionsToDeleteCount, setSessionsToDeleteCount] = useState(0);
- const [checkComplete, setCheckComplete] = useState(false);
-
- useEffect(() => {
- // If warning already acknowledged or retention already enabled, skip check
- if (
- settings.general?.sessionRetention?.warningAcknowledged ||
- (settings.general?.sessionRetention?.enabled &&
- settings.general?.sessionRetention?.maxAge !== undefined)
- ) {
- setShouldShowWarning(false);
- setCheckComplete(true);
- return;
- }
-
- const checkSessions = async () => {
- try {
- const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
- const allFiles = await getAllSessionFiles(
- chatsDir,
- config.getSessionId(),
- );
-
- // Calculate how many sessions would be deleted if we applied a 30-day retention
- const sessionsToDelete = await identifySessionsToDelete(allFiles, {
- enabled: true,
- maxAge: '30d',
- });
-
- if (sessionsToDelete.length > 0) {
- setSessionsToDeleteCount(sessionsToDelete.length);
- setShouldShowWarning(true);
- } else {
- setShouldShowWarning(false);
- // If no sessions to delete, safe to auto-enable retention
- onAutoEnable?.();
- }
- } catch {
- // If we can't check sessions, default to not showing the warning to be safe
- setShouldShowWarning(false);
- } finally {
- setCheckComplete(true);
- }
- };
-
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- checkSessions();
- }, [config, settings.general?.sessionRetention, onAutoEnable]);
-
- return { shouldShowWarning, checkComplete, sessionsToDeleteCount };
-}
diff --git a/packages/cli/src/ui/hooks/useSettingsNavigation.test.ts b/packages/cli/src/ui/hooks/useSettingsNavigation.test.ts
new file mode 100644
index 0000000000..5a64119f40
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useSettingsNavigation.test.ts
@@ -0,0 +1,121 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderHook } from '../../test-utils/render.js';
+import { act } from 'react';
+import { describe, it, expect } from 'vitest';
+import { useSettingsNavigation } from './useSettingsNavigation.js';
+
+describe('useSettingsNavigation', () => {
+ const mockItems = [
+ { key: 'a' },
+ { key: 'b' },
+ { key: 'c' },
+ { key: 'd' },
+ { key: 'e' },
+ ];
+
+ it('should initialize with the first item active', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+ expect(result.current.activeIndex).toBe(0);
+ expect(result.current.activeItemKey).toBe('a');
+ expect(result.current.windowStart).toBe(0);
+ });
+
+ it('should move down correctly', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+ act(() => result.current.moveDown());
+ expect(result.current.activeIndex).toBe(1);
+ expect(result.current.activeItemKey).toBe('b');
+ });
+
+ it('should move up correctly', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+ act(() => result.current.moveDown()); // to index 1
+ act(() => result.current.moveUp()); // back to 0
+ expect(result.current.activeIndex).toBe(0);
+ });
+
+ it('should wrap around from top to bottom', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+ act(() => result.current.moveUp());
+ expect(result.current.activeIndex).toBe(4);
+ expect(result.current.activeItemKey).toBe('e');
+ });
+
+ it('should wrap around from bottom to top', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+ // Move to last item
+ // Move to last item (index 4)
+ act(() => result.current.moveDown()); // 1
+ act(() => result.current.moveDown()); // 2
+ act(() => result.current.moveDown()); // 3
+ act(() => result.current.moveDown()); // 4
+ expect(result.current.activeIndex).toBe(4);
+
+ // Move down once more
+ act(() => result.current.moveDown());
+ expect(result.current.activeIndex).toBe(0);
+ });
+
+ it('should adjust scrollOffset when moving down past visible area', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+
+ act(() => result.current.moveDown()); // index 1
+ act(() => result.current.moveDown()); // index 2, still offset 0
+ expect(result.current.windowStart).toBe(0);
+
+ act(() => result.current.moveDown()); // index 3, offset should be 1
+ expect(result.current.windowStart).toBe(1);
+ });
+
+ it('should adjust scrollOffset when moving up past visible area', () => {
+ const { result } = renderHook(() =>
+ useSettingsNavigation({ items: mockItems, maxItemsToShow: 3 }),
+ );
+
+ act(() => result.current.moveDown()); // 1
+ act(() => result.current.moveDown()); // 2
+ act(() => result.current.moveDown()); // 3
+ expect(result.current.windowStart).toBe(1);
+
+ act(() => result.current.moveUp()); // index 2
+ act(() => result.current.moveUp()); // index 1, offset should become 1
+ act(() => result.current.moveUp()); // index 0, offset should become 0
+ expect(result.current.windowStart).toBe(0);
+ });
+
+ it('should handle item preservation when list filters (Part 1 logic)', () => {
+ let items = mockItems;
+ const { result, rerender } = renderHook(
+ ({ list }) => useSettingsNavigation({ items: list, maxItemsToShow: 3 }),
+ { initialProps: { list: items } },
+ );
+
+ act(() => result.current.moveDown());
+ act(() => result.current.moveDown()); // Item 'c'
+ expect(result.current.activeItemKey).toBe('c');
+
+ // Filter items but keep 'c'
+ items = [mockItems[0], mockItems[2], mockItems[4]]; // 'a', 'c', 'e'
+ rerender({ list: items });
+
+ expect(result.current.activeItemKey).toBe('c');
+ expect(result.current.activeIndex).toBe(1); // 'c' is now at index 1
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useSettingsNavigation.ts b/packages/cli/src/ui/hooks/useSettingsNavigation.ts
new file mode 100644
index 0000000000..1f47b2eb74
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useSettingsNavigation.ts
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useMemo, useReducer, useCallback } from 'react';
+
+export interface UseSettingsNavigationProps {
+ items: Array<{ key: string }>;
+ maxItemsToShow: number;
+}
+
+type NavState = {
+ activeItemKey: string | null;
+ windowStart: number;
+};
+
+type NavAction = { type: 'MOVE_UP' } | { type: 'MOVE_DOWN' };
+
+function calculateSlidingWindow(
+ start: number,
+ activeIndex: number,
+ itemCount: number,
+ windowSize: number,
+): number {
+ // User moves up above the window start
+ if (activeIndex < start) {
+ start = activeIndex;
+ // User moves down below the window end
+ } else if (activeIndex >= start + windowSize) {
+ start = activeIndex - windowSize + 1;
+ }
+ // User is inside the window but performed search or terminal resized
+ const maxScroll = Math.max(0, itemCount - windowSize);
+ const bounded = Math.min(start, maxScroll);
+ return Math.max(0, bounded);
+}
+
+function createNavReducer(
+ items: Array<{ key: string }>,
+ maxItemsToShow: number,
+) {
+ return function navReducer(state: NavState, action: NavAction): NavState {
+ if (items.length === 0) return state;
+
+ const currentIndex = items.findIndex((i) => i.key === state.activeItemKey);
+ const activeIndex = currentIndex !== -1 ? currentIndex : 0;
+
+ switch (action.type) {
+ case 'MOVE_UP': {
+ const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
+ return {
+ activeItemKey: items[newIndex].key,
+ windowStart: calculateSlidingWindow(
+ state.windowStart,
+ newIndex,
+ items.length,
+ maxItemsToShow,
+ ),
+ };
+ }
+ case 'MOVE_DOWN': {
+ const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
+ return {
+ activeItemKey: items[newIndex].key,
+ windowStart: calculateSlidingWindow(
+ state.windowStart,
+ newIndex,
+ items.length,
+ maxItemsToShow,
+ ),
+ };
+ }
+ default: {
+ return state;
+ }
+ }
+ };
+}
+
+export function useSettingsNavigation({
+ items,
+ maxItemsToShow,
+}: UseSettingsNavigationProps) {
+ const reducer = useMemo(
+ () => createNavReducer(items, maxItemsToShow),
+ [items, maxItemsToShow],
+ );
+
+ const [state, dispatch] = useReducer(reducer, {
+ activeItemKey: items[0]?.key ?? null,
+ windowStart: 0,
+ });
+
+ // Retain the proper highlighting when items change (e.g. search)
+ const activeIndex = useMemo(() => {
+ if (items.length === 0) return 0;
+ const idx = items.findIndex((i) => i.key === state.activeItemKey);
+ return idx !== -1 ? idx : 0;
+ }, [items, state.activeItemKey]);
+
+ const windowStart = useMemo(
+ () =>
+ calculateSlidingWindow(
+ state.windowStart,
+ activeIndex,
+ items.length,
+ maxItemsToShow,
+ ),
+ [state.windowStart, activeIndex, items.length, maxItemsToShow],
+ );
+
+ const moveUp = useCallback(() => dispatch({ type: 'MOVE_UP' }), []);
+ const moveDown = useCallback(() => dispatch({ type: 'MOVE_DOWN' }), []);
+
+ return {
+ activeItemKey: state.activeItemKey,
+ activeIndex,
+ windowStart,
+ moveUp,
+ moveDown,
+ };
+}
diff --git a/packages/cli/src/ui/hooks/useShellCompletion.ts b/packages/cli/src/ui/hooks/useShellCompletion.ts
index cc73128344..ec50c98ac9 100644
--- a/packages/cli/src/ui/hooks/useShellCompletion.ts
+++ b/packages/cli/src/ui/hooks/useShellCompletion.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useEffect, useRef, useCallback, useMemo } from 'react';
+import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
@@ -435,6 +435,7 @@ export interface UseShellCompletionReturn {
completionStart: number;
completionEnd: number;
query: string;
+ activeStart: number;
}
const EMPTY_TOKENS: string[] = [];
@@ -451,6 +452,7 @@ export function useShellCompletion({
const pathEnvRef = useRef(process.env['PATH'] ?? '');
const abortRef = useRef(null);
const debounceRef = useRef(null);
+ const [activeStart, setActiveStart] = useState(-1);
const tokenInfo = useMemo(
() => (enabled ? getTokenAtCursor(line, cursorCol) : null),
@@ -467,6 +469,14 @@ export function useShellCompletion({
commandToken = '',
} = tokenInfo || {};
+ // Immediately clear suggestions if the token range has changed.
+ // This avoids a frame of flickering with stale suggestions (e.g. "ls ls")
+ // when moving to a new token.
+ if (enabled && activeStart !== -1 && completionStart !== activeStart) {
+ setSuggestions([]);
+ setActiveStart(-1);
+ }
+
// Invalidate PATH cache when $PATH changes
useEffect(() => {
const currentPath = process.env['PATH'] ?? '';
@@ -558,6 +568,7 @@ export function useShellCompletion({
if (signal.aborted) return;
setSuggestions(results);
+ setActiveStart(completionStart);
} catch (error) {
if (
!(
@@ -571,6 +582,7 @@ export function useShellCompletion({
}
if (!signal.aborted) {
setSuggestions([]);
+ setActiveStart(completionStart);
}
} finally {
if (!signal.aborted) {
@@ -586,6 +598,7 @@ export function useShellCompletion({
cursorIndex,
commandToken,
cwd,
+ completionStart,
setSuggestions,
setIsLoadingSuggestions,
]);
@@ -594,6 +607,7 @@ export function useShellCompletion({
if (!enabled) {
abortRef.current?.abort();
setSuggestions([]);
+ setActiveStart(-1);
setIsLoadingSuggestions(false);
}
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
@@ -633,5 +647,6 @@ export function useShellCompletion({
completionStart,
completionEnd,
query,
+ activeStart,
};
}
diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts
index ea320b80a1..402706dee4 100644
--- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts
@@ -438,6 +438,129 @@ describe('useSlashCompletion', () => {
unmount();
});
+ it('should show the same selectable auto/checkpoint menu for /chat and /resume', async () => {
+ const checkpointSubCommands = [
+ createTestCommand({
+ name: 'list',
+ description: 'List checkpoints',
+ suggestionGroup: 'checkpoints',
+ action: vi.fn(),
+ }),
+ createTestCommand({
+ name: 'save',
+ description: 'Save checkpoint',
+ suggestionGroup: 'checkpoints',
+ action: vi.fn(),
+ }),
+ ];
+
+ const slashCommands = [
+ createTestCommand({
+ name: 'chat',
+ description: 'Chat command',
+ action: vi.fn(),
+ subCommands: checkpointSubCommands,
+ }),
+ createTestCommand({
+ name: 'resume',
+ description: 'Resume command',
+ action: vi.fn(),
+ subCommands: checkpointSubCommands,
+ }),
+ ];
+
+ const { result: chatResult, unmount: unmountChat } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/chat',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(chatResult.current.suggestions[0]).toMatchObject({
+ label: 'list',
+ sectionTitle: 'auto',
+ submitValue: '/chat',
+ });
+ });
+
+ const { result: resumeResult, unmount: unmountResume } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/resume',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(resumeResult.current.suggestions[0]).toMatchObject({
+ label: 'list',
+ sectionTitle: 'auto',
+ submitValue: '/resume',
+ });
+ });
+
+ const chatCheckpointLabels = chatResult.current.suggestions
+ .slice(1)
+ .map((s) => s.label);
+ const resumeCheckpointLabels = resumeResult.current.suggestions
+ .slice(1)
+ .map((s) => s.label);
+
+ expect(chatCheckpointLabels).toEqual(resumeCheckpointLabels);
+
+ unmountChat();
+ unmountResume();
+ });
+
+ it('should show the grouped /resume menu for unique /resum prefix input', async () => {
+ const slashCommands = [
+ createTestCommand({
+ name: 'resume',
+ description: 'Resume command',
+ action: vi.fn(),
+ subCommands: [
+ createTestCommand({
+ name: 'list',
+ description: 'List checkpoints',
+ suggestionGroup: 'checkpoints',
+ }),
+ createTestCommand({
+ name: 'save',
+ description: 'Save checkpoint',
+ suggestionGroup: 'checkpoints',
+ }),
+ ],
+ }),
+ ];
+
+ const { result, unmount } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/resum',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(result.current.suggestions[0]).toMatchObject({
+ label: 'list',
+ sectionTitle: 'auto',
+ submitValue: '/resume',
+ });
+ expect(result.current.isPerfectMatch).toBe(false);
+ expect(result.current.suggestions.slice(1).map((s) => s.label)).toEqual(
+ expect.arrayContaining(['list', 'save']),
+ );
+ });
+
+ unmount();
+ });
+
it('should sort exact altName matches to the top', async () => {
const slashCommands = [
createTestCommand({
@@ -492,8 +615,13 @@ describe('useSlashCompletion', () => {
);
await waitFor(() => {
- // Should show subcommands of 'chat'
- expect(result.current.suggestions).toHaveLength(2);
+ // Should show the auto-session entry plus subcommands of 'chat'
+ expect(result.current.suggestions).toHaveLength(3);
+ expect(result.current.suggestions[0]).toMatchObject({
+ label: 'list',
+ sectionTitle: 'auto',
+ submitValue: '/chat',
+ });
expect(result.current.suggestions.map((s) => s.label)).toEqual(
expect.arrayContaining(['list', 'save']),
);
@@ -1079,7 +1207,7 @@ describe('useSlashCompletion', () => {
{
name: 'custom-script',
description: 'Run custom script',
- kind: CommandKind.FILE,
+ kind: CommandKind.USER_FILE,
action: vi.fn(),
},
] as SlashCommand[];
@@ -1099,7 +1227,7 @@ describe('useSlashCompletion', () => {
label: 'custom-script',
value: 'custom-script',
description: 'Run custom script',
- commandKind: CommandKind.FILE,
+ commandKind: CommandKind.USER_FILE,
},
]);
expect(result.current.completionStart).toBe(1);
diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts
index a53a469571..0548451615 100644
--- a/packages/cli/src/ui/hooks/useSlashCompletion.ts
+++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts
@@ -55,6 +55,7 @@ interface CommandParserResult {
currentLevel: readonly SlashCommand[] | undefined;
leafCommand: SlashCommand | null;
exactMatchAsParent: SlashCommand | undefined;
+ usedPrefixParentDescent: boolean;
isArgumentCompletion: boolean;
}
@@ -71,6 +72,7 @@ function useCommandParser(
currentLevel: slashCommands,
leafCommand: null,
exactMatchAsParent: undefined,
+ usedPrefixParentDescent: false,
isArgumentCompletion: false,
};
}
@@ -88,6 +90,7 @@ function useCommandParser(
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
let leafCommand: SlashCommand | null = null;
+ let usedPrefixParentDescent = false;
for (const part of commandPathParts) {
if (!currentLevel) {
@@ -138,6 +141,32 @@ function useCommandParser(
partial = '';
}
}
+
+ // Phase-one alias UX: allow unique prefix descent for /chat and /resume
+ // so `/cha` and `/resum` expose the same grouped menu immediately.
+ if (!exactMatchAsParent && partial && currentLevel) {
+ const prefixParentMatches = currentLevel.filter(
+ (cmd) =>
+ !!cmd.subCommands &&
+ (cmd.name.toLowerCase().startsWith(partial.toLowerCase()) ||
+ cmd.altNames?.some((alt) =>
+ alt.toLowerCase().startsWith(partial.toLowerCase()),
+ )),
+ );
+
+ if (prefixParentMatches.length === 1) {
+ const candidate = prefixParentMatches[0];
+ if (candidate.name === 'chat' || candidate.name === 'resume') {
+ exactMatchAsParent = candidate;
+ leafCommand = candidate;
+ usedPrefixParentDescent = true;
+ currentLevel = candidate.subCommands as
+ | readonly SlashCommand[]
+ | undefined;
+ partial = '';
+ }
+ }
+ }
}
const depth = commandPathParts.length;
@@ -154,6 +183,7 @@ function useCommandParser(
currentLevel,
leafCommand,
exactMatchAsParent,
+ usedPrefixParentDescent,
isArgumentCompletion,
};
}, [query, slashCommands]);
@@ -312,12 +342,53 @@ function useCommandSuggestions(
return 0;
});
- const finalSuggestions = sortedSuggestions.map((cmd) => ({
- label: cmd.name,
- value: cmd.name,
- description: cmd.description,
- commandKind: cmd.kind,
- }));
+ const finalSuggestions = sortedSuggestions.map((cmd) => {
+ const canonicalParentName =
+ parserResult.usedPrefixParentDescent &&
+ leafCommand &&
+ (leafCommand.name === 'chat' || leafCommand.name === 'resume')
+ ? leafCommand.name
+ : undefined;
+
+ const suggestion: Suggestion = {
+ label: cmd.name,
+ value: cmd.name,
+ insertValue: canonicalParentName
+ ? `${canonicalParentName} ${cmd.name}`
+ : undefined,
+ description: cmd.description,
+ commandKind: cmd.kind,
+ };
+
+ if (cmd.suggestionGroup) {
+ suggestion.sectionTitle = cmd.suggestionGroup;
+ }
+
+ return suggestion;
+ });
+
+ const isTopLevelChatOrResumeContext = !!(
+ leafCommand &&
+ (leafCommand.name === 'chat' || leafCommand.name === 'resume') &&
+ (commandPathParts.length === 0 ||
+ (commandPathParts.length === 1 &&
+ matchesCommand(leafCommand, commandPathParts[0])))
+ );
+
+ if (isTopLevelChatOrResumeContext) {
+ const canonicalParentName = leafCommand.name;
+ const autoSectionSuggestion: Suggestion = {
+ label: 'list',
+ value: 'list',
+ insertValue: canonicalParentName,
+ description: 'Browse auto-saved chats',
+ commandKind: CommandKind.BUILT_IN,
+ sectionTitle: 'auto',
+ submitValue: `/${leafCommand.name}`,
+ };
+ setSuggestions([autoSectionSuggestion, ...finalSuggestions]);
+ return;
+ }
setSuggestions(finalSuggestions);
}
@@ -359,7 +430,9 @@ function useCompletionPositions(
const { hasTrailingSpace, partial, exactMatchAsParent } = parserResult;
// Set completion start/end positions
- if (hasTrailingSpace || exactMatchAsParent) {
+ if (parserResult.usedPrefixParentDescent) {
+ return { start: 1, end: query.length };
+ } else if (hasTrailingSpace || exactMatchAsParent) {
return { start: query.length, end: query.length };
} else if (partial) {
if (parserResult.isArgumentCompletion) {
@@ -388,7 +461,12 @@ function usePerfectMatch(
return { isPerfectMatch: false };
}
- if (leafCommand && partial === '' && leafCommand.action) {
+ if (
+ leafCommand &&
+ partial === '' &&
+ leafCommand.action &&
+ !parserResult.usedPrefixParentDescent
+ ) {
return { isPerfectMatch: true };
}
diff --git a/packages/cli/src/ui/hooks/useSnowfall.test.tsx b/packages/cli/src/ui/hooks/useSnowfall.test.tsx
index 321da83090..e3e6df9100 100644
--- a/packages/cli/src/ui/hooks/useSnowfall.test.tsx
+++ b/packages/cli/src/ui/hooks/useSnowfall.test.tsx
@@ -23,7 +23,7 @@ vi.mock('../themes/theme-manager.js', () => ({
DEFAULT_THEME: { name: 'Default' },
}));
-vi.mock('../themes/holiday.js', () => ({
+vi.mock('../themes/builtin/dark/holiday-dark.js', () => ({
Holiday: { name: 'Holiday' },
}));
diff --git a/packages/cli/src/ui/hooks/useSnowfall.ts b/packages/cli/src/ui/hooks/useSnowfall.ts
index 6edb2e4b92..60c6d6d78f 100644
--- a/packages/cli/src/ui/hooks/useSnowfall.ts
+++ b/packages/cli/src/ui/hooks/useSnowfall.ts
@@ -8,7 +8,7 @@ import { useState, useEffect, useMemo } from 'react';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { debugState } from '../debug.js';
import { themeManager } from '../themes/theme-manager.js';
-import { Holiday } from '../themes/holiday.js';
+import { Holiday } from '../themes/builtin/dark/holiday-dark.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useTerminalSize } from './useTerminalSize.js';
import { shortAsciiLogo } from '../components/AsciiArt.js';
diff --git a/packages/cli/src/ui/hooks/useSuspend.test.ts b/packages/cli/src/ui/hooks/useSuspend.test.ts
index 9aa90d16b3..1d0b34b1a3 100644
--- a/packages/cli/src/ui/hooks/useSuspend.test.ts
+++ b/packages/cli/src/ui/hooks/useSuspend.test.ts
@@ -29,6 +29,8 @@ import {
cleanupTerminalOnExit,
terminalCapabilityManager,
} from '../utils/terminalCapabilityManager.js';
+import { formatCommand } from '../utils/keybindingUtils.js';
+import { Command } from '../../config/keyBindings.js';
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
@@ -99,8 +101,12 @@ describe('useSuspend', () => {
act(() => {
result.current.handleSuspend();
});
+
+ const suspendKey = formatCommand(Command.SUSPEND_APP);
+ const undoKey = formatCommand(Command.UNDO);
+
expect(handleWarning).toHaveBeenCalledWith(
- 'Press Ctrl+Z again to suspend. Undo has moved to Cmd + Z or Alt/Opt + Z.',
+ `Press ${suspendKey} again to suspend. Undo has moved to ${undoKey}.`,
);
act(() => {
@@ -190,8 +196,9 @@ describe('useSuspend', () => {
result.current.handleSuspend();
});
+ const suspendKey = formatCommand(Command.SUSPEND_APP);
expect(handleWarning).toHaveBeenCalledWith(
- 'Ctrl+Z suspend is not supported on Windows.',
+ `${suspendKey} suspend is not supported on Windows.`,
);
expect(killSpy).not.toHaveBeenCalled();
expect(cleanupTerminalOnExit).not.toHaveBeenCalled();
diff --git a/packages/cli/src/ui/hooks/useSuspend.ts b/packages/cli/src/ui/hooks/useSuspend.ts
index 9c986d30d6..7d295b4450 100644
--- a/packages/cli/src/ui/hooks/useSuspend.ts
+++ b/packages/cli/src/ui/hooks/useSuspend.ts
@@ -20,6 +20,8 @@ import {
terminalCapabilityManager,
} from '../utils/terminalCapabilityManager.js';
import { WARNING_PROMPT_DURATION_MS } from '../constants.js';
+import { formatCommand } from '../utils/keybindingUtils.js';
+import { Command } from '../../config/keyBindings.js';
interface UseSuspendProps {
handleWarning: (message: string) => void;
@@ -59,10 +61,11 @@ export function useSuspend({
clearTimeout(ctrlZTimerRef.current);
ctrlZTimerRef.current = null;
}
+ const suspendKey = formatCommand(Command.SUSPEND_APP);
if (ctrlZPressCount > 1) {
setCtrlZPressCount(0);
if (process.platform === 'win32') {
- handleWarning('Ctrl+Z suspend is not supported on Windows.');
+ handleWarning(`${suspendKey} suspend is not supported on Windows.`);
return;
}
@@ -130,8 +133,9 @@ export function useSuspend({
process.kill(0, 'SIGTSTP');
} else if (ctrlZPressCount > 0) {
+ const undoKey = formatCommand(Command.UNDO);
handleWarning(
- 'Press Ctrl+Z again to suspend. Undo has moved to Cmd + Z or Alt/Opt + Z.',
+ `Press ${suspendKey} again to suspend. Undo has moved to ${undoKey}.`,
);
ctrlZTimerRef.current = setTimeout(() => {
setCtrlZPressCount(0);
diff --git a/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts b/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
index 5eb1107a4d..e41a89d66d 100644
--- a/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
+++ b/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
@@ -9,12 +9,18 @@ import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useTabbedNavigation } from './useTabbedNavigation.js';
import { useKeypress } from './useKeypress.js';
+import { useKeyMatchers } from './useKeyMatchers.js';
+import type { KeyMatchers } from '../keyMatchers.js';
import type { Key, KeypressHandler } from '../contexts/KeypressContext.js';
vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
+vi.mock('./useKeyMatchers.js', () => ({
+ useKeyMatchers: vi.fn(),
+}));
+
const createKey = (partial: Partial): Key => ({
name: partial.name || '',
sequence: partial.sequence || '',
@@ -26,13 +32,14 @@ const createKey = (partial: Partial): Key => ({
...partial,
});
+const mockKeyMatchers = {
+ 'cursor.left': vi.fn((key) => key.name === 'left'),
+ 'cursor.right': vi.fn((key) => key.name === 'right'),
+ 'dialog.next': vi.fn((key) => key.name === 'tab' && !key.shift),
+ 'dialog.previous': vi.fn((key) => key.name === 'tab' && key.shift),
+} as unknown as KeyMatchers;
+
vi.mock('../keyMatchers.js', () => ({
- keyMatchers: {
- 'cursor.left': vi.fn((key) => key.name === 'left'),
- 'cursor.right': vi.fn((key) => key.name === 'right'),
- 'dialog.next': vi.fn((key) => key.name === 'tab' && !key.shift),
- 'dialog.previous': vi.fn((key) => key.name === 'tab' && key.shift),
- },
Command: {
MOVE_LEFT: 'cursor.left',
MOVE_RIGHT: 'cursor.right',
@@ -45,6 +52,7 @@ describe('useTabbedNavigation', () => {
let capturedHandler: KeypressHandler;
beforeEach(() => {
+ vi.mocked(useKeyMatchers).mockReturnValue(mockKeyMatchers);
vi.mocked(useKeypress).mockImplementation((handler) => {
capturedHandler = handler;
});
diff --git a/packages/cli/src/ui/hooks/useTabbedNavigation.ts b/packages/cli/src/ui/hooks/useTabbedNavigation.ts
index b4ed73264c..d7e406ce6b 100644
--- a/packages/cli/src/ui/hooks/useTabbedNavigation.ts
+++ b/packages/cli/src/ui/hooks/useTabbedNavigation.ts
@@ -6,7 +6,8 @@
import { useReducer, useCallback, useEffect, useRef } from 'react';
import { useKeypress, type Key } from './useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from './useKeyMatchers.js';
/**
* Options for the useTabbedNavigation hook.
@@ -147,6 +148,7 @@ export function useTabbedNavigation({
isActive = true,
onTabChange,
}: UseTabbedNavigationOptions): UseTabbedNavigationResult {
+ const keyMatchers = useKeyMatchers();
const [state, dispatch] = useReducer(tabbedNavigationReducer, {
currentIndex: Math.max(0, Math.min(initialIndex, tabCount - 1)),
tabCount,
@@ -231,6 +233,7 @@ export function useTabbedNavigation({
goToNextTab,
goToPrevTab,
isNavigationBlocked,
+ keyMatchers,
],
);
diff --git a/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx b/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
index d20c6149b0..31df95495c 100644
--- a/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
+++ b/packages/cli/src/ui/hooks/useTerminalTheme.test.tsx
@@ -65,7 +65,7 @@ vi.mock('../themes/theme-manager.js', async (importOriginal) => {
};
});
-vi.mock('../themes/default-light.js', () => ({
+vi.mock('../themes/builtin/light/default-light.js', () => ({
DefaultLight: { name: 'default-light' },
}));
diff --git a/packages/cli/src/ui/hooks/useTerminalTheme.ts b/packages/cli/src/ui/hooks/useTerminalTheme.ts
index 5590c2a97c..29168b281a 100644
--- a/packages/cli/src/ui/hooks/useTerminalTheme.ts
+++ b/packages/cli/src/ui/hooks/useTerminalTheme.ts
@@ -11,7 +11,7 @@ import {
shouldSwitchTheme,
} from '../themes/color-utils.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
-import { DefaultLight } from '../themes/default-light.js';
+import { DefaultLight } from '../themes/builtin/light/default-light.js';
import { useSettings } from '../contexts/SettingsContext.js';
import type { Config } from '@google/gemini-cli-core';
import { useTerminalContext } from '../contexts/TerminalContext.js';
diff --git a/packages/cli/src/ui/hooks/useToolScheduler.ts b/packages/cli/src/ui/hooks/useToolScheduler.ts
index 496143e590..88e75464fb 100644
--- a/packages/cli/src/ui/hooks/useToolScheduler.ts
+++ b/packages/cli/src/ui/hooks/useToolScheduler.ts
@@ -117,6 +117,20 @@ export function useToolScheduler(
const handler = (event: ToolCallsUpdateMessage) => {
const isRoot = event.schedulerId === ROOT_SCHEDULER_ID;
+ // Update output timer for UI spinners (Side Effect)
+ const hasExecuting = event.toolCalls.some(
+ (tc) =>
+ tc.status === CoreToolCallStatus.Executing ||
+ ((tc.status === CoreToolCallStatus.Success ||
+ tc.status === CoreToolCallStatus.Error) &&
+ 'tailToolCallRequest' in tc &&
+ tc.tailToolCallRequest != null),
+ );
+
+ if (hasExecuting) {
+ setLastToolOutputTime(Date.now());
+ }
+
setToolCallsMap((prev) => {
const prevCalls = prev[event.schedulerId] ?? [];
const prevCallIds = new Set(prevCalls.map((tc) => tc.request.callId));
@@ -151,20 +165,6 @@ export function useToolScheduler(
[event.schedulerId]: adapted,
};
});
-
- // Update output timer for UI spinners (Side Effect)
- const hasExecuting = event.toolCalls.some(
- (tc) =>
- tc.status === CoreToolCallStatus.Executing ||
- ((tc.status === CoreToolCallStatus.Success ||
- tc.status === CoreToolCallStatus.Error) &&
- 'tailToolCallRequest' in tc &&
- tc.tailToolCallRequest != null),
- );
-
- if (hasExecuting) {
- setLastToolOutputTime(Date.now());
- }
};
messageBus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts
index 9de771564c..1fcc0c61ca 100644
--- a/packages/cli/src/ui/hooks/vim.ts
+++ b/packages/cli/src/ui/hooks/vim.ts
@@ -9,7 +9,8 @@ import type { Key } from './useKeypress.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { debugLogger } from '@google/gemini-cli-core';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from './useKeyMatchers.js';
export type VimMode = 'NORMAL' | 'INSERT';
@@ -152,6 +153,7 @@ const vimReducer = (state: VimState, action: VimAction): VimState => {
* @returns Object with vim state and input handler
*/
export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
+ const keyMatchers = useKeyMatchers();
const { vimEnabled, vimMode, setVimMode } = useVimMode();
const [state, dispatch] = useReducer(vimReducer, initialVimState);
@@ -439,7 +441,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
return buffer.handleInput(normalizedKey);
},
- [buffer, dispatch, updateMode, onSubmit, checkDoubleEscape],
+ [buffer, dispatch, updateMode, onSubmit, checkDoubleEscape, keyMatchers],
);
/**
@@ -1202,6 +1204,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
executeCommand,
updateMode,
checkDoubleEscape,
+ keyMatchers,
],
);
diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts
index 763754ec95..e90f6334be 100644
--- a/packages/cli/src/ui/keyMatchers.test.ts
+++ b/packages/cli/src/ui/keyMatchers.test.ts
@@ -5,7 +5,11 @@
*/
import { describe, it, expect } from 'vitest';
-import { keyMatchers, Command, createKeyMatchers } from './keyMatchers.js';
+import {
+ defaultKeyMatchers,
+ Command,
+ createKeyMatchers,
+} from './keyMatchers.js';
import type { KeyBindingConfig } from '../config/keyBindings.js';
import { defaultKeyBindings } from '../config/keyBindings.js';
import type { Key } from './hooks/useKeypress.js';
@@ -32,8 +36,12 @@ describe('keyMatchers', () => {
},
{
command: Command.ESCAPE,
- positive: [createKey('escape'), createKey('escape', { ctrl: true })],
- negative: [createKey('e'), createKey('esc')],
+ positive: [createKey('escape')],
+ negative: [
+ createKey('e'),
+ createKey('esc'),
+ createKey('escape', { ctrl: true }),
+ ],
},
// Cursor movement
@@ -192,13 +200,21 @@ describe('keyMatchers', () => {
},
{
command: Command.PAGE_UP,
- positive: [createKey('pageup'), createKey('pageup', { shift: true })],
- negative: [createKey('pagedown'), createKey('up')],
+ positive: [createKey('pageup')],
+ negative: [
+ createKey('pagedown'),
+ createKey('up'),
+ createKey('pageup', { shift: true }),
+ ],
},
{
command: Command.PAGE_DOWN,
- positive: [createKey('pagedown'), createKey('pagedown', { ctrl: true })],
- negative: [createKey('pageup'), createKey('down')],
+ positive: [createKey('pagedown')],
+ negative: [
+ createKey('pageup'),
+ createKey('down'),
+ createKey('pagedown', { ctrl: true }),
+ ],
},
// History navigation
@@ -214,13 +230,21 @@ describe('keyMatchers', () => {
},
{
command: Command.NAVIGATION_UP,
- positive: [createKey('up'), createKey('up', { ctrl: true })],
- negative: [createKey('p'), createKey('u')],
+ positive: [createKey('up')],
+ negative: [
+ createKey('p'),
+ createKey('u'),
+ createKey('up', { ctrl: true }),
+ ],
},
{
command: Command.NAVIGATION_DOWN,
- positive: [createKey('down'), createKey('down', { ctrl: true })],
- negative: [createKey('n'), createKey('d')],
+ positive: [createKey('down')],
+ negative: [
+ createKey('n'),
+ createKey('d'),
+ createKey('down', { ctrl: true }),
+ ],
},
// Dialog navigation
@@ -333,14 +357,12 @@ describe('keyMatchers', () => {
},
{
command: Command.SUSPEND_APP,
- positive: [
- createKey('z', { ctrl: true }),
- createKey('z', { ctrl: true, shift: true }),
- ],
+ positive: [createKey('z', { ctrl: true })],
negative: [
createKey('z'),
createKey('y', { ctrl: true }),
createKey('z', { alt: true }),
+ createKey('z', { ctrl: true, shift: true }),
],
},
{
@@ -365,8 +387,12 @@ describe('keyMatchers', () => {
},
{
command: Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
- positive: [createKey('tab'), createKey('tab', { ctrl: true })],
- negative: [createKey('return'), createKey('space')],
+ positive: [createKey('tab')],
+ negative: [
+ createKey('return'),
+ createKey('space'),
+ createKey('tab', { ctrl: true }),
+ ],
},
{
command: Command.FOCUS_SHELL_INPUT,
@@ -400,35 +426,19 @@ describe('keyMatchers', () => {
it(`should match ${command} correctly`, () => {
positive.forEach((key) => {
expect(
- keyMatchers[command](key),
+ defaultKeyMatchers[command](key),
`Expected ${command} to match ${JSON.stringify(key)}`,
).toBe(true);
});
negative.forEach((key) => {
expect(
- keyMatchers[command](key),
+ defaultKeyMatchers[command](key),
`Expected ${command} to NOT match ${JSON.stringify(key)}`,
).toBe(false);
});
});
});
-
- it('should properly handle ACCEPT_SUGGESTION_REVERSE_SEARCH cases', () => {
- expect(
- keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](
- createKey('return', { ctrl: true }),
- ),
- ).toBe(false); // ctrl must be false
- expect(
- keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](createKey('tab')),
- ).toBe(true);
- expect(
- keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](
- createKey('tab', { ctrl: true }),
- ),
- ).toBe(true); // modifiers ignored
- });
});
describe('Custom key bindings', () => {
diff --git a/packages/cli/src/ui/keyMatchers.ts b/packages/cli/src/ui/keyMatchers.ts
index 7c61db1016..259f1edd9e 100644
--- a/packages/cli/src/ui/keyMatchers.ts
+++ b/packages/cli/src/ui/keyMatchers.ts
@@ -13,16 +13,15 @@ import { Command, defaultKeyBindings } from '../config/keyBindings.js';
* Pure data-driven matching logic
*/
function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean {
- // Check modifiers - follow original logic:
- // undefined = ignore this modifier (original behavior)
+ // Check modifiers:
// true = modifier must be pressed
- // false = modifier must NOT be pressed
+ // false or undefined = modifier must NOT be pressed
return (
keyBinding.key === key.name &&
- (keyBinding.shift === undefined || key.shift === keyBinding.shift) &&
- (keyBinding.alt === undefined || key.alt === keyBinding.alt) &&
- (keyBinding.ctrl === undefined || key.ctrl === keyBinding.ctrl) &&
- (keyBinding.cmd === undefined || key.cmd === keyBinding.cmd)
+ !!key.shift === !!keyBinding.shift &&
+ !!key.alt === !!keyBinding.alt &&
+ !!key.ctrl === !!keyBinding.ctrl &&
+ !!key.cmd === !!keyBinding.cmd
);
}
@@ -69,7 +68,8 @@ export function createKeyMatchers(
/**
* Default key binding matchers using the default configuration
*/
-export const keyMatchers: KeyMatchers = createKeyMatchers(defaultKeyBindings);
+export const defaultKeyMatchers: KeyMatchers =
+ createKeyMatchers(defaultKeyBindings);
// Re-export Command for convenience
export { Command };
diff --git a/packages/cli/src/ui/textConstants.ts b/packages/cli/src/ui/textConstants.ts
index a7ea77de79..00be0623d2 100644
--- a/packages/cli/src/ui/textConstants.ts
+++ b/packages/cli/src/ui/textConstants.ts
@@ -16,5 +16,5 @@ export const REDIRECTION_WARNING_NOTE_LABEL = 'Note: ';
export const REDIRECTION_WARNING_NOTE_TEXT =
'Command contains redirection which can be undesirable.';
export const REDIRECTION_WARNING_TIP_LABEL = 'Tip: '; // Padded to align with "Note: "
-export const REDIRECTION_WARNING_TIP_TEXT =
- 'Toggle auto-edit (Shift+Tab) to allow redirection in the future.';
+export const getRedirectionWarningTipText = (shiftTabHint: string) =>
+ `Toggle auto-edit (${shiftTabHint}) to allow redirection in the future.`;
diff --git a/packages/cli/src/ui/themes/ansi.ts b/packages/cli/src/ui/themes/builtin/dark/ansi-dark.ts
similarity index 95%
rename from packages/cli/src/ui/themes/ansi.ts
rename to packages/cli/src/ui/themes/builtin/dark/ansi-dark.ts
index 08c0a2c968..79db07f3b2 100644
--- a/packages/cli/src/ui/themes/ansi.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/ansi-dark.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { darkSemanticColors } from './semantic-tokens.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { darkSemanticColors } from '../../semantic-tokens.js';
const ansiColors: ColorsTheme = {
type: 'dark',
@@ -23,6 +23,7 @@ const ansiColors: ColorsTheme = {
Comment: 'gray',
Gray: 'gray',
DarkGray: 'gray',
+ FocusBackground: 'black',
GradientColors: ['cyan', 'green'],
};
diff --git a/packages/cli/src/ui/themes/atom-one-dark.ts b/packages/cli/src/ui/themes/builtin/dark/atom-one-dark.ts
similarity index 95%
rename from packages/cli/src/ui/themes/atom-one-dark.ts
rename to packages/cli/src/ui/themes/builtin/dark/atom-one-dark.ts
index 5217a8bf30..2abb98cb54 100644
--- a/packages/cli/src/ui/themes/atom-one-dark.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/atom-one-dark.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const atomOneDarkColors: ColorsTheme = {
type: 'dark',
diff --git a/packages/cli/src/ui/themes/ayu.ts b/packages/cli/src/ui/themes/builtin/dark/ayu-dark.ts
similarity index 94%
rename from packages/cli/src/ui/themes/ayu.ts
rename to packages/cli/src/ui/themes/builtin/dark/ayu-dark.ts
index 71798aacf2..d4084569e4 100644
--- a/packages/cli/src/ui/themes/ayu.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/ayu-dark.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const ayuDarkColors: ColorsTheme = {
type: 'dark',
diff --git a/packages/cli/src/ui/themes/default.ts b/packages/cli/src/ui/themes/builtin/dark/default-dark.ts
similarity index 97%
rename from packages/cli/src/ui/themes/default.ts
rename to packages/cli/src/ui/themes/builtin/dark/default-dark.ts
index e1d0247c01..817686395d 100644
--- a/packages/cli/src/ui/themes/default.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/default-dark.ts
@@ -1,10 +1,10 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { darkTheme, Theme } from './theme.js';
+import { darkTheme, Theme } from '../../theme.js';
export const DefaultDark: Theme = new Theme(
'Default',
diff --git a/packages/cli/src/ui/themes/dracula.ts b/packages/cli/src/ui/themes/builtin/dark/dracula-dark.ts
similarity index 94%
rename from packages/cli/src/ui/themes/dracula.ts
rename to packages/cli/src/ui/themes/builtin/dark/dracula-dark.ts
index 2cd2802c45..3a9afcea75 100644
--- a/packages/cli/src/ui/themes/dracula.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/dracula-dark.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const draculaColors: ColorsTheme = {
type: 'dark',
diff --git a/packages/cli/src/ui/themes/github-dark.ts b/packages/cli/src/ui/themes/builtin/dark/github-dark.ts
similarity index 95%
rename from packages/cli/src/ui/themes/github-dark.ts
rename to packages/cli/src/ui/themes/builtin/dark/github-dark.ts
index 28c14f598d..27b804857d 100644
--- a/packages/cli/src/ui/themes/github-dark.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/github-dark.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const githubDarkColors: ColorsTheme = {
type: 'dark',
diff --git a/packages/cli/src/ui/themes/holiday.ts b/packages/cli/src/ui/themes/builtin/dark/holiday-dark.ts
similarity index 95%
rename from packages/cli/src/ui/themes/holiday.ts
rename to packages/cli/src/ui/themes/builtin/dark/holiday-dark.ts
index b3e72b1cc1..e49ae046d0 100644
--- a/packages/cli/src/ui/themes/holiday.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/holiday-dark.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const holidayColors: ColorsTheme = {
type: 'dark',
@@ -23,6 +23,7 @@ const holidayColors: ColorsTheme = {
Comment: '#8FBC8F',
Gray: '#D7F5D3',
DarkGray: interpolateColor('#D7F5D3', '#151B18', 0.5),
+ FocusColor: '#33F9FF', // AccentCyan for neon pop
GradientColors: ['#FF0000', '#FFFFFF', '#008000'],
};
diff --git a/packages/cli/src/ui/themes/shades-of-purple.ts b/packages/cli/src/ui/themes/builtin/dark/shades-of-purple-dark.ts
similarity index 98%
rename from packages/cli/src/ui/themes/shades-of-purple.ts
rename to packages/cli/src/ui/themes/builtin/dark/shades-of-purple-dark.ts
index 6e11aaec8b..b9e45fd924 100644
--- a/packages/cli/src/ui/themes/shades-of-purple.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/shades-of-purple-dark.ts
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -8,8 +8,8 @@
* Shades of Purple Theme — for Highlight.js.
* @author Ahmad Awais
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const shadesOfPurpleColors: ColorsTheme = {
type: 'dark',
diff --git a/packages/cli/src/ui/themes/solarized-dark.ts b/packages/cli/src/ui/themes/builtin/dark/solarized-dark.ts
similarity index 91%
rename from packages/cli/src/ui/themes/solarized-dark.ts
rename to packages/cli/src/ui/themes/builtin/dark/solarized-dark.ts
index c2bf3db34d..44168138f7 100644
--- a/packages/cli/src/ui/themes/solarized-dark.ts
+++ b/packages/cli/src/ui/themes/builtin/dark/solarized-dark.ts
@@ -1,11 +1,12 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { type SemanticColors } from './semantic-tokens.js';
+import { type ColorsTheme, Theme, interpolateColor } from '../../theme.js';
+import { type SemanticColors } from '../../semantic-tokens.js';
+import { DEFAULT_SELECTION_OPACITY } from '../../../constants.js';
const solarizedDarkColors: ColorsTheme = {
type: 'dark',
@@ -38,6 +39,7 @@ const semanticColors: SemanticColors = {
primary: '#002b36',
message: '#073642',
input: '#073642',
+ focus: interpolateColor('#002b36', '#859900', DEFAULT_SELECTION_OPACITY),
diff: {
added: '#00382f',
removed: '#3d0115',
@@ -45,13 +47,14 @@ const semanticColors: SemanticColors = {
},
border: {
default: '#073642',
- focused: '#586e75',
},
ui: {
comment: '#586e75',
symbol: '#93a1a1',
+ active: '#268bd2',
dark: '#073642',
- gradient: ['#268bd2', '#2aa198'],
+ focus: '#859900',
+ gradient: ['#268bd2', '#2aa198', '#859900'],
},
status: {
success: '#859900',
diff --git a/packages/cli/src/ui/themes/ansi-light.ts b/packages/cli/src/ui/themes/builtin/light/ansi-light.ts
similarity index 94%
rename from packages/cli/src/ui/themes/ansi-light.ts
rename to packages/cli/src/ui/themes/builtin/light/ansi-light.ts
index 201cc500e5..0d3b2003f8 100644
--- a/packages/cli/src/ui/themes/ansi-light.ts
+++ b/packages/cli/src/ui/themes/builtin/light/ansi-light.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { lightSemanticColors } from './semantic-tokens.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { lightSemanticColors } from '../../semantic-tokens.js';
const ansiLightColors: ColorsTheme = {
type: 'light',
diff --git a/packages/cli/src/ui/themes/ayu-light.ts b/packages/cli/src/ui/themes/builtin/light/ayu-light.ts
similarity index 95%
rename from packages/cli/src/ui/themes/ayu-light.ts
rename to packages/cli/src/ui/themes/builtin/light/ayu-light.ts
index 393ed44ba6..6c5a7616e1 100644
--- a/packages/cli/src/ui/themes/ayu-light.ts
+++ b/packages/cli/src/ui/themes/builtin/light/ayu-light.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const ayuLightColors: ColorsTheme = {
type: 'light',
diff --git a/packages/cli/src/ui/themes/default-light.ts b/packages/cli/src/ui/themes/builtin/light/default-light.ts
similarity index 96%
rename from packages/cli/src/ui/themes/default-light.ts
rename to packages/cli/src/ui/themes/builtin/light/default-light.ts
index 1803e7fae0..2d60f6d2bb 100644
--- a/packages/cli/src/ui/themes/default-light.ts
+++ b/packages/cli/src/ui/themes/builtin/light/default-light.ts
@@ -1,10 +1,10 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { lightTheme, Theme } from './theme.js';
+import { lightTheme, Theme } from '../../theme.js';
export const DefaultLight: Theme = new Theme(
'Default Light',
diff --git a/packages/cli/src/ui/themes/github-light.ts b/packages/cli/src/ui/themes/builtin/light/github-light.ts
similarity index 94%
rename from packages/cli/src/ui/themes/github-light.ts
rename to packages/cli/src/ui/themes/builtin/light/github-light.ts
index 264a9d7a88..a794a9312e 100644
--- a/packages/cli/src/ui/themes/github-light.ts
+++ b/packages/cli/src/ui/themes/builtin/light/github-light.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const githubLightColors: ColorsTheme = {
type: 'light',
@@ -23,6 +23,7 @@ const githubLightColors: ColorsTheme = {
Comment: '#998',
Gray: '#999',
DarkGray: interpolateColor('#999', '#f8f8f8', 0.5),
+ FocusColor: '#458', // AccentBlue for GitHub branding
GradientColors: ['#458', '#008080'],
};
diff --git a/packages/cli/src/ui/themes/googlecode.ts b/packages/cli/src/ui/themes/builtin/light/googlecode-light.ts
similarity index 95%
rename from packages/cli/src/ui/themes/googlecode.ts
rename to packages/cli/src/ui/themes/builtin/light/googlecode-light.ts
index 1795451c91..67f5618d60 100644
--- a/packages/cli/src/ui/themes/googlecode.ts
+++ b/packages/cli/src/ui/themes/builtin/light/googlecode-light.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme, lightTheme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme, lightTheme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const googleCodeColors: ColorsTheme = {
type: 'light',
diff --git a/packages/cli/src/ui/themes/solarized-light.ts b/packages/cli/src/ui/themes/builtin/light/solarized-light.ts
similarity index 91%
rename from packages/cli/src/ui/themes/solarized-light.ts
rename to packages/cli/src/ui/themes/builtin/light/solarized-light.ts
index 297238866d..b30dbb7b7f 100644
--- a/packages/cli/src/ui/themes/solarized-light.ts
+++ b/packages/cli/src/ui/themes/builtin/light/solarized-light.ts
@@ -1,11 +1,12 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { type SemanticColors } from './semantic-tokens.js';
+import { type ColorsTheme, Theme, interpolateColor } from '../../theme.js';
+import { type SemanticColors } from '../../semantic-tokens.js';
+import { DEFAULT_SELECTION_OPACITY } from '../../../constants.js';
const solarizedLightColors: ColorsTheme = {
type: 'light',
@@ -38,6 +39,7 @@ const semanticColors: SemanticColors = {
primary: '#fdf6e3',
message: '#eee8d5',
input: '#eee8d5',
+ focus: interpolateColor('#fdf6e3', '#859900', DEFAULT_SELECTION_OPACITY),
diff: {
added: '#d7f2d7',
removed: '#f2d7d7',
@@ -45,13 +47,14 @@ const semanticColors: SemanticColors = {
},
border: {
default: '#eee8d5',
- focused: '#93a1a1',
},
ui: {
comment: '#93a1a1',
symbol: '#586e75',
+ active: '#268bd2',
dark: '#eee8d5',
- gradient: ['#268bd2', '#2aa198'],
+ focus: '#859900',
+ gradient: ['#268bd2', '#2aa198', '#859900'],
},
status: {
success: '#859900',
diff --git a/packages/cli/src/ui/themes/xcode.ts b/packages/cli/src/ui/themes/builtin/light/xcode-light.ts
similarity index 94%
rename from packages/cli/src/ui/themes/xcode.ts
rename to packages/cli/src/ui/themes/builtin/light/xcode-light.ts
index 5d20f35c36..71c9442f7f 100644
--- a/packages/cli/src/ui/themes/xcode.ts
+++ b/packages/cli/src/ui/themes/builtin/light/xcode-light.ts
@@ -1,11 +1,11 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { type ColorsTheme, Theme } from './theme.js';
-import { interpolateColor } from './color-utils.js';
+import { type ColorsTheme, Theme } from '../../theme.js';
+import { interpolateColor } from '../../color-utils.js';
const xcodeColors: ColorsTheme = {
type: 'light',
@@ -23,6 +23,7 @@ const xcodeColors: ColorsTheme = {
Comment: '#007400',
Gray: '#c0c0c0',
DarkGray: interpolateColor('#c0c0c0', '#fff', 0.5),
+ FocusColor: '#1c00cf', // AccentBlue for more vibrance
GradientColors: ['#1c00cf', '#007400'],
};
diff --git a/packages/cli/src/ui/themes/no-color.ts b/packages/cli/src/ui/themes/builtin/no-color.ts
similarity index 90%
rename from packages/cli/src/ui/themes/no-color.ts
rename to packages/cli/src/ui/themes/builtin/no-color.ts
index 30e34c2c12..6f1a099454 100644
--- a/packages/cli/src/ui/themes/no-color.ts
+++ b/packages/cli/src/ui/themes/builtin/no-color.ts
@@ -1,12 +1,12 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import type { ColorsTheme } from './theme.js';
-import { Theme } from './theme.js';
-import type { SemanticColors } from './semantic-tokens.js';
+import type { ColorsTheme } from '../theme.js';
+import { Theme } from '../theme.js';
+import type { SemanticColors } from '../semantic-tokens.js';
const noColorColorsTheme: ColorsTheme = {
type: 'ansi',
@@ -26,6 +26,7 @@ const noColorColorsTheme: ColorsTheme = {
DarkGray: '',
InputBackground: '',
MessageBackground: '',
+ FocusBackground: '',
};
const noColorSemanticColors: SemanticColors = {
@@ -40,6 +41,7 @@ const noColorSemanticColors: SemanticColors = {
primary: '',
message: '',
input: '',
+ focus: '',
diff: {
added: '',
removed: '',
@@ -47,12 +49,13 @@ const noColorSemanticColors: SemanticColors = {
},
border: {
default: '',
- focused: '',
},
ui: {
comment: '',
symbol: '',
+ active: '',
dark: '',
+ focus: '',
gradient: [],
},
status: {
diff --git a/packages/cli/src/ui/themes/color-utils.ts b/packages/cli/src/ui/themes/color-utils.ts
index 476703a7fc..2901bd6b2e 100644
--- a/packages/cli/src/ui/themes/color-utils.ts
+++ b/packages/cli/src/ui/themes/color-utils.ts
@@ -4,38 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { debugLogger } from '@google/gemini-cli-core';
-import tinygradient from 'tinygradient';
-import tinycolor from 'tinycolor2';
+import {
+ resolveColor,
+ interpolateColor,
+ getThemeTypeFromBackgroundColor,
+ INK_SUPPORTED_NAMES,
+ INK_NAME_TO_HEX_MAP,
+ getLuminance,
+ CSS_NAME_TO_HEX_MAP,
+} from './theme.js';
-// Define the set of Ink's named colors for quick lookup
-export const INK_SUPPORTED_NAMES = new Set([
- 'black',
- 'red',
- 'green',
- 'yellow',
- 'blue',
- 'cyan',
- 'magenta',
- 'white',
- 'gray',
- 'grey',
- 'blackbright',
- 'redbright',
- 'greenbright',
- 'yellowbright',
- 'bluebright',
- 'cyanbright',
- 'magentabright',
- 'whitebright',
-]);
-
-// Use tinycolor's built-in names map for CSS colors, excluding ones Ink supports
-export const CSS_NAME_TO_HEX_MAP = Object.fromEntries(
- Object.entries(tinycolor.names)
- .filter(([name]) => !INK_SUPPORTED_NAMES.has(name))
- .map(([name, hex]) => [name, `#${hex}`]),
-);
+export {
+ resolveColor,
+ interpolateColor,
+ getThemeTypeFromBackgroundColor,
+ INK_SUPPORTED_NAMES,
+ INK_NAME_TO_HEX_MAP,
+ getLuminance,
+ CSS_NAME_TO_HEX_MAP,
+};
/**
* Checks if a color string is valid (hex, Ink-supported color name, or CSS color name).
@@ -66,45 +53,6 @@ export function isValidColor(color: string): boolean {
return false;
}
-/**
- * Resolves a CSS color value (name or hex) into an Ink-compatible color string.
- * @param colorValue The raw color string (e.g., 'blue', '#ff0000', 'darkkhaki').
- * @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
- */
-export function resolveColor(colorValue: string): string | undefined {
- const lowerColor = colorValue.toLowerCase();
-
- // 1. Check if it's already a hex code and valid
- if (lowerColor.startsWith('#')) {
- if (/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
- return lowerColor;
- } else {
- return undefined;
- }
- }
-
- // Handle hex codes without #
- if (/^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
- return `#${lowerColor}`;
- }
-
- // 2. Check if it's an Ink supported name (lowercase)
- if (INK_SUPPORTED_NAMES.has(lowerColor)) {
- return lowerColor; // Use Ink name directly
- }
-
- // 3. Check if it's a known CSS name we can map to hex
- if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
- return CSS_NAME_TO_HEX_MAP[lowerColor]; // Use mapped hex
- }
-
- // 4. Could not resolve
- debugLogger.warn(
- `[ColorUtils] Could not resolve color "${colorValue}" to an Ink-compatible format.`,
- );
- return undefined;
-}
-
/**
* Returns a "safe" background color to use in low-color terminals if the
* terminal background is a standard black or white.
@@ -132,73 +80,6 @@ export function getSafeLowColorBackground(
return undefined;
}
-export function interpolateColor(
- color1: string,
- color2: string,
- factor: number,
-) {
- if (factor <= 0 && color1) {
- return color1;
- }
- if (factor >= 1 && color2) {
- return color2;
- }
- if (!color1 || !color2) {
- return '';
- }
- const gradient = tinygradient(color1, color2);
- const color = gradient.rgbAt(factor);
- return color.toHexString();
-}
-
-export function getThemeTypeFromBackgroundColor(
- backgroundColor: string | undefined,
-): 'light' | 'dark' | undefined {
- if (!backgroundColor) {
- return undefined;
- }
-
- const resolvedColor = resolveColor(backgroundColor);
- if (!resolvedColor) {
- return undefined;
- }
-
- const luminance = getLuminance(resolvedColor);
- return luminance > 128 ? 'light' : 'dark';
-}
-
-// Mapping for ANSI bright colors that are not in tinycolor's standard CSS names
-export const INK_NAME_TO_HEX_MAP: Readonly> = {
- blackbright: '#555555',
- redbright: '#ff5555',
- greenbright: '#55ff55',
- yellowbright: '#ffff55',
- bluebright: '#5555ff',
- magentabright: '#ff55ff',
- cyanbright: '#55ffff',
- whitebright: '#ffffff',
-};
-
-/**
- * Calculates the relative luminance of a color.
- * See https://www.w3.org/TR/WCAG20/#relativeluminancedef
- *
- * @param color Color string (hex or Ink-supported name)
- * @returns Luminance value (0-255)
- */
-export function getLuminance(color: string): number {
- const resolved = color.toLowerCase();
- const hex = INK_NAME_TO_HEX_MAP[resolved] || resolved;
-
- const colorObj = tinycolor(hex);
- if (!colorObj.isValid()) {
- return 0;
- }
-
- // tinycolor returns 0-1, we need 0-255
- return colorObj.getLuminance() * 255;
-}
-
// Hysteresis thresholds to prevent flickering when the background color
// is ambiguous (near the midpoint).
export const LIGHT_THEME_LUMINANCE_THRESHOLD = 140;
diff --git a/packages/cli/src/ui/themes/semantic-tokens.ts b/packages/cli/src/ui/themes/semantic-tokens.ts
index ca46fadb56..b5e9140156 100644
--- a/packages/cli/src/ui/themes/semantic-tokens.ts
+++ b/packages/cli/src/ui/themes/semantic-tokens.ts
@@ -18,6 +18,7 @@ export interface SemanticColors {
primary: string;
message: string;
input: string;
+ focus: string;
diff: {
added: string;
removed: string;
@@ -25,12 +26,13 @@ export interface SemanticColors {
};
border: {
default: string;
- focused: string;
};
ui: {
comment: string;
symbol: string;
+ active: string;
dark: string;
+ focus: string;
gradient: string[] | undefined;
};
status: {
@@ -52,6 +54,7 @@ export const lightSemanticColors: SemanticColors = {
primary: lightTheme.Background,
message: lightTheme.MessageBackground!,
input: lightTheme.InputBackground!,
+ focus: lightTheme.FocusBackground!,
diff: {
added: lightTheme.DiffAdded,
removed: lightTheme.DiffRemoved,
@@ -59,12 +62,13 @@ export const lightSemanticColors: SemanticColors = {
},
border: {
default: lightTheme.DarkGray,
- focused: lightTheme.AccentBlue,
},
ui: {
comment: lightTheme.Comment,
symbol: lightTheme.Gray,
+ active: lightTheme.AccentBlue,
dark: lightTheme.DarkGray,
+ focus: lightTheme.AccentGreen,
gradient: lightTheme.GradientColors,
},
status: {
@@ -86,6 +90,7 @@ export const darkSemanticColors: SemanticColors = {
primary: darkTheme.Background,
message: darkTheme.MessageBackground!,
input: darkTheme.InputBackground!,
+ focus: darkTheme.FocusBackground!,
diff: {
added: darkTheme.DiffAdded,
removed: darkTheme.DiffRemoved,
@@ -93,12 +98,13 @@ export const darkSemanticColors: SemanticColors = {
},
border: {
default: darkTheme.DarkGray,
- focused: darkTheme.AccentBlue,
},
ui: {
comment: darkTheme.Comment,
symbol: darkTheme.Gray,
+ active: darkTheme.AccentBlue,
dark: darkTheme.DarkGray,
+ focus: darkTheme.AccentGreen,
gradient: darkTheme.GradientColors,
},
status: {
diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts
index da54ba5d3e..7456746d95 100644
--- a/packages/cli/src/ui/themes/theme-manager.ts
+++ b/packages/cli/src/ui/themes/theme-manager.ts
@@ -1,42 +1,44 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import { AyuDark } from './ayu.js';
-import { AyuLight } from './ayu-light.js';
-import { AtomOneDark } from './atom-one-dark.js';
-import { Dracula } from './dracula.js';
-import { GitHubDark } from './github-dark.js';
-import { GitHubLight } from './github-light.js';
-import { GoogleCode } from './googlecode.js';
-import { Holiday } from './holiday.js';
-import { DefaultLight } from './default-light.js';
-import { DefaultDark } from './default.js';
-import { ShadesOfPurple } from './shades-of-purple.js';
-import { SolarizedDark } from './solarized-dark.js';
-import { SolarizedLight } from './solarized-light.js';
-import { XCode } from './xcode.js';
+import { AyuDark } from './builtin/dark/ayu-dark.js';
+import { AyuLight } from './builtin/light/ayu-light.js';
+import { AtomOneDark } from './builtin/dark/atom-one-dark.js';
+import { Dracula } from './builtin/dark/dracula-dark.js';
+import { GitHubDark } from './builtin/dark/github-dark.js';
+import { GitHubLight } from './builtin/light/github-light.js';
+import { GoogleCode } from './builtin/light/googlecode-light.js';
+import { Holiday } from './builtin/dark/holiday-dark.js';
+import { DefaultLight } from './builtin/light/default-light.js';
+import { DefaultDark } from './builtin/dark/default-dark.js';
+import { ShadesOfPurple } from './builtin/dark/shades-of-purple-dark.js';
+import { SolarizedDark } from './builtin/dark/solarized-dark.js';
+import { SolarizedLight } from './builtin/light/solarized-light.js';
+import { XCode } from './builtin/light/xcode-light.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Theme, ThemeType, ColorsTheme } from './theme.js';
import type { CustomTheme } from '@google/gemini-cli-core';
-import { createCustomTheme, validateCustomTheme } from './theme.js';
-import type { SemanticColors } from './semantic-tokens.js';
import {
+ createCustomTheme,
+ validateCustomTheme,
interpolateColor,
getThemeTypeFromBackgroundColor,
resolveColor,
-} from './color-utils.js';
+} from './theme.js';
+import type { SemanticColors } from './semantic-tokens.js';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
+ DEFAULT_SELECTION_OPACITY,
DEFAULT_BORDER_OPACITY,
} from '../constants.js';
-import { ANSI } from './ansi.js';
-import { ANSILight } from './ansi-light.js';
-import { NoColorTheme } from './no-color.js';
+import { ANSI } from './builtin/dark/ansi-dark.js';
+import { ANSILight } from './builtin/light/ansi-light.js';
+import { NoColorTheme } from './builtin/no-color.js';
import process from 'node:process';
import { debugLogger, homedir } from '@google/gemini-cli-core';
@@ -369,6 +371,11 @@ class ThemeManager {
colors.Gray,
DEFAULT_BACKGROUND_OPACITY,
),
+ FocusBackground: interpolateColor(
+ this.terminalBackground,
+ activeTheme.colors.FocusColor ?? activeTheme.colors.AccentGreen,
+ DEFAULT_SELECTION_OPACITY,
+ ),
};
} else {
this.cachedColors = colors;
@@ -402,6 +409,7 @@ class ThemeManager {
primary: this.terminalBackground,
message: colors.MessageBackground!,
input: colors.InputBackground!,
+ focus: colors.FocusBackground!,
},
border: {
...semanticColors.border,
@@ -410,6 +418,7 @@ class ThemeManager {
ui: {
...semanticColors.ui,
dark: colors.DarkGray,
+ focus: colors.FocusColor ?? colors.AccentGreen,
},
};
} else {
diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts
index c4277cd834..da7bccf1b2 100644
--- a/packages/cli/src/ui/themes/theme.ts
+++ b/packages/cli/src/ui/themes/theme.ts
@@ -8,18 +8,153 @@ import type { CSSProperties } from 'react';
import type { SemanticColors } from './semantic-tokens.js';
-import {
- resolveColor,
- interpolateColor,
- getThemeTypeFromBackgroundColor,
-} from './color-utils.js';
-
import type { CustomTheme } from '@google/gemini-cli-core';
import {
- DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
+ DEFAULT_SELECTION_OPACITY,
DEFAULT_BORDER_OPACITY,
} from '../constants.js';
+import tinygradient from 'tinygradient';
+import tinycolor from 'tinycolor2';
+
+// Define the set of Ink's named colors for quick lookup
+export const INK_SUPPORTED_NAMES = new Set([
+ 'black',
+ 'red',
+ 'green',
+ 'yellow',
+ 'blue',
+ 'cyan',
+ 'magenta',
+ 'white',
+ 'gray',
+ 'grey',
+ 'blackbright',
+ 'redbright',
+ 'greenbright',
+ 'yellowbright',
+ 'bluebright',
+ 'cyanbright',
+ 'magentabright',
+ 'whitebright',
+]);
+
+// Use tinycolor's built-in names map for CSS colors, excluding ones Ink supports
+export const CSS_NAME_TO_HEX_MAP = Object.fromEntries(
+ Object.entries(tinycolor.names)
+ .filter(([name]) => !INK_SUPPORTED_NAMES.has(name))
+ .map(([name, hex]) => [name, `#${hex}`]),
+);
+
+// Mapping for ANSI bright colors that are not in tinycolor's standard CSS names
+export const INK_NAME_TO_HEX_MAP: Readonly> = {
+ blackbright: '#555555',
+ redbright: '#ff5555',
+ greenbright: '#55ff55',
+ yellowbright: '#ffff55',
+ bluebright: '#5555ff',
+ magentabright: '#ff55ff',
+ cyanbright: '#55ffff',
+ whitebright: '#ffffff',
+};
+
+/**
+ * Calculates the relative luminance of a color.
+ * See https://www.w3.org/TR/WCAG20/#relativeluminancedef
+ *
+ * @param color Color string (hex or Ink-supported name)
+ * @returns Luminance value (0-255)
+ */
+export function getLuminance(color: string): number {
+ const resolved = color.toLowerCase();
+ const hex = INK_NAME_TO_HEX_MAP[resolved] || resolved;
+
+ const colorObj = tinycolor(hex);
+ if (!colorObj.isValid()) {
+ return 0;
+ }
+
+ // tinycolor returns 0-1, we need 0-255
+ return colorObj.getLuminance() * 255;
+}
+
+/**
+ * Resolves a CSS color value (name or hex) into an Ink-compatible color string.
+ * @param colorValue The raw color string (e.g., 'blue', '#ff0000', 'darkkhaki').
+ * @returns An Ink-compatible color string (hex or name), or undefined if not resolvable.
+ */
+export function resolveColor(colorValue: string): string | undefined {
+ const lowerColor = colorValue.toLowerCase();
+
+ // 1. Check if it's already a hex code and valid
+ if (lowerColor.startsWith('#')) {
+ if (/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
+ return lowerColor;
+ } else {
+ return undefined;
+ }
+ }
+
+ // Handle hex codes without #
+ if (/^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
+ return `#${lowerColor}`;
+ }
+
+ // 2. Check if it's an Ink supported name (lowercase)
+ if (INK_SUPPORTED_NAMES.has(lowerColor)) {
+ return lowerColor; // Use Ink name directly
+ }
+
+ // 3. Check if it's a known CSS name we can map to hex
+ // We can't import CSS_NAME_TO_HEX_MAP here due to circular deps,
+ // but we can use tinycolor directly for named colors.
+ const colorObj = tinycolor(lowerColor);
+ if (colorObj.isValid()) {
+ return colorObj.toHexString();
+ }
+
+ // 4. Could not resolve
+ return undefined;
+}
+
+export function interpolateColor(
+ color1: string,
+ color2: string,
+ factor: number,
+) {
+ if (factor <= 0 && color1) {
+ return color1;
+ }
+ if (factor >= 1 && color2) {
+ return color2;
+ }
+ if (!color1 || !color2) {
+ return '';
+ }
+ try {
+ const gradient = tinygradient(color1, color2);
+ const color = gradient.rgbAt(factor);
+ return color.toHexString();
+ } catch (_e) {
+ return color1;
+ }
+}
+
+export function getThemeTypeFromBackgroundColor(
+ backgroundColor: string | undefined,
+): 'light' | 'dark' | undefined {
+ if (!backgroundColor) {
+ return undefined;
+ }
+
+ const resolvedColor = resolveColor(backgroundColor);
+ if (!resolvedColor) {
+ return undefined;
+ }
+
+ const luminance = getLuminance(resolvedColor);
+ return luminance > 128 ? 'light' : 'dark';
+}
export type { CustomTheme };
@@ -43,64 +178,52 @@ export interface ColorsTheme {
DarkGray: string;
InputBackground?: string;
MessageBackground?: string;
+ FocusBackground?: string;
+ FocusColor?: string;
GradientColors?: string[];
}
export const lightTheme: ColorsTheme = {
type: 'light',
- Background: '#FAFAFA',
- Foreground: '',
- LightBlue: '#89BDCD',
- AccentBlue: '#3B82F6',
- AccentPurple: '#8B5CF6',
- AccentCyan: '#06B6D4',
- AccentGreen: '#3CA84B',
- AccentYellow: '#D5A40A',
- AccentRed: '#DD4C4C',
- DiffAdded: '#C6EAD8',
- DiffRemoved: '#FFCCCC',
- Comment: '#008000',
- Gray: '#97a0b0',
- DarkGray: interpolateColor('#FAFAFA', '#97a0b0', DEFAULT_BORDER_OPACITY),
- InputBackground: interpolateColor(
- '#FAFAFA',
- '#97a0b0',
- DEFAULT_INPUT_BACKGROUND_OPACITY,
- ),
- MessageBackground: interpolateColor(
- '#FAFAFA',
- '#97a0b0',
- DEFAULT_BACKGROUND_OPACITY,
- ),
+ Background: '#FFFFFF',
+ Foreground: '#000000',
+ LightBlue: '#005FAF',
+ AccentBlue: '#005FAF',
+ AccentPurple: '#5F00FF',
+ AccentCyan: '#005F87',
+ AccentGreen: '#005F00',
+ AccentYellow: '#875F00',
+ AccentRed: '#AF0000',
+ DiffAdded: '#D7FFD7',
+ DiffRemoved: '#FFD7D7',
+ Comment: '#008700',
+ Gray: '#5F5F5F',
+ DarkGray: '#5F5F5F',
+ InputBackground: '#E4E4E4',
+ MessageBackground: '#FAFAFA',
+ FocusBackground: '#D7FFD7',
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
export const darkTheme: ColorsTheme = {
type: 'dark',
- Background: '#1E1E2E',
- Foreground: '',
- LightBlue: '#ADD8E6',
- AccentBlue: '#89B4FA',
- AccentPurple: '#CBA6F7',
- AccentCyan: '#89DCEB',
- AccentGreen: '#A6E3A1',
- AccentYellow: '#F9E2AF',
- AccentRed: '#F38BA8',
- DiffAdded: '#28350B',
- DiffRemoved: '#430000',
- Comment: '#6C7086',
- Gray: '#6C7086',
- DarkGray: interpolateColor('#1E1E2E', '#6C7086', DEFAULT_BORDER_OPACITY),
- InputBackground: interpolateColor(
- '#1E1E2E',
- '#6C7086',
- DEFAULT_INPUT_BACKGROUND_OPACITY,
- ),
- MessageBackground: interpolateColor(
- '#1E1E2E',
- '#6C7086',
- DEFAULT_BACKGROUND_OPACITY,
- ),
+ Background: '#000000',
+ Foreground: '#FFFFFF',
+ LightBlue: '#AFD7D7',
+ AccentBlue: '#87AFFF',
+ AccentPurple: '#D7AFFF',
+ AccentCyan: '#87D7D7',
+ AccentGreen: '#D7FFD7',
+ AccentYellow: '#FFFFAF',
+ AccentRed: '#FF87AF',
+ DiffAdded: '#005F00',
+ DiffRemoved: '#5F0000',
+ Comment: '#AFAFAF',
+ Gray: '#AFAFAF',
+ DarkGray: '#878787',
+ InputBackground: '#5F5F5F',
+ MessageBackground: '#5F5F5F',
+ FocusBackground: '#005F00',
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -122,6 +245,7 @@ export const ansiTheme: ColorsTheme = {
DarkGray: 'gray',
InputBackground: 'black',
MessageBackground: 'black',
+ FocusBackground: 'black',
};
export class Theme {
@@ -164,7 +288,7 @@ export class Theme {
interpolateColor(
this.colors.Background,
this.colors.Gray,
- DEFAULT_BACKGROUND_OPACITY,
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
),
input:
this.colors.InputBackground ??
@@ -173,6 +297,13 @@ export class Theme {
this.colors.Gray,
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
+ focus:
+ this.colors.FocusBackground ??
+ interpolateColor(
+ this.colors.Background,
+ this.colors.FocusColor ?? this.colors.AccentGreen,
+ DEFAULT_SELECTION_OPACITY,
+ ),
diff: {
added: this.colors.DiffAdded,
removed: this.colors.DiffRemoved,
@@ -180,12 +311,13 @@ export class Theme {
},
border: {
default: this.colors.DarkGray,
- focused: this.colors.AccentBlue,
},
ui: {
comment: this.colors.Gray,
symbol: this.colors.AccentCyan,
+ active: this.colors.AccentBlue,
dark: this.colors.DarkGray,
+ focus: this.colors.FocusColor ?? this.colors.AccentGreen,
gradient: this.colors.GradientColors,
},
status: {
@@ -292,8 +424,14 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
MessageBackground: interpolateColor(
customTheme.background?.primary ?? customTheme.Background ?? '',
customTheme.text?.secondary ?? customTheme.Gray ?? '',
- DEFAULT_BACKGROUND_OPACITY,
+ DEFAULT_INPUT_BACKGROUND_OPACITY,
),
+ FocusBackground: interpolateColor(
+ customTheme.background?.primary ?? customTheme.Background ?? '',
+ customTheme.status?.success ?? customTheme.AccentGreen ?? '#3CA84B', // Fallback to a default green if not found
+ DEFAULT_SELECTION_OPACITY,
+ ),
+ FocusColor: customTheme.ui?.focus ?? customTheme.AccentGreen,
GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors,
};
@@ -450,6 +588,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
primary: customTheme.background?.primary ?? colors.Background,
message: colors.MessageBackground!,
input: colors.InputBackground!,
+ focus: colors.FocusBackground!,
diff: {
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,
@@ -457,12 +596,13 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
},
border: {
default: colors.DarkGray,
- focused: customTheme.border?.focused ?? colors.AccentBlue,
},
ui: {
comment: customTheme.ui?.comment ?? colors.Comment,
symbol: customTheme.ui?.symbol ?? colors.Gray,
+ active: customTheme.ui?.active ?? colors.AccentBlue,
dark: colors.DarkGray,
+ focus: colors.FocusColor ?? colors.AccentGreen,
gradient: customTheme.ui?.gradient ?? colors.GradientColors,
},
status: {
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 55048ef6bc..3898461fb0 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -15,6 +15,7 @@ import {
type SkillDefinition,
type AgentDefinition,
type ApprovalMode,
+ type Kind,
CoreToolCallStatus,
checkExhaustive,
} from '@google/gemini-cli-core';
@@ -98,12 +99,14 @@ export interface ToolCallEvent {
export interface IndividualToolCallDisplay {
callId: string;
+ parentCallId?: string;
name: string;
description: string;
resultDisplay: ToolResultDisplay | undefined;
status: CoreToolCallStatus;
// True when the tool was initiated directly by the user (slash/@/shell flows).
isClientInitiated?: boolean;
+ kind?: Kind;
confirmationDetails: SerializableConfirmationDetails | undefined;
renderOutputAsMarkdown?: boolean;
ptyId?: number;
@@ -150,6 +153,7 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
export type HistoryItemInfo = HistoryItemBase & {
type: 'info';
text: string;
+ secondaryText?: string;
icon?: string;
color?: string;
marginBottom?: number;
@@ -348,18 +352,6 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
showSchema: boolean;
};
-export type HistoryItemHooksList = HistoryItemBase & {
- type: 'hooks_list';
- hooks: Array<{
- config: { command?: string; type: string; timeout?: number };
- source: string;
- eventName: string;
- matcher?: string;
- sequential?: boolean;
- enabled: boolean;
- }>;
-};
-
// Using Omit seems to have some issues with typescript's
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
// 'tools' in historyItem.
@@ -388,8 +380,7 @@ export type HistoryItemWithoutId =
| HistoryItemMcpStatus
| HistoryItemChatList
| HistoryItemThinking
- | HistoryItemHint
- | HistoryItemHooksList;
+ | HistoryItemHint;
export type HistoryItem = HistoryItemWithoutId & { id: number };
@@ -413,7 +404,6 @@ export enum MessageType {
AGENTS_LIST = 'agents_list',
MCP_STATUS = 'mcp_status',
CHAT_LIST = 'chat_list',
- HOOKS_LIST = 'hooks_list',
HINT = 'hint',
}
diff --git a/packages/cli/src/ui/utils/CodeColorizer.test.tsx b/packages/cli/src/ui/utils/CodeColorizer.test.tsx
index 2f231e1bb3..7fc120b58b 100644
--- a/packages/cli/src/ui/utils/CodeColorizer.test.tsx
+++ b/packages/cli/src/ui/utils/CodeColorizer.test.tsx
@@ -50,4 +50,36 @@ describe('colorizeCode', () => {
expect(lastFrame()).toMatch(/line 1\s*\n\s*\n\s*line 3/);
unmount();
});
+
+ it('does not let colors from ansi escape codes leak into colorized code', async () => {
+ const code = 'line 1\n\x1b[41mline 2 with red background\x1b[0m\nline 3';
+ const settings = new LoadedSettings(
+ { path: '', settings: {}, originalSettings: {} },
+ { path: '', settings: {}, originalSettings: {} },
+ {
+ path: '',
+ settings: { ui: { useAlternateBuffer: true, showLineNumbers: false } },
+ originalSettings: {
+ ui: { useAlternateBuffer: true, showLineNumbers: false },
+ },
+ },
+ { path: '', settings: {}, originalSettings: {} },
+ true,
+ [],
+ );
+
+ const result = colorizeCode({
+ code,
+ language: 'javascript',
+ maxWidth: 80,
+ settings,
+ hideLineNumbers: true,
+ });
+
+ const renderResult = renderWithProviders(<>{result}>);
+ await renderResult.waitUntilReady();
+
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
+ });
});
diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx
index 56e34eefa4..e5ce2562af 100644
--- a/packages/cli/src/ui/utils/CodeColorizer.tsx
+++ b/packages/cli/src/ui/utils/CodeColorizer.tsx
@@ -14,6 +14,7 @@ import type {
ElementContent,
RootContent,
} from 'hast';
+import stripAnsi from 'strip-ansi';
import { themeManager } from '../themes/theme-manager.js';
import type { Theme } from '../themes/theme.js';
import {
@@ -98,16 +99,17 @@ function highlightAndRenderLine(
theme: Theme,
): React.ReactNode {
try {
+ const strippedLine = stripAnsi(line);
const getHighlightedLine = () =>
!language || !lowlight.registered(language)
- ? lowlight.highlightAuto(line)
- : lowlight.highlight(language, line);
+ ? lowlight.highlightAuto(strippedLine)
+ : lowlight.highlight(language, strippedLine);
const renderedNode = renderHastNode(getHighlightedLine(), theme, undefined);
- return renderedNode !== null ? renderedNode : line;
+ return renderedNode !== null ? renderedNode : strippedLine;
} catch (_error) {
- return line;
+ return stripAnsi(line);
}
}
@@ -238,7 +240,7 @@ export function colorizeCode({
{`${index + 1}`}
)}
- {line}
+ {stripAnsi(line)}
));
diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx
index 0200fbcb00..b3e88d9a01 100644
--- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx
+++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx
@@ -414,6 +414,7 @@ const RenderListItemInternal: React.FC = ({
}) => {
const prefix = type === 'ol' ? `${marker}. ` : `${marker} `;
const prefixWidth = prefix.length;
+ // Account for leading whitespace (indentation level) plus the standard prefix padding
const indentation = leadingWhitespace.length;
const listResponseColor = theme.text.response ?? theme.text.primary;
@@ -422,7 +423,7 @@ const RenderListItemInternal: React.FC = ({
paddingLeft={indentation + LIST_ITEM_PREFIX_PADDING}
flexDirection="row"
>
-
+ {prefix}
diff --git a/packages/cli/src/ui/utils/__snapshots__/CodeColorizer-colorizeCode-does-not-let-colors-from-ansi-escape-codes-leak-into-colorized-code.snap.svg b/packages/cli/src/ui/utils/__snapshots__/CodeColorizer-colorizeCode-does-not-let-colors-from-ansi-escape-codes-leak-into-colorized-code.snap.svg
new file mode 100644
index 0000000000..89450d03e0
--- /dev/null
+++ b/packages/cli/src/ui/utils/__snapshots__/CodeColorizer-colorizeCode-does-not-let-colors-from-ansi-escape-codes-leak-into-colorized-code.snap.svg
@@ -0,0 +1,16 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/utils/__snapshots__/CodeColorizer.test.tsx.snap b/packages/cli/src/ui/utils/__snapshots__/CodeColorizer.test.tsx.snap
new file mode 100644
index 0000000000..c348c6ef50
--- /dev/null
+++ b/packages/cli/src/ui/utils/__snapshots__/CodeColorizer.test.tsx.snap
@@ -0,0 +1,7 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`colorizeCode > does not let colors from ansi escape codes leak into colorized code 1`] = `
+"line 1
+line 2 with red background
+line 3"
+`;
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-column-widths-based-on-ren-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-column-widths-based-on-ren-.snap.svg
index e01d29e15d..b2704f56ba 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-column-widths-based-on-ren-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-column-widths-based-on-ren-.snap.svg
@@ -6,33 +6,33 @@
┌────────┬────────┬────────┐│
- Col 1
+ Col 1│
- Col 2
+ Col 2│
- Col 3
+ Col 3│├────────┼────────┼────────┤│
- 123456
+ 123456│
- Normal
+ Normal│
- Short
+ Short││
- Short
+ Short│
- 123456
+ 123456│
- Normal
+ Normal││
- Normal
+ Normal│
- Short
+ Short│
- 123456
+ 123456│└────────┴────────┴────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-width-correctly-for-conten-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-width-correctly-for-conten-.snap.svg
index f6f83c0cb0..f631406225 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-width-correctly-for-conten-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-calculates-width-correctly-for-conten-.snap.svg
@@ -6,39 +6,39 @@
┌───────────────────────────────────┬───────────────────────────────┬─────────────────────────────────┐│
- Col 1
+ Col 1│
- Col 2
+ Col 2│
- Col 3
+ Col 3│├───────────────────────────────────┼───────────────────────────────┼─────────────────────────────────┤│
- Visit Google (
- https://google.com
- )
+ Visit Google (
+ https://google.com
+ )│
- Plain Text
+ Plain Text│
- More Info
+ More Info││
- Info Here
+ Info Here│
- Visit Bing (
- https://bing.com
- )
+ Visit Bing (
+ https://bing.com
+ )│
- Links
+ Links││
- Check This
+ Check This│
- Search
+ Search│
- Visit Yahoo (
- https://yahoo.com
- )
+ Visit Yahoo (
+ https://yahoo.com
+ )│└───────────────────────────────────┴───────────────────────────────┴─────────────────────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-does-not-parse-markdown-inside-code-s-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-does-not-parse-markdown-inside-code-s-.snap.svg
index 68069bd0ab..08eab7e946 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-does-not-parse-markdown-inside-code-s-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-does-not-parse-markdown-inside-code-s-.snap.svg
@@ -6,34 +6,34 @@
┌─────────────────┬──────────────────────┬──────────────────┐│
- Col 1
+ Col 1│
- Col 2
+ Col 2│
- Col 3
+ Col 3│├─────────────────┼──────────────────────┼──────────────────┤│
- **not bold**
+ **not bold**│
- _not italic_
+ _not italic_│
- ~~not strike~~
+ ~~not strike~~││
- [not link](url)
+ [not link](url)│
- <u>not underline</u>
+ <u>not underline</u>│
- https://not.link
+ https://not.link││
- Normal Text
+ Normal Text│
- More Code:
- *test*
+ More Code:
+ *test*│
- ***nested***
+ ***nested***│└─────────────────┴──────────────────────┴──────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-nested-markdown-styles-recurs-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-nested-markdown-styles-recurs-.snap.svg
index 3269e29f19..b15120756b 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-nested-markdown-styles-recurs-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-nested-markdown-styles-recurs-.snap.svg
@@ -6,33 +6,39 @@
┌─────────────────────────────┬─────────────────────────────┬─────────────────────────────┐│
- Header 1
+ Header 1│
- Header 2
+ Header 2│
- Header 3
+ Header 3│├─────────────────────────────┼─────────────────────────────┼─────────────────────────────┤│
- Bold with Italic and Strike
+ Bold with
+ Italic
+ and Strike│
- Normal
+ Normal│
- Short
+ Short││
- Short
+ Short│
- Bold with Italic and Strike
+ Bold with
+ Italic
+ and Strike│
- Normal
+ Normal││
- Normal
+ Normal│
- Short
+ Short│
- Bold with Italic and Strike
+ Bold with
+ Italic
+ and Strike│└─────────────────────────────┴─────────────────────────────┴─────────────────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg
index 13898e8641..a4410812dd 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg
@@ -6,26 +6,26 @@
┌──────────────┬────────────┬───────────────┐│
- Emoji 😃
+ Emoji 😃│
- Asian 汉字
+ Asian 汉字│
- Mixed 🚀 Text
+ Mixed 🚀 Text│├──────────────┼────────────┼───────────────┤│
- Start 🌟 End
+ Start 🌟 End│
- 你好世界
+ 你好世界│
- Rocket 🚀 Man
+ Rocket 🚀 Man││
- Thumbs 👍 Up
+ Thumbs 👍 Up│
- こんにちは
+ こんにちは│
- Fire 🔥
+ Fire 🔥│└──────────────┴────────────┴───────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg
index 30d847e86c..99ba8aff43 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg
@@ -6,40 +6,40 @@
┌─────────────┬───────┬─────────┐│
- Very Long
+ Very Long│
- Short
+ Short│
- Another
+ Another││
- Bold Header
+ Bold Header││
- Long
+ Long││
- That Will
+ That Will││
- Header
+ Header││
- Wrap
+ Wrap│││├─────────────┼───────┼─────────┤│
- Data 1
+ Data 1│
- Data
+ Data│
- Data 3
+ Data 3│││
- 2
+ 2││└─────────────┴───────┴─────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg
index dea907221c..ef39407726 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg
@@ -6,33 +6,33 @@
┌──────────────┬──────────────┬──────────────┐│
- Header 1
+ Header 1│
- Header 2
+ Header 2│
- Header 3
+ Header 3│├──────────────┼──────────────┼──────────────┤│
- Row 1, Col 1
+ Row 1, Col 1│
- Row 1, Col 2
+ Row 1, Col 2│
- Row 1, Col 3
+ Row 1, Col 3││
- Row 2, Col 1
+ Row 2, Col 1│
- Row 2, Col 2
+ Row 2, Col 2│
- Row 2, Col 3
+ Row 2, Col 3││
- Row 3, Col 1
+ Row 3, Col 1│
- Row 3, Col 2
+ Row 3, Col 2│
- Row 3, Col 3
+ Row 3, Col 3│└──────────────┴──────────────┴──────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg
index f5a00dbe7c..251476d9e1 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg
@@ -6,56 +6,56 @@
┌─────────────────────────────┬──────────────────────────────┬─────────────────────────────┬──────────────────────────────┬─────┬────────┬─────────┬───────┐│
- Comprehensive Architectural
+ Comprehensive Architectural│
- Implementation Details for
+ Implementation Details for│
- Longitudinal Performance
+ Longitudinal Performance│
- Strategic Security Framework
+ Strategic Security Framework│
- Key
+ Key│
- Status
+ Status│
- Version
+ Version│
- Owner
+ Owner││
- Specification for the
+ Specification for the│
- the High-Throughput
+ the High-Throughput│
- Analysis Across
+ Analysis Across│
- for Mitigating Sophisticated
+ for Mitigating Sophisticated││││││
- Distributed Infrastructure
+ Distributed Infrastructure│
- Asynchronous Message
+ Asynchronous Message│
- Multi-Regional Cloud
+ Multi-Regional Cloud│
- Cross-Site Scripting
+ Cross-Site Scripting││││││
- Layer
+ Layer│
- Processing Pipeline with
+ Processing Pipeline with│
- Deployment Clusters
+ Deployment Clusters│
- Vulnerabilities
+ Vulnerabilities│││
@@ -63,7 +63,7 @@
│││
- Extended Scalability
+ Extended Scalability│││
@@ -73,7 +73,7 @@
│││
- Features and Redundancy
+ Features and Redundancy│││
@@ -83,7 +83,7 @@
│││
- Protocols
+ Protocols│││
@@ -93,105 +93,105 @@
│├─────────────────────────────┼──────────────────────────────┼─────────────────────────────┼──────────────────────────────┼─────┼────────┼─────────┼───────┤│
- The primary architecture
+ The primary architecture│
- Each message is processed
+ Each message is processed│
- Historical data indicates a
+ Historical data indicates a│
- A multi-layered defense
+ A multi-layered defense│
- INF
+ INF│
- Active
+ Active│
- v2.4
+ v2.4│
- J.
+ J.││
- utilizes a decoupled
+ utilizes a decoupled│
- through a series of
+ through a series of│
- significant reduction in
+ significant reduction in│
- strategy incorporates
+ strategy incorporates││││
- Doe
+ Doe││
- microservices approach,
+ microservices approach,│
- specialized workers that
+ specialized workers that│
- tail latency when utilizing
+ tail latency when utilizing│
- content security policies,
+ content security policies,││││││
- leveraging container
+ leveraging container│
- handle data transformation,
+ handle data transformation,│
- edge computing nodes closer
+ edge computing nodes closer│
- input sanitization
+ input sanitization││││││
- orchestration for
+ orchestration for│
- validation, and persistent
+ validation, and persistent│
- to the geographic location
+ to the geographic location│
- libraries, and regular
+ libraries, and regular││││││
- scalability and fault
+ scalability and fault│
- storage using a persistent
+ storage using a persistent│
- of the end-user base.
+ of the end-user base.│
- automated penetration
+ automated penetration││││││
- tolerance in high-load
+ tolerance in high-load│
- queue.
+ queue.││
- testing routines.
+ testing routines.││││││
- scenarios.
+ scenarios.││
- Monitoring tools have
+ Monitoring tools have│││
@@ -200,85 +200,85 @@
│││
- The pipeline features
+ The pipeline features│
- captured a steady increase
+ captured a steady increase│
- Developers are required to
+ Developers are required to││││││
- This layer provides the
+ This layer provides the│
- built-in retry mechanisms
+ built-in retry mechanisms│
- in throughput efficiency
+ in throughput efficiency│
- undergo mandatory security
+ undergo mandatory security││││││
- fundamental building blocks
+ fundamental building blocks│
- with exponential backoff to
+ with exponential backoff to│
- since the introduction of
+ since the introduction of│
- training focusing on the
+ training focusing on the││││││
- for service discovery, load
+ for service discovery, load│
- ensure message delivery
+ ensure message delivery│
- the vectorized query engine
+ the vectorized query engine│
- OWASP Top Ten to ensure that
+ OWASP Top Ten to ensure that││││││
- balancing, and
+ balancing, and│
- integrity even during
+ integrity even during│
- in the primary data
+ in the primary data│
- security is integrated into
+ security is integrated into││││││
- inter-service communication
+ inter-service communication│
- transient network or service
+ transient network or service│
- warehouse.
+ warehouse.│
- the initial design phase.
+ the initial design phase.││││││
- via highly efficient
+ via highly efficient│
- failures.
+ failures.│││
@@ -287,12 +287,12 @@
│││
- protocol buffers.
+ protocol buffers.││
- Resource utilization
+ Resource utilization│
- The implementation of a
+ The implementation of a│││
@@ -300,85 +300,85 @@
│││
- Horizontal autoscaling is
+ Horizontal autoscaling is│
- metrics demonstrate that
+ metrics demonstrate that│
- robust Identity and Access
+ robust Identity and Access││││││
- Advanced telemetry and
+ Advanced telemetry and│
- triggered automatically
+ triggered automatically│
- the transition to
+ the transition to│
- Management system ensures
+ Management system ensures││││││
- logging integrations allow
+ logging integrations allow│
- based on the depth of the
+ based on the depth of the│
- serverless compute for
+ serverless compute for│
- that the principle of least
+ that the principle of least││││││
- for real-time monitoring of
+ for real-time monitoring of│
- processing queue, ensuring
+ processing queue, ensuring│
- intermittent tasks has
+ intermittent tasks has│
- privilege is strictly
+ privilege is strictly││││││
- system health and rapid
+ system health and rapid│
- consistent performance
+ consistent performance│
- resulted in a thirty
+ resulted in a thirty│
- enforced across all
+ enforced across all││││││
- identification of
+ identification of│
- during unexpected traffic
+ during unexpected traffic│
- percent cost optimization.
+ percent cost optimization.│
- environments.
+ environments.││││││
- bottlenecks within the
+ bottlenecks within the│
- spikes.
+ spikes.│││
@@ -387,7 +387,7 @@
│││
- service mesh.
+ service mesh.│││
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg
index 8da55efa8b..828c7fd9fa 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg
@@ -6,57 +6,57 @@
┌───────────────┬───────────────┬──────────────────┬──────────────────┐│
- Very Long
+ Very Long│
- Very Long
+ Very Long│
- Very Long Column
+ Very Long Column│
- Very Long Column
+ Very Long Column││
- Column Header
+ Column Header│
- Column Header
+ Column Header│
- Header Three
+ Header Three│
- Header Four
+ Header Four││
- One
+ One│
- Two
+ Two│││├───────────────┼───────────────┼──────────────────┼──────────────────┤│
- Data 1.1
+ Data 1.1│
- Data 1.2
+ Data 1.2│
- Data 1.3
+ Data 1.3│
- Data 1.4
+ Data 1.4││
- Data 2.1
+ Data 2.1│
- Data 2.2
+ Data 2.2│
- Data 2.3
+ Data 2.3│
- Data 2.4
+ Data 2.4││
- Data 3.1
+ Data 3.1│
- Data 3.2
+ Data 3.2│
- Data 3.3
+ Data 3.3│
- Data 3.4
+ Data 3.4│└───────────────┴───────────────┴──────────────────┴──────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg
index 0db46485e0..3e76bc05e3 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg
@@ -6,26 +6,26 @@
┌───────────────┬───────────────────┬────────────────┐│
- Mixed 😃 中文
+ Mixed 😃 中文│
- Complex 🚀 日本語
+ Complex 🚀 日本語│
- Text 📝 한국어
+ Text 📝 한국어│├───────────────┼───────────────────┼────────────────┤│
- 你好 😃
+ 你好 😃│
- こんにちは 🚀
+ こんにちは 🚀│
- 안녕하세요 📝
+ 안녕하세요 📝││
- World 🌍
+ World 🌍│
- Code 💻
+ Code 💻│
- Pizza 🍕
+ Pizza 🍕│└───────────────┴───────────────────┴────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg
index b808d1e335..7f31b51548 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg
@@ -6,26 +6,26 @@
┌──────────────┬─────────────────┬───────────────┐│
- Chinese 中文
+ Chinese 中文│
- Japanese 日本語
+ Japanese 日本語│
- Korean 한국어
+ Korean 한국어│├──────────────┼─────────────────┼───────────────┤│
- 你好
+ 你好│
- こんにちは
+ こんにちは│
- 안녕하세요
+ 안녕하세요││
- 世界
+ 世界│
- 世界
+ 世界│
- 세계
+ 세계│└──────────────┴─────────────────┴───────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg
index 9277078253..a3abd45c53 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg
@@ -6,26 +6,26 @@
┌──────────┬───────────┬──────────┐│
- Happy 😀
+ Happy 😀│
- Rocket 🚀
+ Rocket 🚀│
- Heart ❤️
+ Heart ❤️│├──────────┼───────────┼──────────┤│
- Smile 😃
+ Smile 😃│
- Fire 🔥
+ Fire 🔥│
- Love 💖
+ Love 💖││
- Cool 😎
+ Cool 😎│
- Star ⭐
+ Star ⭐│
- Blue 💙
+ Blue 💙│└──────────┴───────────┴──────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-complex-markdown-in-rows-and-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-complex-markdown-in-rows-and-.snap.svg
index 8b251c3ab2..b48572438b 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-complex-markdown-in-rows-and-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-complex-markdown-in-rows-and-.snap.svg
@@ -6,47 +6,47 @@
┌───────────────┬─────────────────────────────┐│
- Feature
+ Feature│
- Markdown
+ Markdown│├───────────────┼─────────────────────────────┤│
- Bold
+ Bold│
- Bold Text
+ Bold Text││
- Italic
+ Italic│
- Italic Text
+ Italic Text││
- Combined
+ Combined│
- Bold and Italic
+ Bold and Italic││
- Link
+ Link│
- Google (
- https://google.com
- )
+ Google (
+ https://google.com
+ )││
- Code
+ Code│
- const x = 1
+ const x = 1││
- Strikethrough
+ Strikethrough│
- Strike
+ Strike││
- Underline
+ Underline│
- Underline
+ Underline│└───────────────┴─────────────────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg
index b2523badcd..180c7aeb56 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg
@@ -10,9 +10,9 @@
│├────────┼────────┤│
- Data 1
+ Data 1│
- Data 2
+ Data 2│└────────┴────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg
index 89ad1cfb4c..685260b84d 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg
@@ -6,17 +6,17 @@
┌──────────┬──────────┬──────────┐│
- Header 1
+ Header 1│
- Header 2
+ Header 2│
- Header 3
+ Header 3│├──────────┼──────────┼──────────┤│
- Data 1
+ Data 1│
- Data 2
+ Data 2││└──────────┴──────────┴──────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg
index 717a8803f8..bc33d9e78a 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg
@@ -6,19 +6,19 @@
┌─────────────┬───────────────┬──────────────┐│
- Bold Header
+ Bold Header│
- Normal Header
+ Normal Header│
- Another Bold
+ Another Bold│├─────────────┼───────────────┼──────────────┤│
- Data 1
+ Data 1│
- Data 2
+ Data 2│
- Data 3
+ Data 3│└─────────────┴───────────────┴──────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg
index e59cefbc72..d69f29ece4 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg
@@ -6,46 +6,46 @@
┌────────────────┬────────────────┬─────────────────┐│
- Col 1
+ Col 1│
- Col 2
+ Col 2│
- Col 3
+ Col 3│├────────────────┼────────────────┼─────────────────┤│
- This is a very
+ This is a very│
- This is also a
+ This is also a│
- And this is the
+ And this is the││
- long text that
+ long text that│
- very long text
+ very long text│
- third long text
+ third long text││
- needs wrapping
+ needs wrapping│
- that needs
+ that needs│
- that needs
+ that needs││
- in column 1
+ in column 1│
- wrapping in
+ wrapping in│
- wrapping in
+ wrapping in│││
- column 2
+ column 2│
- column 3
+ column 3│└────────────────┴────────────────┴─────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg
index 42f7b188f8..f16cdd29ae 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg
@@ -6,45 +6,45 @@
┌───────────────────┬───────────────┬─────────────────┐│
- Punctuation 1
+ Punctuation 1│
- Punctuation 2
+ Punctuation 2│
- Punctuation 3
+ Punctuation 3│├───────────────────┼───────────────┼─────────────────┤│
- Start. Stop.
+ Start. Stop.│
- Semi; colon:
+ Semi; colon:│
- At@ Hash#
+ At@ Hash#││
- Comma, separated.
+ Comma, separated.│
- Pipe| Slash/
+ Pipe| Slash/│
- Dollar$
+ Dollar$││
- Exclamation!
+ Exclamation!│
- Backslash\
+ Backslash\│
- Percent% Caret^
+ Percent% Caret^││
- Question?
+ Question?││
- Ampersand&
+ Ampersand&││
- hyphen-ated
+ hyphen-ated││
- Asterisk*
+ Asterisk*│└───────────────────┴───────────────┴─────────────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg
index 2cfd46bc54..f46137df13 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg
@@ -6,28 +6,28 @@
┌───────┬─────────────────────────────┬───────┐│
- Col 1
+ Col 1│
- Col 2
+ Col 2│
- Col 3
+ Col 3│├───────┼─────────────────────────────┼───────┤│
- Short
+ Short│
- This is a very long cell
+ This is a very long cell│
- Short
+ Short│││
- content that should wrap to
+ content that should wrap to││││
- multiple lines
+ multiple lines││└───────┴─────────────────────────────┴───────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg
index 0e5dbcbb30..f517dc3632 100644
--- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg
@@ -6,29 +6,29 @@
┌───────┬──────────────────────────┬────────┐│
- Short
+ Short│
- Long
+ Long│
- Medium
+ Medium│├───────┼──────────────────────────┼────────┤│
- Tiny
+ Tiny│
- This is a very long text
+ This is a very long text│
- Not so
+ Not so│││
- that definitely needs to
+ that definitely needs to│
- long
+ long│││
- wrap to the next line
+ wrap to the next line││└───────┴──────────────────────────┴────────┘
diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg
index b9290efcac..6a693d318b 100644
--- a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg
+++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg
@@ -1,123 +1,32 @@
-/g, '>')}