Compare commits

..

45 Commits

Author SHA1 Message Date
Spencer Tang 90fda1400e fix(core): add timeout to tree-sitter initialization to prevent hanging 2026-04-08 17:06:50 -04:00
Sri Pasumarthi 4ebc43bc66 feat(test-utils): add memory usage integration test harness (#24876) 2026-04-08 17:42:18 +00:00
ruomeng 34b4f1c6e4 refactor(plan): simplify policy priorities and consolidate read-only rules (#24849) 2026-04-08 15:58:29 +00:00
Gaurav e77b22e638 fix: isolate concurrent browser agent instances (#24794) 2026-04-08 14:31:10 +00:00
Adam Weidman 1b3e7d674f docs: update MCP server OAuth redirect port documentation (#24844) 2026-04-08 14:06:30 +00:00
Gaurav Ghosh e7f8d9cf1a Revert "feat: Introduce an AI-driven interactive shell mode with new"
This reverts commit 651ad63ed6.
2026-04-08 07:31:17 -07:00
Gaurav Ghosh 651ad63ed6 feat: Introduce an AI-driven interactive shell mode with new
`read-shell` and `write-to-shell` tools and a configurable mode setting.
2026-04-08 07:27:28 -07:00
Jacob Richman cbacdc67d0 feat(cli): switch to ctrl+g from ctrl-x (#24861) 2026-04-08 06:22:45 +00:00
Jacob Richman 7e1938c1bc fix(cli): switch default back to terminalBuffer=false and fix regressions introduced for that mode (#24873) 2026-04-08 05:47:54 +00:00
Anjaligarhwal b9f1d832c8 fix(core): dispose Scheduler to prevent McpProgress listener leak (#24870) 2026-04-08 03:05:53 +00:00
Dev Randalpura 47c5d25d93 Added flag for ept size and increased default size (#24859) 2026-04-08 03:03:36 +00:00
Jacob Richman 28efab483f fix(cli): always show shell command description or actual command (#24774) 2026-04-08 01:52:33 +00:00
gemini-cli-robot 9fd92c0eea Changelog for v0.37.0-preview.2 (#24848)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-04-08 00:13:06 +00:00
Michael Bleigh 16768c08f2 refactor(core): make LegacyAgentSession dependencies optional (#24287)
Co-authored-by: Adam Weidman <adamfweidman@gmail.com>
Co-authored-by: Adam Weidman <adamfweidman@google.com>
2026-04-07 23:45:22 +00:00
JAYADITYA 1aa798dd18 refactor(cli): remove duplication in interactive shell awaiting input hint (#24801) 2026-04-07 23:36:44 +00:00
Christian Gunderman f96d5f98fe Revert "fix(ui): improve narration suppression and reduce flicker (#2… (#24857) 2026-04-07 22:45:40 +00:00
Yuna Seol 3c5b5db034 feat(core): use experiment flags for default fetch timeouts (#24261) 2026-04-07 22:35:04 +00:00
Michael Bleigh 986293bd38 feat(core): add agent protocol UI types and experimental flag (#24275)
Co-authored-by: Adam Weidman <adamfweidman@gmail.com>
Co-authored-by: Adam Weidman <adamfweidman@google.com>
2026-04-07 21:45:18 +00:00
David Pierce adf7b3b717 Improve sandbox error matching and caching (#24550) 2026-04-07 21:08:18 +00:00
Jack Wotherspoon 9637fb3990 fix(core): remove tmux alternate buffer warning (#24852) 2026-04-07 21:01:14 +00:00
Sri Pasumarthi 06fcdc231c feat(acp): add /help command (#24839) 2026-04-07 20:01:44 +00:00
Sehoon Shon d29da15427 fix(cli): prevent multiple banner increments on remount (#24843) 2026-04-07 19:44:09 +00:00
Enjoy Kumawat ab3075feb9 fix: use directory junctions on Windows for skill linking (#24823) 2026-04-07 19:28:43 +00:00
Abhi 5588000e93 chore: fix formatting for behavioral eval skill reference file (#24846) 2026-04-07 19:26:53 +00:00
krishdef7 68fef8745e fix(core): propagate BeforeModel hook model override end-to-end (#24784)
Signed-off-by: krishdef7 <gargkrish06@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-04-07 17:49:26 +00:00
Michael Bleigh e432f7c009 feat(hooks): display hook system messages in UI (#24616) 2026-04-07 17:42:39 +00:00
Alisa 846051f716 Alisa/approve button (#24645) 2026-04-07 16:44:07 +00:00
Tommaso Sciortino 1c22c5b37b Remove flakey test (#24837) 2026-04-07 09:39:15 -07:00
Emily Hedlund 1762c9c509 temporarily disable sandbox integration test on windows (#24786) 2026-04-07 15:33:40 +00:00
Abhijit Balaji 0025978d76 feat(cli): support selective topic expansion and click-to-expand (#24793) 2026-04-07 15:00:40 +00:00
Gaurav 4c5e887732 feat(telemetry): add browser agent clearcut metrics (#24688) 2026-04-07 07:48:38 +00:00
Abhi 83096c68b0 fix(policy): allow complete_task in plan mode (#24771) 2026-04-07 03:43:42 +00:00
Christian Gunderman d2b775f9a7 Add an eval for and fix unsafe cloning behavior. (#24457) 2026-04-07 03:17:44 +00:00
Spencer 0a8da988ed fix(cli): ensure skills list outputs to stdout in non-interactive environments (#24566) 2026-04-07 02:40:23 +00:00
David Pierce 984f02c180 relax tool sandboxing overrides for plan mode to match defaults. (#24762) 2026-04-06 22:18:10 +00:00
Tommaso Sciortino df67f973ed fix(cli): respect global environment variable allowlist (#24767) 2026-04-06 22:17:55 +00:00
Christian Gunderman 7872d6d7fe fix(ui): improve narration suppression and reduce flicker (#24635) 2026-04-06 21:18:59 +00:00
Gaurav e116aa34f4 fix(browser): remove premature browser cleanup after subagent invocation (#24753) 2026-04-06 21:17:31 +00:00
Abhijit Balaji ad98294352 Revert "feat(core,cli): prioritize summary for topics (#24608)" (#24777) 2026-04-06 20:33:18 +00:00
Dev Randalpura 2353a6d253 fix(ui): fixed auth race condition causing logo to flicker (#24652) 2026-04-06 20:17:05 +00:00
krishdef7 8ac560d2c9 fix(core): handle partial llm_request in BeforeModel hook override (#22326) 2026-04-06 20:11:38 +00:00
Tommaso Sciortino c6a9d3de13 fix(cli): remove -S from shebang to fix Windows and BSD execution (#24756) 2026-04-06 19:06:56 +00:00
Christian Gunderman 8f131ffef7 Fix issue where topic headers can be posted back to back (#24759) 2026-04-06 18:36:22 +00:00
Jacob Richman 70f6d6a992 split context (#24623) 2026-04-06 17:20:38 +00:00
cynthialong0-0 c96cb09e09 feat(cli): add role specific metrics to /stats (#24659) 2026-04-06 16:20:48 +00:00
197 changed files with 5487 additions and 1955 deletions
@@ -33,6 +33,35 @@ evaluation.
- **Warning**: Do not lose test fidelity by making prompts too direct/easy.
- **Primary Fix Trigger**: Adjust tool descriptions, system prompts
(`snippets.ts`), or **modules that contribute to the prompt template**.
- Fixes should generally try to improve the prompt
`@packages/core/src/prompts/snippets.ts` first.
- **Instructional Generality**: Changes to the system prompt should aim to
be as general as possible while still accomplishing the goal. Specificity
should be added only as needed.
- **Principle**: Instead of creating "forbidden lists" for specific syntax
(e.g., "Don't use `Object.create()`"), formulate a broader engineering
principle that covers the underlying issue (e.g., "Prioritize explicit
composition over hidden prototype manipulation"). This improves
steerability across a wider range of similar scenarios.
- _Low Specificity_: "Follow ecosystem best practices"
- _Medium Specificity_: "Utilize OOP and functional best practices, as
applicable"
- _High Specificity_: Provide ecosystem-specific hints as examples of a
broader principle rather than direct instructions. e.g., "NEVER use
hacks like bypassing the type system or employing 'hidden' logic (e.g.:
reflection, prototype manipulation). Instead, use explicit and idiomatic
language features (e.g.: type guards, explicit class instantiation, or
object spread) that maintain structural integrity."
- **Prompt Simplification**: Once the test is passing, use `ask_user` to
determine if prompt simplification is desired.
- **Criteria**: Simplification should be attempted only if there are
related clauses that can be de-duplicated or reparented under a single
heading.
- **Verification**: As part of simplification, you MUST identify and run
any behavioral eval tests that might be affected by the changes to
ensure no regressions are introduced.
- Test fixes should not "cheat" by changing a test's `GEMINI.md` file or by
updating the test's prompt to instruct it to not repro the bug.
- **Warning**: Prompts have multiple configurations; ensure your fix targets
the correct config for the model in question.
4. **Architecture Options**: If prompt or instruction tuning triggers no
+86 -19
View File
@@ -1,7 +1,7 @@
name: 'Evals: PR Evaluation & Regression'
on:
pull_request:
pull_request_target:
types: ['opened', 'synchronize', 'reopened', 'ready_for_review']
paths:
- 'packages/core/src/prompts/**'
@@ -23,13 +23,73 @@ permissions:
actions: 'read'
jobs:
detect-changes:
name: 'Detect Steering Changes'
runs-on: 'gemini-cli-ubuntu-16-core'
# Security: pull_request_target allows secrets, so we must gate carefully.
# Detection should not run code from the fork.
if: "github.repository == 'google-gemini/gemini-cli' && github.event.pull_request.draft == false"
outputs:
SHOULD_RUN: '${{ steps.detect.outputs.SHOULD_RUN }}'
STEERING_DETECTED: '${{ steps.detect.outputs.STEERING_DETECTED }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
with:
# Check out the trusted code from main for detection
fetch-depth: 0
- name: 'Detect Steering Changes'
id: 'detect'
env:
# Use the PR's head SHA for comparison without checking it out
PR_HEAD_SHA: '${{ github.event.pull_request.head.sha }}'
run: |
# Fetch the fork's PR branch for analysis
git fetch origin pull/${{ github.event.pull_request.number }}/head:pr-head
# Run the trusted script from main
SHOULD_RUN=$(node scripts/changed_prompt.js)
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "SHOULD_RUN=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Notify Approval Required'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
COMMENT_BODY="### 🛑 Action Required: Evaluation Approval
Steering changes have been detected in this PR. To prevent regressions, a maintainer must approve the evaluation run before this PR can be merged.
**Maintainers:**
1. Go to the [**Workflow Run Summary**]($RUN_URL).
2. Click the yellow **'Review deployments'** button.
3. Select the **'eval-gate'** environment and click **'Approve'**.
Once approved, the evaluation results will be posted here automatically.
<!-- eval-approval-notification -->"
# Check if comment already exists to avoid spamming
COMMENT_ID=$(gh pr view ${{ github.event.pull_request.number }} --json comments --jq '.comments[] | select(.body | contains("<!-- eval-approval-notification -->")) | .url' | grep -oE "[0-9]+$" | head -n 1)
if [ -z "$COMMENT_ID" ]; then
gh pr comment ${{ github.event.pull_request.number }} --body "$COMMENT_BODY"
else
echo "Updating existing notification comment $COMMENT_ID..."
gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -F body="$COMMENT_BODY"
fi
pr-evaluation:
name: 'Evaluate Steering & Regressions'
needs: 'detect-changes'
if: "needs.detect-changes.outputs.SHOULD_RUN == 'true'"
# Manual approval gate via environment
environment: 'eval-gate'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository))"
# External contributors' PRs will wait for approval in this environment
environment: |-
${{ (github.event.pull_request.head.repo.full_name == github.repository) && 'internal' || 'external-evals' }}
env:
# CENTRALIZED MODEL LIST
MODEL_LIST: 'gemini-3-flash-preview'
@@ -38,32 +98,40 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
with:
# Check out the fork's PR code for the actual evaluation
# This only runs AFTER manual approval
ref: '${{ github.event.pull_request.head.sha }}'
fetch-depth: 0
- name: 'Remove Approval Notification'
# Run even if other steps fail, to ensure we clean up the "Action Required" message
if: 'always()'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_NUMBER: '${{ github.event.pull_request.number }}'
run: |
echo "Debug: PR_NUMBER is '$PR_NUMBER'"
# Search for the notification comment by its hidden tag
COMMENT_ID=$(gh pr view "$PR_NUMBER" --json comments --jq '.comments[] | select(.body | contains("<!-- eval-approval-notification -->")) | .url' | grep -oE "[0-9]+$" | head -n 1)
if [ -n "$COMMENT_ID" ]; then
echo "Removing notification comment $COMMENT_ID now that run is approved..."
gh api -X DELETE "repos/${{ github.repository }}/issues/comments/$COMMENT_ID"
fi
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Detect Steering Changes'
id: 'detect'
run: |
SHOULD_RUN=$(node scripts/changed_prompt.js)
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "SHOULD_RUN=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Install dependencies'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
run: 'npm ci'
- name: 'Build project'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
run: 'npm run build'
- name: 'Analyze PR Content (Guidance)'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
if: "needs.detect-changes.outputs.STEERING_DETECTED == 'true'"
id: 'analysis'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
@@ -81,7 +149,6 @@ jobs:
fi
- name: 'Execute Regression Check'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
@@ -96,7 +163,7 @@ jobs:
fi
- name: 'Post or Update PR Comment'
if: "always() && (steps.detect.outputs.STEERING_DETECTED == 'true' || env.REPORT_FILE != '')"
if: "always() && (needs.detect-changes.outputs.STEERING_DETECTED == 'true' || env.REPORT_FILE != '')"
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
@@ -107,7 +174,7 @@ jobs:
echo ""
fi
if [[ "${{ steps.detect.outputs.STEERING_DETECTED }}" == "true" ]]; then
if [[ "${{ needs.detect-changes.outputs.STEERING_DETECTED }}" == "true" ]]; then
echo "### 🧠 Model Steering Guidance"
echo ""
echo "This PR modifies files that affect the model's behavior (prompts, tools, or instructions)."
+33
View File
@@ -0,0 +1,33 @@
name: 'Memory Tests: Nightly'
on:
schedule:
- cron: '0 2 * * *' # Runs at 2 AM every day
workflow_dispatch: # Allow manual trigger
permissions:
contents: 'read'
jobs:
memory-test:
name: 'Run Memory Usage Tests'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Build project'
run: 'npm run build'
- name: 'Run Memory Tests'
run: 'npm run test:memory'
+2
View File
@@ -44,6 +44,8 @@ powerful tool for developers.
- **Test Commands:**
- **Unit (All):** `npm run test`
- **Integration (E2E):** `npm run test:e2e`
- **Memory (Nightly):** `npm run test:memory` (Runs memory regression tests
against baselines. Excluded from `preflight`, run nightly.)
- **Workspace-Specific:** `npm test -w <pkg> -- <path>` (Note: `<path>` must
be relative to the workspace root, e.g.,
`-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`)
+7 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.37.0-preview.1
# Preview release: v0.37.0-preview.2
Released: April 02, 2026
Released: April 07, 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).
@@ -33,6 +33,10 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick cb7f7d6 to release/v0.37.0-preview.1-pr-24342 to patch
version v0.37.0-preview.1 and create version 0.37.0-preview.2 by
@gemini-cli-robot in
[#24842](https://github.com/google-gemini/gemini-cli/pull/24842)
- fix(patch): cherry-pick 64c928f to release/v0.37.0-preview.0-pr-23257 to patch
version v0.37.0-preview.0 and create version 0.37.0-preview.1 by
@gemini-cli-robot in
@@ -419,4 +423,4 @@ npm install -g @google/gemini-cli@preview
[#23275](https://github.com/google-gemini/gemini-cli/pull/23275)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.36.0-preview.8...v0.37.0-preview.1
https://github.com/google-gemini/gemini-cli/compare/v0.36.0-preview.8...v0.37.0-preview.2
+1 -1
View File
@@ -75,7 +75,7 @@ they appear in the UI.
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Render Process | `ui.renderProcess` | Enable Ink render process for the UI. | `true` |
| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `true` |
| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
+40
View File
@@ -117,6 +117,46 @@ npm run test:integration:sandbox:docker
npm run test:integration:sandbox:podman
```
## Memory regression tests
Memory regression tests are designed to detect heap growth and leaks across key
CLI scenarios. They are located in the `memory-tests` directory.
These tests are distinct from standard integration tests because they measure
memory usage and compare it against committed baselines.
### Running memory tests
Memory tests are not run as part of the default `npm run test` or
`npm run test:e2e` commands. They are run nightly in CI but can be run manually:
```bash
npm run test:memory
```
### Updating baselines
If you intentionally change behavior that affects memory usage, you may need to
update the baselines. Set the `UPDATE_MEMORY_BASELINES` environment variable to
`true`:
```bash
UPDATE_MEMORY_BASELINES=true npm run test:memory
```
This will run the tests, take median snapshots, and overwrite
`memory-tests/baselines.json`. You should review the changes and commit the
updated baseline file.
### How it works
The harness (`MemoryTestHarness` in `packages/test-utils`):
- Forces garbage collection multiple times to reduce noise.
- Takes median snapshots to filter spikes.
- Compares against baselines with a 10% tolerance.
- Can analyze sustained leaks across 3 snapshots using `analyzeSnapshots()`.
## Diagnostics
The integration test runner provides several options for diagnostics to help
+7 -1
View File
@@ -346,7 +346,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`ui.terminalBuffer`** (boolean):
- **Description:** Use the new terminal buffer architecture for rendering.
- **Default:** `true`
- **Default:** `false`
- **Requires restart:** Yes
- **`ui.useBackgroundColor`** (boolean):
@@ -1606,6 +1606,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.adk.agentSessionInteractiveEnabled`** (boolean):
- **Description:** Enable the agent session implementation for the interactive
CLI.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `true`
+9 -9
View File
@@ -86,13 +86,14 @@ available combinations.
#### Text Input
| Command | Action | Keys |
| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
| Command | Action | Keys |
| ------------------------------------ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+G` |
| `input.deprecatedOpenExternalEditor` | Deprecated command to open external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
#### App Controls
@@ -100,11 +101,10 @@ available combinations.
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `app.showErrorDetails` | Toggle detailed error information. | `F12` |
| `app.showFullTodos` | Toggle the full TODO list. | `Ctrl+T` |
| `app.showIdeContextDetail` | Show IDE context details. | `Ctrl+G` |
| `app.showIdeContextDetail` | Show IDE context details. | `F4` |
| `app.toggleMarkdown` | Toggle Markdown rendering. | `Alt+M` |
| `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `F9` |
| `app.toggleMouseMode` | Toggle mouse mode (scrolling and clicking). | `Ctrl+S` |
| `app.toggleAlternateBuffer` | Toggle alternate screen buffer. | `Alt+A` |
| `app.toggleYolo` | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl+Y` |
| `app.cycleApprovalMode` | 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` |
| `app.showMoreLines` | Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl+O` |
+3 -3
View File
@@ -290,7 +290,7 @@ When connecting to an OAuth-enabled server:
> OAuth authentication requires that your local machine can:
>
> - Open a web browser for authentication
> - Receive redirects on `http://localhost:7777/oauth/callback`
> - Receive redirects on `http://localhost:<random-port>/oauth/callback` (or a specific port if configured via `redirectUri`)
This feature will not work in:
@@ -323,8 +323,8 @@ Use the `/mcp auth` command to manage OAuth authentication:
if omitted)
- **`tokenUrl`** (string): OAuth token endpoint (auto-discovered if omitted)
- **`scopes`** (string[]): Required OAuth scopes
- **`redirectUri`** (string): Custom redirect URI (defaults to
`http://localhost:7777/oauth/callback`)
- **`redirectUri`** (string): Custom redirect URI (defaults to an OS-assigned
random port, e.g., `http://localhost:<random-port>/oauth/callback`)
- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs
- **`audiences`** (string[]): Audiences the token is valid for
+64
View File
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { evalTest, TestRig } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'Reproduction: Agent uses Object.create() for cloning/delegation',
prompt:
'Create a utility function `createScopedConfig(config: Config, additionalDirectories: string[]): Config` in `packages/core/src/config/scoped-config.ts` that returns a new Config instance. This instance should override `getWorkspaceContext()` to include the additional directories, but delegate all other method calls (like `isPathAllowed` or `validatePathAccess`) to the original config. Note that `Config` is a complex class with private state and cannot be easily shallow-copied or reconstructed.',
files: {
'packages/core/src/config/config.ts': `
export class Config {
private _internalState = 'secret';
constructor(private workspaceContext: any) {}
getWorkspaceContext() { return this.workspaceContext; }
isPathAllowed(path: string) {
return this.getWorkspaceContext().isPathWithinWorkspace(path);
}
validatePathAccess(path: string) {
if (!this.isPathAllowed(path)) return 'Denied';
return null;
}
}`,
'packages/core/src/utils/workspaceContext.ts': `
export class WorkspaceContext {
constructor(private root: string, private additional: string[] = []) {}
getDirectories() { return [this.root, ...this.additional]; }
isPathWithinWorkspace(path: string) {
return this.getDirectories().some(d => path.startsWith(d));
}
}`,
'package.json': JSON.stringify({
name: 'test-project',
version: '1.0.0',
type: 'module',
}),
},
assert: async (rig: TestRig) => {
const filePath = 'packages/core/src/config/scoped-config.ts';
const content = rig.readFile(filePath);
if (!content) {
throw new Error(`File ${filePath} was not created.`);
}
// Strip comments to avoid false positives.
const codeWithoutComments = content.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '');
// Ensure that the agent did not use Object.create() in the implementation.
// We check for the call pattern specifically using a regex to avoid false positives in comments.
const hasObjectCreate = /\bObject\.create\s*\(/.test(codeWithoutComments);
if (hasObjectCreate) {
throw new Error(
'Evaluation Failed: Agent used Object.create() for cloning. ' +
'This behavior is forbidden by the project lint rules (no-restricted-syntax). ' +
'Implementation found:\n\n' +
content,
);
}
},
});
+43
View File
@@ -215,4 +215,47 @@ export default app;
expect(fs.existsSync(path.join(rig.testDir, 'src/routes.ts'))).toBe(true);
},
});
/**
* Regression test for a bug where update_topic was called multiple times in a
* row. We have seen cases of this occurring in earlier versions of the update_topic
* system instruction, prior to https://github.com/google-gemini/gemini-cli/pull/24640.
* This test demonstrated that there are cases where it can still occur and validates
* the prompt change that improves the behavior.
*/
evalTest('USUALLY_PASSES', {
name: 'update_topic should not be called twice in a row',
prompt: `
We need to build a C compiler.
Before you write any code, you must formally declare your strategy.
First, declare that you will build a Lexer.
Then, immediately realize that is wrong and declare that you will actually build a Parser instead.
Finally, create 'parser.c'.
`,
files: {
'package.json': JSON.stringify({ name: 'test-project' }),
'.gemini/settings.json': JSON.stringify({
experimental: {
topicUpdateNarration: true,
},
}),
},
assert: async (rig) => {
const toolLogs = rig.readToolLogs();
// Check for back-to-back update_topic calls
for (let i = 1; i < toolLogs.length; i++) {
if (
toolLogs[i - 1].toolRequest.name === UPDATE_TOPIC_TOOL_NAME &&
toolLogs[i].toolRequest.name === UPDATE_TOPIC_TOOL_NAME
) {
throw new Error(
`Detected back-to-back ${UPDATE_TOPIC_TOOL_NAME} calls at index ${i - 1} and ${i}`,
);
}
}
},
});
});
@@ -0,0 +1,8 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll launch two browser agents concurrently to check both repositories."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and get the page title"}}},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":15,"totalTokenCount":165}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":15,"totalTokenCount":165}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Page title is Example Domain."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Page title is Example Domain."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Both browser agents completed successfully. Agent 1 and Agent 2 both navigated to their respective pages and confirmed the page titles."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":40,"totalTokenCount":340}}]}
@@ -0,0 +1,8 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll browse to example.com twice to verify the content. Let me first check the page title, then check the links on the page."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and tell me the page title using the accessibility tree"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":30,"totalTokenCount":130}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":20,"totalTokenCount":170}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Page title is 'Example Domain'."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":40,"totalTokenCount":240}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title is 'Example Domain'. Now let me check the links on the page."},{"functionCall":{"name":"browser_agent","args":{"task":"Take a snapshot of the accessibility tree on the currently open page and tell me about any links"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":20,"totalTokenCount":170}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Found a link 'More information...' pointing to iana.org."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":40,"totalTokenCount":240}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I browsed example.com twice using persistent browser sessions:\n\n1. **First visit**: Page title is 'Example Domain'\n2. **Second visit**: Found a link 'More information...' pointing to iana.org\n\nThe browser stayed open between both visits, confirming persistent session management works correctly."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":60,"totalTokenCount":360}}]}
+89
View File
@@ -229,6 +229,51 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => {
assertModelHasOutput(result);
});
it('should keep browser open across multiple browser_agent invocations', async () => {
rig.setup('browser-persistent-session', {
fakeResponsesPath: join(
__dirname,
'browser-agent.persistent-session.responses',
),
settings: {
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: true,
sessionMode: 'isolated',
},
},
},
});
const result = await rig.run({
args: 'Browse to example.com twice: first get the page title, then check for links.',
});
const toolLogs = rig.readToolLogs();
const browserCalls = toolLogs.filter(
(t) => t.toolRequest.name === 'browser_agent',
);
// Both browser_agent invocations must succeed — if the browser was
// incorrectly closed after the first call (regression #24210),
// the second call would fail.
expect(
browserCalls.length,
'Expected browser_agent to be called twice',
).toBe(2);
expect(
browserCalls.every((c) => c.toolRequest.success),
'Both browser_agent calls should succeed',
).toBe(true);
assertModelHasOutput(result);
});
it('should handle tool confirmation for write_file without crashing', async () => {
rig.setup('tool-confirmation', {
fakeResponsesPath: join(
@@ -262,4 +307,48 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => {
await run.expectText('successfully written', 15000);
});
it('should handle concurrent browser agents with isolated session mode', async () => {
rig.setup('browser-concurrent', {
fakeResponsesPath: join(__dirname, 'browser-agent.concurrent.responses'),
settings: {
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: true,
// Isolated mode supports concurrent browser agents.
// Persistent/existing modes reject concurrent calls to prevent
// Chrome profile lock conflicts.
sessionMode: 'isolated',
},
},
},
});
const result = await rig.run({
args: 'Launch two browser agents concurrently to check example.com',
});
assertModelHasOutput(result);
const toolLogs = rig.readToolLogs();
const browserCalls = toolLogs.filter(
(t) => t.toolRequest.name === 'browser_agent',
);
// Both browser_agent invocations should have been called
expect(browserCalls.length).toBe(2);
// Both should complete successfully (no errors)
for (const call of browserCalls) {
expect(
call.toolRequest.success,
`browser_agent call failed: ${JSON.stringify(call.toolRequest)}`,
).toBe(true);
}
});
});
+2 -19
View File
@@ -5,8 +5,8 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { writeFileSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestRig } from './test-helper.js';
// BOM encoders
@@ -116,21 +116,4 @@ describe('BOM end-to-end integraion', () => {
'BOM_OK UTF-32BE',
);
});
it('Can describe a PNG file', async () => {
const imagePath = resolve(
process.cwd(),
'docs/assets/gemini-screenshot.png',
);
const imageContent = readFileSync(imagePath);
const filename = 'gemini-screenshot.png';
writeFileSync(join(rig.testDir!, filename), imageContent);
const prompt = `What is shown in the image ${filename}?`;
const output = await rig.run({ args: prompt });
await rig.waitForToolCall('read_file');
const lower = output.toLowerCase();
// The response is non-deterministic, so we just check for some
// keywords that are very likely to be in the response.
expect(lower.includes('gemini')).toBeTruthy();
});
});
+30
View File
@@ -0,0 +1,30 @@
{
"version": 1,
"updatedAt": "2026-04-08T01:21:58.770Z",
"scenarios": {
"multi-turn-conversation": {
"heapUsedBytes": 120082704,
"heapTotalBytes": 177586176,
"rssBytes": 269172736,
"timestamp": "2026-04-08T01:21:57.127Z"
},
"multi-function-call-repo-search": {
"heapUsedBytes": 104644984,
"heapTotalBytes": 111575040,
"rssBytes": 204079104,
"timestamp": "2026-04-08T01:21:58.770Z"
},
"idle-session-startup": {
"heapUsedBytes": 119813672,
"heapTotalBytes": 177061888,
"rssBytes": 267943936,
"timestamp": "2026-04-08T01:21:53.855Z"
},
"simple-prompt-response": {
"heapUsedBytes": 119722064,
"heapTotalBytes": 177324032,
"rssBytes": 268812288,
"timestamp": "2026-04-08T01:21:55.491Z"
}
}
}
+71
View File
@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { mkdir, readdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
const memoryTestsDir = join(rootDir, '.memory-tests');
let runDir = '';
export async function setup() {
runDir = join(memoryTestsDir, `${Date.now()}`);
await mkdir(runDir, { recursive: true });
// Set the home directory to the test run directory to avoid conflicts
// with the user's local config.
process.env['HOME'] = runDir;
if (process.platform === 'win32') {
process.env['USERPROFILE'] = runDir;
}
process.env['GEMINI_CONFIG_DIR'] = join(runDir, '.gemini');
// Download ripgrep to avoid race conditions
const available = await canUseRipgrep();
if (!available) {
throw new Error('Failed to download ripgrep binary');
}
// Clean up old test runs, keeping the latest few for debugging
try {
const testRuns = await readdir(memoryTestsDir);
if (testRuns.length > 3) {
const oldRuns = testRuns.sort().slice(0, testRuns.length - 3);
await Promise.all(
oldRuns.map((oldRun) =>
rm(join(memoryTestsDir, oldRun), {
recursive: true,
force: true,
}),
),
);
}
} catch (e) {
console.error('Error cleaning up old memory test runs:', e);
}
process.env['INTEGRATION_TEST_FILE_DIR'] = runDir;
process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true';
process.env['GEMINI_FORCE_FILE_STORAGE'] = 'true';
process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log');
process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false';
console.log(`\nMemory test output directory: ${runDir}`);
}
export async function teardown() {
// Cleanup unless KEEP_OUTPUT is set
if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) {
try {
await rm(runDir, { recursive: true, force: true });
} catch (e) {
console.warn('Failed to clean up memory test directory:', e);
}
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, beforeAll, afterAll, afterEach } from 'vitest';
import { TestRig, MemoryTestHarness } from '@google/gemini-cli-test-utils';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASELINES_PATH = join(__dirname, 'baselines.json');
const UPDATE_BASELINES = process.env['UPDATE_MEMORY_BASELINES'] === 'true';
const TOLERANCE_PERCENT = 10;
// Fake API key for tests using fake responses
const TEST_ENV = { GEMINI_API_KEY: 'fake-memory-test-key' };
describe('Memory Usage Tests', () => {
let harness: MemoryTestHarness;
let rig: TestRig;
beforeAll(() => {
harness = new MemoryTestHarness({
baselinesPath: BASELINES_PATH,
defaultTolerancePercent: TOLERANCE_PERCENT,
gcCycles: 3,
gcDelayMs: 100,
sampleCount: 3,
});
});
afterEach(async () => {
await rig.cleanup();
});
afterAll(async () => {
// Generate the summary report after all tests
await harness.generateReport();
});
it('idle-session-startup: memory usage within baseline', async () => {
rig = new TestRig();
rig.setup('memory-idle-startup', {
fakeResponsesPath: join(__dirname, 'memory.idle-startup.responses'),
});
const result = await harness.runScenario(
'idle-session-startup',
async (recordSnapshot) => {
await rig.run({
args: ['hello'],
timeout: 120000,
env: TEST_ENV,
});
await recordSnapshot('after-startup');
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
console.log(
`Updated baseline for idle-session-startup: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
);
} else {
harness.assertWithinBaseline(result);
}
});
it('simple-prompt-response: memory usage within baseline', async () => {
rig = new TestRig();
rig.setup('memory-simple-prompt', {
fakeResponsesPath: join(__dirname, 'memory.simple-prompt.responses'),
});
const result = await harness.runScenario(
'simple-prompt-response',
async (recordSnapshot) => {
await rig.run({
args: ['What is the capital of France?'],
timeout: 120000,
env: TEST_ENV,
});
await recordSnapshot('after-response');
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
console.log(
`Updated baseline for simple-prompt-response: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
);
} else {
harness.assertWithinBaseline(result);
}
});
it('multi-turn-conversation: memory remains stable over turns', async () => {
rig = new TestRig();
rig.setup('memory-multi-turn', {
fakeResponsesPath: join(__dirname, 'memory.multi-turn.responses'),
});
const prompts = [
'Hello, what can you help me with?',
'Tell me about JavaScript',
'How is TypeScript different?',
'Can you write a simple TypeScript function?',
'What are some TypeScript best practices?',
];
const result = await harness.runScenario(
'multi-turn-conversation',
async (recordSnapshot) => {
// Run through all turns as a piped sequence
const stdinContent = prompts.join('\n');
await rig.run({
stdin: stdinContent,
timeout: 120000,
env: TEST_ENV,
});
// Take snapshots after the conversation completes
await recordSnapshot('after-all-turns');
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
console.log(
`Updated baseline for multi-turn-conversation: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
);
} else {
harness.assertWithinBaseline(result);
}
});
it('multi-function-call-repo-search: memory after tool use', async () => {
rig = new TestRig();
rig.setup('memory-multi-func-call', {
fakeResponsesPath: join(
__dirname,
'memory.multi-function-call.responses',
),
});
// Create directories first, then files in the workspace so the tools have targets
rig.mkdir('packages/core/src/telemetry');
rig.createFile(
'packages/core/src/telemetry/memory-monitor.ts',
'export class MemoryMonitor { constructor() {} }',
);
rig.createFile(
'packages/core/src/telemetry/metrics.ts',
'export function recordMemoryUsage() {}',
);
const result = await harness.runScenario(
'multi-function-call-repo-search',
async (recordSnapshot) => {
await rig.run({
args: [
'Search this repository for MemoryMonitor and tell me what it does',
],
timeout: 120000,
env: TEST_ENV,
});
await recordSnapshot('after-tool-calls');
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
console.log(
`Updated baseline for multi-function-call-repo-search: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
);
} else {
harness.assertWithinBaseline(result);
}
});
});
@@ -0,0 +1,2 @@
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to help. What would you like to work on?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":12,"totalTokenCount":17,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
@@ -0,0 +1,4 @@
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll search for MemoryMonitor in the repository and analyze what it does."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":30,"candidatesTokenCount":15,"totalTokenCount":45,"promptTokensDetails":[{"modality":"TEXT","tokenCount":30}]}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"grep_search","args":{"pattern":"MemoryMonitor","path":".","include_pattern":"*.ts"}}},{"functionCall":{"name":"list_directory","args":{"path":"packages/core/src/telemetry"}}},{"functionCall":{"name":"read_file","args":{"file_path":"packages/core/src/telemetry/memory-monitor.ts"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":30,"candidatesTokenCount":80,"totalTokenCount":110,"promptTokensDetails":[{"modality":"TEXT","tokenCount":30}]}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I found the memory monitoring code. Here's a summary:\n\nThe `MemoryMonitor` class in `packages/core/src/telemetry/memory-monitor.ts` provides:\n\n1. **Continuous monitoring** via `start()`/`stop()` with configurable intervals\n2. **V8 heap snapshots** using `v8.getHeapStatistics()` and `process.memoryUsage()`\n3. **High-water mark tracking** to detect significant memory growth\n4. **Rate-limited recording** to avoid metric flood\n5. **Activity detection** — only records when user is active\n\nThe class uses a singleton pattern via `initializeMemoryMonitor()` for global access."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":500,"candidatesTokenCount":120,"totalTokenCount":620,"promptTokensDetails":[{"modality":"TEXT","tokenCount":500}]}}]}
+10
View File
@@ -0,0 +1,10 @@
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to help you with your coding tasks. What would you like to work on today?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":18,"totalTokenCount":23,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"JavaScript is a high-level, interpreted programming language. It was originally designed for adding interactivity to web pages."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":25,"candidatesTokenCount":60,"totalTokenCount":85,"promptTokensDetails":[{"modality":"TEXT","tokenCount":25}]}}]}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"TypeScript is a typed superset of JavaScript developed by Microsoft. The main differences from JavaScript are static typing and better tooling."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":45,"candidatesTokenCount":80,"totalTokenCount":125,"promptTokensDetails":[{"modality":"TEXT","tokenCount":45}]}}]}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here is a simple TypeScript function:\n\nfunction greet(name: string): string { return `Hello, ${name}!`; }"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":60,"candidatesTokenCount":55,"totalTokenCount":115,"promptTokensDetails":[{"modality":"TEXT","tokenCount":60}]}}]}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are 5 key TypeScript best practices: Enable strict mode, prefer interfaces, use union types, leverage type inference, and use readonly."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":75,"candidatesTokenCount":70,"totalTokenCount":145,"promptTokensDetails":[{"modality":"TEXT","tokenCount":75}]}}]}
@@ -0,0 +1,2 @@
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The capital of France is Paris. It has been the capital since the 10th century and is known for iconic landmarks like the Eiffel Tower, the Louvre Museum, and Notre-Dame Cathedral. Paris is also the most populous city in France, with a metropolitan area population of over 12 million people."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":55,"totalTokenCount":62,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"allowJs": true
},
"include": ["**/*.ts"],
"references": [
{ "path": "../packages/core" },
{ "path": "../packages/test-utils" }
]
}
+28
View File
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
testTimeout: 600000, // 10 minutes — memory profiling is slow
globalSetup: './globalSetup.ts',
reporters: ['default'],
include: ['**/*.test.ts'],
retry: 0, // No retries for memory tests — noise is handled by tolerance
fileParallelism: false, // Must run serially to avoid memory interference
pool: 'forks', // Use forks pool for --expose-gc support
poolOptions: {
forks: {
singleFork: true, // Single process for accurate per-test memory readings
execArgv: ['--expose-gc'], // Enable global.gc() for forced GC
},
},
env: {
GEMINI_TEST_TYPE: 'memory',
},
},
});
+38 -3
View File
@@ -446,7 +446,8 @@
"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)"
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
@@ -1449,6 +1450,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -2155,6 +2157,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2335,6 +2338,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2384,6 +2388,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2758,6 +2763,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2791,6 +2797,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2845,6 +2852,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4081,6 +4089,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4355,6 +4364,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5228,6 +5238,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5569,6 +5580,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/asciichart": {
"version": "1.5.25",
"resolved": "https://registry.npmjs.org/asciichart/-/asciichart-1.5.25.tgz",
"integrity": "sha512-PNxzXIPPOtWq8T7bgzBtk9cI2lgS4SJZthUHEiQ1aoIc3lNzGfUvIvo9LiAnq26TACo9t1/4qP6KTGAUbzX9Xg==",
"license": "MIT"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -7362,7 +7379,8 @@
"version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7946,6 +7964,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8463,6 +8482,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9775,6 +9795,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10053,6 +10074,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.7.tgz",
"integrity": "sha512-bDzQLpLzK/dn9Ur/Ku88ZZR9totVcMGrGYAgPHidsAAbe9NKztU1fggj/iu0wRp5g1kBeALb3cfagFGdDxAU1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.3",
@@ -13826,6 +13848,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13836,6 +13859,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15985,6 +16009,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16207,7 +16232,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16215,6 +16241,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16380,6 +16407,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16602,6 +16630,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16715,6 +16744,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16727,6 +16757,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17374,6 +17405,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17817,6 +17849,7 @@
"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",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -17920,6 +17953,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17979,6 +18013,7 @@
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@lydell/node-pty": "1.1.0",
"asciichart": "^1.5.25",
"strip-ansi": "^7.1.2",
"vitest": "^3.2.4"
},
+2
View File
@@ -51,6 +51,8 @@
"test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman",
"test:integration:flaky": "cross-env RUN_FLAKY_INTEGRATION=1 npm run test:integration:sandbox:none",
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
"test:memory": "vitest run --root ./memory-tests",
"test:memory:update-baselines": "cross-env UPDATE_MEMORY_BASELINES=true vitest run --root ./memory-tests",
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
"lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --cache --max-warnings 0",
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --no-warnings=DEP0040
#!/usr/bin/env node
/**
* @license
@@ -86,7 +86,7 @@ const Demo = () => {
});
return (
<MouseProvider>
<MouseProvider mouseEventsEnabled={true}>
<KeypressProvider>
<ScrollProvider>
<Box
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --no-warnings=DEP0040
#!/usr/bin/env node
/**
* @license
@@ -29,5 +29,8 @@ describe('CommandHandler', () => {
const about = parse('/about');
expect(about.commandToExecute?.name).toBe('about');
const help = parse('/help');
expect(help.commandToExecute?.name).toBe('help');
});
});
+2
View File
@@ -11,6 +11,7 @@ import { ExtensionsCommand } from './commands/extensions.js';
import { InitCommand } from './commands/init.js';
import { RestoreCommand } from './commands/restore.js';
import { AboutCommand } from './commands/about.js';
import { HelpCommand } from './commands/help.js';
export class CommandHandler {
private registry: CommandRegistry;
@@ -26,6 +27,7 @@ export class CommandHandler {
registry.register(new InitCommand());
registry.register(new RestoreCommand());
registry.register(new AboutCommand());
registry.register(new HelpCommand(registry));
return registry;
}
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { HelpCommand } from './help.js';
import { CommandRegistry } from './commandRegistry.js';
import type { Command, CommandContext } from './types.js';
describe('HelpCommand', () => {
it('returns formatted help text with sorted commands', async () => {
const registry = new CommandRegistry();
const cmdB: Command = {
name: 'bravo',
description: 'Bravo command',
execute: async () => ({ name: 'bravo', data: '' }),
};
const cmdA: Command = {
name: 'alpha',
description: 'Alpha command',
execute: async () => ({ name: 'alpha', data: '' }),
};
registry.register(cmdB);
registry.register(cmdA);
const helpCommand = new HelpCommand(registry);
const context = {} as CommandContext;
const response = await helpCommand.execute(context, []);
expect(response.name).toBe('help');
const data = response.data as string;
expect(data).toContain('Gemini CLI Help:');
expect(data).toContain('### Basics');
expect(data).toContain('### Commands');
const lines = data.split('\n');
const alphaIndex = lines.findIndex((l) => l.includes('/alpha'));
const bravoIndex = lines.findIndex((l) => l.includes('/bravo'));
expect(alphaIndex).toBeLessThan(bravoIndex);
expect(alphaIndex).not.toBe(-1);
expect(bravoIndex).not.toBe(-1);
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { CommandRegistry } from './commandRegistry.js';
export class HelpCommand implements Command {
readonly name = 'help';
readonly description = 'Show available commands';
constructor(private registry: CommandRegistry) {}
async execute(
_context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
const commands = this.registry
.getAllCommands()
.sort((a, b) => a.name.localeCompare(b.name));
const lines: string[] = [];
lines.push('Gemini CLI Help:');
lines.push('');
lines.push('### Basics');
lines.push(
'- **Add context**: Use `@` to specify files for context (e.g., `@src/myFile.ts`) to target specific files or folders.',
);
lines.push('');
lines.push('### Commands');
for (const cmd of commands) {
if (cmd.description) {
lines.push(`- **/${cmd.name}** - ${cmd.description}`);
}
}
return {
name: this.name,
data: lines.join('\n'),
};
}
}
+26 -27
View File
@@ -4,8 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { coreEvents, type Config } from '@google/gemini-cli-core';
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import { type Config } from '@google/gemini-cli-core';
import { handleList, listCommand } from './list.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { loadCliConfig } from '../../config/config.js';
@@ -32,12 +40,16 @@ vi.mock('../utils.js', () => ({
describe('skills list command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockLoadCliConfig = vi.mocked(loadCliConfig);
let stdoutWriteSpy: MockInstance<typeof process.stdout.write>;
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
stdoutWriteSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
@@ -56,10 +68,7 @@ describe('skills list command', () => {
await handleList({});
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
'No skills discovered.',
);
expect(stdoutWriteSpy).toHaveBeenCalledWith('No skills discovered.\n');
});
it('should list all discovered skills', async () => {
@@ -87,24 +96,19 @@ describe('skills list command', () => {
await handleList({});
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
chalk.bold('Discovered Agent Skills:'),
expect(stdoutWriteSpy).toHaveBeenCalledWith(
chalk.bold('Discovered Agent Skills:') + '\n\n',
);
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('skill1'),
);
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining(chalk.green('[Enabled]')),
);
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('skill2'),
);
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining(chalk.red('[Disabled]')),
);
});
@@ -135,12 +139,10 @@ describe('skills list command', () => {
// Default
await handleList({ all: false });
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('regular'),
);
expect(coreEvents.emitConsoleLog).not.toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).not.toHaveBeenCalledWith(
expect.stringContaining('builtin'),
);
@@ -148,16 +150,13 @@ describe('skills list command', () => {
// With all: true
await handleList({ all: true });
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('regular'),
);
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('builtin'),
);
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining(chalk.gray(' [Built-in]')),
);
});
+7 -8
View File
@@ -5,7 +5,6 @@
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import { loadSettings } from '../../config/settings.js';
import { loadCliConfig, type CliArgs } from '../../config/config.js';
import { exitCli } from '../utils.js';
@@ -42,12 +41,11 @@ export async function handleList(args: { all?: boolean }) {
});
if (skills.length === 0) {
debugLogger.log('No skills discovered.');
process.stdout.write('No skills discovered.\n');
return;
}
debugLogger.log(chalk.bold('Discovered Agent Skills:'));
debugLogger.log('');
process.stdout.write(chalk.bold('Discovered Agent Skills:') + '\n\n');
for (const skill of skills) {
const status = skill.disabled
@@ -56,10 +54,11 @@ export async function handleList(args: { all?: boolean }) {
const builtinSuffix = skill.isBuiltin ? chalk.gray(' [Built-in]') : '';
debugLogger.log(`${chalk.bold(skill.name)} ${status}${builtinSuffix}`);
debugLogger.log(` Description: ${skill.description}`);
debugLogger.log(` Location: ${skill.location}`);
debugLogger.log('');
process.stdout.write(
`${chalk.bold(skill.name)} ${status}${builtinSuffix}\n`,
);
process.stdout.write(` Description: ${skill.description}\n`);
process.stdout.write(` Location: ${skill.location}\n\n`);
}
}
+2
View File
@@ -938,6 +938,8 @@ export async function loadCliConfig(
: undefined,
blockedEnvironmentVariables:
settings.security?.environmentVariableRedaction?.blocked,
allowedEnvironmentVariables:
settings.security?.environmentVariableRedaction?.allowed,
enableEnvironmentVariableRedaction:
settings.security?.environmentVariableRedaction?.enabled,
userMemory: memoryContent,
@@ -520,8 +520,8 @@ describe('Policy Engine Integration Tests', () => {
const readOnlyToolRule = rules.find(
(r) => r.toolName === 'glob' && !r.subagent,
);
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5);
// Priority 50 in default tier → 1.05 (Overriding Plan Mode Deny)
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
// Verify the engine applies these priorities correctly
expect(
@@ -677,8 +677,8 @@ describe('Policy Engine Integration Tests', () => {
expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier)
const globRule = rules.find((r) => r.toolName === 'glob' && !r.subagent);
// Priority 70 in default tier → 1.07
expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only
// Priority 50 in default tier → 1.05
expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only
// The PolicyEngine will sort these by priority when it's created
const engine = new PolicyEngine(config);
+11 -1
View File
@@ -757,7 +757,7 @@ const SETTINGS_SCHEMA = {
label: 'Terminal Buffer',
category: 'UI',
requiresRestart: true,
default: true,
default: false,
description: 'Use the new terminal buffer architecture for rendering.',
showInDialog: true,
},
@@ -1970,6 +1970,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable non-interactive agent sessions.',
showInDialog: false,
},
agentSessionInteractiveEnabled: {
type: 'boolean',
label: 'Interactive Agent Session Enabled',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable the agent session implementation for the interactive CLI.',
showInDialog: false,
},
},
},
enableAgents: {
+38 -21
View File
@@ -28,10 +28,7 @@ import {
} from './config/config.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
import {
terminalCapabilityManager,
cleanupTerminalOnExit,
} from './ui/utils/terminalCapabilityManager.js';
import { terminalCapabilityManager } from './ui/utils/terminalCapabilityManager.js';
import { start_sandbox } from './utils/sandbox.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import os from 'node:os';
@@ -173,6 +170,7 @@ class MockProcessExitError extends Error {
}
}
// Mock dependencies
vi.mock('./config/settings.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./config/settings.js')>();
return {
@@ -188,7 +186,6 @@ vi.mock('./config/settings.js', async (importOriginal) => {
});
vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({
cleanupTerminalOnExit: vi.fn(),
terminalCapabilityManager: {
detectCapabilities: vi.fn(),
getTerminalBackgroundColor: vi.fn(),
@@ -382,15 +379,30 @@ describe('initializeOutputListenersAndFlush', () => {
describe('getNodeMemoryArgs', () => {
let osTotalMemSpy: MockInstance;
let v8GetHeapStatisticsSpy: MockInstance;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let originalConfig: any;
beforeEach(() => {
osTotalMemSpy = vi.spyOn(os, 'totalmem');
v8GetHeapStatisticsSpy = vi.spyOn(v8, 'getHeapStatistics');
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
originalConfig = process.config;
Object.defineProperty(process, 'config', {
value: {
...originalConfig,
variables: { ...originalConfig?.variables, v8_enable_sandbox: 1 },
},
configurable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(process, 'config', {
value: originalConfig,
configurable: true,
});
});
it('should return empty array if GEMINI_CLI_NO_RELAUNCH is set', () => {
@@ -403,8 +415,10 @@ describe('getNodeMemoryArgs', () => {
v8GetHeapStatisticsSpy.mockReturnValue({
heap_size_limit: 8 * 1024 * 1024 * 1024, // 8GB
});
// Target is 50% of 16GB = 8GB. Current is 8GB. No relaunch needed.
expect(getNodeMemoryArgs(false)).toEqual([]);
// Target is 50% of 16GB = 8GB. Current is 8GB. Relaunch needed for EPT size only.
expect(getNodeMemoryArgs(false)).toEqual([
'--max-external-pointer-table-size=268435456',
]);
});
it('should return memory args if current heap limit is insufficient', () => {
@@ -412,8 +426,11 @@ describe('getNodeMemoryArgs', () => {
v8GetHeapStatisticsSpy.mockReturnValue({
heap_size_limit: 4 * 1024 * 1024 * 1024, // 4GB
});
// Target is 50% of 16GB = 8GB. Current is 4GB. Relaunch needed.
expect(getNodeMemoryArgs(false)).toEqual(['--max-old-space-size=8192']);
// Target is 50% of 16GB = 8GB. Current is 4GB. Relaunch needed for both.
expect(getNodeMemoryArgs(false)).toEqual([
'--max-external-pointer-table-size=268435456',
'--max-old-space-size=8192',
]);
});
it('should log debug info when isDebugMode is true', () => {
@@ -1451,7 +1468,9 @@ describe('startInteractiveUI', () => {
// Verify render options
expect(options).toEqual(
expect.objectContaining({
alternateBuffer: true,
exitOnCtrlC: false,
incrementalRendering: true,
isScreenReaderEnabled: false,
onRender: expect.any(Function),
patchConsole: false,
@@ -1505,15 +1524,12 @@ describe('startInteractiveUI', () => {
// Verify all startup tasks were called
expect(getVersion).toHaveBeenCalledTimes(1);
// 4 cleanups: consolePatcher, instance.unmount, TTY check, and terminal mode cleanup
expect(registerCleanup).toHaveBeenCalledTimes(4);
// 5 cleanups: mouseEvents, consolePatcher, lineWrapping, instance.unmount, and TTY check
expect(registerCleanup).toHaveBeenCalledTimes(5);
// Verify cleanup handler is registered with unmount function
const cleanupCalls = vi.mocked(registerCleanup).mock.calls;
expect(cleanupCalls.some((call) => call[0] instanceof Function)).toBe(true);
expect(cleanupCalls.some((call) => call[0] === cleanupTerminalOnExit)).toBe(
true,
);
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
expect(typeof cleanupFn).toBe('function');
// checkForUpdates should be called asynchronously (not waited for)
// We need a small delay to let it execute
@@ -1565,9 +1581,9 @@ describe('startInteractiveUI', () => {
name: 'should disable line wrapping when not in screen reader mode',
},
])('$name', async ({ screenReader, expectedCalls }) => {
const { disableLineWrapping } = await import('@google/gemini-cli-core');
const disableLineWrappingSpy = vi.mocked(disableLineWrapping);
const writeSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
const mockConfigWithScreenReader = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
@@ -1584,9 +1600,10 @@ describe('startInteractiveUI', () => {
);
if (expectedCalls.length > 0) {
expect(disableLineWrappingSpy).toHaveBeenCalled();
expect(writeSpy).toHaveBeenCalledWith(expectedCalls[0][0]);
} else {
expect(disableLineWrappingSpy).not.toHaveBeenCalled();
expect(writeSpy).not.toHaveBeenCalledWith('\x1b[?7l');
}
writeSpy.mockRestore();
});
});
+23 -2
View File
@@ -111,6 +111,8 @@ export function validateDnsResolutionOrder(
return defaultValue;
}
const DEFAULT_EPT_SIZE = (256 * 1024 * 1024).toString();
export function getNodeMemoryArgs(isDebugMode: boolean): string[] {
const totalMemoryMB = os.totalmem() / (1024 * 1024);
const heapStats = v8.getHeapStatistics();
@@ -130,16 +132,35 @@ export function getNodeMemoryArgs(isDebugMode: boolean): string[] {
return [];
}
const args: string[] = [];
// Automatically expand the V8 External Pointer Table to 256MB to prevent
// out-of-memory crashes during high native-handle concurrency.
// Note: Only supported in specific Node.js versions compiled with V8 Sandbox enabled.
const eptFlag = `--max-external-pointer-table-size=${DEFAULT_EPT_SIZE}`;
const isV8SandboxEnabled =
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
(process.config?.variables as any)?.v8_enable_sandbox === 1;
if (
isV8SandboxEnabled &&
!process.execArgv.some((arg) =>
arg.startsWith('--max-external-pointer-table-size'),
)
) {
args.push(eptFlag);
}
if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) {
if (isDebugMode) {
debugLogger.debug(
`Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`,
);
}
return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`];
args.push(`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`);
}
return [];
return args;
}
export function setupUnhandledRejectionHandler() {
+13 -7
View File
@@ -10,17 +10,17 @@ import { basename } from 'node:path';
import { AppContainer } from './ui/AppContainer.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
import { cleanupTerminalOnExit } from './ui/utils/terminalCapabilityManager.js';
import {
type StartupWarning,
type Config,
type ResumedSessionData,
coreEvents,
createWorkingStdio,
disableMouseEvents,
enableMouseEvents,
disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen,
enterAlternateScreen,
recordSlowRender,
writeToStdout,
getVersion,
@@ -66,8 +66,12 @@ export async function startInteractiveUI(
config.getUseAlternateBuffer(),
config.getScreenReader(),
);
if (useAlternateBuffer) {
const mouseEventsEnabled = useAlternateBuffer;
if (mouseEventsEnabled) {
enableMouseEvents();
registerCleanup(() => {
disableMouseEvents();
});
}
const { matchers, errors } = await loadKeyMatchers();
@@ -99,7 +103,7 @@ export async function startInteractiveUI(
<SettingsContext.Provider value={settings}>
<KeyMatchersProvider value={matchers}>
<KeypressProvider config={config}>
<MouseProvider>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<TerminalProvider>
<ScrollProvider>
<OverflowProvider>
@@ -152,8 +156,9 @@ export async function startInteractiveUI(
useAlternateBuffer || config.getUseTerminalBuffer(),
patchConsole: false,
alternateBuffer: useAlternateBuffer,
renderProcess: config.getUseRenderProcess(),
terminalBuffer: config.getUseTerminalBuffer(),
renderProcess:
config.getUseRenderProcess() && config.getUseTerminalBuffer(),
incrementalRendering:
settings.merged.ui.incrementalRendering !== false &&
useAlternateBuffer &&
@@ -162,8 +167,10 @@ export async function startInteractiveUI(
);
if (useAlternateBuffer) {
enterAlternateScreen();
disableLineWrapping();
registerCleanup(() => {
enableLineWrapping();
});
}
checkForUpdates(settings)
@@ -180,7 +187,6 @@ export async function startInteractiveUI(
registerCleanup(() => instance.unmount());
registerCleanup(setupTtyCheck());
registerCleanup(cleanupTerminalOnExit);
}
function setWindowTitle(title: string, settings: LoadedSettings) {
@@ -71,6 +71,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
Scheduler: class {
schedule = mockSchedulerSchedule;
cancelAll = vi.fn();
dispose = vi.fn();
},
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
ChatRecordingService: MockChatRecordingService,
+3 -1
View File
@@ -187,6 +187,7 @@ export async function runNonInteractive(
};
let errorToHandle: unknown | undefined;
let scheduler: Scheduler | undefined;
try {
consolePatcher.patch();
@@ -215,7 +216,7 @@ export async function runNonInteractive(
});
const geminiClient = config.getGeminiClient();
const scheduler = new Scheduler({
scheduler = new Scheduler({
context: config,
messageBus: config.getMessageBus(),
getPreferredEditor: () => undefined,
@@ -528,6 +529,7 @@ export async function runNonInteractive(
// Cleanup stdin cancellation before other cleanup
cleanupStdinCancellation();
scheduler?.dispose();
consolePatcher.cleanup();
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
}
@@ -71,6 +71,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
Scheduler: class {
schedule = mockSchedulerSchedule;
cancelAll = vi.fn();
dispose = vi.fn();
},
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
ChatRecordingService: MockChatRecordingService,
@@ -37,6 +37,7 @@ import {
LegacyAgentSession,
ToolErrorType,
geminiPartsToContentParts,
debugLogger,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
@@ -183,6 +184,7 @@ export async function runNonInteractive({
};
let errorToHandle: unknown | undefined;
let scheduler: Scheduler | undefined;
let abortSession = () => {};
try {
consolePatcher.patch();
@@ -214,7 +216,7 @@ export async function runNonInteractive({
});
const geminiClient = config.getGeminiClient();
const scheduler = new Scheduler({
scheduler = new Scheduler({
context: config,
messageBus: config.getMessageBus(),
getPreferredEditor: () => undefined,
@@ -599,6 +601,7 @@ export async function runNonInteractive({
// Explicitly ignore these non-interactive events
break;
default:
debugLogger.error('Unknown agent event type:', event);
event satisfies never;
break;
}
@@ -610,6 +613,7 @@ export async function runNonInteractive({
cleanupStdinCancellation();
abortController.signal.removeEventListener('abort', abortSession);
scheduler?.dispose();
consolePatcher.cleanup();
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
}
@@ -136,6 +136,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getRetryFetchErrors: vi.fn().mockReturnValue(true),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
getShellExecutionConfig: vi.fn().mockReturnValue({
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
+91 -69
View File
@@ -501,11 +501,11 @@ export const mockSettings = createMockSettings();
// A minimal mock UIState to satisfy the context provider.
// Tests that need specific UIState values should provide their own.
const baseMockUiState = {
isAlternateBuffer: false,
isTransitioningAltBuffer: false,
history: [],
renderMarkdown: true,
streamingState: StreamingState.Idle,
isConfigInitialized: true,
isAuthenticating: false,
terminalWidth: 100,
terminalHeight: 40,
currentModel: 'gemini-pro',
@@ -600,6 +600,9 @@ const mockUIActions: UIActions = {
clearAccountSuspension: vi.fn(),
};
import { type TextBuffer } from '../ui/components/shared/text-buffer.js';
import { InputContext, type InputState } from '../ui/contexts/InputContext.js';
let capturedOverflowState: OverflowState | undefined;
let capturedOverflowActions: OverflowActions | undefined;
const ContextCapture: React.FC<{ children: React.ReactNode }> = ({
@@ -616,7 +619,9 @@ export const renderWithProviders = async (
shellFocus = true,
settings = mockSettings,
uiState: providedUiState,
inputState: providedInputState,
width,
mouseEventsEnabled = false,
config,
uiActions,
toolActions,
@@ -626,7 +631,9 @@ export const renderWithProviders = async (
shellFocus?: boolean;
settings?: LoadedSettings;
uiState?: Partial<UIState>;
inputState?: Partial<InputState>;
width?: number;
mouseEventsEnabled?: boolean;
config?: Config;
uiActions?: Partial<UIActions>;
toolActions?: Partial<{
@@ -641,20 +648,8 @@ export const renderWithProviders = async (
appState?: AppState;
} = {},
): Promise<RenderWithProvidersInstance> => {
if (persistentState?.get) {
persistentStateMock.get.mockImplementation(persistentState.get);
}
if (persistentState?.set) {
persistentStateMock.set.mockImplementation(persistentState.set);
}
persistentStateMock.mockClear();
const baseState: UIState = new Proxy(
{
...baseMockUiState,
...providedUiState,
},
{ ...baseMockUiState, ...providedUiState },
{
get(target, prop) {
if (prop in target) {
@@ -671,6 +666,27 @@ export const renderWithProviders = async (
},
) as UIState;
const inputState = {
buffer: { text: '' } as unknown as TextBuffer,
userMessages: [],
shellModeActive: false,
showEscapePrompt: false,
copyModeEnabled: false,
inputWidth: 80,
suggestionsWidth: 40,
...(providedUiState as unknown as Partial<InputState>),
...providedInputState,
};
if (persistentState?.get) {
persistentStateMock.get.mockImplementation(persistentState.get);
}
if (persistentState?.set) {
persistentStateMock.set.mockImplementation(persistentState.set);
}
persistentStateMock.mockClear();
const terminalWidth = width ?? baseState.terminalWidth;
if (!config) {
@@ -680,6 +696,7 @@ export const renderWithProviders = async (
accessibility: settings.merged.ui?.accessibility,
});
}
const mainAreaWidth = providedUiState?.mainAreaWidth ?? terminalWidth;
const finalUiState = {
@@ -710,61 +727,65 @@ export const renderWithProviders = async (
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={config}>
<SettingsContext.Provider value={settings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider>
<ShellFocusContext.Provider value={shellFocus}>
<SessionStatsProvider>
<StreamingContext.Provider
value={finalUiState.streamingState}
>
<UIActionsContext.Provider value={finalUIActions}>
<OverflowProvider>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={
toolActions?.isExpanded ??
vi.fn().mockReturnValue(false)
}
toggleExpansion={
toolActions?.toggleExpansion ?? vi.fn()
}
toggleAllExpansion={
toolActions?.toggleAllExpansion ?? vi.fn()
}
>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
<InputContext.Provider value={inputState}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider>
<ShellFocusContext.Provider value={shellFocus}>
<SessionStatsProvider>
<StreamingContext.Provider
value={finalUiState.streamingState}
>
<UIActionsContext.Provider value={finalUIActions}>
<OverflowProvider>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={
toolActions?.isExpanded ??
vi.fn().mockReturnValue(false)
}
toggleExpansion={
toolActions?.toggleExpansion ?? vi.fn()
}
toggleAllExpansion={
toolActions?.toggleAllExpansion ?? vi.fn()
}
>
<KeypressProvider>
<MouseProvider>
<TerminalProvider>
<ScrollProvider>
<ContextCapture>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{comp}
</Box>
</ContextCapture>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
</OverflowProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</SessionStatsProvider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<ContextCapture>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{comp}
</Box>
</ContextCapture>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
</OverflowProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</SessionStatsProvider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
</InputContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>
</AppContext.Provider>
@@ -857,6 +878,7 @@ export async function renderHookWithProviders<Result, Props>(
settings?: LoadedSettings;
uiState?: Partial<UIState>;
width?: number;
mouseEventsEnabled?: boolean;
config?: Config;
} = {},
): Promise<{
+8 -8
View File
@@ -127,7 +127,7 @@ describe('App', () => {
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: { ...quittingUIState, isAlternateBuffer: true },
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
@@ -143,7 +143,7 @@ describe('App', () => {
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: { ...dialogUIState, isAlternateBuffer: true },
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
@@ -179,7 +179,7 @@ describe('App', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: { ...mockUIState, isAlternateBuffer: true },
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
@@ -194,7 +194,7 @@ describe('App', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: { ...mockUIState, isAlternateBuffer: true },
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
@@ -245,7 +245,7 @@ describe('App', () => {
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: { ...stateWithConfirmingTool, isAlternateBuffer: true },
uiState: stateWithConfirmingTool,
config: configWithExperiment,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
@@ -262,7 +262,7 @@ describe('App', () => {
it('renders default layout correctly', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: { ...mockUIState, isAlternateBuffer: true },
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toMatchSnapshot();
@@ -272,7 +272,7 @@ describe('App', () => {
it('renders screen reader layout correctly', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: { ...mockUIState, isAlternateBuffer: true },
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toMatchSnapshot();
@@ -285,7 +285,7 @@ describe('App', () => {
dialogsVisible: true,
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: { ...dialogUIState, isAlternateBuffer: true },
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toMatchSnapshot();
+9 -91
View File
@@ -122,13 +122,17 @@ vi.mock('ink', async (importOriginal) => {
};
});
import { InputContext, type InputState } from './contexts/InputContext.js';
// Helper component will read the context values provided by AppContainer
// so we can assert against them in our tests.
let capturedUIState: UIState;
let capturedInputState: InputState;
let capturedUIActions: UIActions;
let capturedOverflowActions: OverflowActions;
function TestContextConsumer() {
capturedUIState = useContext(UIStateContext)!;
capturedInputState = useContext(InputContext)!;
capturedUIActions = useContext(UIActionsContext)!;
capturedOverflowActions = useOverflowActions()!;
return null;
@@ -2690,92 +2694,6 @@ describe('AppContainer State Management', () => {
});
});
describe('Alternate Buffer Toggle (ALT+A)', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let mockedUseKeypress: Mock;
let unmount: () => void;
const setupToggleTest = async (
isAlternateMode = false,
isScreenReader = false,
) => {
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(
isAlternateMode,
);
vi.spyOn(mockConfig, 'getScreenReader').mockReturnValue(isScreenReader);
const testSettings = createMockSettings({
ui: { useAlternateBuffer: isAlternateMode },
});
const renderResult = await act(async () =>
renderAppContainer({
config: mockConfig,
settings: testSettings,
}),
);
unmount = renderResult.unmount;
};
const pressToggle = async () => {
await act(async () => {
handleGlobalKeypress({
name: 'a',
alt: true,
shift: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '',
} as Key);
});
await act(async () => {
vi.advanceTimersByTime(150);
});
};
beforeEach(() => {
vi.useFakeTimers();
mockedUseKeypress = vi.spyOn(useKeypressModule, 'useKeypress') as Mock;
mockedUseKeypress.mockImplementation(
(callback: (key: Key) => boolean, options: { isActive: boolean }) => {
if (options?.isActive) {
handleGlobalKeypress = callback;
}
},
);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('toggles alternate buffer mode on and off', async () => {
await setupToggleTest(false, false);
expect(capturedUIState.isAlternateBuffer).toBe(false);
await pressToggle();
expect(capturedUIState.isAlternateBuffer).toBe(true);
await pressToggle();
expect(capturedUIState.isAlternateBuffer).toBe(false);
unmount();
});
it('does not toggle when screen reader mode is on', async () => {
await setupToggleTest(false, true);
expect(capturedUIState.isAlternateBuffer).toBe(false);
await pressToggle();
expect(capturedUIState.isAlternateBuffer).toBe(false);
unmount();
});
});
describe('Model Dialog Integration', () => {
it('should provide isModelDialogOpen in the UIStateContext', async () => {
mockedUseModelCommand.mockReturnValue({
@@ -3122,7 +3040,7 @@ describe('AppContainer State Management', () => {
});
const { unmount } = await act(async () => renderAppContainer());
expect(capturedUIState.userMessages).toContain('previous message');
expect(capturedInputState.userMessages).toContain('previous message');
const { onCancelSubmit } = extractUseGeminiStreamArgs(
mockedUseGeminiStream.mock.lastCall!,
@@ -3150,8 +3068,8 @@ describe('AppContainer State Management', () => {
const { rerender, unmount } = await act(async () => renderAppContainer());
// Verify userMessages is populated from inputHistory
expect(capturedUIState.userMessages).toContain('first prompt');
expect(capturedUIState.userMessages).toContain('second prompt');
expect(capturedInputState.userMessages).toContain('first prompt');
expect(capturedInputState.userMessages).toContain('second prompt');
// Clear the conversation history (simulating /clear command)
const mockClearItems = vi.fn();
@@ -3170,8 +3088,8 @@ describe('AppContainer State Management', () => {
// Verify that userMessages still contains the input history
// (it should not be affected by clearing conversation history)
expect(capturedUIState.userMessages).toContain('first prompt');
expect(capturedUIState.userMessages).toContain('second prompt');
expect(capturedInputState.userMessages).toContain('first prompt');
expect(capturedInputState.userMessages).toContain('second prompt');
unmount();
});
+76 -129
View File
@@ -36,9 +36,11 @@ import {
type ConfirmationRequest,
type PermissionConfirmationRequest,
type QuotaStats,
MessageType,
StreamingState,
type HistoryItemInfo,
} from './types.js';
import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import { MouseProvider } from './contexts/MouseContext.js';
import { ScrollProvider } from './contexts/ScrollProvider.js';
@@ -51,6 +53,7 @@ import {
type UserTierId,
type GeminiUserTier,
type UserFeedbackPayload,
type HookSystemMessagePayload,
type AgentDefinition,
type ApprovalMode,
IdeClient,
@@ -72,10 +75,8 @@ import {
writeToStdout,
disableMouseEvents,
enterAlternateScreen,
exitAlternateScreen,
enableMouseEvents,
disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen,
startupProfiler,
SessionStartSource,
@@ -196,6 +197,8 @@ import {
} from './hooks/useVisibilityToggle.js';
import { useKeyMatchers } from './hooks/useKeyMatchers.js';
import { InputContext } from './contexts/InputContext.js';
/**
* The fraction of the terminal width to allocate to the shell.
* This provides horizontal padding.
@@ -224,6 +227,7 @@ export const AppContainer = (props: AppContainerProps) => {
});
useMemoryMonitor(historyManager);
const isAlternateBuffer = config.getUseAlternateBuffer();
const [mouseMode, setMouseMode] = useState(() =>
config.getUseAlternateBuffer(),
);
@@ -239,18 +243,6 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [mouseMode, setOptions]);
const [isAlternateBuffer, setIsAlternateBuffer] = useState(
shouldEnterAlternateScreen(
config.getUseAlternateBuffer(),
config.getScreenReader(),
),
);
const [isTransitioningAltBuffer, setIsTransitioningAltBuffer] =
useState(false);
const isAlternateBufferRef = useRef(isAlternateBuffer);
useEffect(() => {
isAlternateBufferRef.current = isAlternateBuffer;
}, [isAlternateBuffer]);
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -609,10 +601,7 @@ export const AppContainer = (props: AppContainerProps) => {
const { errorCount, clearErrorCount } = useErrorCount();
const mainAreaWidth = calculateMainAreaWidth(
terminalWidth,
isAlternateBuffer,
);
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config);
// Derive widths for InputPrompt using shared helper
const { inputWidth, suggestionsWidth } = useMemo(() => {
const { inputWidth, suggestionsWidth } =
@@ -1705,8 +1694,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
setRawMode,
refreshStatic,
setForceRerenderKey,
isAlternateBufferRef,
config,
shouldUseAlternateScreen,
});
useEffect(() => {
@@ -1767,35 +1755,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
[handleSlashCommand, settings],
);
const applyAlternateBufferMode = useCallback(
(next: boolean) => {
if (next) {
enterAlternateScreen();
disableLineWrapping();
enableMouseEvents();
} else {
exitAlternateScreen();
enableLineWrapping();
disableMouseEvents();
setCopyModeEnabled(false);
}
},
[setCopyModeEnabled],
);
useEffect(() => {
if (isTransitioningAltBuffer) {
const timer = setTimeout(() => {
isAlternateBufferRef.current = false;
applyAlternateBufferMode(false);
refreshStatic();
setIsAlternateBuffer(false);
setIsTransitioningAltBuffer(false);
}, 150); // 150ms ensures Ink flushes the 'display: none' layout to the alternate buffer BEFORE we swap
return () => clearTimeout(timer);
}
return undefined;
}, [isTransitioningAltBuffer, applyAlternateBufferMode, refreshStatic]);
const handleGlobalKeypress = useCallback(
(key: Key): boolean => {
// Debug log keystrokes if enabled
@@ -1815,29 +1774,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
}
if (keyMatchers[Command.TOGGLE_ALTERNATE_BUFFER](key)) {
if (config.getScreenReader()) {
return true;
}
const next = !isAlternateBufferRef.current;
if (next) {
// Entering alternate buffer mode: Safe to do synchronously as we won't erase scrollback
isAlternateBufferRef.current = true;
applyAlternateBufferMode(true);
setIsAlternateBuffer(true);
} else {
// Exiting alternate buffer mode: Trigger a delay to let Ink clear its 40-line alternate buffer frame
// to 0 lines *before* swapping the terminal, otherwise it will erase normal scrollback.
setIsTransitioningAltBuffer(true);
}
return true;
}
if (
isAlternateBufferRef.current &&
keyMatchers[Command.TOGGLE_COPY_MODE](key)
) {
if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) {
setCopyModeEnabled(true);
disableMouseEvents();
return true;
@@ -1887,7 +1824,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
} else if (
keyMatchers[Command.TOGGLE_COPY_MODE](key) &&
!isAlternateBufferRef.current
!isAlternateBuffer
) {
showTransientMessage({
text: 'Use Ctrl+O to expand and collapse blocks of content.',
@@ -1916,7 +1853,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
toggleLastTurnTools();
}
if (!isAlternateBufferRef.current) {
if (!isAlternateBuffer) {
refreshStatic();
}
}
@@ -1963,11 +1900,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
) {
setConstrainHeight(false);
toggleLastTurnTools();
// If the user manually expands the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
if (!isAlternateBufferRef.current) {
refreshStatic();
}
refreshStatic();
return true;
} else if (
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
@@ -2046,7 +1979,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
setConstrainHeight,
setShowErrorDetails,
config,
isAlternateBuffer,
ideContextState,
handleCtrlCPress,
handleCtrlDPress,
@@ -2059,8 +1991,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
refreshStatic,
setCopyModeEnabled,
tabFocusTimeoutRef,
isAlternateBuffer,
shortcutsHelpVisible,
applyAlternateBufferMode,
backgroundCurrentExecution,
toggleBackgroundTasks,
backgroundTasks,
@@ -2083,10 +2015,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
useKeypress(handleGlobalKeypress, {
isActive: true,
priority: KeypressPriority.Critical,
});
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useKeypress(
(key: Key) => {
@@ -2185,7 +2114,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
};
const handleHookSystemMessage = (payload: HookSystemMessagePayload) => {
historyManager.addItem(
{
type: MessageType.INFO,
text: payload.message,
source: payload.hookName,
} as HistoryItemInfo,
Date.now(),
);
};
coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback);
coreEvents.on(CoreEvent.HookSystemMessage, handleHookSystemMessage);
// Flush any messages that happened during startup before this component
// mounted.
@@ -2193,6 +2134,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
return () => {
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
coreEvents.off(CoreEvent.HookSystemMessage, handleHookSystemMessage);
};
}, [historyManager]);
@@ -2404,12 +2346,31 @@ Logging in with Google... Restarting Gemini CLI to continue.
};
}, [config, refreshStatic]);
const inputState = useMemo(
() => ({
buffer,
userMessages: inputHistory,
shellModeActive,
showEscapePrompt,
copyModeEnabled,
inputWidth,
suggestionsWidth,
}),
[
buffer,
inputHistory,
shellModeActive,
showEscapePrompt,
copyModeEnabled,
inputWidth,
suggestionsWidth,
],
);
const uiState: UIState = useMemo(
() => ({
history: historyManager.history,
historyManager,
isAlternateBuffer,
isTransitioningAltBuffer,
isThemeDialogOpen,
themeError,
@@ -2449,11 +2410,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
initError,
pendingGeminiHistoryItems,
thought,
shellModeActive,
userMessages: inputHistory,
buffer,
inputWidth,
suggestionsWidth,
isInputActive,
isResuming,
shouldShowIdePrompt,
@@ -2469,7 +2425,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
renderMarkdown,
ctrlCPressedOnce: ctrlCPressCount >= 1,
ctrlDPressedOnce: ctrlDPressCount >= 1,
showEscapePrompt,
shortcutsHelpVisible,
cleanUiDetailsVisible,
isFocused,
@@ -2521,7 +2476,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
embeddedShellFocused,
showDebugProfiler,
customDialog,
copyModeEnabled,
transientMessage,
bannerData,
bannerVisible,
@@ -2576,11 +2530,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
initError,
pendingGeminiHistoryItems,
thought,
shellModeActive,
inputHistory,
buffer,
inputWidth,
suggestionsWidth,
isInputActive,
isResuming,
shouldShowIdePrompt,
@@ -2596,7 +2545,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
renderMarkdown,
ctrlCPressCount,
ctrlDPressCount,
showEscapePrompt,
shortcutsHelpVisible,
cleanUiDetailsVisible,
isFocused,
@@ -2648,7 +2596,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
customDialog,
apiKeyDefaultValue,
authState,
copyModeEnabled,
transientMessage,
bannerData,
bannerVisible,
@@ -2661,8 +2608,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
isAlternateBuffer,
isTransitioningAltBuffer,
],
);
@@ -2837,32 +2782,34 @@ Logging in with Google... Restarting Gemini CLI to continue.
return (
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<ConfigContext.Provider value={config}>
<AppContext.Provider
value={{
version: props.version,
startupWarnings: props.startupWarnings || [],
}}
>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={isExpanded}
toggleExpansion={toggleExpansion}
toggleAllExpansion={toggleAllExpansion}
<InputContext.Provider value={inputState}>
<UIActionsContext.Provider value={uiActions}>
<ConfigContext.Provider value={config}>
<AppContext.Provider
value={{
version: props.version,
startupWarnings: props.startupWarnings || [],
}}
>
<ShellFocusContext.Provider value={isFocused}>
<MouseProvider>
<ScrollProvider>
<App key={`app-${forceRerenderKey}`} />
</ScrollProvider>
</MouseProvider>
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
</ConfigContext.Provider>
</UIActionsContext.Provider>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={isExpanded}
toggleExpansion={toggleExpansion}
toggleAllExpansion={toggleAllExpansion}
>
<ShellFocusContext.Provider value={isFocused}>
<MouseProvider mouseEventsEnabled={mouseMode}>
<ScrollProvider>
<App key={`app-${forceRerenderKey}`} />
</ScrollProvider>
</MouseProvider>
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
</ConfigContext.Provider>
</UIActionsContext.Provider>
</InputContext.Provider>
</UIStateContext.Provider>
);
};
@@ -144,7 +144,7 @@ describe('Full Terminal Tool Confirmation Snapshot', () => {
const { waitUntilReady, lastFrame, generateSvg, unmount } =
await renderWithProviders(<App />, {
uiState: { ...mockUIState, isAlternateBuffer: true },
uiState: mockUIState,
config: mockConfig,
settings: createMockSettings({
merged: {
@@ -10,15 +10,20 @@ import {
} from '../../test-utils/render.js';
import type { LoadedSettings } from '../../config/settings.js';
import { AppHeader } from './AppHeader.js';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
import crypto from 'node:crypto';
import { _clearSessionBannersForTest } from '../hooks/useBanner.js';
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: () => null,
}));
describe('<AppHeader />', () => {
beforeEach(() => {
_clearSessionBannersForTest();
});
it('should render the banner with default text', async () => {
const uiState = {
history: [],
+9 -2
View File
@@ -59,13 +59,20 @@ const NARROW_TERMINAL_BREAKPOINT = 60;
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState();
const {
terminalWidth,
bannerData,
bannerVisible,
updateInfo,
isConfigInitialized,
isAuthenticating,
} = useUIState();
const { bannerText } = useBanner(bannerData);
const { showTips } = useTips();
const authType = config.getContentGeneratorConfig()?.authType;
const loggedOut = !authType;
const loggedOut = isConfigInitialized && !isAuthenticating && !authType;
const showHeader = !(
settings.merged.ui.hideBanner || config.getScreenReader()
@@ -317,7 +317,6 @@ describe('AskUserDialog', () => {
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
uiState: { isAlternateBuffer: useAlternateBuffer },
},
);
@@ -1291,7 +1290,6 @@ describe('AskUserDialog', () => {
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { isAlternateBuffer: false },
},
);
@@ -1331,7 +1329,6 @@ describe('AskUserDialog', () => {
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiState: { isAlternateBuffer: true },
},
);
@@ -245,20 +245,37 @@ const createMockConfig = (overrides = {}): Config =>
...overrides,
}) as unknown as Config;
import { InputContext, type InputState } from '../contexts/InputContext.js';
const renderComposer = async (
uiState: UIState,
settings = createMockSettings({ ui: {} }),
config = createMockConfig(),
uiActions = createMockUIActions(),
inputStateOverrides: Partial<InputState> = {},
) => {
const inputState = {
buffer: { text: '' } as unknown as TextBuffer,
userMessages: [],
shellModeActive: false,
showEscapePrompt: false,
copyModeEnabled: false,
inputWidth: 80,
suggestionsWidth: 40,
...(uiState as unknown as Partial<InputState>),
...inputStateOverrides,
};
const result = await render(
<ConfigContext.Provider value={config as unknown as Config}>
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer isFocused={true} />
</UIActionsContext.Provider>
</UIStateContext.Provider>
<InputContext.Provider value={inputState}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer isFocused={true} />
</UIActionsContext.Provider>
</UIStateContext.Provider>
</InputContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
@@ -541,7 +558,6 @@ describe('Composer', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
});
const { lastFrame } = await renderComposer(uiState);
@@ -631,7 +647,6 @@ describe('Composer', () => {
async (mode) => {
const uiState = createMockUIState({
showApprovalModeIndicator: mode,
shellModeActive: false,
});
const { lastFrame } = await renderComposer(uiState);
@@ -641,11 +656,15 @@ describe('Composer', () => {
);
it('shows ShellModeIndicator when shell mode is active', async () => {
const uiState = createMockUIState({
shellModeActive: true,
});
const uiState = createMockUIState();
const { lastFrame } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(
uiState,
undefined,
undefined,
undefined,
{ shellModeActive: true },
);
expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
});
@@ -724,11 +743,16 @@ describe('Composer', () => {
it('shows Esc rewind prompt in minimal mode without showing full UI', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'msg' }],
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(
uiState,
undefined,
undefined,
undefined,
{ showEscapePrompt: true },
);
const output = lastFrame();
expect(output).toContain('Press Esc again to rewind.');
expect(output).not.toContain('ContextSummaryDisplay');
@@ -828,11 +852,16 @@ describe('Composer', () => {
describe('Shortcuts Hint', () => {
it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => {
const uiState = createMockUIState({
buffer: { text: '' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(
uiState,
undefined,
undefined,
undefined,
{ buffer: { text: '' } as unknown as TextBuffer },
);
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
@@ -845,11 +874,16 @@ describe('Composer', () => {
it('hides shortcuts hint when text is typed in buffer', async () => {
const uiState = createMockUIState({
buffer: { text: 'hello' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(
uiState,
undefined,
undefined,
undefined,
{ buffer: { text: 'hello' } as unknown as TextBuffer },
);
expect(lastFrame()).not.toContain('press tab twice for more');
expect(lastFrame()).not.toContain('? for shortcuts');
+5 -9
View File
@@ -9,6 +9,7 @@ import { useState, useEffect } from 'react';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useInputState } from '../contexts/InputContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
@@ -30,6 +31,7 @@ import { appEvents, AppEvent } from '../../utils/events.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const uiState = useUIState();
const inputState = useInputState();
const uiActions = useUIActions();
const settings = useSettings();
const config = useConfig();
@@ -81,12 +83,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
return null;
}
const hasToast = shouldShowToast(uiState);
const showToast = shouldShowToast(uiState, inputState);
const hideUiDetailsForSuggestions =
suggestionsVisible && suggestionsPosition === 'above';
// Mini Mode VIP Flags (Pure Content Triggers)
const showMinimalToast = hasToast;
const showMinimalToast = showToast;
return (
<Box
@@ -141,17 +143,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}
inputWidth={uiState.inputWidth}
suggestionsWidth={uiState.suggestionsWidth}
onSubmit={uiActions.handleFinalSubmit}
userMessages={uiState.userMessages}
setBannerVisible={uiActions.setBannerVisible}
onClearScreen={uiActions.handleClearScreen}
config={config}
slashCommands={uiState.slashCommands || []}
commandContext={uiState.commandContext}
shellModeActive={uiState.shellModeActive}
setShellModeActive={uiActions.setShellModeActive}
approvalMode={uiState.showApprovalModeIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
@@ -165,7 +162,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
? vimMode === 'INSERT'
? " Press 'Esc' for NORMAL mode."
: " Press 'i' for INSERT mode."
: uiState.shellModeActive
: inputState.shellModeActive
? ' Type your shell command'
: ' Type your message or @path/to/file'
}
@@ -173,7 +170,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
streamingState={uiState.streamingState}
suggestionsPosition={suggestionsPosition}
onSuggestionsVisibilityChange={setSuggestionsVisible}
copyModeEnabled={uiState.copyModeEnabled}
/>
)}
@@ -8,6 +8,8 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { type IdeContext, type MCPServerConfig } from '@google/gemini-cli-core';
import { Command } from '../key/keyMatchers.js';
import { formatCommand } from '../key/keybindingUtils.js';
interface ContextSummaryDisplayProps {
geminiMdFileCount: number;
@@ -49,7 +51,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
}
return `${openFileCount} open file${
openFileCount > 1 ? 's' : ''
} (ctrl+g to view)`;
} (${formatCommand(Command.SHOW_IDE_CONTEXT_DETAIL)} to view)`;
})();
const geminiMdText = (() => {
@@ -4,34 +4,36 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { CopyModeWarning } from './CopyModeWarning.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { useInputState } from '../contexts/InputContext.js';
vi.mock('../contexts/UIStateContext.js');
vi.mock('../contexts/InputContext.js');
describe('CopyModeWarning', () => {
const mockUseUIState = vi.mocked(useUIState);
beforeEach(() => {
vi.clearAllMocks();
});
it('renders nothing when copy mode is disabled', async () => {
mockUseUIState.mockReturnValue({
vi.mocked(useInputState).mockReturnValue({
copyModeEnabled: false,
} as unknown as UIState);
const { lastFrame, unmount } = await render(<CopyModeWarning />);
} as unknown as ReturnType<typeof useInputState>);
const { lastFrame, unmount } = await renderWithProviders(
<CopyModeWarning />,
);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders warning when copy mode is enabled', async () => {
mockUseUIState.mockReturnValue({
vi.mocked(useInputState).mockReturnValue({
copyModeEnabled: true,
} as unknown as UIState);
const { lastFrame, unmount } = await render(<CopyModeWarning />);
} as unknown as ReturnType<typeof useInputState>);
const { lastFrame, unmount } = await renderWithProviders(
<CopyModeWarning />,
);
expect(lastFrame()).toContain('In Copy Mode');
expect(lastFrame()).toContain('Use Page Up/Down to scroll');
expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit');
@@ -6,11 +6,11 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { useInputState } from '../contexts/InputContext.js';
import { theme } from '../semantic-colors.js';
export const CopyModeWarning: React.FC = () => {
const { copyModeEnabled } = useUIState();
const { copyModeEnabled } = useInputState();
return (
<Box height={1}>
@@ -169,7 +169,11 @@ Implement a comprehensive authentication system with multiple providers.
getUseTerminalBuffer: () => false,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({ ui: { useAlternateBuffer } }),
uiState: { isAlternateBuffer: useAlternateBuffer },
inputState: {
buffer: { text: '' } as never,
showEscapePrompt: false,
shellModeActive: false,
},
},
);
};
@@ -467,14 +471,17 @@ Implement a comprehensive authentication system with multiple providers.
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
uiState: { isAlternateBuffer: useAlternateBuffer ?? true },
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
getUseTerminalBuffer: () => false,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
}),
uiState: { isAlternateBuffer: useAlternateBuffer ?? true },
inputState: {
buffer: { text: '' } as never,
showEscapePrompt: false,
shellModeActive: false,
},
},
),
);
@@ -580,7 +587,7 @@ Implement a comprehensive authentication system with multiple providers.
expect(onFeedback).not.toHaveBeenCalled();
});
it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => {
it('automatically submits feedback when Ctrl+G is used to edit the plan', async () => {
const { stdin, lastFrame } = await act(async () =>
renderDialog({ useAlternateBuffer }),
);
@@ -593,9 +600,9 @@ Implement a comprehensive authentication system with multiple providers.
expect(lastFrame()).toContain('Add user authentication');
});
// Press Ctrl+X
// Press Ctrl+G
await act(async () => {
writeKey(stdin, '\x18'); // Ctrl+X
writeKey(stdin, '\x07'); // Ctrl+G
});
await waitFor(() => {
@@ -25,6 +25,11 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { formatCommand } from '../key/keybindingUtils.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import {
appEvents,
AppEvent,
TransientMessageType,
} from '../../utils/events.js';
export interface ExitPlanModeDialogProps {
planPath: string;
@@ -173,6 +178,14 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
void handleOpenEditor();
return true;
}
if (keyMatchers[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR](key)) {
const cmdKey = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
appEvents.emit(AppEvent.TransientMessage, {
message: `Use ${cmdKey} to open the external editor.`,
type: TransientMessageType.Hint,
});
return true;
}
return false;
},
{ isActive: true, priority: true },
+3 -4
View File
@@ -26,6 +26,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useInputState } from '../contexts/InputContext.js';
import {
ALL_ITEMS,
type FooterItemId,
@@ -173,6 +174,7 @@ interface FooterColumn {
export const Footer: React.FC = () => {
const uiState = useUIState();
const { copyModeEnabled } = useInputState();
const config = useConfig();
const settings = useSettings();
const { vimEnabled, vimMode } = useVimMode();
@@ -365,10 +367,7 @@ export const Footer: React.FC = () => {
id,
header,
() => (
<MemoryUsageDisplay
color={itemColor}
isActive={!uiState.copyModeEnabled}
/>
<MemoryUsageDisplay color={itemColor} isActive={!copyModeEnabled} />
),
10,
);
@@ -85,7 +85,6 @@ describe('<HistoryItemDisplay />', () => {
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
uiState: { isAlternateBuffer: useAlternateBuffer },
},
);
expect(lastFrame()).toMatchSnapshot();
@@ -339,7 +338,6 @@ describe('<HistoryItemDisplay />', () => {
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
uiState: { isAlternateBuffer: useAlternateBuffer },
},
);
expect(lastFrame()).toMatchSnapshot();
@@ -363,7 +361,6 @@ describe('<HistoryItemDisplay />', () => {
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
uiState: { isAlternateBuffer: useAlternateBuffer },
},
);
expect(lastFrame()).toMatchSnapshot();
@@ -386,7 +383,6 @@ describe('<HistoryItemDisplay />', () => {
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
uiState: { isAlternateBuffer: useAlternateBuffer },
},
);
expect(lastFrame()).toMatchSnapshot();
@@ -410,7 +406,6 @@ describe('<HistoryItemDisplay />', () => {
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
uiState: { isAlternateBuffer: useAlternateBuffer },
},
);
expect(lastFrame()).toMatchSnapshot();
@@ -134,6 +134,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<InfoMessage
text={itemForDisplay.text}
secondaryText={itemForDisplay.secondaryText}
source={itemForDisplay.source}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
marginBottom={itemForDisplay.marginBottom}
File diff suppressed because it is too large Load Diff
+68 -32
View File
@@ -5,7 +5,14 @@
*/
import type React from 'react';
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import {
useCallback,
useEffect,
useState,
useRef,
useMemo,
Fragment,
} from 'react';
import clipboardy from 'clipboardy';
import { Box, Text, useStdout, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
@@ -70,6 +77,7 @@ import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useInputState } from '../contexts/InputContext.js';
import {
appEvents,
AppEvent,
@@ -104,18 +112,13 @@ export type ScrollableItem =
| { type: 'ghostLine'; ghostLine: string; index: number };
export interface InputPromptProps {
buffer: TextBuffer;
onSubmit: (value: string) => void;
userMessages: readonly string[];
onClearScreen: () => void;
config: Config;
slashCommands: readonly SlashCommand[];
commandContext: CommandContext;
placeholder?: string;
focus?: boolean;
inputWidth: number;
suggestionsWidth: number;
shellModeActive: boolean;
setShellModeActive: (value: boolean) => void;
approvalMode: ApprovalMode;
onEscapePromptChange?: (showPrompt: boolean) => void;
@@ -128,7 +131,6 @@ export interface InputPromptProps {
onQueueMessage?: (message: string) => void;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
copyModeEnabled?: boolean;
}
// The input content, input container, and input suggestions list may have different widths
@@ -199,18 +201,13 @@ export function tryTogglePasteExpansion(buffer: TextBuffer): boolean {
}
export const InputPrompt: React.FC<InputPromptProps> = ({
buffer,
onSubmit,
userMessages,
onClearScreen,
config,
slashCommands,
commandContext,
placeholder = ' Type your message or @path/to/file',
focus = true,
inputWidth,
suggestionsWidth,
shellModeActive,
setShellModeActive,
approvalMode,
onEscapePromptChange,
@@ -223,8 +220,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onQueueMessage,
suggestionsPosition = 'below',
setBannerVisible,
copyModeEnabled = false,
}) => {
const inputState = useInputState();
const {
buffer,
userMessages,
shellModeActive,
copyModeEnabled,
inputWidth,
suggestionsWidth,
} = inputState;
const isHelpDismissKey = useIsHelpDismissKey();
const keyMatchers = useKeyMatchers();
const { stdout } = useStdout();
@@ -434,7 +439,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
slashCommands,
);
if (commandToExecute?.isSafeConcurrent) {
inputHistory.handleSubmit(trimmedMessage);
handleSubmitAndClear(trimmedMessage);
return;
}
}
@@ -452,6 +457,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
streamingState,
setQueueErrorMessage,
slashCommands,
handleSubmitAndClear,
],
);
@@ -1266,6 +1272,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (keyMatchers[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR](key)) {
const cmdKey = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
appEvents.emit(AppEvent.TransientMessage, {
message: `Use ${cmdKey} to open the external editor.`,
type: TransientMessageType.Hint,
});
return true;
}
// Ctrl+V for clipboard paste
if (keyMatchers[Command.PASTE_CLIPBOARD](key)) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -1821,24 +1836,45 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
height={Math.min(buffer.viewportHeight, scrollableData.length)}
width="100%"
>
<ScrollableList
ref={listRef}
hasFocus={focus}
data={scrollableData}
renderItem={renderItem}
estimatedItemHeight={() => 1}
keyExtractor={(item) =>
item.type === 'visualLine'
? `line-${item.absoluteVisualIdx}`
: `ghost-${item.index}`
}
width="100%"
backgroundColor={listBackgroundColor}
containerHeight={Math.min(
buffer.viewportHeight,
scrollableData.length,
)}
/>
{isAlternateBuffer ? (
<ScrollableList
ref={listRef}
hasFocus={focus}
data={scrollableData}
renderItem={renderItem}
estimatedItemHeight={() => 1}
fixedItemHeight={true}
keyExtractor={(item) =>
item.type === 'visualLine'
? `line-${item.absoluteVisualIdx}`
: `ghost-${item.index}`
}
width={inputWidth}
backgroundColor={listBackgroundColor}
containerHeight={Math.min(
buffer.viewportHeight,
scrollableData.length,
)}
/>
) : (
scrollableData
.slice(
buffer.visualScrollRow,
buffer.visualScrollRow + buffer.viewportHeight,
)
.map((item, index) => {
const actualIndex = buffer.visualScrollRow + index;
const key =
item.type === 'visualLine'
? `line-${item.absoluteVisualIdx}`
: `ghost-${item.index}`;
return (
<Fragment key={key}>
{renderItem({ item, index: actualIndex })}
</Fragment>
);
})
)}
</Box>
)}
</Box>
+9 -13
View File
@@ -336,27 +336,23 @@ export const MainContent = () => {
isAlternateBuffer,
]);
const allStaticItems = useMemo(
() => [
<AppHeader key="app-header" version={version} />,
...staticHistoryItems,
...lastResponseHistoryItems,
],
[version, staticHistoryItems, lastResponseHistoryItems],
);
if (!uiState.isConfigInitialized) {
return null;
}
if (isAlternateBufferOrTerminalBuffer) {
if (uiState.isTransitioningAltBuffer) {
return pendingItems;
}
return scrollableList;
}
return (
<>
<Static
key={`main-history-static-${uiState.historyRemountKey}`}
items={uiState.isTransitioningAltBuffer ? [] : allStaticItems}
key={uiState.historyRemountKey}
items={[
<AppHeader key="app-header" version={version} />,
...staticHistoryItems,
...lastResponseHistoryItems,
]}
>
{(item) => item}
</Static>
@@ -9,7 +9,7 @@ import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { type SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision } from '@google/gemini-cli-core';
import { ToolCallDecision, LlmRole } from '@google/gemini-cli-core';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
@@ -131,6 +131,66 @@ describe('<StatsDisplay />', () => {
expect(output).toMatchSnapshot();
});
it('renders role breakdown correctly under models', async () => {
const metrics = createTestMetrics({
models: {
'gemini-2.5-flash': {
api: { totalRequests: 10, totalErrors: 0, totalLatencyMs: 10000 },
tokens: {
input: 1000,
prompt: 1200,
candidates: 2000,
total: 3200,
cached: 200,
thoughts: 0,
tool: 0,
},
roles: {
[LlmRole.MAIN]: {
totalRequests: 7,
totalErrors: 0,
totalLatencyMs: 7000,
tokens: {
input: 800,
prompt: 900,
candidates: 1500,
total: 2400,
cached: 100,
thoughts: 0,
tool: 0,
},
},
[LlmRole.UTILITY_TOOL]: {
totalRequests: 3,
totalErrors: 0,
totalLatencyMs: 3000,
tokens: {
input: 200,
prompt: 300,
candidates: 500,
total: 800,
cached: 100,
thoughts: 0,
tool: 0,
},
},
},
},
},
});
const { lastFrame } = await renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('gemini-2.5-flash');
expect(output).toContain('10'); // Total requests
expect(output).toContain('↳ main');
expect(output).toContain('7'); // main requests
expect(output).toContain('↳ utility_tool');
expect(output).toContain('3'); // tool requests
expect(output).toMatchSnapshot();
});
it('renders all sections when all data is present', async () => {
const metrics = createTestMetrics({
models: {
+75 -12
View File
@@ -12,6 +12,7 @@ import { formatDuration } from '../utils/formatters.js';
import {
useSessionStats,
type ModelMetrics,
type RoleMetrics,
} from '../contexts/SessionContext.js';
import {
getStatusColor,
@@ -23,6 +24,7 @@ import {
import { computeSessionStats } from '../utils/computeStats.js';
import { useSettings } from '../contexts/SettingsContext.js';
import type { QuotaStats } from '../types.js';
import { LlmRole } from '@google/gemini-cli-core';
// A more flexible and powerful StatRow component
interface StatRowProps {
@@ -77,6 +79,16 @@ interface ModelUsageTableProps {
models: Record<string, ModelMetrics>;
}
interface ModelRow {
name: string;
displayName: string;
requests: number | string;
cachedTokens: string;
inputTokens: string;
outputTokens: string;
isSubRow: boolean;
}
const ModelUsageTable: React.FC<ModelUsageTableProps> = ({ models }) => {
const nameWidth = 28;
const requestsWidth = 8;
@@ -84,6 +96,46 @@ const ModelUsageTable: React.FC<ModelUsageTableProps> = ({ models }) => {
const cacheReadsWidth = 14;
const outputTokensWidth = 14;
const rows: ModelRow[] = [];
Object.entries(models).forEach(([name, metrics]) => {
rows.push({
name,
displayName: name,
requests: metrics.api.totalRequests,
cachedTokens: metrics.tokens.cached.toLocaleString(),
inputTokens: metrics.tokens.prompt.toLocaleString(),
outputTokens: metrics.tokens.candidates.toLocaleString(),
isSubRow: false,
});
if (metrics.roles) {
const roleEntries = Object.entries(metrics.roles).filter(
(entry): entry is [string, RoleMetrics] =>
entry[1] !== undefined && entry[1].totalRequests > 0,
);
roleEntries.sort(([a], [b]) => {
if (a === b) return 0;
if (a === LlmRole.MAIN) return -1;
if (b === LlmRole.MAIN) return 1;
return a.localeCompare(b);
});
roleEntries.forEach(([role, roleMetrics]) => {
rows.push({
name: `${name}-${role}`,
displayName: `${role}`,
requests: roleMetrics.totalRequests,
cachedTokens: roleMetrics.tokens.cached.toLocaleString(),
inputTokens: roleMetrics.tokens.prompt.toLocaleString(),
outputTokens: roleMetrics.tokens.candidates.toLocaleString(),
isSubRow: true,
});
});
}
});
return (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
@@ -131,31 +183,42 @@ const ModelUsageTable: React.FC<ModelUsageTableProps> = ({ models }) => {
</Box>
{/* Rows */}
{Object.entries(models).map(([name, modelMetrics]) => (
<Box key={name}>
{rows.map((row) => (
<Box key={row.name}>
<Box width={nameWidth}>
<Text color={theme.text.primary} wrap="truncate-end">
{name}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
wrap="truncate-end"
>
{row.displayName}
</Text>
</Box>
<Box width={requestsWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.api.totalRequests}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
>
{row.requests}
</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.tokens.prompt.toLocaleString()}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
>
{row.inputTokens}
</Text>
</Box>
<Box width={cacheReadsWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.tokens.cached.toLocaleString()}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
>
{row.cachedTokens}
</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.tokens.candidates.toLocaleString()}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
>
{row.outputTokens}
</Text>
</Box>
</Box>
@@ -9,7 +9,7 @@ import { StatusRow } from './StatusRow.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { type TextBuffer } from '../components/shared/text-buffer.js';
import { type SessionStatsState } from '../contexts/SessionContext.js';
import { type ThoughtSummary } from '../types.js';
import { ApprovalMode } from '@google/gemini-cli-core';
@@ -29,13 +29,11 @@ describe('<StatusRow />', () => {
elapsedTime: 0,
currentWittyPhrase: undefined,
activeHooks: [],
buffer: { text: '' } as unknown as TextBuffer,
sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState,
shortcutsHelpVisible: false,
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
allowPlanMode: false,
shellModeActive: false,
renderMarkdown: true,
currentModel: 'gemini-3',
};
+13 -9
View File
@@ -153,6 +153,8 @@ export const StatusNode: React.FC<{
);
};
import { useInputState } from '../contexts/InputContext.js';
export const StatusRow: React.FC<StatusRowProps> = ({
showUiDetails,
isNarrow,
@@ -162,6 +164,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
hasPendingActionRequired,
}) => {
const uiState = useUIState();
const inputState = useInputState();
const settings = useSettings();
const {
isInteractiveShellWaiting,
@@ -225,7 +228,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
!hasPendingActionRequired &&
uiState.buffer.text.length === 0
inputState.buffer.text.length === 0
) {
return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
}
@@ -328,7 +331,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
) : isInteractiveShellWaiting ? (
<Box width="100%" marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<Text color={theme.status.warning}>
! Shell awaiting input (Tab to focus)
{INTERACTIVE_SHELL_WAITING_PHRASE}
</Text>
</Box>
) : (
@@ -391,13 +394,14 @@ export const StatusRow: React.FC<StatusRowProps> = ({
>
{showUiDetails ? (
<>
{!hideUiDetailsForSuggestions && !uiState.shellModeActive && (
<ApprovalModeIndicator
approvalMode={uiState.showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{uiState.shellModeActive && (
{!hideUiDetailsForSuggestions &&
!inputState.shellModeActive && (
<ApprovalModeIndicator
approvalMode={uiState.showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{inputState.shellModeActive && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<ShellModeIndicator />
</Box>
@@ -9,16 +9,24 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { TransientMessageType } from '../../utils/events.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { type InputState } from '../contexts/InputContext.js';
import { type TextBuffer } from './shared/text-buffer.js';
import { type HistoryItem } from '../types.js';
const renderToastDisplay = async (uiState: Partial<UIState> = {}) =>
const renderToastDisplay = async (
uiState: Partial<UIState> = {},
inputState: Partial<InputState> = {},
) =>
renderWithProviders(<ToastDisplay />, {
uiState: {
buffer: { text: '' } as TextBuffer,
history: [] as HistoryItem[],
...uiState,
},
inputState: {
buffer: { text: '' } as TextBuffer,
showEscapePrompt: false,
...inputState,
},
});
describe('ToastDisplay', () => {
@@ -27,86 +35,121 @@ describe('ToastDisplay', () => {
});
describe('shouldShowToast', () => {
const baseState: Partial<UIState> = {
const baseUIState: Partial<UIState> = {
ctrlCPressedOnce: false,
transientMessage: null,
ctrlDPressedOnce: false,
showEscapePrompt: false,
buffer: { text: '' } as TextBuffer,
history: [] as HistoryItem[],
queueErrorMessage: null,
showIsExpandableHint: false,
};
const baseInputState: Partial<InputState> = {
showEscapePrompt: false,
buffer: { text: '' } as TextBuffer,
};
it('returns false for default state', () => {
expect(shouldShowToast(baseState as UIState)).toBe(false);
expect(
shouldShowToast(baseUIState as UIState, baseInputState as InputState),
).toBe(false);
});
it('returns true when showIsExpandableHint is true', () => {
expect(
shouldShowToast({
...baseState,
showIsExpandableHint: true,
} as UIState),
shouldShowToast(
{
...baseUIState,
showIsExpandableHint: true,
} as UIState,
baseInputState as InputState,
),
).toBe(true);
});
it('returns true when ctrlCPressedOnce is true', () => {
expect(
shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState),
shouldShowToast(
{ ...baseUIState, ctrlCPressedOnce: true } as UIState,
baseInputState as InputState,
),
).toBe(true);
});
it('returns true when transientMessage is present', () => {
expect(
shouldShowToast({
...baseState,
transientMessage: { text: 'test', type: TransientMessageType.Hint },
} as UIState),
shouldShowToast(
{
...baseUIState,
transientMessage: { text: 'test', type: TransientMessageType.Hint },
} as UIState,
baseInputState as InputState,
),
).toBe(true);
});
it('returns true when ctrlDPressedOnce is true', () => {
expect(
shouldShowToast({ ...baseState, ctrlDPressedOnce: true } as UIState),
shouldShowToast(
{ ...baseUIState, ctrlDPressedOnce: true } as UIState,
baseInputState as InputState,
),
).toBe(true);
});
it('returns true when showEscapePrompt is true and buffer is NOT empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
} as UIState),
shouldShowToast(
{
...baseUIState,
} as UIState,
{
...baseInputState,
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
} as InputState,
),
).toBe(true);
});
it('returns true when showEscapePrompt is true and history is NOT empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
history: [{ id: '1' } as unknown as HistoryItem],
} as UIState),
shouldShowToast(
{
...baseUIState,
history: [{ id: '1' } as unknown as HistoryItem],
} as UIState,
{
...baseInputState,
showEscapePrompt: true,
} as InputState,
),
).toBe(true);
});
it('returns false when showEscapePrompt is true but buffer and history are empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
} as UIState),
shouldShowToast(
{
...baseUIState,
} as UIState,
{
...baseInputState,
showEscapePrompt: true,
} as InputState,
),
).toBe(false);
});
it('returns true when queueErrorMessage is present', () => {
expect(
shouldShowToast({
...baseState,
queueErrorMessage: 'error',
} as UIState),
shouldShowToast(
{
...baseUIState,
queueErrorMessage: 'error',
} as UIState,
baseInputState as InputState,
),
).toBe(true);
});
});
@@ -151,18 +194,25 @@ describe('ToastDisplay', () => {
});
it('renders Escape prompt when buffer is empty', async () => {
const { lastFrame } = await renderToastDisplay({
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[],
});
const { lastFrame } = await renderToastDisplay(
{
history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[],
},
{
showEscapePrompt: true,
},
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is NOT empty', async () => {
const { lastFrame } = await renderToastDisplay({
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
});
const { lastFrame } = await renderToastDisplay(
{},
{
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
},
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -8,15 +8,19 @@ import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { useInputState, type InputState } from '../contexts/InputContext.js';
import { TransientMessageType } from '../../utils/events.js';
export function shouldShowToast(uiState: UIState): boolean {
export function shouldShowToast(
uiState: UIState,
inputState: InputState,
): boolean {
return (
uiState.ctrlCPressedOnce ||
Boolean(uiState.transientMessage) ||
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
(inputState.showEscapePrompt &&
(inputState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage) ||
uiState.showIsExpandableHint
);
@@ -24,6 +28,7 @@ export function shouldShowToast(uiState: UIState): boolean {
export const ToastDisplay: React.FC = () => {
const uiState = useUIState();
const inputState = useInputState();
if (uiState.ctrlCPressedOnce) {
return (
@@ -46,8 +51,8 @@ export const ToastDisplay: React.FC = () => {
);
}
if (uiState.showEscapePrompt) {
const isPromptEmpty = uiState.buffer.text.length === 0;
if (inputState.showEscapePrompt) {
const isPromptEmpty = inputState.buffer.text.length === 0;
const hasHistory = uiState.history.length > 0;
if (isPromptEmpty && !hasHistory) {
@@ -1,16 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ContextSummaryDisplay /> > should not render empty parts 1`] = `
" 1 open file (ctrl+g to view)
" 1 open file (F4 to view)
"
`;
exports[`<ContextSummaryDisplay /> > should render on a single line on a wide screen 1`] = `
" 1 open file (ctrl+g to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
" 1 open file (F4 to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
"
`;
exports[`<ContextSummaryDisplay /> > should render on multiple lines on a narrow screen 1`] = `
" 1 open file (ctrl+g to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
" 1 open file (F4 to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
"
`;
@@ -23,7 +23,7 @@ Files to Modify
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
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -50,7 +50,7 @@ Files to Modify
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
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -82,7 +82,7 @@ Implementation Steps
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
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -109,7 +109,7 @@ Files to Modify
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
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -136,7 +136,7 @@ Files to Modify
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
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -163,7 +163,7 @@ Files to Modify
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
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -216,7 +216,7 @@ Testing Strategy
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
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -243,6 +243,6 @@ Files to Modify
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
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -93,7 +93,7 @@ exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios >
exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> second message
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
@@ -120,30 +120,30 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
git commit -m "feat: add search" in src/app
"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
git commit -m "feat: add search" in src/app
"
`;
exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Image ...reenshot2x.png]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> @/path/to/screenshots/screenshot2x.png
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
@@ -12,7 +12,7 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
Ctrl+V paste images
Alt+M raw markdown mode
Ctrl+R reverse-search history
Ctrl+X open external editor
Ctrl+G open external editor
"
`;
@@ -28,7 +28,7 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
Ctrl+V paste images
Option+M raw markdown mode
Ctrl+R reverse-search history
Ctrl+X open external editor
Ctrl+G open external editor
"
`;
@@ -37,7 +37,7 @@ 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
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+G 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
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+G open external editor
Tab focus UI
"
`;
@@ -263,3 +263,32 @@ exports[`<StatsDisplay /> > renders only the Performance section in its zero sta
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<StatsDisplay /> > renders role breakdown correctly under models 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 10.0s │
│ » API Time: 10.0s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Use /model to view model quota information │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-flash 10 1,200 200 2,000 │
│ ↳ main 7 900 100 1,500 │
│ ↳ utility_tool 3 300 100 500 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -191,7 +191,7 @@ exports[`ToolConfirmationQueue > renders ExitPlanMode tool confirmation with Suc
│ 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 │
│ Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -474,7 +474,6 @@ describe('DenseToolMessage', () => {
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiState: { isAlternateBuffer: true },
},
);
await waitUntilReady();
@@ -12,6 +12,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
secondaryText?: string;
source?: string;
icon?: string;
color?: string;
marginBottom?: number;
@@ -20,6 +21,7 @@ interface InfoMessageProps {
export const InfoMessage: React.FC<InfoMessageProps> = ({
text,
secondaryText,
source,
icon,
color,
marginBottom,
@@ -40,6 +42,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
{index === text.split('\n').length - 1 && secondaryText && (
<Text color={theme.text.secondary}> {secondaryText}</Text>
)}
{index === text.split('\n').length - 1 && source && (
<Text color={theme.text.secondary}> [{source}]</Text>
)}
</Text>
))}
</Box>
@@ -72,7 +72,7 @@ describe('<ShellToolMessage />', () => {
const { lastFrame, simulateClick, unmount, waitUntilReady } =
await renderWithProviders(
<ShellToolMessage {...baseProps} name={name} />,
{ uiActions },
{ uiActions, mouseEventsEnabled: true },
);
await waitUntilReady();
@@ -257,7 +257,6 @@ describe('<ShellToolMessage />', () => {
activePtyId: focused ? 1 : 2,
embeddedShellFocused: focused,
constrainHeight,
isAlternateBuffer: true,
},
},
);
@@ -294,7 +293,7 @@ describe('<ShellToolMessage />', () => {
await waitUntilReady();
const frame = lastFrame();
// Since it's Executing, it might still constrain to ACTIVE_SHELL_MAX_LINES (10)
// Actually let's just assert on the behaviour that happens right now (which is 100 lines)
// Actually let's just assert on the behaviour that happens right now (which is 100 lines because we removed the terminalBuffer check)
expect(frame.match(/Line \d+/g)?.length).toBe(100);
unmount();
});
@@ -315,7 +314,6 @@ describe('<ShellToolMessage />', () => {
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiState: {
constrainHeight: false,
isAlternateBuffer: true,
},
},
);
@@ -344,7 +342,6 @@ describe('<ShellToolMessage />', () => {
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiState: {
constrainHeight: false,
isAlternateBuffer: true,
},
},
);
@@ -258,15 +258,42 @@ describe('<ToolGroupMessage />', () => {
unmount();
});
it('renders update_topic tool call prioritizing summary over strategic_intent', async () => {
it('renders update_topic tool call using TopicMessage', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool-priority',
callId: 'topic-tool',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
const output = lastFrame();
expect(output).toContain('Testing Topic: ');
expect(output).toContain('This is the description');
expect(output).toMatchSnapshot('update_topic_tool');
unmount();
});
it('renders update_topic tool call with summary instead of strategic_intent', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool-summary',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_SUMMARY]: 'This is the summary',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This should be ignored',
},
}),
];
@@ -283,34 +310,6 @@ describe('<ToolGroupMessage />', () => {
const output = lastFrame();
expect(output).toContain('Testing Topic: ');
expect(output).toContain('This is the summary');
expect(output).not.toContain('This should be ignored');
unmount();
});
it('renders update_topic tool call falling back to strategic_intent', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool-fallback',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Fallback intent',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
const output = lastFrame();
expect(output).toContain('Testing Topic: ');
expect(output).toContain('Fallback intent');
unmount();
});
@@ -321,7 +320,7 @@ describe('<ToolGroupMessage />', () => {
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_SUMMARY]: 'This is the summary',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
},
}),
createToolCall({
@@ -444,18 +444,18 @@ describe('<ToolMessage />', () => {
constrainHeight: true,
},
width: 80,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
const output = lastFrame();
// Since kind=Kind.Agent and availableTerminalHeight is provided, it should truncate to SUBAGENT_MAX_LINES (15)
// It should constrain the height, showing the head of the output (overflowDirection='bottom')
expect(output).toMatch(/Line 1\b/);
expect(output).toMatch(/Line 14\b/);
expect(output).not.toMatch(/Line 16\b/);
expect(output).not.toMatch(/Line 30\b/);
// It should constrain the height, showing the tail of the output (overflowDirection='top' or due to scroll)
expect(output).not.toMatch(/Line 1\b/);
expect(output).not.toMatch(/Line 14\b/);
expect(output).toMatch(/Line 16\b/);
expect(output).toMatch(/Line 30\b/);
unmount();
});
@@ -5,6 +5,7 @@
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
import { describe, it, expect, vi } from 'vitest';
@@ -351,9 +352,10 @@ describe('ToolResultDisplay', () => {
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).not.toContain('Line 3');
expect(output).toContain('Line 4');
expect(output).toContain('Line 5');
expect(output).toContain('hidden');
expect(output).toMatchSnapshot();
unmount();
});
@@ -391,4 +393,86 @@ describe('ToolResultDisplay', () => {
await expect(renderResult).toMatchSvgSnapshot();
unmount();
});
it('stays scrolled to the bottom when lines are incrementally added', async () => {
const createAnsiLine = (text: string) => [
{
text,
fg: '',
bg: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
isUninitialized: false,
},
];
let currentLines: AnsiOutput = [];
// Start with 3 lines, max lines 5. It should fit without scrolling.
for (let i = 1; i <= 3; i++) {
currentLines.push(createAnsiLine(`Line ${i}`));
}
const renderResult = await renderWithProviders(
<ToolResultDisplay
resultDisplay={currentLines}
terminalWidth={80}
maxLines={5}
availableTerminalHeight={5}
overflowDirection="top"
/>,
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true, terminalHeight: 10 },
},
);
const { waitUntilReady, rerender, lastFrame, unmount } = renderResult;
await waitUntilReady();
// Verify initial render has the first 3 lines
expect(lastFrame()).toContain('Line 1');
expect(lastFrame()).toContain('Line 3');
// Incrementally add lines up to 8. Max lines is 5.
// So by the end, it should only show lines 4-8.
for (let i = 4; i <= 8; i++) {
currentLines = [...currentLines, createAnsiLine(`Line ${i}`)];
rerender(
<ToolResultDisplay
resultDisplay={currentLines}
terminalWidth={80}
maxLines={5}
availableTerminalHeight={5}
overflowDirection="top"
/>,
);
// Wait for the new line to be rendered
await waitFor(() => {
expect(lastFrame()).toContain(`Line ${i}`);
});
}
await waitUntilReady();
const output = lastFrame();
// The component should have automatically scrolled to the bottom.
// Lines 1, 2, 3, 4 should be scrolled out of view.
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).not.toContain('Line 3');
expect(output).not.toContain('Line 4');
// Lines 5, 6, 7, 8 should be visible along with the truncation indicator.
expect(output).toContain('hidden');
expect(output).toContain('Line 5');
expect(output).toContain('Line 8');
expect(output).toMatchSnapshot();
unmount();
});
});
@@ -10,6 +10,7 @@ import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
import { SlicingMaxSizedBox } from '../shared/SlicingMaxSizedBox.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
import {
type AnsiOutput,
@@ -51,7 +52,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
hasFocus = false,
overflowDirection = 'top',
}) => {
const { renderMarkdown } = useUIState();
const { renderMarkdown, constrainHeight } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const availableHeight = calculateToolContentMaxLines({
@@ -209,30 +210,73 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
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,
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const data = resultDisplay as AnsiOutput;
const initialScrollIndex =
overflowDirection === 'bottom' ? 0 : SCROLL_TO_ITEM_END;
// Calculate list height: if not constrained, use full data length.
// If constrained (e.g. alternate buffer), limit to available height
// to ensure virtualization works and fits within the viewport.
const listHeight = !constrainHeight
? data.length
: Math.min(data.length, limit);
return (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
<ScrollableList
width={childWidth}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data={resultDisplay as AnsiOutput}
renderItem={renderVirtualizedAnsiLine}
estimatedItemHeight={() => 1}
keyExtractor={keyExtractor}
initialScrollIndex={initialScrollIndex}
hasFocus={hasFocus}
fixedItemHeight={true}
/>
</Box>
);
if (isAlternateBuffer) {
const initialScrollIndex =
overflowDirection === 'bottom' ? 0 : SCROLL_TO_ITEM_END;
return (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
<ScrollableList
width={childWidth}
containerHeight={listHeight}
data={data}
renderItem={renderVirtualizedAnsiLine}
estimatedItemHeight={() => 1}
fixedItemHeight={true}
keyExtractor={keyExtractor}
initialScrollIndex={initialScrollIndex}
hasFocus={hasFocus}
/>
</Box>
);
} else {
let displayData = data;
let hiddenLines = 0;
if (constrainHeight && data.length > listHeight) {
hiddenLines = data.length - listHeight;
if (overflowDirection === 'top') {
displayData = data.slice(hiddenLines);
} else {
displayData = data.slice(0, listHeight);
}
}
return (
<Box width={childWidth} flexDirection="column">
<MaxSizedBox
maxHeight={constrainHeight ? listHeight : undefined}
maxWidth={childWidth}
overflowDirection={overflowDirection}
additionalHiddenLinesCount={hiddenLines}
>
{displayData.map((item, index) => {
const actualIndex =
(overflowDirection === 'top' ? hiddenLines : 0) + index;
return (
<Box
key={keyExtractor(item, actualIndex)}
height={1}
overflow="hidden"
>
<AnsiLineText line={item} />
</Box>
);
})}
</MaxSizedBox>
</Box>
);
}
}
// ASB Mode Handling (Interactive/Fullscreen)
@@ -34,6 +34,7 @@ describe('ToolResultDisplay Overflow', () => {
expect(output).not.toContain('Line 3');
expect(output).not.toContain('Line 4');
expect(output).not.toContain('Line 5');
expect(output).toContain('hidden');
unmount();
});
@@ -60,6 +61,7 @@ describe('ToolResultDisplay Overflow', () => {
expect(output).not.toContain('Line 3');
expect(output).toContain('Line 4');
expect(output).toContain('Line 5');
expect(output).toContain('hidden');
unmount();
});
@@ -95,11 +97,10 @@ describe('ToolResultDisplay Overflow', () => {
expect(output).toContain('Line 1');
expect(output).toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).not.toContain('Line 3');
expect(output).not.toContain('Line 4');
expect(output).not.toContain('Line 5');
// ScrollableList uses a scroll thumb rather than writing "hidden"
expect(output).toContain('█');
expect(output).toContain('hidden');
unmount();
});
});
@@ -0,0 +1,114 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { TopicMessage } from './TopicMessage.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import {
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
CoreToolCallStatus,
UPDATE_TOPIC_TOOL_NAME,
} from '@google/gemini-cli-core';
describe('<TopicMessage />', () => {
const baseArgs = {
[TOPIC_PARAM_TITLE]: 'Test Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the strategic intent.',
[TOPIC_PARAM_SUMMARY]:
'This is the detailed summary that should be expandable.',
};
const renderTopic = async (
args: Record<string, unknown>,
height?: number,
toolActions?: {
isExpanded?: (callId: string) => boolean;
toggleExpansion?: (callId: string) => void;
},
) =>
renderWithProviders(
<TopicMessage
args={args}
terminalWidth={80}
availableTerminalHeight={height}
callId="test-topic"
name={UPDATE_TOPIC_TOOL_NAME}
description="Updating topic"
status={CoreToolCallStatus.Success}
confirmationDetails={undefined}
resultDisplay={undefined}
/>,
{ toolActions, mouseEventsEnabled: true },
);
it('renders title and intent by default (collapsed)', async () => {
const { lastFrame } = await renderTopic(baseArgs, 40);
const frame = lastFrame();
expect(frame).toContain('Test Topic:');
expect(frame).toContain('This is the strategic intent.');
expect(frame).not.toContain('This is the detailed summary');
expect(frame).not.toContain('(ctrl+o to expand)');
});
it('renders summary when globally expanded (Ctrl+O)', async () => {
const { lastFrame } = await renderTopic(baseArgs, undefined);
const frame = lastFrame();
expect(frame).toContain('Test Topic:');
expect(frame).toContain('This is the strategic intent.');
expect(frame).toContain('This is the detailed summary');
expect(frame).not.toContain('(ctrl+o to collapse)');
});
it('renders summary when selectively expanded via context', async () => {
const isExpanded = vi.fn((id) => id === 'test-topic');
const { lastFrame } = await renderTopic(baseArgs, 40, { isExpanded });
const frame = lastFrame();
expect(frame).toContain('Test Topic:');
expect(frame).toContain('This is the detailed summary');
expect(frame).not.toContain('(ctrl+o to collapse)');
});
it('calls toggleExpansion when clicked', async () => {
const toggleExpansion = vi.fn();
const { simulateClick } = await renderTopic(baseArgs, 40, {
toggleExpansion,
});
// In renderWithProviders, the component is wrapped in a Box with terminalWidth.
// The TopicMessage has marginLeft={2}.
// So col 5 should definitely hit the text content.
// row 1 is the first line of the TopicMessage.
await simulateClick(5, 1);
expect(toggleExpansion).toHaveBeenCalledWith('test-topic');
});
it('falls back to summary if strategic_intent is missing', async () => {
const args = {
[TOPIC_PARAM_TITLE]: 'Test Topic',
[TOPIC_PARAM_SUMMARY]: 'Only summary is present.',
};
const { lastFrame } = await renderTopic(args, 40);
const frame = lastFrame();
expect(frame).toContain('Test Topic:');
expect(frame).toContain('Only summary is present.');
expect(frame).not.toContain('(ctrl+o to expand)');
});
it('renders only strategic_intent if summary is missing', async () => {
const args = {
[TOPIC_PARAM_TITLE]: 'Test Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Only intent is present.',
};
const { lastFrame } = await renderTopic(args, 40);
const frame = lastFrame();
expect(frame).toContain('Test Topic:');
expect(frame).toContain('Only intent is present.');
expect(frame).not.toContain('(ctrl+o to expand)');
});
});
@@ -5,7 +5,8 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { useEffect, useId, useRef, useCallback } from 'react';
import { Box, Text, type DOMElement } from 'ink';
import {
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
@@ -15,32 +16,103 @@ import {
} from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../../types.js';
import { theme } from '../../semantic-colors.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
interface TopicMessageProps extends IndividualToolCallDisplay {
terminalWidth: number;
availableTerminalHeight?: number;
isExpandable?: boolean;
}
export const isTopicTool = (name: string): boolean =>
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
export const TopicMessage: React.FC<TopicMessageProps> = ({ args }) => {
export const TopicMessage: React.FC<TopicMessageProps> = ({
callId,
args,
availableTerminalHeight,
isExpandable = true,
}) => {
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
// Expansion is active if either:
// 1. The individual callId is expanded in the ToolActionsContext
// 2. The entire turn is expanded (Ctrl+O) which sets availableTerminalHeight to undefined
const isExpanded =
(isExpandedInContext ? isExpandedInContext(callId) : false) ||
availableTerminalHeight === undefined;
const overflowActions = useOverflowActions();
const uniqueId = useId();
const overflowId = `topic-${uniqueId}`;
const containerRef = useRef<DOMElement>(null);
const rawTitle = args?.[TOPIC_PARAM_TITLE];
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
const rawDescription =
args?.[TOPIC_PARAM_SUMMARY] || args?.[TOPIC_PARAM_STRATEGIC_INTENT];
const description =
typeof rawDescription === 'string' ? rawDescription : undefined;
const rawStrategicIntent = args?.[TOPIC_PARAM_STRATEGIC_INTENT];
const strategicIntent =
typeof rawStrategicIntent === 'string' ? rawStrategicIntent : undefined;
const rawSummary = args?.[TOPIC_PARAM_SUMMARY];
const summary = typeof rawSummary === 'string' ? rawSummary : undefined;
// Top line intent: prefer strategic_intent, fallback to summary
const intent = strategicIntent || summary;
// Extra summary: only if both exist and are different (or just summary if we want to show it below)
const hasExtraSummary = !!(
strategicIntent &&
summary &&
strategicIntent !== summary
);
const handleToggle = useCallback(() => {
if (toggleExpansion && hasExtraSummary) {
toggleExpansion(callId);
}
}, [toggleExpansion, hasExtraSummary, callId]);
useMouseClick(containerRef, handleToggle, {
isActive: isExpandable && hasExtraSummary,
});
useEffect(() => {
// Only register if there is more content (summary) and it's currently hidden
const hasHiddenContent = isExpandable && hasExtraSummary && !isExpanded;
if (hasHiddenContent && overflowActions) {
overflowActions.addOverflowingId(overflowId);
} else if (overflowActions) {
overflowActions.removeOverflowingId(overflowId);
}
return () => {
overflowActions?.removeOverflowingId(overflowId);
};
}, [isExpandable, hasExtraSummary, isExpanded, overflowActions, overflowId]);
return (
<Box flexDirection="row" marginLeft={2} flexWrap="wrap">
<Text color={theme.text.primary} bold wrap="truncate-end">
{title || 'Topic'}
{description && <Text>: </Text>}
</Text>
{description && (
<Text color={theme.text.secondary} wrap="wrap">
{description}
<Box ref={containerRef} flexDirection="column" marginLeft={2}>
<Box flexDirection="row" flexWrap="wrap">
<Text color={theme.text.primary} bold wrap="truncate-end">
{title || 'Topic'}
{intent && <Text>: </Text>}
</Text>
{intent && (
<Text color={theme.text.secondary} wrap="wrap">
{intent}
</Text>
)}
</Box>
{isExpanded && hasExtraSummary && summary && (
<Box marginTop={1} marginLeft={0}>
<Text color={theme.text.secondary} wrap="wrap">
{summary}
</Text>
</Box>
)}
</Box>
);
@@ -78,7 +78,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including update_topic 1`] = `
"
Testing Topic: This is the summary
Testing Topic: This is the description
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ read_file Read a file │
@@ -142,6 +142,12 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
"
Testing Topic: This is the description
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-result Tool with output │
@@ -4,7 +4,7 @@
</style>
<rect width="920" height="445" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 26 </text>
<text x="0" y="2" fill="#afafaf" textLength="234" lengthAdjust="spacingAndGlyphs">... 26 hidden (Ctrl+O) ...</text>
<text x="0" y="19" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 27 </text>
<text x="0" y="36" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 28 </text>
<text x="0" y="53" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 29 </text>
@@ -16,31 +16,18 @@
<text x="0" y="155" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 35 </text>
<text x="0" y="172" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 36 </text>
<text x="0" y="189" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 37 </text>
<text x="0" y="206" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 38 </text>
<text x="675" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 39 </text>
<text x="675" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 40 </text>
<text x="675" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 41 </text>
<text x="675" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 42 </text>
<text x="675" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 43 </text>
<text x="675" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 44 </text>
<text x="675" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 45 </text>
<text x="675" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 46 </text>
<text x="675" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 47 </text>
<text x="675" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 48 </text>
<text x="675" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 49 </text>
<text x="675" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 50 </text>
<text x="675" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 38 </text>
<text x="0" y="223" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 39 </text>
<text x="0" y="240" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 40 </text>
<text x="0" y="257" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 41 </text>
<text x="0" y="274" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 42 </text>
<text x="0" y="291" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 43 </text>
<text x="0" y="308" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 44 </text>
<text x="0" y="325" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 45 </text>
<text x="0" y="342" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 46 </text>
<text x="0" y="359" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 47 </text>
<text x="0" y="376" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 48 </text>
<text x="0" y="393" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 49 </text>
<text x="0" y="410" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 50 </text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -33,15 +33,24 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
"
`;
exports[`ToolResultDisplay > stays scrolled to the bottom when lines are incrementally added 1`] = `
"... 4 hidden (Ctrl+O) ...
Line 5
Line 6
Line 7
Line 8
"
`;
exports[`ToolResultDisplay > truncates ANSI output when maxLines is provided 1`] = `
"Line 3
Line 4
Line 5
"... 3 hidden (Ctrl+O) ...
Line 4
Line 5
"
`;
exports[`ToolResultDisplay > truncates ANSI output when maxLines is provided, even if availableTerminalHeight is undefined 1`] = `
"Line 26
"... 26 hidden (Ctrl+O) ...
Line 27
Line 28
Line 29
@@ -53,19 +62,19 @@ Line 34
Line 35
Line 36
Line 37
Line 38
Line 39
Line 40
Line 41
Line 42
Line 43
Line 44
Line 45
Line 46
Line 47
Line 48
Line 49
Line 50"
Line 38
Line 39
Line 40
Line 41
Line 42
Line 43
Line 44
Line 45
Line 46
Line 47
Line 48
Line 49
Line 50"
`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
@@ -33,7 +33,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame()).toContain('Hello, World!');
@@ -54,7 +54,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame()).toContain(
@@ -77,7 +77,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame()).toContain(
@@ -100,7 +100,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame()).toContain(
@@ -121,7 +121,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame()).toContain(
@@ -144,7 +144,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame()).toContain(
@@ -166,7 +166,7 @@ describe('<MaxSizedBox />', () => {
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame()).toContain('This is a');
@@ -186,7 +186,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame()).toContain('Line 1');
@@ -201,7 +201,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).equals('');
@@ -223,7 +223,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame()).toContain('Line 1 from Fragment');
@@ -247,7 +247,7 @@ describe('<MaxSizedBox />', () => {
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame()).toContain(
@@ -273,7 +273,7 @@ describe('<MaxSizedBox />', () => {
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame()).toContain(
@@ -300,7 +300,7 @@ describe('<MaxSizedBox />', () => {
);
await act(async () => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
await waitUntilReady();
expect(lastFrame()).toContain('... last');
@@ -115,7 +115,7 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
[id, removeOverflowingId],
);
if (effectiveMaxHeight === undefined) {
if (effectiveMaxHeight === undefined && totalHiddenLines === 0) {
return (
<Box flexDirection="column" width={maxWidth}>
{children}
@@ -85,32 +85,43 @@ const VirtualizedListItem = memo(
width,
containerWidth,
itemKey,
itemRef,
index,
onSetRef,
}: {
content: React.ReactElement;
shouldBeStatic: boolean;
width: number | string | undefined;
containerWidth: number;
itemKey: string;
itemRef: (el: DOMElement | null) => void;
}) => (
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
{shouldBeStatic ? (
<StaticRender
width={typeof width === 'number' ? width : containerWidth}
key={
itemKey +
'-static-' +
(typeof width === 'number' ? width : containerWidth)
}
>
{content}
</StaticRender>
) : (
content
)}
</Box>
),
index: number;
onSetRef: (index: number, el: DOMElement | null) => void;
}) => {
const itemRef = useCallback(
(el: DOMElement | null) => {
onSetRef(index, el);
},
[index, onSetRef],
);
return (
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
{shouldBeStatic ? (
<StaticRender
width={typeof width === 'number' ? width : containerWidth}
key={
itemKey +
'-static-' +
(typeof width === 'number' ? width : containerWidth)
}
>
{content}
</StaticRender>
) : (
content
)}
</Box>
);
},
);
VirtualizedListItem.displayName = 'VirtualizedListItem';
@@ -195,6 +206,10 @@ function VirtualizedList<T>(
const containerObserverRef = useRef<ResizeObserver | null>(null);
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
const onSetRef = useCallback((index: number, el: DOMElement | null) => {
itemRefs.current[index] = el;
}, []);
const containerRefCallback = useCallback((node: DOMElement | null) => {
containerObserverRef.current?.disconnect();
containerRef.current = node;
@@ -517,7 +532,6 @@ function VirtualizedList<T>(
observedNodes.current = currentNodes;
});
const renderedItems = [];
const renderRangeStart =
renderStatic || overflowToBackbuffer ? 0 : startIndex;
const renderRangeEnd = renderStatic ? data.length - 1 : endIndex;
@@ -533,7 +547,12 @@ function VirtualizedList<T>(
process.env['NODE_ENV'] === 'test' ||
(width !== undefined && typeof width === 'number');
if (isReady) {
const renderedItems = useMemo(() => {
if (!isReady) {
return [];
}
const items = [];
for (let i = renderRangeStart; i <= renderRangeEnd; i++) {
const item = data[i];
if (item) {
@@ -545,7 +564,7 @@ function VirtualizedList<T>(
const content = renderItem({ item, index: i });
const key = keyExtractor(item, i);
renderedItems.push(
items.push(
<VirtualizedListItem
key={key}
itemKey={key}
@@ -553,16 +572,28 @@ function VirtualizedList<T>(
shouldBeStatic={shouldBeStatic}
width={width}
containerWidth={containerWidth}
itemRef={(el: DOMElement | null) => {
if (i >= renderRangeStart && i <= renderRangeEnd) {
itemRefs.current[i] = el;
}
}}
index={i}
onSetRef={onSetRef}
/>,
);
}
}
}
return items;
}, [
isReady,
renderRangeStart,
renderRangeEnd,
data,
startIndex,
endIndex,
renderStatic,
isStaticItem,
renderItem,
keyExtractor,
width,
containerWidth,
onSetRef,
]);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
+2 -2
View File
@@ -111,10 +111,10 @@ export const INFORMATIVE_TIPS = [
'Paste from your clipboard with Ctrl+V',
'Undo text edits in the input with Alt+Z or Cmd+Z',
'Redo undone text edits with Shift+Alt+Z or Shift+Cmd+Z',
'Open the current prompt in an external editor with Ctrl+X',
'Open the current prompt in an external editor with Ctrl+G',
'In menus, move up/down with k/j or the arrow keys',
'In menus, select an item by typing its number',
"If you're using an IDE, see the context with Ctrl+G",
"If you're using an IDE, see the context with F4",
'Toggle background shells with Ctrl+B or /shells',
'Toggle the background shell process list with Ctrl+L',
// Keyboard shortcut tips end here
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import type { TextBuffer } from '../components/shared/text-buffer.js';
export interface InputState {
buffer: TextBuffer;
userMessages: string[];
shellModeActive: boolean;
showEscapePrompt: boolean;
copyModeEnabled: boolean | undefined;
inputWidth: number;
suggestionsWidth: number;
}
export const InputContext = createContext<InputState | null>(null);
export const useInputState = () => {
const context = useContext(InputContext);
if (!context) {
throw new Error('useInputState must be used within an InputProvider');
}
return context;
};
@@ -1078,7 +1078,7 @@ describe('KeypressContext', () => {
// Advance timers to trigger the backslash timeout
act(() => {
vi.advanceTimersByTime(500);
vi.runAllTimers();
});
expect(keyHandler).toHaveBeenCalledWith(
@@ -64,7 +64,9 @@ describe('MouseContext', () => {
it('should subscribe and unsubscribe a handler', async () => {
const handler = vi.fn();
const { result } = await renderHookWithProviders(() => useMouseContext());
const { result } = await renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => {
result.current.subscribe(handler);
@@ -89,7 +91,12 @@ describe('MouseContext', () => {
it('should not call handler if not active', async () => {
const handler = vi.fn();
await renderHookWithProviders(() => useMouse(handler, { isActive: false }));
await renderHookWithProviders(
() => useMouse(handler, { isActive: false }),
{
mouseEventsEnabled: true,
},
);
act(() => {
stdin.write('\x1b[<0;10;20M');
@@ -99,7 +106,9 @@ describe('MouseContext', () => {
});
it('should emit SelectionWarning when move event is unhandled and has coordinates', async () => {
await renderHookWithProviders(() => useMouseContext());
await renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => {
// Move event (32) at 10, 20
@@ -111,7 +120,9 @@ describe('MouseContext', () => {
it('should not emit SelectionWarning when move event is handled', async () => {
const handler = vi.fn().mockReturnValue(true);
const { result } = await renderHookWithProviders(() => useMouseContext());
const { result } = await renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => {
result.current.subscribe(handler);
@@ -211,8 +222,11 @@ describe('MouseContext', () => {
'should recognize sequence "$sequence" as $expected.name',
async ({ sequence, expected }) => {
const mouseHandler = vi.fn();
const { result } = await renderHookWithProviders(() =>
useMouseContext(),
const { result } = await renderHookWithProviders(
() => useMouseContext(),
{
mouseEventsEnabled: true,
},
);
act(() => result.current.subscribe(mouseHandler));
@@ -227,7 +241,9 @@ describe('MouseContext', () => {
it('should emit a double-click event when two left-presses occur quickly at the same position', async () => {
const handler = vi.fn();
const { result } = await renderHookWithProviders(() => useMouseContext());
const { result } = await renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => {
result.current.subscribe(handler);
@@ -257,7 +273,9 @@ describe('MouseContext', () => {
it('should NOT emit a double-click event if clicks are too far apart', async () => {
const handler = vi.fn();
const { result } = await renderHookWithProviders(() => useMouseContext());
const { result } = await renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => {
result.current.subscribe(handler);
@@ -282,7 +300,9 @@ describe('MouseContext', () => {
it('should NOT emit a double-click event if too much time passes', async () => {
vi.useFakeTimers();
const handler = vi.fn();
const { result } = await renderHookWithProviders(() => useMouseContext());
const { result } = await renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => {
result.current.subscribe(handler);

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