mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 16:50:59 -07:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90fda1400e | |||
| 4ebc43bc66 | |||
| 34b4f1c6e4 | |||
| e77b22e638 | |||
| 1b3e7d674f | |||
| e7f8d9cf1a | |||
| 651ad63ed6 | |||
| cbacdc67d0 | |||
| 7e1938c1bc | |||
| b9f1d832c8 | |||
| 47c5d25d93 | |||
| 28efab483f | |||
| 9fd92c0eea | |||
| 16768c08f2 | |||
| 1aa798dd18 | |||
| f96d5f98fe | |||
| 3c5b5db034 | |||
| 986293bd38 | |||
| adf7b3b717 | |||
| 9637fb3990 | |||
| 06fcdc231c | |||
| d29da15427 | |||
| ab3075feb9 | |||
| 5588000e93 | |||
| 68fef8745e | |||
| e432f7c009 | |||
| 846051f716 | |||
| 1c22c5b37b | |||
| 1762c9c509 | |||
| 0025978d76 | |||
| 4c5e887732 | |||
| 83096c68b0 | |||
| d2b775f9a7 | |||
| 0a8da988ed | |||
| 984f02c180 | |||
| df67f973ed | |||
| 7872d6d7fe | |||
| e116aa34f4 | |||
| ad98294352 | |||
| 2353a6d253 | |||
| 8ac560d2c9 | |||
| c6a9d3de13 | |||
| 8f131ffef7 | |||
| 70f6d6a992 | |||
| c96cb09e09 |
@@ -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
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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'
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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}}]}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}]}}]}
|
||||
@@ -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}]}}]}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../packages/core" },
|
||||
{ "path": "../packages/test-utils" }
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+38
-3
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,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,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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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]')),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+7
-1
@@ -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 │
|
||||
|
||||
+14
-27
@@ -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 |
+26
-17
@@ -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);
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user