mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a49437cea |
@@ -33,35 +33,6 @@ 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_target:
|
||||
pull_request:
|
||||
types: ['opened', 'synchronize', 'reopened', 'ready_for_review']
|
||||
paths:
|
||||
- 'packages/core/src/prompts/**'
|
||||
@@ -23,73 +23,13 @@ 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'
|
||||
@@ -98,40 +38,32 @@ 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: "needs.detect-changes.outputs.STEERING_DETECTED == 'true'"
|
||||
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
|
||||
id: 'analysis'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
@@ -149,6 +81,7 @@ 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 }}'
|
||||
@@ -163,7 +96,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: 'Post or Update PR Comment'
|
||||
if: "always() && (needs.detect-changes.outputs.STEERING_DETECTED == 'true' || env.REPORT_FILE != '')"
|
||||
if: "always() && (steps.detect.outputs.STEERING_DETECTED == 'true' || env.REPORT_FILE != '')"
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: |
|
||||
@@ -174,7 +107,7 @@ jobs:
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.detect-changes.outputs.STEERING_DETECTED }}" == "true" ]]; then
|
||||
if [[ "${{ steps.detect.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)."
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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,8 +44,6 @@ 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.2
|
||||
# Preview release: v0.37.0-preview.1
|
||||
|
||||
Released: April 07, 2026
|
||||
Released: April 02, 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,10 +33,6 @@ 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
|
||||
@@ -423,4 +419,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.2
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.36.0-preview.8...v0.37.0-preview.1
|
||||
|
||||
@@ -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. | `false` |
|
||||
| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `true` |
|
||||
| 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,46 +117,6 @@ 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:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.useBackgroundColor`** (boolean):
|
||||
@@ -1606,12 +1606,6 @@ 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,14 +86,13 @@ 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+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` |
|
||||
| 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` |
|
||||
|
||||
#### App Controls
|
||||
|
||||
@@ -101,7 +100,7 @@ available combinations.
|
||||
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
||||
| `app.showErrorDetails` | Toggle detailed error information. | `F12` |
|
||||
| `app.showFullTodos` | Toggle the full TODO list. | `Ctrl+T` |
|
||||
| `app.showIdeContextDetail` | Show IDE context details. | `F4` |
|
||||
| `app.showIdeContextDetail` | Show IDE context details. | `Ctrl+G` |
|
||||
| `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` |
|
||||
|
||||
@@ -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:<random-port>/oauth/callback` (or a specific port if configured via `redirectUri`)
|
||||
> - Receive redirects on `http://localhost:7777/oauth/callback`
|
||||
|
||||
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 an OS-assigned
|
||||
random port, e.g., `http://localhost:<random-port>/oauth/callback`)
|
||||
- **`redirectUri`** (string): Custom redirect URI (defaults to
|
||||
`http://localhost:7777/oauth/callback`)
|
||||
- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs
|
||||
- **`audiences`** (string[]): Audiences the token is valid for
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* @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,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
{"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}}]}
|
||||
@@ -307,48 +307,4 @@ 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 } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
// BOM encoders
|
||||
@@ -116,4 +116,21 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
{"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}]}}]}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"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}]}}]}
|
||||
@@ -1,10 +0,0 @@
|
||||
{"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}]}}]}
|
||||
@@ -1,2 +0,0 @@
|
||||
{"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}]}}]}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../packages/core" },
|
||||
{ "path": "../packages/test-utils" }
|
||||
]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* @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
+3
-38
@@ -446,8 +446,7 @@
|
||||
"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)",
|
||||
"peer": true
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@bundled-es-modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
@@ -1450,7 +1449,6 @@
|
||||
"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"
|
||||
@@ -2157,7 +2155,6 @@
|
||||
"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",
|
||||
@@ -2338,7 +2335,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -2388,7 +2384,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2763,7 +2758,6 @@
|
||||
"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"
|
||||
@@ -2797,7 +2791,6 @@
|
||||
"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"
|
||||
@@ -2852,7 +2845,6 @@
|
||||
"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",
|
||||
@@ -4089,7 +4081,6 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4364,7 +4355,6 @@
|
||||
"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",
|
||||
@@ -5238,7 +5228,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -5580,12 +5569,6 @@
|
||||
"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",
|
||||
@@ -7379,8 +7362,7 @@
|
||||
"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",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
@@ -7964,7 +7946,6 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8482,7 +8463,6 @@
|
||||
"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",
|
||||
@@ -9795,7 +9775,6 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -10074,7 +10053,6 @@
|
||||
"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",
|
||||
@@ -13848,7 +13826,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -13859,7 +13836,6 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -16009,7 +15985,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16232,8 +16207,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16241,7 +16215,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16407,7 +16380,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16630,7 +16602,6 @@
|
||||
"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",
|
||||
@@ -16744,7 +16715,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16757,7 +16727,6 @@
|
||||
"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",
|
||||
@@ -17405,7 +17374,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -17849,7 +17817,6 @@
|
||||
"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"
|
||||
@@ -17953,7 +17920,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18013,7 +17979,6 @@
|
||||
"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,8 +51,6 @@
|
||||
"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",
|
||||
|
||||
@@ -29,8 +29,5 @@ describe('CommandHandler', () => {
|
||||
|
||||
const about = parse('/about');
|
||||
expect(about.commandToExecute?.name).toBe('about');
|
||||
|
||||
const help = parse('/help');
|
||||
expect(help.commandToExecute?.name).toBe('help');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ 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;
|
||||
@@ -27,7 +26,6 @@ export class CommandHandler {
|
||||
registry.register(new InitCommand());
|
||||
registry.register(new RestoreCommand());
|
||||
registry.register(new AboutCommand());
|
||||
registry.register(new HelpCommand(registry));
|
||||
return registry;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* @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,16 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { coreEvents, 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';
|
||||
@@ -40,16 +32,12 @@ 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(() => {
|
||||
@@ -68,7 +56,10 @@ describe('skills list command', () => {
|
||||
|
||||
await handleList({});
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith('No skills discovered.\n');
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
'No skills discovered.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should list all discovered skills', async () => {
|
||||
@@ -96,19 +87,24 @@ describe('skills list command', () => {
|
||||
|
||||
await handleList({});
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
chalk.bold('Discovered Agent Skills:') + '\n\n',
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
chalk.bold('Discovered Agent Skills:'),
|
||||
);
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining('skill1'),
|
||||
);
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining(chalk.green('[Enabled]')),
|
||||
);
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining('skill2'),
|
||||
);
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining(chalk.red('[Disabled]')),
|
||||
);
|
||||
});
|
||||
@@ -139,10 +135,12 @@ describe('skills list command', () => {
|
||||
|
||||
// Default
|
||||
await handleList({ all: false });
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining('regular'),
|
||||
);
|
||||
expect(stdoutWriteSpy).not.toHaveBeenCalledWith(
|
||||
expect(coreEvents.emitConsoleLog).not.toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining('builtin'),
|
||||
);
|
||||
|
||||
@@ -150,13 +148,16 @@ describe('skills list command', () => {
|
||||
|
||||
// With all: true
|
||||
await handleList({ all: true });
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining('regular'),
|
||||
);
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining('builtin'),
|
||||
);
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining(chalk.gray(' [Built-in]')),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
@@ -41,11 +42,12 @@ export async function handleList(args: { all?: boolean }) {
|
||||
});
|
||||
|
||||
if (skills.length === 0) {
|
||||
process.stdout.write('No skills discovered.\n');
|
||||
debugLogger.log('No skills discovered.');
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(chalk.bold('Discovered Agent Skills:') + '\n\n');
|
||||
debugLogger.log(chalk.bold('Discovered Agent Skills:'));
|
||||
debugLogger.log('');
|
||||
|
||||
for (const skill of skills) {
|
||||
const status = skill.disabled
|
||||
@@ -54,11 +56,10 @@ export async function handleList(args: { all?: boolean }) {
|
||||
|
||||
const builtinSuffix = skill.isBuiltin ? chalk.gray(' [Built-in]') : '';
|
||||
|
||||
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`);
|
||||
debugLogger.log(`${chalk.bold(skill.name)} ${status}${builtinSuffix}`);
|
||||
debugLogger.log(` Description: ${skill.description}`);
|
||||
debugLogger.log(` Location: ${skill.location}`);
|
||||
debugLogger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -938,8 +938,6 @@ 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 50 in default tier → 1.05 (Overriding Plan Mode Deny)
|
||||
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
|
||||
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
|
||||
expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 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 50 in default tier → 1.05
|
||||
expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only
|
||||
// Priority 70 in default tier → 1.07
|
||||
expect(globRule?.priority).toBeCloseTo(1.07, 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: false,
|
||||
default: true,
|
||||
description: 'Use the new terminal buffer architecture for rendering.',
|
||||
showInDialog: true,
|
||||
},
|
||||
@@ -1970,16 +1970,6 @@ 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: {
|
||||
|
||||
@@ -379,30 +379,15 @@ 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', () => {
|
||||
@@ -415,10 +400,8 @@ describe('getNodeMemoryArgs', () => {
|
||||
v8GetHeapStatisticsSpy.mockReturnValue({
|
||||
heap_size_limit: 8 * 1024 * 1024 * 1024, // 8GB
|
||||
});
|
||||
// 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',
|
||||
]);
|
||||
// Target is 50% of 16GB = 8GB. Current is 8GB. No relaunch needed.
|
||||
expect(getNodeMemoryArgs(false)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return memory args if current heap limit is insufficient', () => {
|
||||
@@ -426,11 +409,8 @@ describe('getNodeMemoryArgs', () => {
|
||||
v8GetHeapStatisticsSpy.mockReturnValue({
|
||||
heap_size_limit: 4 * 1024 * 1024 * 1024, // 4GB
|
||||
});
|
||||
// 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',
|
||||
]);
|
||||
// Target is 50% of 16GB = 8GB. Current is 4GB. Relaunch needed.
|
||||
expect(getNodeMemoryArgs(false)).toEqual(['--max-old-space-size=8192']);
|
||||
});
|
||||
|
||||
it('should log debug info when isDebugMode is true', () => {
|
||||
|
||||
@@ -111,8 +111,6 @@ 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();
|
||||
@@ -132,35 +130,16 @@ 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`,
|
||||
);
|
||||
}
|
||||
args.push(`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`);
|
||||
return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`];
|
||||
}
|
||||
|
||||
return args;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function setupUnhandledRejectionHandler() {
|
||||
|
||||
@@ -156,9 +156,8 @@ 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 &&
|
||||
|
||||
@@ -71,7 +71,6 @@ 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,7 +187,6 @@ export async function runNonInteractive(
|
||||
};
|
||||
|
||||
let errorToHandle: unknown | undefined;
|
||||
let scheduler: Scheduler | undefined;
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
|
||||
@@ -216,7 +215,7 @@ export async function runNonInteractive(
|
||||
});
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
scheduler = new Scheduler({
|
||||
const scheduler = new Scheduler({
|
||||
context: config,
|
||||
messageBus: config.getMessageBus(),
|
||||
getPreferredEditor: () => undefined,
|
||||
@@ -529,7 +528,6 @@ export async function runNonInteractive(
|
||||
// Cleanup stdin cancellation before other cleanup
|
||||
cleanupStdinCancellation();
|
||||
|
||||
scheduler?.dispose();
|
||||
consolePatcher.cleanup();
|
||||
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ 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,7 +37,6 @@ import {
|
||||
LegacyAgentSession,
|
||||
ToolErrorType,
|
||||
geminiPartsToContentParts,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
@@ -184,7 +183,6 @@ export async function runNonInteractive({
|
||||
};
|
||||
|
||||
let errorToHandle: unknown | undefined;
|
||||
let scheduler: Scheduler | undefined;
|
||||
let abortSession = () => {};
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
@@ -216,7 +214,7 @@ export async function runNonInteractive({
|
||||
});
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
scheduler = new Scheduler({
|
||||
const scheduler = new Scheduler({
|
||||
context: config,
|
||||
messageBus: config.getMessageBus(),
|
||||
getPreferredEditor: () => undefined,
|
||||
@@ -601,7 +599,6 @@ export async function runNonInteractive({
|
||||
// Explicitly ignore these non-interactive events
|
||||
break;
|
||||
default:
|
||||
debugLogger.error('Unknown agent event type:', event);
|
||||
event satisfies never;
|
||||
break;
|
||||
}
|
||||
@@ -613,7 +610,6 @@ export async function runNonInteractive({
|
||||
cleanupStdinCancellation();
|
||||
abortController.signal.removeEventListener('abort', abortSession);
|
||||
|
||||
scheduler?.dispose();
|
||||
consolePatcher.cleanup();
|
||||
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,6 @@ 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: {
|
||||
|
||||
@@ -515,6 +515,8 @@ const baseMockUiState = {
|
||||
activePtyId: undefined,
|
||||
backgroundTasks: new Map(),
|
||||
backgroundTaskHeight: 0,
|
||||
copyModeEnabled: false,
|
||||
mouseMode: true,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
|
||||
@@ -36,11 +36,9 @@ 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';
|
||||
@@ -53,7 +51,6 @@ import {
|
||||
type UserTierId,
|
||||
type GeminiUserTier,
|
||||
type UserFeedbackPayload,
|
||||
type HookSystemMessagePayload,
|
||||
type AgentDefinition,
|
||||
type ApprovalMode,
|
||||
IdeClient,
|
||||
@@ -2017,6 +2014,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
|
||||
|
||||
const isSelectionMode = isAlternateBuffer && !mouseMode;
|
||||
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (
|
||||
@@ -2031,13 +2030,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
|
||||
setCopyModeEnabled(false);
|
||||
if (mouseMode) {
|
||||
|
||||
if (isSelectionMode) {
|
||||
setMouseMode(true);
|
||||
} else if (mouseMode) {
|
||||
enableMouseEvents();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
isActive: copyModeEnabled,
|
||||
isActive: copyModeEnabled || isSelectionMode,
|
||||
// We need to receive keypresses first so they do not bubble to other
|
||||
// handlers.
|
||||
priority: KeypressPriority.Critical,
|
||||
@@ -2114,19 +2116,7 @@ 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.
|
||||
@@ -2134,7 +2124,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
|
||||
coreEvents.off(CoreEvent.HookSystemMessage, handleHookSystemMessage);
|
||||
};
|
||||
}, [historyManager]);
|
||||
|
||||
|
||||
@@ -55,12 +55,6 @@ Footer
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
Composer
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -10,20 +10,15 @@ import {
|
||||
} from '../../test-utils/render.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi } 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: [],
|
||||
|
||||
@@ -8,8 +8,6 @@ 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;
|
||||
@@ -51,7 +49,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
}
|
||||
return `${openFileCount} open file${
|
||||
openFileCount > 1 ? 's' : ''
|
||||
} (${formatCommand(Command.SHOW_IDE_CONTEXT_DETAIL)} to view)`;
|
||||
} (ctrl+g to view)`;
|
||||
})();
|
||||
|
||||
const geminiMdText = (() => {
|
||||
|
||||
@@ -8,18 +8,56 @@ import { CopyModeWarning } from './CopyModeWarning.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { useInputState } from '../contexts/InputContext.js';
|
||||
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../contexts/InputContext.js');
|
||||
vi.mock('../contexts/UIStateContext.js');
|
||||
vi.mock('../contexts/ConfigContext.js');
|
||||
|
||||
describe('CopyModeWarning', () => {
|
||||
const mockUseUIState = vi.mocked(useUIState);
|
||||
const mockUseConfig = vi.mocked(useConfig);
|
||||
const mockUseInputState = vi.mocked(useInputState);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders nothing when copy mode is disabled', async () => {
|
||||
vi.mocked(useInputState).mockReturnValue({
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => false,
|
||||
} as unknown as Config);
|
||||
mockUseInputState.mockReturnValue({
|
||||
copyModeEnabled: false,
|
||||
} as unknown as ReturnType<typeof useInputState>);
|
||||
mockUseUIState.mockReturnValue({
|
||||
mouseMode: true,
|
||||
} as unknown as UIState);
|
||||
});
|
||||
|
||||
it('renders nothing when copy mode is disabled and not in alternate buffer', async () => {
|
||||
mockUseInputState.mockReturnValue({
|
||||
copyModeEnabled: false,
|
||||
} as unknown as ReturnType<typeof useInputState>);
|
||||
mockUseUIState.mockReturnValue({
|
||||
mouseMode: true,
|
||||
} as unknown as UIState);
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<CopyModeWarning />,
|
||||
);
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders nothing when copy mode is disabled and mouse mode is disabled but not in alternate buffer', async () => {
|
||||
mockUseInputState.mockReturnValue({
|
||||
copyModeEnabled: false,
|
||||
} as unknown as ReturnType<typeof useInputState>);
|
||||
mockUseUIState.mockReturnValue({
|
||||
mouseMode: false,
|
||||
} as unknown as UIState);
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => false,
|
||||
} as unknown as Config);
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<CopyModeWarning />,
|
||||
);
|
||||
@@ -28,9 +66,31 @@ describe('CopyModeWarning', () => {
|
||||
});
|
||||
|
||||
it('renders warning when copy mode is enabled', async () => {
|
||||
vi.mocked(useInputState).mockReturnValue({
|
||||
mockUseInputState.mockReturnValue({
|
||||
copyModeEnabled: true,
|
||||
} as unknown as ReturnType<typeof useInputState>);
|
||||
mockUseUIState.mockReturnValue({
|
||||
mouseMode: true,
|
||||
} as unknown as UIState);
|
||||
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');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders warning when in alternate buffer and mouse mode is disabled', async () => {
|
||||
mockUseInputState.mockReturnValue({
|
||||
copyModeEnabled: false,
|
||||
} as unknown as ReturnType<typeof useInputState>);
|
||||
mockUseUIState.mockReturnValue({
|
||||
mouseMode: false,
|
||||
} as unknown as UIState);
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as Config);
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<CopyModeWarning />,
|
||||
);
|
||||
|
||||
@@ -7,14 +7,22 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useInputState } from '../contexts/InputContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const CopyModeWarning: React.FC = () => {
|
||||
const { copyModeEnabled } = useInputState();
|
||||
const { mouseMode } = useUIState();
|
||||
const config = useConfig();
|
||||
const isTrueAlternateBuffer = config.getUseAlternateBuffer();
|
||||
|
||||
const isSelectionMode = isTrueAlternateBuffer && !mouseMode;
|
||||
const showWarning = copyModeEnabled || isSelectionMode;
|
||||
|
||||
return (
|
||||
<Box height={1}>
|
||||
{copyModeEnabled && (
|
||||
{showWarning && (
|
||||
<Text color={theme.status.warning}>
|
||||
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other
|
||||
key to exit.
|
||||
|
||||
@@ -587,7 +587,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
expect(onFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('automatically submits feedback when Ctrl+G is used to edit the plan', async () => {
|
||||
it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => {
|
||||
const { stdin, lastFrame } = await act(async () =>
|
||||
renderDialog({ useAlternateBuffer }),
|
||||
);
|
||||
@@ -600,9 +600,9 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Press Ctrl+G
|
||||
// Press Ctrl+X
|
||||
await act(async () => {
|
||||
writeKey(stdin, '\x07'); // Ctrl+G
|
||||
writeKey(stdin, '\x18'); // Ctrl+X
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -25,11 +25,6 @@ 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;
|
||||
@@ -178,14 +173,6 @@ 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 },
|
||||
|
||||
@@ -134,7 +134,6 @@ 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}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders, cleanup } from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import {
|
||||
ApprovalMode,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as path from 'node:path';
|
||||
@@ -69,7 +68,6 @@ import {
|
||||
AppEvent,
|
||||
TransientMessageType,
|
||||
} from '../../utils/events.js';
|
||||
import '../../test-utils/customMatchers.js';
|
||||
|
||||
vi.mock('../hooks/useShellHistory.js');
|
||||
vi.mock('../hooks/useCommandCompletion.js');
|
||||
@@ -95,8 +93,6 @@ vi.mock('ink', async (importOriginal) => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
@@ -240,7 +236,6 @@ describe('InputPrompt', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
coreEvents.removeAllListeners();
|
||||
vi.spyOn(
|
||||
terminalCapabilityManager,
|
||||
'isKittyProtocolEnabled',
|
||||
@@ -255,7 +250,7 @@ describe('InputPrompt', () => {
|
||||
setText: vi.fn(
|
||||
(newText: string, cursorPosition?: 'start' | 'end' | number) => {
|
||||
mockBuffer.text = newText;
|
||||
mockBuffer.lines = newText.split('\n');
|
||||
mockBuffer.lines = [newText];
|
||||
let col = 0;
|
||||
if (typeof cursorPosition === 'number') {
|
||||
col = cursorPosition;
|
||||
@@ -265,18 +260,11 @@ describe('InputPrompt', () => {
|
||||
col = newText.length;
|
||||
}
|
||||
mockBuffer.cursor = [0, col];
|
||||
mockBuffer.allVisualLines = newText.split('\n');
|
||||
mockBuffer.viewportVisualLines = newText.split('\n');
|
||||
mockBuffer.visualToLogicalMap = newText
|
||||
.split('\n')
|
||||
.map((_, i) => [i, 0] as [number, number]);
|
||||
mockBuffer.allVisualLines = [newText];
|
||||
mockBuffer.viewportVisualLines = [newText];
|
||||
mockBuffer.allVisualLines = [newText];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, col];
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
mockBuffer.viewportHeight = 10;
|
||||
mockBuffer.visualToTransformedMap = newText
|
||||
.split('\n')
|
||||
.map((_, i) => i);
|
||||
mockBuffer.transformationsByLine = newText.split('\n').map(() => []);
|
||||
},
|
||||
),
|
||||
replaceRangeByOffset: vi.fn(),
|
||||
@@ -284,7 +272,6 @@ describe('InputPrompt', () => {
|
||||
allVisualLines: [''],
|
||||
visualCursor: [0, 0],
|
||||
visualScrollRow: 0,
|
||||
viewportHeight: 10,
|
||||
handleInput: vi.fn((key: Key) => {
|
||||
if (defaultKeyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (mockBuffer.text.length > 0) {
|
||||
@@ -418,7 +405,6 @@ describe('InputPrompt', () => {
|
||||
getTargetDir: () => path.join('test', 'project', 'src'),
|
||||
getVimMode: () => false,
|
||||
getUseBackgroundColor: () => true,
|
||||
getUseTerminalBuffer: () => false,
|
||||
getTerminalBackground: () => undefined,
|
||||
getWorkspaceContext: () => ({
|
||||
getDirectories: () => ['/test/project/src'],
|
||||
@@ -3789,7 +3775,11 @@ describe('InputPrompt', () => {
|
||||
);
|
||||
|
||||
it('should unfocus embedded shell on click', async () => {
|
||||
props.buffer.setText('hello');
|
||||
props.buffer.text = 'hello';
|
||||
props.buffer.lines = ['hello'];
|
||||
props.buffer.allVisualLines = ['hello'];
|
||||
props.buffer.viewportVisualLines = ['hello'];
|
||||
props.buffer.visualToLogicalMap = [[0, 0]];
|
||||
props.isEmbeddedShellFocused = true;
|
||||
|
||||
const { stdin, stdout, unmount } = await renderWithProviders(
|
||||
@@ -4297,7 +4287,11 @@ describe('InputPrompt', () => {
|
||||
describe('IME Cursor Support', () => {
|
||||
it('should report correct cursor position for simple ASCII text', async () => {
|
||||
const text = 'hello';
|
||||
mockBuffer.setText(text);
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.allVisualLines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 3]; // Cursor after 'hel'
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
@@ -4324,7 +4318,11 @@ describe('InputPrompt', () => {
|
||||
|
||||
it('should report correct cursor position for text with double-width characters', async () => {
|
||||
const text = '👍hello';
|
||||
mockBuffer.setText(text);
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.allVisualLines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 2]; // Cursor after '👍h' (Note: '👍' is one code point but width 2)
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
@@ -4350,7 +4348,11 @@ describe('InputPrompt', () => {
|
||||
|
||||
it('should report correct cursor position for a line full of "😀" emojis', async () => {
|
||||
const text = '😀😀😀';
|
||||
mockBuffer.setText(text);
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.allVisualLines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 2]; // Cursor after 2 emojis (each 1 code point, width 2)
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
@@ -4495,12 +4497,12 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = [logicalLine];
|
||||
mockBuffer.allVisualLines = [visualLine];
|
||||
mockBuffer.viewportVisualLines = [visualLine];
|
||||
mockBuffer.allVisualLines = [visualLine];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualToTransformedMap = [0];
|
||||
mockBuffer.transformationsByLine = [transformations];
|
||||
mockBuffer.cursor = [0, cursorCol];
|
||||
mockBuffer.visualCursor = [0, cursorCol];
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
mockBuffer.visualCursor = [0, 0];
|
||||
};
|
||||
|
||||
it('should snapshot collapsed image path', async () => {
|
||||
@@ -5065,8 +5067,8 @@ describe('InputPrompt', () => {
|
||||
input: '\x12',
|
||||
},
|
||||
{
|
||||
name: 'Ctrl+G hotkey is pressed',
|
||||
input: '\x07',
|
||||
name: 'Ctrl+X hotkey is pressed',
|
||||
input: '\x18',
|
||||
},
|
||||
{
|
||||
name: 'F12 hotkey is pressed',
|
||||
|
||||
@@ -5,14 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
Fragment,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import clipboardy from 'clipboardy';
|
||||
import { Box, Text, useStdout, type DOMElement } from 'ink';
|
||||
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||
@@ -439,7 +432,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
slashCommands,
|
||||
);
|
||||
if (commandToExecute?.isSafeConcurrent) {
|
||||
handleSubmitAndClear(trimmedMessage);
|
||||
inputHistory.handleSubmit(trimmedMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -457,7 +450,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
streamingState,
|
||||
setQueueErrorMessage,
|
||||
slashCommands,
|
||||
handleSubmitAndClear,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1272,15 +1264,6 @@ 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
|
||||
@@ -1836,45 +1819,24 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
height={Math.min(buffer.viewportHeight, scrollableData.length)}
|
||||
width="100%"
|
||||
>
|
||||
{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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<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,
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import {
|
||||
makeFakeConfig,
|
||||
CoreToolCallStatus,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { MainContent } from './MainContent.js';
|
||||
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
|
||||
@@ -728,6 +732,158 @@ describe('MainContent', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('Narration Suppression', () => {
|
||||
const settingsWithNarration = createMockSettings({
|
||||
merged: {
|
||||
ui: { inlineThinkingMode: 'expanded' },
|
||||
experimental: { topicUpdateNarration: true },
|
||||
},
|
||||
});
|
||||
|
||||
it('suppresses thinking ALWAYS when narration is enabled', async () => {
|
||||
mockUseSettings.mockReturnValue(settingsWithNarration);
|
||||
const uiState = {
|
||||
...defaultMockUiState,
|
||||
history: [
|
||||
{ id: 1, type: 'user' as const, text: 'Hello' },
|
||||
{
|
||||
id: 2,
|
||||
type: 'thinking' as const,
|
||||
thought: {
|
||||
subject: 'Thinking...',
|
||||
description: 'Thinking about hello',
|
||||
},
|
||||
},
|
||||
{ id: 3, type: 'gemini' as const, text: 'I am helping.' },
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<MainContent />,
|
||||
{
|
||||
uiState: uiState as Partial<UIState>,
|
||||
settings: settingsWithNarration,
|
||||
},
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Thinking...');
|
||||
expect(output).toContain('I am helping.');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('suppresses text in intermediate turns (contains non-topic tools)', async () => {
|
||||
mockUseSettings.mockReturnValue(settingsWithNarration);
|
||||
const uiState = {
|
||||
...defaultMockUiState,
|
||||
history: [
|
||||
{ id: 100, type: 'user' as const, text: 'Search' },
|
||||
{
|
||||
id: 101,
|
||||
type: 'gemini' as const,
|
||||
text: 'I will now search the files.',
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
type: 'tool_group' as const,
|
||||
tools: [
|
||||
{
|
||||
callId: '1',
|
||||
name: 'ls',
|
||||
args: { path: '.' },
|
||||
status: CoreToolCallStatus.Success,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<MainContent />,
|
||||
{
|
||||
uiState: uiState as Partial<UIState>,
|
||||
settings: settingsWithNarration,
|
||||
},
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('I will now search the files.');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('suppresses text that precedes a topic tool in the same turn', async () => {
|
||||
mockUseSettings.mockReturnValue(settingsWithNarration);
|
||||
const uiState = {
|
||||
...defaultMockUiState,
|
||||
history: [
|
||||
{ id: 200, type: 'user' as const, text: 'Hello' },
|
||||
{ id: 201, type: 'gemini' as const, text: 'I will now help you.' },
|
||||
{
|
||||
id: 202,
|
||||
type: 'tool_group' as const,
|
||||
tools: [
|
||||
{
|
||||
callId: '1',
|
||||
name: UPDATE_TOPIC_TOOL_NAME,
|
||||
args: { title: 'Helping', summary: 'Helping the user' },
|
||||
status: CoreToolCallStatus.Success,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<MainContent />,
|
||||
{
|
||||
uiState: uiState as Partial<UIState>,
|
||||
settings: settingsWithNarration,
|
||||
},
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('I will now help you.');
|
||||
expect(output).toContain('Helping');
|
||||
expect(output).toContain('Helping the user');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows text in the final turn if it comes AFTER the topic tool', async () => {
|
||||
mockUseSettings.mockReturnValue(settingsWithNarration);
|
||||
const uiState = {
|
||||
...defaultMockUiState,
|
||||
history: [
|
||||
{ id: 300, type: 'user' as const, text: 'Hello' },
|
||||
{
|
||||
id: 301,
|
||||
type: 'tool_group' as const,
|
||||
tools: [
|
||||
{
|
||||
callId: '1',
|
||||
name: UPDATE_TOPIC_TOOL_NAME,
|
||||
args: { title: 'Final Answer', summary: 'I have finished' },
|
||||
status: CoreToolCallStatus.Success,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: 302, type: 'gemini' as const, text: 'Here is your answer.' },
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<MainContent />,
|
||||
{
|
||||
uiState: uiState as Partial<UIState>,
|
||||
settings: settingsWithNarration,
|
||||
},
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Here is your answer.');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders multiple thinking messages sequentially correctly', async () => {
|
||||
mockUseSettings.mockReturnValue({
|
||||
merged: {
|
||||
|
||||
@@ -91,20 +91,47 @@ export const MainContent = () => {
|
||||
const flags = new Array<boolean>(combinedHistory.length).fill(false);
|
||||
|
||||
if (topicUpdateNarrationEnabled) {
|
||||
let toolGroupInTurn = false;
|
||||
let turnIsIntermediate = false;
|
||||
let hasTopicToolInTurn = false;
|
||||
|
||||
for (let i = combinedHistory.length - 1; i >= 0; i--) {
|
||||
const item = combinedHistory[i];
|
||||
if (item.type === 'user' || item.type === 'user_shell') {
|
||||
toolGroupInTurn = false;
|
||||
turnIsIntermediate = false;
|
||||
hasTopicToolInTurn = false;
|
||||
} else if (item.type === 'tool_group') {
|
||||
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
|
||||
const hasTopic = item.tools.some((t) => isTopicTool(t.name));
|
||||
const hasNonTopic = item.tools.some((t) => !isTopicTool(t.name));
|
||||
if (hasTopic) {
|
||||
hasTopicToolInTurn = true;
|
||||
}
|
||||
if (hasNonTopic) {
|
||||
turnIsIntermediate = true;
|
||||
}
|
||||
} else if (
|
||||
(item.type === 'thinking' ||
|
||||
item.type === 'gemini' ||
|
||||
item.type === 'gemini_content') &&
|
||||
toolGroupInTurn
|
||||
item.type === 'thinking' ||
|
||||
item.type === 'gemini' ||
|
||||
item.type === 'gemini_content'
|
||||
) {
|
||||
flags[i] = true;
|
||||
// Rule 1: Always suppress thinking when narration is enabled to avoid
|
||||
// "flashing" as the model starts its response, and because the Topic
|
||||
// UI provides the necessary high-level intent.
|
||||
if (item.type === 'thinking') {
|
||||
flags[i] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rule 2: Suppress text in intermediate turns (turns containing non-topic
|
||||
// tools) to hide mechanical narration.
|
||||
if (turnIsIntermediate) {
|
||||
flags[i] = true;
|
||||
}
|
||||
|
||||
// Rule 3: Suppress text that precedes a topic tool in the same turn,
|
||||
// as the topic tool "replaces" it.
|
||||
if (hasTopicToolInTurn) {
|
||||
flags[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
) : isInteractiveShellWaiting ? (
|
||||
<Box width="100%" marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
|
||||
<Text color={theme.status.warning}>
|
||||
{INTERACTIVE_SHELL_WAITING_PHRASE}
|
||||
! Shell awaiting input (Tab to focus)
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ContextSummaryDisplay /> > should not render empty parts 1`] = `
|
||||
" 1 open file (F4 to view)
|
||||
" 1 open file (ctrl+g to view)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ContextSummaryDisplay /> > should render on a single line on a wide screen 1`] = `
|
||||
" 1 open file (F4 to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
|
||||
" 1 open file (ctrl+g 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 (F4 to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
|
||||
" 1 open file (ctrl+g 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+G to edit plan · Esc to cancel
|
||||
Enter to select · ↑/↓ to navigate · Ctrl+X 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+G to edit plan · Esc to cancel
|
||||
Enter to select · ↑/↓ to navigate · Ctrl+X 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+G to edit plan · Esc to cancel
|
||||
Enter to select · ↑/↓ to navigate · Ctrl+X 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+G to edit plan · Esc to cancel
|
||||
Enter to select · ↑/↓ to navigate · Ctrl+X 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+G to edit plan · Esc to cancel
|
||||
Enter to select · ↑/↓ to navigate · Ctrl+X 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+G to edit plan · Esc to cancel
|
||||
Enter to select · ↑/↓ to navigate · Ctrl+X 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+G to edit plan · Esc to cancel
|
||||
Enter to select · ↑/↓ to navigate · Ctrl+X 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+G to edit plan · Esc to cancel
|
||||
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -112,7 +112,48 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = `
|
||||
"✦ Example code block:
|
||||
... 42 hidden (Ctrl+O) ...
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
@@ -126,7 +167,48 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = `
|
||||
" Example code block:
|
||||
... 42 hidden (Ctrl+O) ...
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
|
||||
@@ -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+G open external editor
|
||||
Ctrl+X 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+G open external editor
|
||||
Ctrl+X 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+G open external editor
|
||||
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
|
||||
Tab focus UI
|
||||
"
|
||||
`;
|
||||
@@ -47,7 +47,7 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
|
||||
Shortcuts See /help for more
|
||||
! shell mode Shift+Tab cycle mode Ctrl+V paste images
|
||||
@ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
|
||||
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+G open external editor
|
||||
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
|
||||
Tab focus UI
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -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+G to edit plan · Esc to cancel │
|
||||
│ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -12,7 +12,6 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
interface InfoMessageProps {
|
||||
text: string;
|
||||
secondaryText?: string;
|
||||
source?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
marginBottom?: number;
|
||||
@@ -21,7 +20,6 @@ interface InfoMessageProps {
|
||||
export const InfoMessage: React.FC<InfoMessageProps> = ({
|
||||
text,
|
||||
secondaryText,
|
||||
source,
|
||||
icon,
|
||||
color,
|
||||
marginBottom,
|
||||
@@ -42,9 +40,6 @@ 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>
|
||||
|
||||
@@ -293,8 +293,8 @@ 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 because we removed the terminalBuffer check)
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(100);
|
||||
// Actually let's just assert on the behaviour that happens right now (which is 10 lines)
|
||||
expect(frame.match(/Line \d+/g)?.length).toBe(10);
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import {
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
TOPIC_PARAM_SUMMARY,
|
||||
TOPIC_PARAM_STRATEGIC_INTENT,
|
||||
makeFakeConfig,
|
||||
CoreToolCallStatus,
|
||||
@@ -293,7 +292,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
name: UPDATE_TOPIC_TOOL_NAME,
|
||||
args: {
|
||||
[TOPIC_PARAM_TITLE]: 'Testing Topic',
|
||||
[TOPIC_PARAM_SUMMARY]: 'This is the summary',
|
||||
summary: 'This is the summary',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -444,8 +444,8 @@ describe('<ToolMessage />', () => {
|
||||
constrainHeight: true,
|
||||
},
|
||||
width: 80,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
config: makeFakeConfig({ useAlternateBuffer: false }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
|
||||
},
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
@@ -352,10 +351,9 @@ describe('ToolResultDisplay', () => {
|
||||
|
||||
expect(output).not.toContain('Line 1');
|
||||
expect(output).not.toContain('Line 2');
|
||||
expect(output).not.toContain('Line 3');
|
||||
expect(output).toContain('Line 3');
|
||||
expect(output).toContain('Line 4');
|
||||
expect(output).toContain('Line 5');
|
||||
expect(output).toContain('hidden');
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -393,86 +391,4 @@ 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,7 +10,6 @@ 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,
|
||||
@@ -52,7 +51,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
hasFocus = false,
|
||||
overflowDirection = 'top',
|
||||
}) => {
|
||||
const { renderMarkdown, constrainHeight } = useUIState();
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const availableHeight = calculateToolContentMaxLines({
|
||||
@@ -210,73 +209,30 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
if (Array.isArray(resultDisplay)) {
|
||||
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const data = resultDisplay as AnsiOutput;
|
||||
const listHeight = Math.min(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(resultDisplay as AnsiOutput).length,
|
||||
limit,
|
||||
);
|
||||
|
||||
// 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);
|
||||
const initialScrollIndex =
|
||||
overflowDirection === 'bottom' ? 0 : SCROLL_TO_ITEM_END;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ASB Mode Handling (Interactive/Fullscreen)
|
||||
|
||||
@@ -29,12 +29,11 @@ describe('ToolResultDisplay Overflow', () => {
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Line 1');
|
||||
expect(output).toContain('Line 2');
|
||||
expect(output).not.toContain('Line 3');
|
||||
expect(output).not.toContain('Line 4');
|
||||
expect(output).not.toContain('Line 5');
|
||||
expect(output).toContain('hidden');
|
||||
expect(output).not.toContain('Line 1');
|
||||
expect(output).not.toContain('Line 2');
|
||||
expect(output).toContain('Line 3');
|
||||
expect(output).toContain('Line 4');
|
||||
expect(output).toContain('Line 5');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -58,10 +57,9 @@ describe('ToolResultDisplay Overflow', () => {
|
||||
|
||||
expect(output).not.toContain('Line 1');
|
||||
expect(output).not.toContain('Line 2');
|
||||
expect(output).not.toContain('Line 3');
|
||||
expect(output).toContain('Line 3');
|
||||
expect(output).toContain('Line 4');
|
||||
expect(output).toContain('Line 5');
|
||||
expect(output).toContain('hidden');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -97,10 +95,11 @@ describe('ToolResultDisplay Overflow', () => {
|
||||
|
||||
expect(output).toContain('Line 1');
|
||||
expect(output).toContain('Line 2');
|
||||
expect(output).not.toContain('Line 3');
|
||||
expect(output).toContain('Line 3');
|
||||
expect(output).not.toContain('Line 4');
|
||||
expect(output).not.toContain('Line 5');
|
||||
expect(output).toContain('hidden');
|
||||
// ScrollableList uses a scroll thumb rather than writing "hidden"
|
||||
expect(output).toContain('█');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* @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,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useId, useRef, useCallback } from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
@@ -16,103 +15,31 @@ 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> = ({
|
||||
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);
|
||||
|
||||
export const TopicMessage: React.FC<TopicMessageProps> = ({ args }) => {
|
||||
const rawTitle = args?.[TOPIC_PARAM_TITLE];
|
||||
const title = typeof rawTitle === 'string' ? rawTitle : 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]);
|
||||
const rawIntent =
|
||||
args?.[TOPIC_PARAM_STRATEGIC_INTENT] || args?.[TOPIC_PARAM_SUMMARY];
|
||||
const intent = typeof rawIntent === 'string' ? rawIntent : undefined;
|
||||
|
||||
return (
|
||||
<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>}
|
||||
<Box flexDirection="row" marginLeft={2} 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>
|
||||
{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>
|
||||
);
|
||||
|
||||
+4
-19
@@ -1,33 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="37" viewBox="0 0 920 37">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="88" fill="#000000" />
|
||||
<rect width="920" height="37" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="18" y="2" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">✓</text>
|
||||
<text x="45" y="2" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs" font-weight="bold">edit </text>
|
||||
<text x="99" y="2" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">test.ts</text>
|
||||
<text x="171" y="2" fill="#d7afff" textLength="90" lengthAdjust="spacingAndGlyphs">→ Accepted</text>
|
||||
<text x="171" y="2" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">→ </text>
|
||||
<text x="189" y="2" fill="#d7afff" textLength="72" lengthAdjust="spacingAndGlyphs" text-decoration="underline">Accepted</text>
|
||||
<text x="270" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">(</text>
|
||||
<text x="279" y="2" fill="#d7ffd7" textLength="18" lengthAdjust="spacingAndGlyphs">+1</text>
|
||||
<text x="297" y="2" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">, </text>
|
||||
<text x="315" y="2" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-1</text>
|
||||
<text x="333" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">)</text>
|
||||
<rect x="54" y="34" width="9" height="17" fill="#5f0000" />
|
||||
<text x="54" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
|
||||
<rect x="63" y="34" width="9" height="17" fill="#5f0000" />
|
||||
<rect x="72" y="34" width="9" height="17" fill="#5f0000" />
|
||||
<text x="72" y="36" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
|
||||
<rect x="81" y="34" width="9" height="17" fill="#5f0000" />
|
||||
<rect x="90" y="34" width="27" height="17" fill="#5f0000" />
|
||||
<text x="90" y="36" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">old</text>
|
||||
<rect x="54" y="51" width="9" height="17" fill="#005f00" />
|
||||
<text x="54" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
|
||||
<rect x="63" y="51" width="9" height="17" fill="#005f00" />
|
||||
<rect x="72" y="51" width="9" height="17" fill="#005f00" />
|
||||
<text x="72" y="53" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
|
||||
<rect x="81" y="51" width="9" height="17" fill="#005f00" />
|
||||
<rect x="90" y="51" width="27" height="17" fill="#005f00" />
|
||||
<text x="90" y="53" fill="#0000ee" textLength="27" lengthAdjust="spacingAndGlyphs">new</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.3 KiB |
+1
-31
@@ -7,21 +7,12 @@ exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > hides diff
|
||||
|
||||
exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > shows diff content by default when NOT in alternate buffer mode 1`] = `
|
||||
" ✓ test-tool test.ts → Accepted
|
||||
|
||||
1 - old line
|
||||
1 + new line
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for a Rejected tool call 1`] = `" - read_file Reading important.txt"`;
|
||||
|
||||
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = `
|
||||
" ✓ edit test.ts → Accepted (+1, -1)
|
||||
|
||||
1 - old
|
||||
1 + new
|
||||
"
|
||||
`;
|
||||
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = `" ✓ edit test.ts → Accepted (+1, -1)"`;
|
||||
|
||||
exports[`DenseToolMessage > does not render result arrow if resultDisplay is missing 1`] = `
|
||||
" o test-tool Test description
|
||||
@@ -35,17 +26,11 @@ exports[`DenseToolMessage > flattens newlines in string results 1`] = `
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Edit tool using confirmationDetails 1`] = `
|
||||
" ? Edit styles.scss → Confirming
|
||||
|
||||
1 - body { color: blue; }
|
||||
1 + body { color: red; }
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Errored Edit tool 1`] = `
|
||||
" x Edit styles.scss → Failed (+1, -1)
|
||||
|
||||
1 - old line
|
||||
1 + new line
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -60,33 +45,21 @@ exports[`DenseToolMessage > renders correctly for ReadManyFiles results 1`] = `
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Rejected Edit tool 1`] = `
|
||||
" - Edit styles.scss → Rejected (+1, -1)
|
||||
|
||||
1 - old line
|
||||
1 + new line
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Rejected Edit tool with confirmationDetails and diffStat 1`] = `
|
||||
" - Edit styles.scss → Rejected (+1, -1)
|
||||
|
||||
1 - body { color: blue; }
|
||||
1 + body { color: red; }
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for Rejected WriteFile tool 1`] = `
|
||||
" - WriteFile config.json → Rejected
|
||||
|
||||
1 - old content
|
||||
1 + new content
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for WriteFile tool 1`] = `
|
||||
" ✓ WriteFile config.json → Accepted (+1, -1)
|
||||
|
||||
1 - old content
|
||||
1 + new content
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -102,9 +75,6 @@ exports[`DenseToolMessage > renders correctly for error status with string messa
|
||||
|
||||
exports[`DenseToolMessage > renders correctly for file diff results with stats 1`] = `
|
||||
" ✓ test-tool test.ts → Accepted (+15, -6)
|
||||
|
||||
1 - old line
|
||||
1 + diff content
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
+27
-14
@@ -4,7 +4,7 @@
|
||||
</style>
|
||||
<rect width="920" height="445" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#afafaf" textLength="234" lengthAdjust="spacingAndGlyphs">... 26 hidden (Ctrl+O) ...</text>
|
||||
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 26 </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,18 +16,31 @@
|
||||
<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="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>
|
||||
<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>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 6.1 KiB |
+30
-41
@@ -33,24 +33,15 @@ 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`] = `
|
||||
"... 3 hidden (Ctrl+O) ...
|
||||
Line 4
|
||||
Line 5
|
||||
"Line 3
|
||||
Line 4 █
|
||||
Line 5 █
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolResultDisplay > truncates ANSI output when maxLines is provided, even if availableTerminalHeight is undefined 1`] = `
|
||||
"... 26 hidden (Ctrl+O) ...
|
||||
"Line 26
|
||||
Line 27
|
||||
Line 28
|
||||
Line 29
|
||||
@@ -62,36 +53,34 @@ 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`] = `
|
||||
"... 250 hidden (Ctrl+O) ...
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaa
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… █
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -115,7 +115,7 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
|
||||
[id, removeOverflowingId],
|
||||
);
|
||||
|
||||
if (effectiveMaxHeight === undefined && totalHiddenLines === 0) {
|
||||
if (effectiveMaxHeight === undefined) {
|
||||
return (
|
||||
<Box flexDirection="column" width={maxWidth}>
|
||||
{children}
|
||||
|
||||
@@ -33,7 +33,6 @@ interface ScrollableListProps<T> extends VirtualizedListProps<T> {
|
||||
width?: string | number;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
copyModeEnabled?: boolean;
|
||||
isStatic?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
targetScrollIndex?: number;
|
||||
|
||||
@@ -316,9 +316,8 @@ describe('<VirtualizedList />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders correctly in copyModeEnabled when scrolled', async () => {
|
||||
it('renders correctly with scrollbar={false} when scrolled', async () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
// Use copy mode
|
||||
const { lastFrame, unmount } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
@@ -331,7 +330,7 @@ describe('<VirtualizedList />', () => {
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={50}
|
||||
copyModeEnabled={true}
|
||||
scrollbar={false}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
@@ -39,7 +39,6 @@ export type VirtualizedListProps<T> = {
|
||||
overflowToBackbuffer?: boolean;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
copyModeEnabled?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
containerHeight?: number;
|
||||
};
|
||||
@@ -144,7 +143,6 @@ function VirtualizedList<T>(
|
||||
overflowToBackbuffer,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
copyModeEnabled = false,
|
||||
fixedItemHeight = false,
|
||||
} = props;
|
||||
const dataRef = useRef(data);
|
||||
@@ -727,25 +725,20 @@ function VirtualizedList<T>(
|
||||
return (
|
||||
<Box
|
||||
ref={containerRefCallback}
|
||||
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={copyModeEnabled ? 0 : scrollTop}
|
||||
scrollTop={scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={copyModeEnabled ? 0 : 1}
|
||||
paddingRight={1}
|
||||
overflowToBackbuffer={overflowToBackbuffer}
|
||||
scrollbar={scrollbar}
|
||||
stableScrollback={stableScrollback}
|
||||
>
|
||||
<Box
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
marginTop={copyModeEnabled ? -actualScrollTop : 0}
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
{renderedItems}
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
|
||||
@@ -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+G',
|
||||
'Open the current prompt in an external editor with Ctrl+X',
|
||||
'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 F4",
|
||||
"If you're using an IDE, see the context with Ctrl+G",
|
||||
'Toggle background shells with Ctrl+B or /shells',
|
||||
'Toggle the background shell process list with Ctrl+L',
|
||||
// Keyboard shortcut tips end here
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type MockedFunction,
|
||||
} from 'vitest';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useBanner, _clearSessionBannersForTest } from './useBanner.js';
|
||||
import { useBanner } from './useBanner.js';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
@@ -56,7 +56,6 @@ describe('useBanner', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
_clearSessionBannersForTest();
|
||||
|
||||
// Default persistentState behavior: return empty object (no counts)
|
||||
mockedPersistentStateGet.mockReturnValue({});
|
||||
@@ -102,18 +101,13 @@ describe('useBanner', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should increment count if warning text is shown instead', async () => {
|
||||
it('should NOT increment count if warning text is shown instead', async () => {
|
||||
const data = { defaultText: 'Standard', warningText: 'Warning' };
|
||||
|
||||
await renderHook(() => useBanner(data));
|
||||
|
||||
// Warning text now also gets counted
|
||||
expect(mockedPersistentStateSet).toHaveBeenCalledWith(
|
||||
'defaultBannerShownCount',
|
||||
{
|
||||
[crypto.createHash('sha256').update(data.warningText).digest('hex')]: 1,
|
||||
},
|
||||
);
|
||||
// Since warning text takes precedence, default banner logic (and increment) is skipped
|
||||
expect(mockedPersistentStateSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle newline replacements', async () => {
|
||||
|
||||
@@ -4,21 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const DEFAULT_MAX_BANNER_SHOWN_COUNT = 5;
|
||||
|
||||
// Track banners incremented during this session to prevent multiple increments
|
||||
// on React unmounts/remounts
|
||||
const sessionIncrementedBanners = new Set<string>();
|
||||
|
||||
// For testing purposes
|
||||
export function _clearSessionBannersForTest() {
|
||||
sessionIncrementedBanners.clear();
|
||||
}
|
||||
|
||||
interface BannerData {
|
||||
defaultText: string;
|
||||
warningText: string;
|
||||
@@ -31,25 +22,25 @@ export function useBanner(bannerData: BannerData) {
|
||||
() => persistentState.get('defaultBannerShownCount') || {},
|
||||
);
|
||||
|
||||
const activeText = warningText ? warningText : defaultText;
|
||||
|
||||
const hashedText = crypto
|
||||
.createHash('sha256')
|
||||
.update(activeText)
|
||||
.update(defaultText)
|
||||
.digest('hex');
|
||||
|
||||
const currentBannerCount = bannerCounts[hashedText] || 0;
|
||||
|
||||
const showBanner =
|
||||
activeText !== '' && currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT;
|
||||
const showDefaultBanner =
|
||||
warningText === '' && currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT;
|
||||
|
||||
const rawBannerText = showBanner ? activeText : '';
|
||||
const rawBannerText = showDefaultBanner ? defaultText : warningText;
|
||||
const bannerText = rawBannerText.replace(/\\n/g, '\n');
|
||||
|
||||
const lastIncrementedKey = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showBanner && activeText) {
|
||||
if (!sessionIncrementedBanners.has(activeText)) {
|
||||
sessionIncrementedBanners.add(activeText);
|
||||
if (showDefaultBanner && defaultText) {
|
||||
if (lastIncrementedKey.current !== defaultText) {
|
||||
lastIncrementedKey.current = defaultText;
|
||||
|
||||
const allCounts = persistentState.get('defaultBannerShownCount') || {};
|
||||
const current = allCounts[hashedText] || 0;
|
||||
@@ -60,7 +51,7 @@ export function useBanner(bannerData: BannerData) {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [showBanner, activeText, hashedText]);
|
||||
}, [showDefaultBanner, defaultText, hashedText]);
|
||||
|
||||
return {
|
||||
bannerText,
|
||||
|
||||
@@ -77,7 +77,6 @@ export enum Command {
|
||||
QUEUE_MESSAGE = 'input.queueMessage',
|
||||
NEWLINE = 'input.newline',
|
||||
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
|
||||
DEPRECATED_OPEN_EXTERNAL_EDITOR = 'input.deprecatedOpenExternalEditor',
|
||||
PASTE_CLIPBOARD = 'input.paste',
|
||||
|
||||
// App Controls
|
||||
@@ -376,8 +375,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
new KeyBinding('ctrl+j'),
|
||||
],
|
||||
],
|
||||
[Command.OPEN_EXTERNAL_EDITOR, [new KeyBinding('ctrl+g')]],
|
||||
[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR, [new KeyBinding('ctrl+x')]],
|
||||
[Command.OPEN_EXTERNAL_EDITOR, [new KeyBinding('ctrl+x')]],
|
||||
[
|
||||
Command.PASTE_CLIPBOARD,
|
||||
[
|
||||
@@ -390,7 +388,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
// App Controls
|
||||
[Command.SHOW_ERROR_DETAILS, [new KeyBinding('f12')]],
|
||||
[Command.SHOW_FULL_TODOS, [new KeyBinding('ctrl+t')]],
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL, [new KeyBinding('f4')]],
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL, [new KeyBinding('ctrl+g')]],
|
||||
[Command.TOGGLE_MARKDOWN, [new KeyBinding('alt+m')]],
|
||||
[Command.TOGGLE_COPY_MODE, [new KeyBinding('f9')]],
|
||||
[Command.TOGGLE_MOUSE_MODE, [new KeyBinding('ctrl+s')]],
|
||||
@@ -512,7 +510,6 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.QUEUE_MESSAGE,
|
||||
Command.NEWLINE,
|
||||
Command.OPEN_EXTERNAL_EDITOR,
|
||||
Command.DEPRECATED_OPEN_EXTERNAL_EDITOR,
|
||||
Command.PASTE_CLIPBOARD,
|
||||
],
|
||||
},
|
||||
@@ -629,8 +626,6 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.NEWLINE]: 'Insert a newline without submitting.',
|
||||
[Command.OPEN_EXTERNAL_EDITOR]:
|
||||
'Open the current prompt or the plan in an external editor.',
|
||||
[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR]:
|
||||
'Deprecated command to open external editor.',
|
||||
[Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.',
|
||||
|
||||
// App Controls
|
||||
|
||||
@@ -311,11 +311,6 @@ describe('keyMatchers', () => {
|
||||
// External tools
|
||||
{
|
||||
command: Command.OPEN_EXTERNAL_EDITOR,
|
||||
positive: [createKey('g', { ctrl: true })],
|
||||
negative: [createKey('g'), createKey('c', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.DEPRECATED_OPEN_EXTERNAL_EDITOR,
|
||||
positive: [createKey('x', { ctrl: true })],
|
||||
negative: [createKey('x'), createKey('c', { ctrl: true })],
|
||||
},
|
||||
@@ -341,8 +336,8 @@ describe('keyMatchers', () => {
|
||||
},
|
||||
{
|
||||
command: Command.SHOW_IDE_CONTEXT_DETAIL,
|
||||
positive: [createKey('f4')],
|
||||
negative: [createKey('f5'), createKey('t', { ctrl: true })],
|
||||
positive: [createKey('g', { ctrl: true })],
|
||||
negative: [createKey('g'), createKey('t', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.TOGGLE_MARKDOWN,
|
||||
|
||||
@@ -174,7 +174,6 @@ export type HistoryItemInfo = HistoryItemBase & {
|
||||
type: 'info';
|
||||
text: string;
|
||||
secondaryText?: string;
|
||||
source?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
marginBottom?: number;
|
||||
|
||||
@@ -26,49 +26,66 @@ describe('skillUtils', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
const itif = (condition: boolean) => (condition ? it : it.skip);
|
||||
|
||||
describe('linkSkill', () => {
|
||||
it('should successfully link from a local directory', async () => {
|
||||
// Create a mock skill directory
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
// TODO: issue 19388 - Enable linkSkill tests on Windows
|
||||
itif(process.platform !== 'win32')(
|
||||
'should successfully link from a local directory',
|
||||
async () => {
|
||||
// Create a mock skill directory
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
|
||||
expect(skills.length).toBe(1);
|
||||
expect(skills[0].name).toBe('test-skill');
|
||||
const skills = await linkSkill(
|
||||
mockSkillSourceDir,
|
||||
'workspace',
|
||||
() => {},
|
||||
);
|
||||
expect(skills.length).toBe(1);
|
||||
expect(skills[0].name).toBe('test-skill');
|
||||
|
||||
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
|
||||
const stats = await fs.lstat(linkedPath);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
|
||||
const stats = await fs.lstat(linkedPath);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
|
||||
const linkTarget = await fs.readlink(linkedPath);
|
||||
expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir));
|
||||
});
|
||||
const linkTarget = await fs.readlink(linkedPath);
|
||||
expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir));
|
||||
},
|
||||
);
|
||||
|
||||
it('should overwrite existing skill at destination', async () => {
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
itif(process.platform !== 'win32')(
|
||||
'should overwrite existing skill at destination',
|
||||
async () => {
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
|
||||
await fs.mkdir(skillSubDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillSubDir, 'SKILL.md'),
|
||||
'---\nname: test-skill\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
const targetDir = path.join(tempDir, '.gemini/skills');
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
const existingPath = path.join(targetDir, 'test-skill');
|
||||
await fs.mkdir(existingPath);
|
||||
const targetDir = path.join(tempDir, '.gemini/skills');
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
const existingPath = path.join(targetDir, 'test-skill');
|
||||
await fs.mkdir(existingPath);
|
||||
|
||||
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
|
||||
expect(skills.length).toBe(1);
|
||||
const skills = await linkSkill(
|
||||
mockSkillSourceDir,
|
||||
'workspace',
|
||||
() => {},
|
||||
);
|
||||
expect(skills.length).toBe(1);
|
||||
|
||||
const stats = await fs.lstat(existingPath);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
});
|
||||
const stats = await fs.lstat(existingPath);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('should abort linking if consent is rejected', async () => {
|
||||
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
|
||||
@@ -220,40 +237,39 @@ describe('skillUtils', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should successfully uninstall a skill even if its name was updated after linking', async () => {
|
||||
// 1. Create source skill
|
||||
const sourceDir = path.join(tempDir, 'source-skill');
|
||||
await fs.mkdir(sourceDir, { recursive: true });
|
||||
const skillMdPath = path.join(sourceDir, 'SKILL.md');
|
||||
await fs.writeFile(
|
||||
skillMdPath,
|
||||
'---\nname: original-name\ndescription: test\n---\nbody',
|
||||
);
|
||||
itif(process.platform !== 'win32')(
|
||||
'should successfully uninstall a skill even if its name was updated after linking',
|
||||
async () => {
|
||||
// 1. Create source skill
|
||||
const sourceDir = path.join(tempDir, 'source-skill');
|
||||
await fs.mkdir(sourceDir, { recursive: true });
|
||||
const skillMdPath = path.join(sourceDir, 'SKILL.md');
|
||||
await fs.writeFile(
|
||||
skillMdPath,
|
||||
'---\nname: original-name\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
// 2. Link it
|
||||
const skillsDir = path.join(tempDir, '.gemini/skills');
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
const destPath = path.join(skillsDir, 'original-name');
|
||||
await fs.symlink(
|
||||
sourceDir,
|
||||
destPath,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
// 2. Link it
|
||||
const skillsDir = path.join(tempDir, '.gemini/skills');
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
const destPath = path.join(skillsDir, 'original-name');
|
||||
await fs.symlink(sourceDir, destPath, 'dir');
|
||||
|
||||
// 3. Update name in source
|
||||
await fs.writeFile(
|
||||
skillMdPath,
|
||||
'---\nname: updated-name\ndescription: test\n---\nbody',
|
||||
);
|
||||
// 3. Update name in source
|
||||
await fs.writeFile(
|
||||
skillMdPath,
|
||||
'---\nname: updated-name\ndescription: test\n---\nbody',
|
||||
);
|
||||
|
||||
// 4. Uninstall by NEW name (this is the bug fix)
|
||||
const result = await uninstallSkill('updated-name', 'user');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.location).toBe(destPath);
|
||||
// 4. Uninstall by NEW name (this is the bug fix)
|
||||
const result = await uninstallSkill('updated-name', 'user');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.location).toBe(destPath);
|
||||
|
||||
const exists = await fs.lstat(destPath).catch(() => null);
|
||||
expect(exists).toBeNull();
|
||||
});
|
||||
const exists = await fs.lstat(destPath).catch(() => null);
|
||||
expect(exists).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('should successfully uninstall a skill by directory name if metadata is missing (fallback)', async () => {
|
||||
const skillsDir = path.join(tempDir, '.gemini/skills');
|
||||
|
||||
@@ -248,13 +248,7 @@ export async function linkSkill(
|
||||
await fs.rm(destPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Use 'junction' on Windows to avoid EPERM errors — junctions don't
|
||||
// require elevated privileges or Developer Mode (fixes #24816)
|
||||
await fs.symlink(
|
||||
skillSourceDir,
|
||||
destPath,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
await fs.symlink(skillSourceDir, destPath, 'dir');
|
||||
linkedSkills.push({ name: skillName, location: destPath });
|
||||
}
|
||||
|
||||
|
||||
@@ -432,7 +432,6 @@ function isStructuredError(error: unknown): error is StructuredError {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'status' in error &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
);
|
||||
|
||||
@@ -17,9 +17,6 @@ import type {
|
||||
ToolCallRequestInfo,
|
||||
} from '../scheduler/types.js';
|
||||
import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import type { Scheduler } from '../scheduler/scheduler.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers
|
||||
@@ -27,7 +24,7 @@ import type { Config } from '../config/config.js';
|
||||
|
||||
function createMockDeps(
|
||||
overrides?: Partial<LegacyAgentSessionDeps>,
|
||||
): Required<LegacyAgentSessionDeps> {
|
||||
): LegacyAgentSessionDeps {
|
||||
const mockClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
getChat: vi.fn().mockReturnValue({
|
||||
@@ -43,22 +40,18 @@ function createMockDeps(
|
||||
const mockConfig = {
|
||||
getMaxSessionTurns: vi.fn().mockReturnValue(-1),
|
||||
getModel: vi.fn().mockReturnValue('gemini-2.5-pro'),
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockClient),
|
||||
getMessageBus: vi.fn().mockImplementation(() => ({
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
})),
|
||||
};
|
||||
|
||||
return {
|
||||
client: mockClient as unknown as GeminiClient,
|
||||
scheduler: mockScheduler as unknown as Scheduler,
|
||||
config: mockConfig as unknown as Config,
|
||||
client: mockClient as unknown as LegacyAgentSessionDeps['client'],
|
||||
|
||||
scheduler: mockScheduler as unknown as LegacyAgentSessionDeps['scheduler'],
|
||||
|
||||
config: mockConfig as unknown as LegacyAgentSessionDeps['config'],
|
||||
promptId: 'test-prompt',
|
||||
streamId: 'test-stream',
|
||||
getPreferredEditor: vi.fn().mockReturnValue(undefined),
|
||||
...overrides,
|
||||
} as Required<LegacyAgentSessionDeps>;
|
||||
};
|
||||
}
|
||||
|
||||
async function* makeStream(
|
||||
@@ -136,7 +129,7 @@ async function collectEvents(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('LegacyAgentSession', () => {
|
||||
let deps: Required<LegacyAgentSessionDeps>;
|
||||
let deps: LegacyAgentSessionDeps;
|
||||
|
||||
beforeEach(() => {
|
||||
deps = createMockDeps();
|
||||
|
||||
@@ -14,11 +14,10 @@ import type { Part } from '@google/genai';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
import { Scheduler } from '../scheduler/scheduler.js';
|
||||
import type { Scheduler } from '../scheduler/scheduler.js';
|
||||
import { recordToolCallInteractions } from '../code_assist/telemetry.js';
|
||||
import { ToolErrorType, isFatalToolError } from '../tools/tool-error.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { EditorType } from '../utils/editor.js';
|
||||
import {
|
||||
buildToolResponseData,
|
||||
contentPartsToGeminiParts,
|
||||
@@ -46,17 +45,14 @@ function isAbortLikeError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
export interface LegacyAgentSessionDeps {
|
||||
client: GeminiClient;
|
||||
scheduler: Scheduler;
|
||||
config: Config;
|
||||
client?: GeminiClient;
|
||||
scheduler?: Scheduler;
|
||||
promptId?: string;
|
||||
promptId: string;
|
||||
streamId?: string;
|
||||
getPreferredEditor?: () => EditorType | undefined;
|
||||
}
|
||||
|
||||
const schedulerMap = new WeakMap<Config, Scheduler>();
|
||||
|
||||
export class LegacyAgentProtocol implements AgentProtocol {
|
||||
class LegacyAgentProtocol implements AgentProtocol {
|
||||
private _events: AgentEvent[] = [];
|
||||
private _subscribers = new Set<(event: AgentEvent) => void>();
|
||||
private _translationState: TranslationState;
|
||||
@@ -73,26 +69,10 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
constructor(deps: LegacyAgentSessionDeps) {
|
||||
this._translationState = createTranslationState(deps.streamId);
|
||||
this._nextStreamIdOverride = deps.streamId;
|
||||
this._client = deps.client;
|
||||
this._scheduler = deps.scheduler;
|
||||
this._config = deps.config;
|
||||
this._client = deps.client ?? deps.config.getGeminiClient();
|
||||
this._promptId = deps.promptId ?? deps.config.promptId ?? '';
|
||||
|
||||
if (deps.scheduler) {
|
||||
this._scheduler = deps.scheduler;
|
||||
} else {
|
||||
let scheduler = schedulerMap.get(deps.config);
|
||||
if (!scheduler) {
|
||||
const sessionId = deps.config.getSessionId();
|
||||
const schedulerId = `legacy-agent-scheduler-${sessionId}`;
|
||||
scheduler = new Scheduler({
|
||||
context: deps.config,
|
||||
schedulerId,
|
||||
getPreferredEditor: deps.getPreferredEditor ?? (() => undefined),
|
||||
});
|
||||
schedulerMap.set(deps.config, scheduler);
|
||||
}
|
||||
this._scheduler = scheduler;
|
||||
}
|
||||
this._promptId = deps.promptId;
|
||||
}
|
||||
|
||||
get events(): readonly AgentEvent[] {
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Kind } from '../tools/tools.js';
|
||||
|
||||
export type WithMeta = { _meta?: Record<string, unknown> };
|
||||
|
||||
export type Unsubscribe = () => void;
|
||||
@@ -182,16 +180,6 @@ export interface ToolRequest {
|
||||
name: string;
|
||||
/** The arguments for the tool. */
|
||||
args: Record<string, unknown>;
|
||||
/** UI specific metadata */
|
||||
_meta?: {
|
||||
legacyState?: {
|
||||
displayName?: string;
|
||||
isOutputMarkdown?: boolean;
|
||||
description?: string;
|
||||
kind?: Kind;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,18 +192,6 @@ export interface ToolUpdate {
|
||||
displayContent?: ContentPart[];
|
||||
content?: ContentPart[];
|
||||
data?: Record<string, unknown>;
|
||||
/** UI specific metadata */
|
||||
_meta?: {
|
||||
legacyState?: {
|
||||
status?: string;
|
||||
progressMessage?: string;
|
||||
progress?: number;
|
||||
progressTotal?: number;
|
||||
pid?: number;
|
||||
description?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolResponse {
|
||||
@@ -229,13 +205,6 @@ export interface ToolResponse {
|
||||
data?: Record<string, unknown>;
|
||||
/** When true, the tool call encountered an error that will be sent to the model. */
|
||||
isError?: boolean;
|
||||
/** UI specific metadata */
|
||||
_meta?: {
|
||||
legacyState?: {
|
||||
outputFile?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export type ElicitationRequest = {
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
vi.mock('../scheduler/scheduler.js', () => ({
|
||||
Scheduler: vi.fn().mockImplementation(() => ({
|
||||
schedule: vi.fn().mockResolvedValue([{ status: 'success' }]),
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -126,57 +125,6 @@ describe('agent-scheduler', () => {
|
||||
expect(schedulerConfig.toolRegistry).not.toBe(mainRegistry);
|
||||
});
|
||||
|
||||
it('should dispose the scheduler after schedule completes', async () => {
|
||||
const mockConfig = {
|
||||
getPromptRegistry: vi.fn(),
|
||||
getResourceRegistry: vi.fn(),
|
||||
messageBus: mockMessageBus,
|
||||
toolRegistry: mockToolRegistry,
|
||||
} as unknown as Mocked<Config>;
|
||||
|
||||
const options = {
|
||||
schedulerId: 'subagent-1',
|
||||
toolRegistry: mockToolRegistry as unknown as ToolRegistry,
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
|
||||
await scheduleAgentTools(mockConfig as unknown as Config, [], options);
|
||||
|
||||
const schedulerInstance = vi.mocked(Scheduler).mock.results[0].value;
|
||||
expect(schedulerInstance.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should dispose the scheduler even when schedule throws', async () => {
|
||||
const scheduleError = new Error('schedule failed');
|
||||
vi.mocked(Scheduler).mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
schedule: vi.fn().mockRejectedValue(scheduleError),
|
||||
dispose: vi.fn(),
|
||||
}) as unknown as Scheduler,
|
||||
);
|
||||
|
||||
const mockConfig = {
|
||||
getPromptRegistry: vi.fn(),
|
||||
getResourceRegistry: vi.fn(),
|
||||
messageBus: mockMessageBus,
|
||||
toolRegistry: mockToolRegistry,
|
||||
} as unknown as Mocked<Config>;
|
||||
|
||||
const options = {
|
||||
schedulerId: 'subagent-1',
|
||||
toolRegistry: mockToolRegistry as unknown as ToolRegistry,
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
|
||||
await expect(
|
||||
scheduleAgentTools(mockConfig as unknown as Config, [], options),
|
||||
).rejects.toThrow('schedule failed');
|
||||
|
||||
const schedulerInstance = vi.mocked(Scheduler).mock.results[0].value;
|
||||
expect(schedulerInstance.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should create an AgentLoopContext that has a defined .config property', async () => {
|
||||
const mockConfig = {
|
||||
getPromptRegistry: vi.fn(),
|
||||
|
||||
@@ -85,9 +85,5 @@ export async function scheduleAgentTools(
|
||||
onWaitingForConfirmation,
|
||||
});
|
||||
|
||||
try {
|
||||
return await scheduler.schedule(requests, signal);
|
||||
} finally {
|
||||
scheduler.dispose();
|
||||
}
|
||||
return scheduler.schedule(requests, signal);
|
||||
}
|
||||
|
||||
@@ -38,8 +38,6 @@ const mockBrowserManager = {
|
||||
]),
|
||||
callTool: vi.fn().mockResolvedValue({ content: [] }),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
acquire: vi.fn(),
|
||||
release: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
@@ -32,11 +32,11 @@ import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
|
||||
import { injectAutomationOverlay } from './automationOverlay.js';
|
||||
import { injectInputBlocker } from './inputBlocker.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { recordBrowserAgentToolDiscovery } from '../../telemetry/metrics.js';
|
||||
import {
|
||||
logBrowserAgentVisionStatus,
|
||||
logBrowserAgentCleanup,
|
||||
} from '../../telemetry/loggers.js';
|
||||
recordBrowserAgentToolDiscovery,
|
||||
recordBrowserAgentVisionStatus,
|
||||
recordBrowserAgentCleanup,
|
||||
} from '../../telemetry/metrics.js';
|
||||
import {
|
||||
PolicyDecision,
|
||||
PRIORITY_SUBAGENT_TOOL,
|
||||
@@ -81,218 +81,207 @@ export async function createBrowserAgentDefinition(
|
||||
|
||||
// Get or create browser manager singleton for this session mode/profile
|
||||
const browserManager = BrowserManager.getInstance(config);
|
||||
browserManager.acquire();
|
||||
await browserManager.ensureConnection();
|
||||
|
||||
try {
|
||||
await browserManager.ensureConnection();
|
||||
debugLogger.log('Browser connected with isolated MCP client.');
|
||||
|
||||
debugLogger.log('Browser connected with isolated MCP client.');
|
||||
// Determine if input blocker should be active (non-headless + enabled)
|
||||
const shouldDisableInput = config.shouldDisableBrowserUserInput();
|
||||
// Inject automation overlay and input blocker if not in headless mode
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
if (!browserConfig?.customConfig?.headless) {
|
||||
debugLogger.log('Injecting automation overlay...');
|
||||
await injectAutomationOverlay(browserManager);
|
||||
if (shouldDisableInput) {
|
||||
debugLogger.log('Injecting input blocker...');
|
||||
await injectInputBlocker(browserManager);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if input blocker should be active (non-headless + enabled)
|
||||
const shouldDisableInput = config.shouldDisableBrowserUserInput();
|
||||
// Inject automation overlay and input blocker if not in headless mode
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
if (!browserConfig?.customConfig?.headless) {
|
||||
debugLogger.log('Injecting automation overlay...');
|
||||
await injectAutomationOverlay(browserManager);
|
||||
if (shouldDisableInput) {
|
||||
debugLogger.log('Injecting input blocker...');
|
||||
await injectInputBlocker(browserManager);
|
||||
// Create declarative tools from dynamically discovered MCP tools
|
||||
// These tools dispatch to browserManager's isolated client
|
||||
const mcpTools = await createMcpDeclarativeTools(
|
||||
browserManager,
|
||||
messageBus,
|
||||
shouldDisableInput,
|
||||
browserConfig.customConfig.blockFileUploads,
|
||||
);
|
||||
const availableToolNames = mcpTools.map((t) => t.name);
|
||||
|
||||
// Register high-priority policy rules for sensitive actions which is not
|
||||
// able to be overwrite by YOLO mode.
|
||||
const policyEngine = config.getPolicyEngine();
|
||||
|
||||
if (policyEngine) {
|
||||
const existingRules = policyEngine.getRules();
|
||||
|
||||
const restrictedTools = ['fill', 'fill_form'];
|
||||
|
||||
// ASK_USER for upload_file and evaluate_script when sensitive action
|
||||
// need confirmation.
|
||||
if (browserConfig.customConfig.confirmSensitiveActions) {
|
||||
restrictedTools.push('upload_file', 'evaluate_script');
|
||||
}
|
||||
|
||||
for (const toolName of restrictedTools) {
|
||||
const rule = generateAskUserRules(toolName);
|
||||
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
|
||||
policyEngine.addRule(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Create declarative tools from dynamically discovered MCP tools
|
||||
// These tools dispatch to browserManager's isolated client
|
||||
const mcpTools = await createMcpDeclarativeTools(
|
||||
browserManager,
|
||||
messageBus,
|
||||
shouldDisableInput,
|
||||
browserConfig.customConfig.blockFileUploads,
|
||||
);
|
||||
const availableToolNames = mcpTools.map((t) => t.name);
|
||||
// Reduce noise for read-only tools in default mode
|
||||
const readOnlyTools = (await browserManager.getDiscoveredTools())
|
||||
.filter((t) => !!t.annotations?.readOnlyHint)
|
||||
.map((t) => t.name);
|
||||
const allowlistedReadonlyTools = ['take_snapshot', 'take_screenshot'];
|
||||
|
||||
// Register high-priority policy rules for sensitive actions which is not
|
||||
// able to be overwrite by YOLO mode.
|
||||
const policyEngine = config.getPolicyEngine();
|
||||
|
||||
if (policyEngine) {
|
||||
const existingRules = policyEngine.getRules();
|
||||
|
||||
const restrictedTools = ['fill', 'fill_form'];
|
||||
|
||||
// ASK_USER for upload_file and evaluate_script when sensitive action
|
||||
// need confirmation.
|
||||
if (browserConfig.customConfig.confirmSensitiveActions) {
|
||||
restrictedTools.push('upload_file', 'evaluate_script');
|
||||
}
|
||||
|
||||
for (const toolName of restrictedTools) {
|
||||
const rule = generateAskUserRules(toolName);
|
||||
for (const toolName of [...readOnlyTools, ...allowlistedReadonlyTools]) {
|
||||
if (availableToolNames.includes(toolName)) {
|
||||
const rule = generateAllowRules(toolName);
|
||||
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
|
||||
policyEngine.addRule(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce noise for read-only tools in default mode
|
||||
const readOnlyTools = (await browserManager.getDiscoveredTools())
|
||||
.filter((t) => !!t.annotations?.readOnlyHint)
|
||||
.map((t) => t.name);
|
||||
const allowlistedReadonlyTools = ['take_snapshot', 'take_screenshot'];
|
||||
|
||||
for (const toolName of [...readOnlyTools, ...allowlistedReadonlyTools]) {
|
||||
if (availableToolNames.includes(toolName)) {
|
||||
const rule = generateAllowRules(toolName);
|
||||
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
|
||||
policyEngine.addRule(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateAskUserRules(toolName: string): PolicyRule {
|
||||
return {
|
||||
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: 999,
|
||||
source: 'BrowserAgent (Sensitive Actions)',
|
||||
mcpName: BROWSER_AGENT_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
function generateAllowRules(toolName: string): PolicyRule {
|
||||
return {
|
||||
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
source: 'BrowserAgent (Read-Only)',
|
||||
mcpName: BROWSER_AGENT_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if policy rule the same in all the attributes that we care about
|
||||
function isRuleEqual(rule1: PolicyRule, rule2: PolicyRule) {
|
||||
return (
|
||||
rule1.toolName === rule2.toolName &&
|
||||
rule1.decision === rule2.decision &&
|
||||
rule1.priority === rule2.priority &&
|
||||
rule1.mcpName === rule2.mcpName
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required semantic tools are available
|
||||
const requiredSemanticTools = [
|
||||
'click',
|
||||
'fill',
|
||||
'navigate_page',
|
||||
'take_snapshot',
|
||||
];
|
||||
const missingSemanticTools = requiredSemanticTools.filter(
|
||||
(t) => !availableToolNames.includes(t),
|
||||
);
|
||||
|
||||
const rawSessionMode = browserConfig?.customConfig?.sessionMode;
|
||||
const sessionMode =
|
||||
rawSessionMode === 'isolated' || rawSessionMode === 'existing'
|
||||
? rawSessionMode
|
||||
: 'persistent';
|
||||
|
||||
recordBrowserAgentToolDiscovery(
|
||||
config,
|
||||
mcpTools.length,
|
||||
missingSemanticTools,
|
||||
sessionMode,
|
||||
);
|
||||
|
||||
if (missingSemanticTools.length > 0) {
|
||||
debugLogger.warn(
|
||||
`Semantic tools missing (${missingSemanticTools.join(', ')}). ` +
|
||||
'Some browser interactions may not work correctly.',
|
||||
);
|
||||
}
|
||||
|
||||
// Only click_at is strictly required — text input can use press_key or fill.
|
||||
const requiredVisualTools = ['click_at'];
|
||||
const missingVisualTools = requiredVisualTools.filter(
|
||||
(t) => !availableToolNames.includes(t),
|
||||
);
|
||||
|
||||
// Check whether vision can be enabled; returns structured type with code and message.
|
||||
function getVisionDisabledReason(): VisionDisabledReason {
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
if (!browserConfig.customConfig.visualModel) {
|
||||
return {
|
||||
code: 'no_visual_model',
|
||||
message: 'No visualModel configured.',
|
||||
};
|
||||
}
|
||||
if (missingVisualTools.length > 0) {
|
||||
return {
|
||||
code: 'missing_visual_tools',
|
||||
message:
|
||||
`Visual tools missing (${missingVisualTools.join(', ')}). ` +
|
||||
`The installed chrome-devtools-mcp version may be too old.`,
|
||||
};
|
||||
}
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
const blockedAuthTypes = new Set([
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
AuthType.LEGACY_CLOUD_SHELL,
|
||||
AuthType.COMPUTE_ADC,
|
||||
]);
|
||||
if (authType && blockedAuthTypes.has(authType)) {
|
||||
return {
|
||||
code: 'blocked_auth_type',
|
||||
message: 'Visual agent model not available for current auth type.',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allTools: AnyDeclarativeTool[] = [...mcpTools];
|
||||
const visionDisabledReason = getVisionDisabledReason();
|
||||
|
||||
logBrowserAgentVisionStatus(config, {
|
||||
enabled: !visionDisabledReason,
|
||||
disabled_reason: visionDisabledReason?.code,
|
||||
});
|
||||
|
||||
if (visionDisabledReason) {
|
||||
debugLogger.log(`Vision disabled: ${visionDisabledReason.message}`);
|
||||
} else {
|
||||
allTools.push(
|
||||
createAnalyzeScreenshotTool(browserManager, config, messageBus),
|
||||
);
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`Created ${allTools.length} tools for browser agent: ` +
|
||||
allTools.map((t) => t.name).join(', '),
|
||||
);
|
||||
|
||||
// Create configured definition with tools
|
||||
// BrowserAgentDefinition is a factory function - call it with config
|
||||
const baseDefinition = BrowserAgentDefinition(
|
||||
config,
|
||||
!visionDisabledReason,
|
||||
);
|
||||
const definition: LocalAgentDefinition<typeof BrowserTaskResultSchema> = {
|
||||
...baseDefinition,
|
||||
toolConfig: {
|
||||
tools: allTools,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
definition,
|
||||
browserManager,
|
||||
visionEnabled: !visionDisabledReason,
|
||||
sessionMode,
|
||||
};
|
||||
} catch (error) {
|
||||
// Release the browser manager if setup fails, so concurrent tasks can try again.
|
||||
browserManager.release();
|
||||
throw error;
|
||||
}
|
||||
|
||||
function generateAskUserRules(toolName: string): PolicyRule {
|
||||
return {
|
||||
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
priority: 999,
|
||||
source: 'BrowserAgent (Sensitive Actions)',
|
||||
mcpName: BROWSER_AGENT_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
function generateAllowRules(toolName: string): PolicyRule {
|
||||
return {
|
||||
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
source: 'BrowserAgent (Read-Only)',
|
||||
mcpName: BROWSER_AGENT_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if policy rule the same in all the attributes that we care about
|
||||
function isRuleEqual(rule1: PolicyRule, rule2: PolicyRule) {
|
||||
return (
|
||||
rule1.toolName === rule2.toolName &&
|
||||
rule1.decision === rule2.decision &&
|
||||
rule1.priority === rule2.priority &&
|
||||
rule1.mcpName === rule2.mcpName
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required semantic tools are available
|
||||
const requiredSemanticTools = [
|
||||
'click',
|
||||
'fill',
|
||||
'navigate_page',
|
||||
'take_snapshot',
|
||||
];
|
||||
const missingSemanticTools = requiredSemanticTools.filter(
|
||||
(t) => !availableToolNames.includes(t),
|
||||
);
|
||||
|
||||
const rawSessionMode = browserConfig?.customConfig?.sessionMode;
|
||||
const sessionMode =
|
||||
rawSessionMode === 'isolated' || rawSessionMode === 'existing'
|
||||
? rawSessionMode
|
||||
: 'persistent';
|
||||
|
||||
recordBrowserAgentToolDiscovery(
|
||||
config,
|
||||
mcpTools.length,
|
||||
missingSemanticTools,
|
||||
sessionMode,
|
||||
);
|
||||
|
||||
if (missingSemanticTools.length > 0) {
|
||||
debugLogger.warn(
|
||||
`Semantic tools missing (${missingSemanticTools.join(', ')}). ` +
|
||||
'Some browser interactions may not work correctly.',
|
||||
);
|
||||
}
|
||||
|
||||
// Only click_at is strictly required — text input can use press_key or fill.
|
||||
const requiredVisualTools = ['click_at'];
|
||||
const missingVisualTools = requiredVisualTools.filter(
|
||||
(t) => !availableToolNames.includes(t),
|
||||
);
|
||||
|
||||
// Check whether vision can be enabled; returns structured type with code and message.
|
||||
function getVisionDisabledReason(): VisionDisabledReason {
|
||||
const browserConfig = config.getBrowserAgentConfig();
|
||||
if (!browserConfig.customConfig.visualModel) {
|
||||
return {
|
||||
code: 'no_visual_model',
|
||||
message: 'No visualModel configured.',
|
||||
};
|
||||
}
|
||||
if (missingVisualTools.length > 0) {
|
||||
return {
|
||||
code: 'missing_visual_tools',
|
||||
message:
|
||||
`Visual tools missing (${missingVisualTools.join(', ')}). ` +
|
||||
`The installed chrome-devtools-mcp version may be too old.`,
|
||||
};
|
||||
}
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
const blockedAuthTypes = new Set([
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
AuthType.LEGACY_CLOUD_SHELL,
|
||||
AuthType.COMPUTE_ADC,
|
||||
]);
|
||||
if (authType && blockedAuthTypes.has(authType)) {
|
||||
return {
|
||||
code: 'blocked_auth_type',
|
||||
message: 'Visual agent model not available for current auth type.',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allTools: AnyDeclarativeTool[] = [...mcpTools];
|
||||
const visionDisabledReason = getVisionDisabledReason();
|
||||
|
||||
recordBrowserAgentVisionStatus(config, {
|
||||
enabled: !visionDisabledReason,
|
||||
disabled_reason: visionDisabledReason?.code,
|
||||
});
|
||||
|
||||
if (visionDisabledReason) {
|
||||
debugLogger.log(`Vision disabled: ${visionDisabledReason.message}`);
|
||||
} else {
|
||||
allTools.push(
|
||||
createAnalyzeScreenshotTool(browserManager, config, messageBus),
|
||||
);
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`Created ${allTools.length} tools for browser agent: ` +
|
||||
allTools.map((t) => t.name).join(', '),
|
||||
);
|
||||
|
||||
// Create configured definition with tools
|
||||
// BrowserAgentDefinition is a factory function - call it with config
|
||||
const baseDefinition = BrowserAgentDefinition(config, !visionDisabledReason);
|
||||
const definition: LocalAgentDefinition<typeof BrowserTaskResultSchema> = {
|
||||
...baseDefinition,
|
||||
toolConfig: {
|
||||
tools: allTools,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
definition,
|
||||
browserManager,
|
||||
visionEnabled: !visionDisabledReason,
|
||||
sessionMode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,13 +299,13 @@ export async function cleanupBrowserAgent(
|
||||
const startMs = Date.now();
|
||||
try {
|
||||
await browserManager.close();
|
||||
logBrowserAgentCleanup(config, Date.now() - startMs, {
|
||||
recordBrowserAgentCleanup(config, Date.now() - startMs, {
|
||||
session_mode: sessionMode,
|
||||
success: true,
|
||||
});
|
||||
debugLogger.log('Browser agent cleanup complete');
|
||||
} catch (error) {
|
||||
logBrowserAgentCleanup(config, Date.now() - startMs, {
|
||||
recordBrowserAgentCleanup(config, Date.now() - startMs, {
|
||||
session_mode: sessionMode,
|
||||
success: false,
|
||||
});
|
||||
|
||||
@@ -192,10 +192,7 @@ describe('BrowserAgentInvocation', () => {
|
||||
promptConfig: { query: '', systemPrompt: '' },
|
||||
toolConfig: { tools: ['analyze_screenshot', 'click'] },
|
||||
},
|
||||
browserManager: {
|
||||
release: vi.fn(),
|
||||
callTool: vi.fn().mockResolvedValue({ content: [] }),
|
||||
} as never,
|
||||
browserManager: {} as never,
|
||||
visionEnabled: true,
|
||||
sessionMode: 'persistent',
|
||||
});
|
||||
@@ -769,7 +766,6 @@ describe('BrowserAgentInvocation', () => {
|
||||
}
|
||||
return { isError: false };
|
||||
}),
|
||||
release: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(createBrowserAgentDefinition).mockResolvedValue({
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
import { createBrowserAgentDefinition } from './browserAgentFactory.js';
|
||||
import { removeInputBlocker } from './inputBlocker.js';
|
||||
import { logBrowserAgentTaskOutcome } from '../../telemetry/loggers.js';
|
||||
import { recordBrowserAgentTaskOutcome } from '../../telemetry/metrics.js';
|
||||
import {
|
||||
sanitizeThoughtContent,
|
||||
sanitizeToolArgs,
|
||||
@@ -397,7 +397,7 @@ ${output.result}`;
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
logBrowserAgentTaskOutcome(this.config, {
|
||||
recordBrowserAgentTaskOutcome(this.config, {
|
||||
success: taskSuccess,
|
||||
session_mode: sessionMode,
|
||||
vision_enabled: visionEnabled,
|
||||
@@ -440,8 +440,6 @@ ${output.result}`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors for removing the overlays.
|
||||
} finally {
|
||||
browserManager.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +373,6 @@ describe('BrowserManager', () => {
|
||||
session_mode: 'persistent',
|
||||
headless: false,
|
||||
success: true,
|
||||
tool_count: 4,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -873,122 +872,6 @@ describe('BrowserManager', () => {
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
|
||||
it('should throw when acquired instance is requested in persistent mode', () => {
|
||||
// mockConfig defaults to persistent mode
|
||||
const instance1 = BrowserManager.getInstance(mockConfig);
|
||||
instance1.acquire();
|
||||
|
||||
expect(() => BrowserManager.getInstance(mockConfig)).toThrow(
|
||||
/Cannot launch a concurrent browser agent in "persistent" session mode/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when acquired instance is requested in existing mode', () => {
|
||||
const existingConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { sessionMode: 'existing' },
|
||||
},
|
||||
});
|
||||
|
||||
const instance1 = BrowserManager.getInstance(existingConfig);
|
||||
instance1.acquire();
|
||||
|
||||
expect(() => BrowserManager.getInstance(existingConfig)).toThrow(
|
||||
/Cannot launch a concurrent browser agent in "existing" session mode/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a different instance when the primary is acquired in isolated mode', () => {
|
||||
const isolatedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { sessionMode: 'isolated' },
|
||||
},
|
||||
});
|
||||
|
||||
const instance1 = BrowserManager.getInstance(isolatedConfig);
|
||||
instance1.acquire();
|
||||
|
||||
const instance2 = BrowserManager.getInstance(isolatedConfig);
|
||||
|
||||
expect(instance2).not.toBe(instance1);
|
||||
expect(instance1.isAcquired()).toBe(true);
|
||||
expect(instance2.isAcquired()).toBe(false);
|
||||
});
|
||||
|
||||
it('should reuse the primary when it has been released', () => {
|
||||
const instance1 = BrowserManager.getInstance(mockConfig);
|
||||
instance1.acquire();
|
||||
instance1.release();
|
||||
|
||||
const instance2 = BrowserManager.getInstance(mockConfig);
|
||||
|
||||
expect(instance2).toBe(instance1);
|
||||
expect(instance1.isAcquired()).toBe(false);
|
||||
});
|
||||
|
||||
it('should reuse a released parallel instance in isolated mode', () => {
|
||||
const isolatedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { sessionMode: 'isolated' },
|
||||
},
|
||||
});
|
||||
|
||||
const instance1 = BrowserManager.getInstance(isolatedConfig);
|
||||
instance1.acquire();
|
||||
|
||||
const instance2 = BrowserManager.getInstance(isolatedConfig);
|
||||
instance2.acquire();
|
||||
instance2.release();
|
||||
|
||||
// Primary is still acquired, parallel is released — should reuse parallel
|
||||
const instance3 = BrowserManager.getInstance(isolatedConfig);
|
||||
expect(instance3).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should create multiple parallel instances in isolated mode', () => {
|
||||
const isolatedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { sessionMode: 'isolated' },
|
||||
},
|
||||
});
|
||||
|
||||
const instance1 = BrowserManager.getInstance(isolatedConfig);
|
||||
instance1.acquire();
|
||||
|
||||
const instance2 = BrowserManager.getInstance(isolatedConfig);
|
||||
instance2.acquire();
|
||||
|
||||
const instance3 = BrowserManager.getInstance(isolatedConfig);
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
expect(instance2).not.toBe(instance3);
|
||||
expect(instance1).not.toBe(instance3);
|
||||
});
|
||||
|
||||
it('should throw when MAX_PARALLEL_INSTANCES is reached in isolated mode', () => {
|
||||
const isolatedConfig = makeFakeConfig({
|
||||
agents: {
|
||||
overrides: { browser_agent: { enabled: true } },
|
||||
browser: { sessionMode: 'isolated' },
|
||||
},
|
||||
});
|
||||
|
||||
// Acquire MAX_PARALLEL_INSTANCES instances
|
||||
for (let i = 0; i < BrowserManager.MAX_PARALLEL_INSTANCES; i++) {
|
||||
const instance = BrowserManager.getInstance(isolatedConfig);
|
||||
instance.acquire();
|
||||
}
|
||||
|
||||
// Next call should throw
|
||||
expect(() => BrowserManager.getInstance(isolatedConfig)).toThrow(
|
||||
/Maximum number of parallel browser instances/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetAll', () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { injectAutomationOverlay } from './automationOverlay.js';
|
||||
import { logBrowserAgentConnection } from '../../telemetry/loggers.js';
|
||||
import { recordBrowserAgentConnection } from '../../telemetry/metrics.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -114,12 +114,6 @@ export class BrowserManager {
|
||||
// --- Static singleton management ---
|
||||
private static instances = new Map<string, BrowserManager>();
|
||||
|
||||
/**
|
||||
* Maximum number of parallel browser instances allowed in isolated mode.
|
||||
* Prevents unbounded resource consumption from concurrent browser_agent calls.
|
||||
*/
|
||||
static readonly MAX_PARALLEL_INSTANCES = 5;
|
||||
|
||||
/**
|
||||
* Returns the cache key for a given config.
|
||||
* Uses `sessionMode:profilePath` so different profiles get separate instances.
|
||||
@@ -134,64 +128,14 @@ export class BrowserManager {
|
||||
/**
|
||||
* Returns an existing BrowserManager for the current config's session mode
|
||||
* and profile, or creates a new one.
|
||||
*
|
||||
* Concurrency rules:
|
||||
* - **persistent / existing mode**: Only one instance is allowed at a time.
|
||||
* If the instance is already in-use, an error is thrown instructing the
|
||||
* caller to run browser tasks sequentially.
|
||||
* - **isolated mode**: Parallel instances are allowed up to
|
||||
* MAX_PARALLEL_INSTANCES. Each isolated instance gets its own temp profile.
|
||||
*/
|
||||
static getInstance(config: Config): BrowserManager {
|
||||
const key = BrowserManager.getInstanceKey(config);
|
||||
const sessionMode =
|
||||
config.getBrowserAgentConfig().customConfig.sessionMode ?? 'persistent';
|
||||
let instance = BrowserManager.instances.get(key);
|
||||
if (!instance) {
|
||||
instance = new BrowserManager(config);
|
||||
BrowserManager.instances.set(key, instance);
|
||||
debugLogger.log(`Created new BrowserManager singleton (key: ${key})`);
|
||||
} else if (instance.inUse) {
|
||||
// Persistent and existing modes share a browser profile directory.
|
||||
// Chrome prevents multiple instances from using the same profile, so
|
||||
// concurrent usage would cause "profile locked" errors.
|
||||
if (sessionMode === 'persistent' || sessionMode === 'existing') {
|
||||
throw new Error(
|
||||
`Cannot launch a concurrent browser agent in "${sessionMode}" session mode. ` +
|
||||
`The browser instance is already in use by another task. ` +
|
||||
`Please run browser tasks sequentially, or switch to "isolated" session mode for concurrent browser usage.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Isolated mode: allow parallel instances up to the limit.
|
||||
let inUseCount = 1; // primary is already in-use
|
||||
let suffix = 1;
|
||||
let parallelKey = `${key}:${suffix}`;
|
||||
let parallel = BrowserManager.instances.get(parallelKey);
|
||||
while (parallel?.inUse) {
|
||||
inUseCount++;
|
||||
if (inUseCount >= BrowserManager.MAX_PARALLEL_INSTANCES) {
|
||||
throw new Error(
|
||||
`Maximum number of parallel browser instances (${BrowserManager.MAX_PARALLEL_INSTANCES}) reached. ` +
|
||||
`Please wait for an existing browser task to complete before starting a new one.`,
|
||||
);
|
||||
}
|
||||
suffix++;
|
||||
parallelKey = `${key}:${suffix}`;
|
||||
parallel = BrowserManager.instances.get(parallelKey);
|
||||
}
|
||||
if (!parallel) {
|
||||
parallel = new BrowserManager(config);
|
||||
BrowserManager.instances.set(parallelKey, parallel);
|
||||
debugLogger.log(
|
||||
`Created parallel BrowserManager (key: ${parallelKey})`,
|
||||
);
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`Reusing released parallel BrowserManager (key: ${parallelKey})`,
|
||||
);
|
||||
}
|
||||
instance = parallel;
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`Reusing existing BrowserManager singleton (key: ${key})`,
|
||||
@@ -236,36 +180,6 @@ export class BrowserManager {
|
||||
private isClosing = false;
|
||||
private connectionPromise: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
* Whether this instance is currently acquired by an active invocation.
|
||||
* Used by getInstance() to avoid handing the same browser to concurrent
|
||||
* browser_agent calls.
|
||||
*/
|
||||
private inUse = false;
|
||||
|
||||
/**
|
||||
* Marks this instance as in-use. Call this when starting a browser agent
|
||||
* invocation so concurrent calls get a separate instance.
|
||||
*/
|
||||
acquire(): void {
|
||||
this.inUse = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this instance as available for reuse. Call this in the finally
|
||||
* block of a browser agent invocation.
|
||||
*/
|
||||
release(): void {
|
||||
this.inUse = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this instance is currently acquired by an active invocation.
|
||||
*/
|
||||
isAcquired(): boolean {
|
||||
return this.inUse;
|
||||
}
|
||||
|
||||
/** State for action rate limiting */
|
||||
private actionCounter = 0;
|
||||
private readonly maxActionsPerTask: number;
|
||||
@@ -649,9 +563,7 @@ export class BrowserManager {
|
||||
// Add optional settings from config.
|
||||
// Force headless in seatbelt sandbox since Chrome profile/display access
|
||||
// may be restricted, and the user is running in a sandboxed environment.
|
||||
const effectiveHeadless =
|
||||
!!browserConfig.customConfig.headless || isSeatbeltSandbox;
|
||||
if (effectiveHeadless) {
|
||||
if (browserConfig.customConfig.headless || isSeatbeltSandbox) {
|
||||
mcpArgs.push('--headless');
|
||||
}
|
||||
if (browserConfig.customConfig.profilePath) {
|
||||
@@ -755,12 +667,15 @@ export class BrowserManager {
|
||||
// clear the action counter for each connection
|
||||
this.actionCounter = 0;
|
||||
|
||||
logBrowserAgentConnection(this.config, Date.now() - connectStartMs, {
|
||||
session_mode: sessionMode,
|
||||
headless: effectiveHeadless,
|
||||
success: true,
|
||||
tool_count: this.discoveredTools.length,
|
||||
});
|
||||
recordBrowserAgentConnection(
|
||||
this.config,
|
||||
Date.now() - connectStartMs,
|
||||
{
|
||||
session_mode: sessionMode,
|
||||
headless: !!browserConfig.customConfig.headless,
|
||||
success: true,
|
||||
},
|
||||
);
|
||||
})(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(
|
||||
@@ -781,9 +696,9 @@ export class BrowserManager {
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorType = BrowserManager.classifyConnectionError(rawErrorMessage);
|
||||
|
||||
logBrowserAgentConnection(this.config, Date.now() - connectStartMs, {
|
||||
recordBrowserAgentConnection(this.config, Date.now() - connectStartMs, {
|
||||
session_mode: sessionMode,
|
||||
headless: effectiveHeadless,
|
||||
headless: !!browserConfig.customConfig.headless,
|
||||
success: false,
|
||||
error_type: errorType,
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user