mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 17:21:01 -07:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78b22bed61 | |||
| ec39aa17c2 | |||
| 7a1f2f3288 | |||
| 59c0e73718 | |||
| 3b2632fe40 | |||
| e709789067 | |||
| 514d431049 | |||
| 32e777f838 | |||
| 14dd07be00 | |||
| d7320f5425 | |||
| 25ade7bcb7 | |||
| ac4d0c20d8 | |||
| 4d9cc36146 | |||
| 66b8922d66 | |||
| 58df1c6237 | |||
| 522e95439c |
@@ -31,6 +31,7 @@ jobs:
|
||||
name: 'Merge Queue Skipper'
|
||||
permissions: 'read-all'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
outputs:
|
||||
skip: '${{ steps.merge-queue-e2e-skipper.outputs.skip-check }}'
|
||||
steps:
|
||||
@@ -42,7 +43,7 @@ jobs:
|
||||
|
||||
download_repo_name:
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
if: "${{github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run'}}"
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run')"
|
||||
outputs:
|
||||
repo_name: '${{ steps.output-repo-name.outputs.repo_name }}'
|
||||
head_sha: '${{ steps.output-repo-name.outputs.head_sha }}'
|
||||
@@ -91,7 +92,7 @@ jobs:
|
||||
name: 'Parse run context'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
needs: 'download_repo_name'
|
||||
if: 'always()'
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && always()"
|
||||
outputs:
|
||||
repository: '${{ steps.set_context.outputs.REPO }}'
|
||||
sha: '${{ steps.set_context.outputs.SHA }}'
|
||||
@@ -111,11 +112,11 @@ jobs:
|
||||
permissions: 'write-all'
|
||||
needs:
|
||||
- 'parse_run_context'
|
||||
if: 'always()'
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && always()"
|
||||
steps:
|
||||
- name: 'Set pending status'
|
||||
uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master
|
||||
if: 'always()'
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && always()"
|
||||
with:
|
||||
allowForks: 'true'
|
||||
repo: '${{ github.repository }}'
|
||||
@@ -131,7 +132,7 @@ jobs:
|
||||
- 'parse_run_context'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
if: |
|
||||
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -184,7 +185,7 @@ jobs:
|
||||
- 'parse_run_context'
|
||||
runs-on: 'macos-latest'
|
||||
if: |
|
||||
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
@@ -222,7 +223,7 @@ jobs:
|
||||
- 'merge_queue_skipper'
|
||||
- 'parse_run_context'
|
||||
if: |
|
||||
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
@@ -282,7 +283,7 @@ jobs:
|
||||
- 'parse_run_context'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
if: |
|
||||
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
@@ -309,7 +310,7 @@ jobs:
|
||||
e2e:
|
||||
name: 'E2E'
|
||||
if: |
|
||||
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
|
||||
needs:
|
||||
- 'e2e_linux'
|
||||
- 'e2e_mac'
|
||||
@@ -337,14 +338,14 @@ jobs:
|
||||
set_workflow_status:
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
permissions: 'write-all'
|
||||
if: 'always()'
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && always()"
|
||||
needs:
|
||||
- 'parse_run_context'
|
||||
- 'e2e'
|
||||
steps:
|
||||
- name: 'Set workflow status'
|
||||
uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master
|
||||
if: 'always()'
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && always()"
|
||||
with:
|
||||
allowForks: 'true'
|
||||
repo: '${{ github.repository }}'
|
||||
|
||||
@@ -37,6 +37,7 @@ jobs:
|
||||
permissions: 'read-all'
|
||||
name: 'Merge Queue Skipper'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
outputs:
|
||||
skip: '${{ steps.merge-queue-ci-skipper.outputs.skip-check }}'
|
||||
steps:
|
||||
@@ -49,7 +50,7 @@ jobs:
|
||||
name: 'Lint'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
|
||||
env:
|
||||
GEMINI_LINT_TEMP_DIR: '${{ github.workspace }}/.gemini-linters'
|
||||
steps:
|
||||
@@ -116,6 +117,7 @@ jobs:
|
||||
link_checker:
|
||||
name: 'Link Checker'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
@@ -129,7 +131,7 @@ jobs:
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
needs:
|
||||
- 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
|
||||
permissions:
|
||||
contents: 'read'
|
||||
checks: 'write'
|
||||
@@ -216,7 +218,7 @@ jobs:
|
||||
runs-on: 'macos-latest'
|
||||
needs:
|
||||
- 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
|
||||
permissions:
|
||||
contents: 'read'
|
||||
checks: 'write'
|
||||
@@ -311,7 +313,7 @@ jobs:
|
||||
name: 'CodeQL'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
|
||||
permissions:
|
||||
actions: 'read'
|
||||
contents: 'read'
|
||||
@@ -334,7 +336,7 @@ jobs:
|
||||
bundle_size:
|
||||
name: 'Check Bundle Size'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: "${{github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'"
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
permissions:
|
||||
contents: 'read' # For checkout
|
||||
@@ -359,7 +361,7 @@ jobs:
|
||||
name: 'Slow Test - Win - ${{ matrix.shard }}'
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -451,7 +453,7 @@ jobs:
|
||||
|
||||
ci:
|
||||
name: 'CI'
|
||||
if: 'always()'
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && always()"
|
||||
needs:
|
||||
- 'lint'
|
||||
- 'link_checker'
|
||||
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
deflake_e2e_linux:
|
||||
name: 'E2E Test (Linux) - ${{ matrix.sandbox }}'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -77,6 +78,7 @@ jobs:
|
||||
deflake_e2e_mac:
|
||||
name: 'E2E Test (macOS)'
|
||||
runs-on: 'macos-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
|
||||
@@ -114,6 +116,7 @@ jobs:
|
||||
deflake_e2e_windows:
|
||||
name: 'Slow E2E - Win'
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: 'Deploy GitHub Pages'
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
pages: 'write'
|
||||
id-token: 'write'
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run
|
||||
# in-progress and latest queued. However, do NOT cancel in-progress runs as we
|
||||
# want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: |-
|
||||
${{ !contains(github.ref_name, 'nightly') }}
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
|
||||
- name: 'Setup Pages'
|
||||
uses: 'actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b' # ratchet:actions/configure-pages@v5
|
||||
|
||||
- name: 'Build with Jekyll'
|
||||
uses: 'actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697' # ratchet:actions/jekyll-build-pages@v1
|
||||
with:
|
||||
source: './'
|
||||
destination: './_site'
|
||||
|
||||
- name: 'Upload artifact'
|
||||
uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: 'github-pages'
|
||||
url: '${{ steps.deployment.outputs.page_url }}'
|
||||
runs-on: 'ubuntu-latest'
|
||||
needs: 'build'
|
||||
steps:
|
||||
- name: 'Deploy to GitHub Pages'
|
||||
id: 'deployment'
|
||||
uses: 'actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e' # ratchet:actions/deploy-pages@v4
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- 'docs/**'
|
||||
jobs:
|
||||
trigger-rebuild:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Trigger rebuild'
|
||||
|
||||
@@ -23,6 +23,7 @@ jobs:
|
||||
evals:
|
||||
name: 'Evals (USUALLY_PASSING) nightly run'
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -85,7 +86,7 @@ jobs:
|
||||
aggregate-results:
|
||||
name: 'Aggregate Results'
|
||||
needs: ['evals']
|
||||
if: 'always()'
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && always()"
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
|
||||
@@ -21,6 +21,7 @@ defaults:
|
||||
|
||||
jobs:
|
||||
close-stale-issues:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
issues: 'write'
|
||||
|
||||
@@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
# Event-based: Quick reaction to new/edited issues in THIS repo
|
||||
labeler:
|
||||
if: "github.event_name == 'issues'"
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issues'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
# Scheduled/Manual: Recursive sync across multiple repos
|
||||
sync-maintainer-labels:
|
||||
if: "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'"
|
||||
if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')"
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
issues: 'write'
|
||||
|
||||
@@ -32,6 +32,7 @@ on:
|
||||
|
||||
jobs:
|
||||
change-tags:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
environment: "${{ github.event.inputs.environment || 'prod' }}"
|
||||
permissions:
|
||||
|
||||
@@ -47,6 +47,7 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
environment: "${{ github.event.inputs.environment || 'prod' }}"
|
||||
permissions:
|
||||
|
||||
@@ -22,6 +22,7 @@ on:
|
||||
|
||||
jobs:
|
||||
generate-release-notes:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
contents: 'write'
|
||||
|
||||
@@ -42,6 +42,7 @@ on:
|
||||
|
||||
jobs:
|
||||
change-tags:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
environment: "${{ github.event.inputs.environment || 'prod' }}"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
|
||||
@@ -16,6 +16,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
|
||||
@@ -20,6 +20,7 @@ on:
|
||||
|
||||
jobs:
|
||||
smoke-test:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
contents: 'write'
|
||||
|
||||
@@ -15,6 +15,7 @@ on:
|
||||
|
||||
jobs:
|
||||
save_repo_name:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
steps:
|
||||
- name: 'Save Repo name'
|
||||
@@ -31,6 +32,7 @@ jobs:
|
||||
path: 'pr/'
|
||||
trigger_e2e:
|
||||
name: 'Trigger e2e'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'gemini-cli-ubuntu-16-core'
|
||||
steps:
|
||||
- id: 'trigger-e2e'
|
||||
|
||||
@@ -28,6 +28,7 @@ on:
|
||||
|
||||
jobs:
|
||||
verify-release:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
environment: "${{ github.event.inputs.environment || 'prod' }}"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -8,7 +8,7 @@ import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('validation_fidelity', () => {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should perform exhaustive validation autonomously when guided by system instructions',
|
||||
files: {
|
||||
'src/types.ts': `
|
||||
|
||||
@@ -843,6 +843,7 @@ export async function loadCliConfig(
|
||||
interactive,
|
||||
trustedFolder,
|
||||
useBackgroundColor: settings.ui?.useBackgroundColor,
|
||||
useAlternateBuffer: settings.ui?.useAlternateBuffer,
|
||||
useRipgrep: settings.tools?.useRipgrep,
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
resolveWorkspacePolicyState,
|
||||
autoAcceptWorkspacePolicies,
|
||||
setAutoAcceptWorkspacePolicies,
|
||||
disableWorkspacePolicies,
|
||||
setDisableWorkspacePolicies,
|
||||
} from './policy.js';
|
||||
import { writeToStderr } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -45,6 +47,9 @@ describe('resolveWorkspacePolicyState', () => {
|
||||
fs.mkdirSync(workspaceDir);
|
||||
policiesDir = path.join(workspaceDir, '.gemini', 'policies');
|
||||
|
||||
// Enable policies for these tests to verify loading logic
|
||||
setDisableWorkspacePolicies(false);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -67,6 +72,13 @@ describe('resolveWorkspacePolicyState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should have disableWorkspacePolicies set to true by default', () => {
|
||||
// We explicitly set it to false in beforeEach for other tests,
|
||||
// so here we test that setting it to true works.
|
||||
setDisableWorkspacePolicies(true);
|
||||
expect(disableWorkspacePolicies).toBe(true);
|
||||
});
|
||||
|
||||
it('should return policy directory if integrity matches', async () => {
|
||||
// Set up policies directory with a file
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
@@ -188,7 +200,26 @@ describe('resolveWorkspacePolicyState', () => {
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
|
||||
it('should return empty state if disableWorkspacePolicies is true even if folder is trusted', async () => {
|
||||
setDisableWorkspacePolicies(true);
|
||||
|
||||
// Set up policies directory with a file
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
workspacePoliciesDir: undefined,
|
||||
policyUpdateConfirmationRequest: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty state if cwd is a symlink to the home directory', async () => {
|
||||
const policiesDir = path.join(tempDir, '.gemini', 'policies');
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
@@ -35,6 +35,20 @@ export function setAutoAcceptWorkspacePolicies(value: boolean) {
|
||||
autoAcceptWorkspacePolicies = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary flag to disable workspace level policies altogether.
|
||||
* Exported as 'let' to allow monkey patching in tests via the setter.
|
||||
*/
|
||||
export let disableWorkspacePolicies = true;
|
||||
|
||||
/**
|
||||
* Sets the disableWorkspacePolicies flag.
|
||||
* Used primarily for testing purposes.
|
||||
*/
|
||||
export function setDisableWorkspacePolicies(value: boolean) {
|
||||
disableWorkspacePolicies = value;
|
||||
}
|
||||
|
||||
export async function createPolicyEngineConfig(
|
||||
settings: Settings,
|
||||
approvalMode: ApprovalMode,
|
||||
@@ -81,7 +95,7 @@ export async function resolveWorkspacePolicyState(options: {
|
||||
| PolicyUpdateConfirmationRequest
|
||||
| undefined;
|
||||
|
||||
if (trustedFolder) {
|
||||
if (trustedFolder && !disableWorkspacePolicies) {
|
||||
const storage = new Storage(cwd);
|
||||
|
||||
// If we are in the home directory (or rather, our target Gemini dir is the global one),
|
||||
|
||||
@@ -54,6 +54,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Policy.setDisableWorkspacePolicies(false);
|
||||
// Default to MATCH for existing tests
|
||||
mockCheckIntegrity.mockResolvedValue({
|
||||
status: 'match',
|
||||
|
||||
@@ -1182,6 +1182,7 @@ describe('startInteractiveUI', () => {
|
||||
getProjectRoot: () => '/root',
|
||||
getScreenReader: () => false,
|
||||
getDebugMode: () => false,
|
||||
getUseAlternateBuffer: () => true,
|
||||
});
|
||||
const mockSettings = {
|
||||
merged: {
|
||||
|
||||
@@ -102,8 +102,8 @@ import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { deleteSession, listSessions } from './utils/sessions.js';
|
||||
import { createPolicyUpdater } from './config/policy.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
@@ -196,7 +196,7 @@ export async function startInteractiveUI(
|
||||
// and the Ink alternate buffer mode requires line wrapping harmful to
|
||||
// screen readers.
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(settings),
|
||||
isAlternateBufferEnabled(config),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const mouseEventsEnabled = useAlternateBuffer;
|
||||
@@ -678,7 +678,7 @@ export async function main() {
|
||||
|
||||
let input = config.getQuestion();
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(settings),
|
||||
isAlternateBufferEnabled(config),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const rawStartupWarnings = await getStartupWarnings();
|
||||
|
||||
@@ -156,6 +156,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getExperiments: vi.fn().mockReturnValue(undefined),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getUseAlternateBuffer: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
|
||||
@@ -703,6 +703,21 @@ export const renderWithProviders = (
|
||||
});
|
||||
}
|
||||
|
||||
// Wrap config in a Proxy so useAlternateBuffer hook (which reads from Config) gets the correct value,
|
||||
// without replacing the entire config object and its other values.
|
||||
let finalConfig = config;
|
||||
if (useAlternateBuffer !== undefined) {
|
||||
finalConfig = new Proxy(config, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'getUseAlternateBuffer') {
|
||||
return () => useAlternateBuffer;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const mainAreaWidth = terminalWidth;
|
||||
|
||||
const finalUiState = {
|
||||
@@ -731,7 +746,7 @@ export const renderWithProviders = (
|
||||
|
||||
const renderResult = render(
|
||||
<AppContext.Provider value={appState}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<ConfigContext.Provider value={finalConfig}>
|
||||
<SettingsContext.Provider value={finalSettings}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider settings={finalSettings}>
|
||||
@@ -743,7 +758,7 @@ export const renderWithProviders = (
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<OverflowProvider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
config={finalConfig}
|
||||
toolCalls={allToolCalls}
|
||||
>
|
||||
<AskUserActionsProvider
|
||||
|
||||
@@ -2675,6 +2675,10 @@ describe('AppContainer State Management', () => {
|
||||
isAlternateMode = false,
|
||||
childHandler?: Mock,
|
||||
) => {
|
||||
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(
|
||||
isAlternateMode,
|
||||
);
|
||||
|
||||
// Update settings for this test run
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const testSettings = {
|
||||
@@ -3364,6 +3368,8 @@ describe('AppContainer State Management', () => {
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue([]);
|
||||
|
||||
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
unmount = renderAppContainer({
|
||||
@@ -3596,6 +3602,8 @@ describe('AppContainer State Management', () => {
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer({
|
||||
|
||||
@@ -145,7 +145,6 @@ import { useSessionResume } from './hooks/useSessionResume.js';
|
||||
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
|
||||
import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js';
|
||||
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
|
||||
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
||||
import { useSettings } from './contexts/SettingsContext.js';
|
||||
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
|
||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
@@ -228,7 +227,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
});
|
||||
|
||||
useMemoryMonitor(historyManager);
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [forceRerenderKey, setForceRerenderKey] = useState(0);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
@@ -264,14 +263,16 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
() => isWorkspaceTrusted(settings.merged).isTrusted,
|
||||
);
|
||||
|
||||
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
|
||||
null,
|
||||
const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage<string>(
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
);
|
||||
|
||||
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
|
||||
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||
const [showIsExpandableHint, setShowIsExpandableHint] = useState(false);
|
||||
const expandHintTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [expandHintTrigger, triggerExpandHint] = useTimedMessage<boolean>(
|
||||
EXPAND_HINT_DURATION_MS,
|
||||
);
|
||||
const showIsExpandableHint = Boolean(expandHintTrigger);
|
||||
const overflowState = useOverflowState();
|
||||
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
|
||||
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
|
||||
@@ -284,39 +285,15 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
* boolean dependency (hasOverflowState) to ensure the timer only resets on
|
||||
* genuine state transitions, preventing it from infinitely resetting during
|
||||
* active text streaming.
|
||||
*
|
||||
* In alternate buffer mode, we don't trigger the hint automatically on overflow
|
||||
* to avoid noise, but the user can still trigger it manually with Ctrl+O.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isAlternateBuffer) {
|
||||
setShowIsExpandableHint(false);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
return;
|
||||
if (hasOverflowState && !isAlternateBuffer) {
|
||||
triggerExpandHint(true);
|
||||
}
|
||||
|
||||
if (hasOverflowState) {
|
||||
setShowIsExpandableHint(true);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
expandHintTimerRef.current = setTimeout(() => {
|
||||
setShowIsExpandableHint(false);
|
||||
}, EXPAND_HINT_DURATION_MS);
|
||||
}
|
||||
}, [hasOverflowState, isAlternateBuffer, constrainHeight]);
|
||||
|
||||
/**
|
||||
* Safe cleanup to ensure the expansion hint timer is cancelled when the
|
||||
* component unmounts, preventing memory leaks.
|
||||
*/
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
}, [hasOverflowState, isAlternateBuffer, triggerExpandHint]);
|
||||
|
||||
const [defaultBannerText, setDefaultBannerText] = useState('');
|
||||
const [warningBannerText, setWarningBannerText] = useState('');
|
||||
@@ -567,7 +544,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
|
||||
useConsoleMessages();
|
||||
|
||||
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings);
|
||||
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config);
|
||||
// Derive widths for InputPrompt using shared helper
|
||||
const { inputWidth, suggestionsWidth } = useMemo(() => {
|
||||
const { inputWidth, suggestionsWidth } =
|
||||
@@ -1252,10 +1229,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
async (submittedValue: string) => {
|
||||
reset();
|
||||
// Explicitly hide the expansion hint and clear its x-second timer when a new turn begins.
|
||||
setShowIsExpandableHint(false);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
triggerExpandHint(null);
|
||||
if (!constrainHeight) {
|
||||
setConstrainHeight(true);
|
||||
if (!isAlternateBuffer) {
|
||||
@@ -1327,16 +1301,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
refreshStatic,
|
||||
reset,
|
||||
handleHintSubmit,
|
||||
triggerExpandHint,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClearScreen = useCallback(() => {
|
||||
reset();
|
||||
// Explicitly hide the expansion hint and clear its x-second timer when clearing the screen.
|
||||
setShowIsExpandableHint(false);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
triggerExpandHint(null);
|
||||
historyManager.clearItems();
|
||||
clearConsoleMessagesState();
|
||||
refreshStatic();
|
||||
@@ -1345,7 +1317,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
clearConsoleMessagesState,
|
||||
refreshStatic,
|
||||
reset,
|
||||
setShowIsExpandableHint,
|
||||
triggerExpandHint,
|
||||
]);
|
||||
|
||||
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
||||
@@ -1632,17 +1604,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}, [ideNeedsRestart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queueErrorMessage) {
|
||||
const timer = setTimeout(() => {
|
||||
setQueueErrorMessage(null);
|
||||
}, QUEUE_ERROR_DISPLAY_DURATION_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [queueErrorMessage, setQueueErrorMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
@@ -1748,13 +1709,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setConstrainHeight(true);
|
||||
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
|
||||
// If the user manually collapses the view, show the hint and reset the x-second timer.
|
||||
setShowIsExpandableHint(true);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
expandHintTimerRef.current = setTimeout(() => {
|
||||
setShowIsExpandableHint(false);
|
||||
}, EXPAND_HINT_DURATION_MS);
|
||||
triggerExpandHint(true);
|
||||
}
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
@@ -1803,13 +1758,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
) {
|
||||
setConstrainHeight(false);
|
||||
// If the user manually expands the view, show the hint and reset the x-second timer.
|
||||
setShowIsExpandableHint(true);
|
||||
if (expandHintTimerRef.current) {
|
||||
clearTimeout(expandHintTimerRef.current);
|
||||
}
|
||||
expandHintTimerRef.current = setTimeout(() => {
|
||||
setShowIsExpandableHint(false);
|
||||
}, EXPAND_HINT_DURATION_MS);
|
||||
triggerExpandHint(true);
|
||||
if (!isAlternateBuffer) {
|
||||
refreshStatic();
|
||||
}
|
||||
@@ -1914,6 +1863,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
showTransientMessage,
|
||||
settings.merged.general.devtools,
|
||||
showErrorDetails,
|
||||
triggerExpandHint,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { act, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useEffect } from 'react';
|
||||
import { Composer } from './Composer.js';
|
||||
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
@@ -34,6 +34,7 @@ import { StreamingState } from '../types.js';
|
||||
import { TransientMessageType } from '../../utils/events.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import type { SessionMetrics } from '../contexts/SessionContext.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
|
||||
const composerTestControls = vi.hoisted(() => ({
|
||||
suggestionsVisible: false,
|
||||
@@ -263,16 +264,26 @@ const renderComposer = async (
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
await result.waitUntilReady();
|
||||
|
||||
// Wait for shortcuts hint debounce if using fake timers
|
||||
if (vi.isFakeTimers()) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('Composer', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
composerTestControls.suggestionsVisible = false;
|
||||
composerTestControls.isAlternateBuffer = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -391,7 +402,7 @@ describe('Composer', () => {
|
||||
expect(output).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator without thought when loadingPhrases is off', async () => {
|
||||
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
thought: { subject: 'Hidden', description: 'Should not show' },
|
||||
@@ -404,7 +415,7 @@ describe('Composer', () => {
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('Should not show');
|
||||
expect(output).toContain('LoadingIndicator: Hidden');
|
||||
});
|
||||
|
||||
it('does not render LoadingIndicator when waiting for confirmation', async () => {
|
||||
@@ -809,6 +820,28 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
describe('Shortcuts Hint', () => {
|
||||
it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => {
|
||||
const { lastFrame } = await renderComposer(
|
||||
createMockUIState({
|
||||
buffer: { text: '' } as unknown as TextBuffer,
|
||||
cleanUiDetailsVisible: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('does not show shortcuts hint immediately when buffer has text', async () => {
|
||||
const uiState = createMockUIState({
|
||||
buffer: { text: 'hello' } as unknown as TextBuffer,
|
||||
cleanUiDetailsVisible: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({
|
||||
@@ -857,6 +890,27 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint while loading when full UI details are visible', async () => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint when text is typed in buffer', async () => {
|
||||
const uiState = createMockUIState({
|
||||
buffer: { text: 'hello' } as unknown as TextBuffer,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint while loading in minimal mode', async () => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: false,
|
||||
@@ -930,9 +984,10 @@ describe('Composer', () => {
|
||||
streamingState: StreamingState.Idle,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const { lastFrame, unmount } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShortcutsHelp');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides shortcuts help while streaming', async () => {
|
||||
@@ -941,9 +996,10 @@ describe('Composer', () => {
|
||||
streamingState: StreamingState.Responding,
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const { lastFrame, unmount } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHelp');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides shortcuts help when action is required', async () => {
|
||||
@@ -956,9 +1012,10 @@ describe('Composer', () => {
|
||||
),
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const { lastFrame, unmount } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHelp');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -151,11 +151,30 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
: undefined,
|
||||
);
|
||||
const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions;
|
||||
const isModelIdle = uiState.streamingState === StreamingState.Idle;
|
||||
const isBufferEmpty = uiState.buffer.text.length === 0;
|
||||
const canShowShortcutsHint =
|
||||
isModelIdle && isBufferEmpty && !hasPendingActionRequired;
|
||||
const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] =
|
||||
useState(canShowShortcutsHint);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canShowShortcutsHint) {
|
||||
setShowShortcutsHintDebounced(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setShowShortcutsHintDebounced(true);
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [canShowShortcutsHint]);
|
||||
|
||||
const showShortcutsHint =
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideShortcutsHintForSuggestions &&
|
||||
!hideMinimalModeHintWhileBusy &&
|
||||
!hasPendingActionRequired;
|
||||
showShortcutsHintDebounced;
|
||||
const showMinimalModeBleedThrough =
|
||||
!hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
|
||||
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
|
||||
@@ -210,8 +229,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
inline
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation ||
|
||||
settings.merged.ui.loadingPhrases === 'off'
|
||||
StreamingState.WaitingForConfirmation
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
@@ -254,8 +272,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
inline
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation ||
|
||||
settings.merged.ui.loadingPhrases === 'off'
|
||||
StreamingState.WaitingForConfirmation
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
}),
|
||||
getUseAlternateBuffer: () => options?.useAlternateBuffer ?? true,
|
||||
} as unknown as import('@google/gemini-cli-core').Config,
|
||||
},
|
||||
);
|
||||
@@ -443,6 +444,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
}),
|
||||
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
|
||||
} as unknown as import('@google/gemini-cli-core').Config,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('ToolConfirmationQueue', () => {
|
||||
storage: {
|
||||
getPlansDir: () => '/mock/temp/plans',
|
||||
},
|
||||
getUseAlternateBuffer: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -227,7 +228,7 @@ describe('ToolConfirmationQueue', () => {
|
||||
// availableContentHeight = Math.max(9 - 6, 4) = 4
|
||||
// MaxSizedBox in ToolConfirmationMessage will use 4
|
||||
// It should show truncation message
|
||||
await waitFor(() => expect(lastFrame()).toContain('first 49 lines hidden'));
|
||||
await waitFor(() => expect(lastFrame()).toContain('49 hidden (Ctrl+O)'));
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ Footer
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
|
||||
" LoadingIndicator: Thinking ShortcutsHint
|
||||
" LoadingIndicator: Thinking
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
ApprovalModeIndicator
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
|
||||
@@ -74,7 +74,7 @@ Implementation Steps
|
||||
6. Add LDAP provider support in src/auth/providers/LDAPProvider.ts
|
||||
7. Create token refresh mechanism in src/auth/TokenManager.ts
|
||||
8. Add multi-factor authentication in src/auth/MFAService.ts
|
||||
... last 22 lines hidden ...
|
||||
... last 22 lines hidden (Ctrl+O to show) ...
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
|
||||
@@ -112,7 +112,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = `
|
||||
"✦ Example code block:
|
||||
... first 42 lines hidden ...
|
||||
... 42 hidden (Ctrl+O) ...
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
@@ -126,7 +126,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = `
|
||||
" Example code block:
|
||||
... first 42 lines hidden ...
|
||||
... 42 hidden (Ctrl+O) ...
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
|
||||
@@ -49,7 +49,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ ... first 11 lines hidden ... │
|
||||
│ ... first 11 lines hidden (Ctrl+O to show) ... │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
|
||||
@@ -6,7 +6,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
|
||||
│ │
|
||||
│ ? replace edit file │
|
||||
│ │
|
||||
│ ... first 49 lines hidden ... │
|
||||
│ ... 49 hidden (Ctrl+O) ... │
|
||||
│ 50 line │
|
||||
│ Apply this change? │
|
||||
│ │
|
||||
@@ -96,7 +96,7 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and
|
||||
│ │
|
||||
│ ? replace edit file │
|
||||
│ │
|
||||
│ ... first 49 lines hidden ... │
|
||||
│ ... 49 hidden (Ctrl+O) ... │
|
||||
│ 50 line │
|
||||
│ Apply this change? │
|
||||
│ │
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
|
||||
);
|
||||
|
||||
// Verify truncation is occurring (standard mode uses MaxSizedBox)
|
||||
await waitFor(() => expect(lastFrame()).toContain('hidden ...'));
|
||||
await waitFor(() => expect(lastFrame()).toContain('hidden (Ctrl+O'));
|
||||
|
||||
// In Standard mode, ToolGroupMessage calculates hasOverflow correctly now.
|
||||
// While Standard mode doesn't render the inline hint (ShowMoreLines returns null),
|
||||
|
||||
@@ -10,7 +10,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = `
|
||||
"... first 10 lines hidden ...
|
||||
"... 10 hidden (Ctrl+O) ...
|
||||
'test';
|
||||
21 + const anotherNew =
|
||||
'test';
|
||||
@@ -20,7 +20,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
|
||||
"... first 4 lines hidden ...
|
||||
"... first 4 lines hidden (Ctrl+O to show) ...
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
@@ -103,7 +103,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = `
|
||||
"... first 10 lines hidden ...
|
||||
"... 10 hidden (Ctrl+O) ...
|
||||
'test';
|
||||
21 + const anotherNew =
|
||||
'test';
|
||||
@@ -113,7 +113,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
|
||||
"... first 4 lines hidden ...
|
||||
"... first 4 lines hidden (Ctrl+O to show) ...
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
|
||||
`;
|
||||
|
||||
exports[`ToolResultDisplay > truncates very long string results 1`] = `
|
||||
"... first 248 lines hidden ...
|
||||
"... 248 hidden (Ctrl+O) ...
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
|
||||
@@ -41,7 +41,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... first 2 lines hidden ...');
|
||||
expect(lastFrame()).toContain(
|
||||
'... first 2 lines hidden (Ctrl+O to show) ...',
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -59,7 +61,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... last 2 lines hidden ...');
|
||||
expect(lastFrame()).toContain(
|
||||
'... last 2 lines hidden (Ctrl+O to show) ...',
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -77,7 +81,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... first 2 lines hidden ...');
|
||||
expect(lastFrame()).toContain(
|
||||
'... first 2 lines hidden (Ctrl+O to show) ...',
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -93,7 +99,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... first 1 line hidden ...');
|
||||
expect(lastFrame()).toContain(
|
||||
'... first 1 line hidden (Ctrl+O to show) ...',
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -111,7 +119,9 @@ describe('<MaxSizedBox />', () => {
|
||||
</OverflowProvider>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... first 7 lines hidden ...');
|
||||
expect(lastFrame()).toContain(
|
||||
'... first 7 lines hidden (Ctrl+O to show) ...',
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -197,7 +207,9 @@ describe('<MaxSizedBox />', () => {
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... first 21 lines hidden ...');
|
||||
expect(lastFrame()).toContain(
|
||||
'... first 21 lines hidden (Ctrl+O to show) ...',
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -218,7 +230,9 @@ describe('<MaxSizedBox />', () => {
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('... last 21 lines hidden ...');
|
||||
expect(lastFrame()).toContain(
|
||||
'... last 21 lines hidden (Ctrl+O to show) ...',
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -247,7 +261,9 @@ describe('<MaxSizedBox />', () => {
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
// The last line should only contain the hidden indicator, no leaked content
|
||||
expect(lastLine).toMatch(/^\.\.\. last \d+ lines? hidden \.\.\.$/);
|
||||
expect(lastLine).toMatch(
|
||||
/^\.\.\. last \d+ lines? hidden \(Ctrl\+O to show\) \.\.\.$/,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
import { isNarrowWidth } from '../../utils/isNarrowWidth.js';
|
||||
import { Command } from '../../../config/keyBindings.js';
|
||||
import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
|
||||
/**
|
||||
* Minimum height for the MaxSizedBox component.
|
||||
@@ -84,6 +87,9 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
|
||||
|
||||
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
|
||||
|
||||
const isNarrow = maxWidth !== undefined && isNarrowWidth(maxWidth);
|
||||
const showMoreKey = formatCommand(Command.SHOW_MORE_LINES);
|
||||
|
||||
useEffect(() => {
|
||||
if (totalHiddenLines > 0) {
|
||||
addOverflowingId?.(id);
|
||||
@@ -116,8 +122,9 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
|
||||
>
|
||||
{totalHiddenLines > 0 && overflowDirection === 'top' && (
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
|
||||
hidden ...
|
||||
{isNarrow
|
||||
? `... ${totalHiddenLines} hidden (${showMoreKey}) ...`
|
||||
: `... first ${totalHiddenLines} line${totalHiddenLines === 1 ? '' : 's'} hidden (${showMoreKey} to show) ...`}
|
||||
</Text>
|
||||
)}
|
||||
<Box
|
||||
@@ -137,8 +144,9 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
|
||||
</Box>
|
||||
{totalHiddenLines > 0 && overflowDirection === 'bottom' && (
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
|
||||
hidden ...
|
||||
{isNarrow
|
||||
? `... ${totalHiddenLines} hidden (${showMoreKey}) ...`
|
||||
: `... last ${totalHiddenLines} line${totalHiddenLines === 1 ? '' : 's'} hidden (${showMoreKey} to show) ...`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -6,20 +6,11 @@
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { Scrollable } from './Scrollable.js';
|
||||
import { Text } from 'ink';
|
||||
import { Text, Box } from 'ink';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as ScrollProviderModule from '../../contexts/ScrollProvider.js';
|
||||
import { act } from 'react';
|
||||
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...actual,
|
||||
getInnerHeight: vi.fn(() => 5),
|
||||
getScrollHeight: vi.fn(() => 10),
|
||||
getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 5 })),
|
||||
};
|
||||
});
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
|
||||
vi.mock('../../hooks/useAnimatedScrollbar.js', () => ({
|
||||
useAnimatedScrollbar: (
|
||||
@@ -129,20 +120,26 @@ describe('<Scrollable />', () => {
|
||||
</Scrollable>,
|
||||
);
|
||||
await waitUntilReady2();
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(5);
|
||||
await waitFor(() => {
|
||||
expect(capturedEntry?.getScrollState().scrollTop).toBe(5);
|
||||
});
|
||||
|
||||
// Call scrollBy multiple times (upwards) in the same tick
|
||||
await act(async () => {
|
||||
capturedEntry!.scrollBy(-1);
|
||||
capturedEntry!.scrollBy(-1);
|
||||
capturedEntry?.scrollBy(-1);
|
||||
capturedEntry?.scrollBy(-1);
|
||||
});
|
||||
// Should have moved up by 2 (5 -> 3)
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(3);
|
||||
await waitFor(() => {
|
||||
expect(capturedEntry?.getScrollState().scrollTop).toBe(3);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
capturedEntry!.scrollBy(-2);
|
||||
capturedEntry?.scrollBy(-2);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(capturedEntry?.getScrollState().scrollTop).toBe(1);
|
||||
});
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(1);
|
||||
unmount2();
|
||||
});
|
||||
|
||||
@@ -191,10 +188,6 @@ describe('<Scrollable />', () => {
|
||||
keySequence,
|
||||
expectedScrollTop,
|
||||
}) => {
|
||||
// Dynamically import ink to mock getScrollHeight
|
||||
const ink = await import('ink');
|
||||
vi.mocked(ink.getScrollHeight).mockReturnValue(scrollHeight);
|
||||
|
||||
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
|
||||
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
|
||||
async (entry, isActive) => {
|
||||
@@ -206,7 +199,9 @@ describe('<Scrollable />', () => {
|
||||
|
||||
const { stdin, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Scrollable hasFocus={true} height={5}>
|
||||
<Text>Content</Text>
|
||||
<Box height={scrollHeight}>
|
||||
<Text>Content</Text>
|
||||
</Box>
|
||||
</Scrollable>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -4,15 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Box, getInnerHeight, getScrollHeight, type DOMElement } from 'ink';
|
||||
import type React from 'react';
|
||||
import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
|
||||
import { Box, ResizeObserver, type DOMElement } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
@@ -41,62 +35,101 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
flexGrow,
|
||||
}) => {
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const ref = useRef<DOMElement>(null);
|
||||
const viewportRef = useRef<DOMElement | null>(null);
|
||||
const contentRef = useRef<DOMElement | null>(null);
|
||||
const [size, setSize] = useState({
|
||||
innerHeight: 0,
|
||||
innerHeight: typeof height === 'number' ? height : 0,
|
||||
scrollHeight: 0,
|
||||
});
|
||||
const sizeRef = useRef(size);
|
||||
useEffect(() => {
|
||||
const scrollTopRef = useRef(scrollTop);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
sizeRef.current = size;
|
||||
}, [size]);
|
||||
|
||||
const childrenCountRef = useRef(0);
|
||||
|
||||
// This effect needs to run on every render to correctly measure the container
|
||||
// and scroll to the bottom if new children are added.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useLayoutEffect(() => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
scrollTopRef.current = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
const viewportObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const contentObserverRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const viewportRefCallback = useCallback((node: DOMElement | null) => {
|
||||
viewportObserverRef.current?.disconnect();
|
||||
viewportRef.current = node;
|
||||
|
||||
if (node) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
const innerHeight = Math.round(entry.contentRect.height);
|
||||
setSize((prev) => {
|
||||
const scrollHeight = prev.scrollHeight;
|
||||
const isAtBottom =
|
||||
scrollHeight > prev.innerHeight &&
|
||||
scrollTopRef.current >= scrollHeight - prev.innerHeight - 1;
|
||||
|
||||
if (isAtBottom) {
|
||||
setScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
return { ...prev, innerHeight };
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
viewportObserverRef.current = observer;
|
||||
}
|
||||
const innerHeight = Math.round(getInnerHeight(ref.current));
|
||||
const scrollHeight = Math.round(getScrollHeight(ref.current));
|
||||
}, []);
|
||||
|
||||
const isAtBottom =
|
||||
scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1;
|
||||
const contentRefCallback = useCallback(
|
||||
(node: DOMElement | null) => {
|
||||
contentObserverRef.current?.disconnect();
|
||||
contentRef.current = node;
|
||||
|
||||
if (
|
||||
size.innerHeight !== innerHeight ||
|
||||
size.scrollHeight !== scrollHeight
|
||||
) {
|
||||
setSize({ innerHeight, scrollHeight });
|
||||
if (isAtBottom) {
|
||||
setScrollTop(Math.max(0, scrollHeight - innerHeight));
|
||||
if (node) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
const scrollHeight = Math.round(entry.contentRect.height);
|
||||
setSize((prev) => {
|
||||
const innerHeight = prev.innerHeight;
|
||||
const isAtBottom =
|
||||
prev.scrollHeight > innerHeight &&
|
||||
scrollTopRef.current >= prev.scrollHeight - innerHeight - 1;
|
||||
|
||||
if (
|
||||
isAtBottom ||
|
||||
(scrollToBottom && scrollHeight > prev.scrollHeight)
|
||||
) {
|
||||
setScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
return { ...prev, scrollHeight };
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
contentObserverRef.current = observer;
|
||||
}
|
||||
}
|
||||
|
||||
const childCountCurrent = React.Children.count(children);
|
||||
if (scrollToBottom && childrenCountRef.current !== childCountCurrent) {
|
||||
setScrollTop(Math.max(0, scrollHeight - innerHeight));
|
||||
}
|
||||
childrenCountRef.current = childCountCurrent;
|
||||
});
|
||||
},
|
||||
[scrollToBottom],
|
||||
);
|
||||
|
||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||
|
||||
const scrollBy = useCallback(
|
||||
(delta: number) => {
|
||||
const { scrollHeight, innerHeight } = sizeRef.current;
|
||||
const current = getScrollTop();
|
||||
const next = Math.min(
|
||||
Math.max(0, current + delta),
|
||||
Math.max(0, scrollHeight - innerHeight),
|
||||
);
|
||||
const maxScroll = Math.max(0, scrollHeight - innerHeight);
|
||||
const current = Math.min(getScrollTop(), maxScroll);
|
||||
let next = Math.max(0, current + delta);
|
||||
if (next >= maxScroll) {
|
||||
next = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
setPendingScrollTop(next);
|
||||
setScrollTop(next);
|
||||
},
|
||||
[sizeRef, getScrollTop, setPendingScrollTop],
|
||||
[getScrollTop, setPendingScrollTop],
|
||||
);
|
||||
|
||||
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
||||
@@ -107,10 +140,11 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
const { scrollHeight, innerHeight } = sizeRef.current;
|
||||
const scrollTop = getScrollTop();
|
||||
const maxScroll = Math.max(0, scrollHeight - innerHeight);
|
||||
const actualScrollTop = Math.min(scrollTop, maxScroll);
|
||||
|
||||
// Only capture scroll-up events if there's room;
|
||||
// otherwise allow events to bubble.
|
||||
if (scrollTop > 0) {
|
||||
if (actualScrollTop > 0) {
|
||||
if (keyMatchers[Command.PAGE_UP](key)) {
|
||||
scrollByWithAnimation(-innerHeight);
|
||||
return true;
|
||||
@@ -123,7 +157,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
|
||||
// Only capture scroll-down events if there's room;
|
||||
// otherwise allow events to bubble.
|
||||
if (scrollTop < maxScroll) {
|
||||
if (actualScrollTop < maxScroll) {
|
||||
if (keyMatchers[Command.PAGE_DOWN](key)) {
|
||||
scrollByWithAnimation(innerHeight);
|
||||
return true;
|
||||
@@ -140,21 +174,21 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
{ isActive: hasFocus },
|
||||
);
|
||||
|
||||
const getScrollState = useCallback(
|
||||
() => ({
|
||||
scrollTop: getScrollTop(),
|
||||
const getScrollState = useCallback(() => {
|
||||
const maxScroll = Math.max(0, size.scrollHeight - size.innerHeight);
|
||||
return {
|
||||
scrollTop: Math.min(getScrollTop(), maxScroll),
|
||||
scrollHeight: size.scrollHeight,
|
||||
innerHeight: size.innerHeight,
|
||||
}),
|
||||
[getScrollTop, size.scrollHeight, size.innerHeight],
|
||||
);
|
||||
};
|
||||
}, [getScrollTop, size.scrollHeight, size.innerHeight]);
|
||||
|
||||
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
|
||||
|
||||
const scrollableEntry = useMemo(
|
||||
() => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ref: ref as React.RefObject<DOMElement>,
|
||||
ref: viewportRef as React.RefObject<DOMElement>,
|
||||
getScrollState,
|
||||
scrollBy: scrollByWithAnimation,
|
||||
hasFocus: hasFocusCallback,
|
||||
@@ -167,7 +201,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
ref={viewportRefCallback}
|
||||
maxHeight={maxHeight}
|
||||
width={width ?? maxWidth}
|
||||
height={height}
|
||||
@@ -183,7 +217,12 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
based on the children's content. It also adds a right padding to
|
||||
make room for the scrollbar.
|
||||
*/}
|
||||
<Box flexShrink={0} paddingRight={1} flexDirection="column">
|
||||
<Box
|
||||
ref={contentRefCallback}
|
||||
flexShrink={0}
|
||||
paddingRight={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -479,4 +479,277 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('regression: remove last item and add 2 items when scrolled to bottom', async () => {
|
||||
let listRef: ScrollableListRef<Item> | null = null;
|
||||
let setItemsFunc: React.Dispatch<React.SetStateAction<Item[]>> | null =
|
||||
null;
|
||||
|
||||
const TestComp = () => {
|
||||
const [items, setItems] = useState<Item[]>(
|
||||
Array.from({ length: 10 }, (_, i) => ({
|
||||
id: String(i),
|
||||
title: `Item ${i}`,
|
||||
})),
|
||||
);
|
||||
useEffect(() => {
|
||||
setItemsFunc = setItems;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MouseProvider mouseEventsEnabled={false}>
|
||||
<KeypressProvider>
|
||||
<ScrollProvider>
|
||||
<Box flexDirection="column" width={80} height={5}>
|
||||
<ScrollableList
|
||||
ref={(ref) => {
|
||||
listRef = ref;
|
||||
}}
|
||||
data={items}
|
||||
renderItem={({ item }) => <Text>{item.title}</Text>}
|
||||
estimatedItemHeight={() => 1}
|
||||
keyExtractor={(item) => item.id}
|
||||
hasFocus={true}
|
||||
initialScrollIndex={Number.MAX_SAFE_INTEGER}
|
||||
/>
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</KeypressProvider>
|
||||
</MouseProvider>
|
||||
);
|
||||
};
|
||||
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(<TestComp />);
|
||||
});
|
||||
|
||||
await result!.waitUntilReady();
|
||||
|
||||
// Scrolled to bottom, max scroll = 10 - 5 = 5
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(5);
|
||||
});
|
||||
|
||||
// Remove last element and add 2 elements
|
||||
await act(async () => {
|
||||
setItemsFunc!((prev) => {
|
||||
const next = prev.slice(0, prev.length - 1);
|
||||
next.push({ id: '10', title: 'Item 10' });
|
||||
next.push({ id: '11', title: 'Item 11' });
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
await result!.waitUntilReady();
|
||||
|
||||
// Auto scrolls to new bottom: max scroll = 11 - 5 = 6
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(6);
|
||||
});
|
||||
|
||||
// Scroll up slightly
|
||||
await act(async () => {
|
||||
listRef?.scrollBy(-2);
|
||||
});
|
||||
await result!.waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(4);
|
||||
});
|
||||
|
||||
// Scroll back to bottom
|
||||
await act(async () => {
|
||||
listRef?.scrollToEnd();
|
||||
});
|
||||
await result!.waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(6);
|
||||
});
|
||||
|
||||
// Add two more elements
|
||||
await act(async () => {
|
||||
setItemsFunc!((prev) => [
|
||||
...prev,
|
||||
{ id: '12', title: 'Item 12' },
|
||||
{ id: '13', title: 'Item 13' },
|
||||
]);
|
||||
});
|
||||
|
||||
await result!.waitUntilReady();
|
||||
|
||||
// Auto scrolls to bottom: max scroll = 13 - 5 = 8
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(8);
|
||||
});
|
||||
|
||||
result!.unmount();
|
||||
});
|
||||
|
||||
it('regression: bottom-most element changes size but list does not update', async () => {
|
||||
let listRef: ScrollableListRef<Item> | null = null;
|
||||
let expandLastFunc: (() => void) | null = null;
|
||||
|
||||
const ItemWithState = ({
|
||||
item,
|
||||
isLast,
|
||||
}: {
|
||||
item: Item;
|
||||
isLast: boolean;
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (isLast) {
|
||||
expandLastFunc = () => setExpanded(true);
|
||||
}
|
||||
}, [isLast]);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>{item.title}</Text>
|
||||
{expanded && <Text>Expanded content</Text>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const TestComp = () => {
|
||||
// items array is stable
|
||||
const [items] = useState(() =>
|
||||
Array.from({ length: 5 }, (_, i) => ({
|
||||
id: String(i),
|
||||
title: `Item ${i}`,
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<MouseProvider mouseEventsEnabled={false}>
|
||||
<KeypressProvider>
|
||||
<ScrollProvider>
|
||||
<Box flexDirection="column" width={80} height={4}>
|
||||
<ScrollableList
|
||||
ref={(ref) => {
|
||||
listRef = ref;
|
||||
}}
|
||||
data={items}
|
||||
renderItem={({ item, index }) => (
|
||||
<ItemWithState item={item} isLast={index === 4} />
|
||||
)}
|
||||
estimatedItemHeight={() => 1}
|
||||
keyExtractor={(item) => item.id}
|
||||
hasFocus={true}
|
||||
initialScrollIndex={Number.MAX_SAFE_INTEGER}
|
||||
/>
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</KeypressProvider>
|
||||
</MouseProvider>
|
||||
);
|
||||
};
|
||||
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(<TestComp />);
|
||||
});
|
||||
|
||||
await result!.waitUntilReady();
|
||||
|
||||
// Initially, total height is 5. viewport is 4. scroll is 1.
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(1);
|
||||
});
|
||||
|
||||
// Expand the last item locally, without re-rendering the list!
|
||||
await act(async () => {
|
||||
expandLastFunc!();
|
||||
});
|
||||
|
||||
await result!.waitUntilReady();
|
||||
|
||||
// The total height becomes 6. It should remain scrolled to bottom, so scroll becomes 2.
|
||||
// This is expected to FAIL currently because VirtualizedList won't remeasure
|
||||
// unless data changes or container height changes.
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(2);
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
|
||||
result!.unmount();
|
||||
});
|
||||
|
||||
it('regression: prepending items does not corrupt heights (total height correct)', async () => {
|
||||
let listRef: ScrollableListRef<Item> | null = null;
|
||||
let setItemsFunc: React.Dispatch<React.SetStateAction<Item[]>> | null =
|
||||
null;
|
||||
|
||||
const TestComp = () => {
|
||||
// Items 1 to 5. Item 1 is very tall.
|
||||
const [items, setItems] = useState<Item[]>(
|
||||
Array.from({ length: 5 }, (_, i) => ({
|
||||
id: String(i + 1),
|
||||
title: `Item ${i + 1}`,
|
||||
})),
|
||||
);
|
||||
useEffect(() => {
|
||||
setItemsFunc = setItems;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MouseProvider mouseEventsEnabled={false}>
|
||||
<KeypressProvider>
|
||||
<ScrollProvider>
|
||||
<Box flexDirection="column" width={80} height={10}>
|
||||
<ScrollableList
|
||||
ref={(ref) => {
|
||||
listRef = ref;
|
||||
}}
|
||||
data={items}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={item.id === '1' ? 10 : 2}>
|
||||
<Text>{item.title}</Text>
|
||||
</Box>
|
||||
)}
|
||||
estimatedItemHeight={() => 2}
|
||||
keyExtractor={(item) => item.id}
|
||||
hasFocus={true}
|
||||
initialScrollIndex={Number.MAX_SAFE_INTEGER}
|
||||
/>
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</KeypressProvider>
|
||||
</MouseProvider>
|
||||
);
|
||||
};
|
||||
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(<TestComp />);
|
||||
});
|
||||
|
||||
await result!.waitUntilReady();
|
||||
|
||||
// Scroll is at bottom.
|
||||
// Heights: Item 1: 10, Item 2: 2, Item 3: 2, Item 4: 2, Item 5: 2.
|
||||
// Total height = 18. Container = 10. Max scroll = 8.
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(8);
|
||||
});
|
||||
|
||||
// Prepend an item!
|
||||
await act(async () => {
|
||||
setItemsFunc!((prev) => [{ id: '0', title: 'Item 0' }, ...prev]);
|
||||
});
|
||||
|
||||
await result!.waitUntilReady();
|
||||
|
||||
// Now items: 0(2), 1(10), 2(2), 3(2), 4(2), 5(2).
|
||||
// Total height = 20. Container = 10. Max scroll = 10.
|
||||
// Auto-scrolls to bottom because it was sticking!
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(10);
|
||||
});
|
||||
|
||||
result!.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
@@ -105,7 +105,7 @@ function ScrollableList<T>(
|
||||
smoothScrollState.current.active = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
|
||||
useLayoutEffect(() => stopSmoothScroll, [stopSmoothScroll]);
|
||||
|
||||
const smoothScrollTo = useCallback(
|
||||
(
|
||||
@@ -120,15 +120,19 @@ function ScrollableList<T>(
|
||||
innerHeight: 0,
|
||||
};
|
||||
const {
|
||||
scrollTop: startScrollTop,
|
||||
scrollTop: rawStartScrollTop,
|
||||
scrollHeight,
|
||||
innerHeight,
|
||||
} = scrollState;
|
||||
|
||||
const maxScrollTop = Math.max(0, scrollHeight - innerHeight);
|
||||
const startScrollTop = Math.min(rawStartScrollTop, maxScrollTop);
|
||||
|
||||
let effectiveTarget = targetScrollTop;
|
||||
if (targetScrollTop === SCROLL_TO_ITEM_END) {
|
||||
if (
|
||||
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||
targetScrollTop >= maxScrollTop
|
||||
) {
|
||||
effectiveTarget = maxScrollTop;
|
||||
}
|
||||
|
||||
@@ -138,8 +142,11 @@ function ScrollableList<T>(
|
||||
);
|
||||
|
||||
if (duration === 0) {
|
||||
if (targetScrollTop === SCROLL_TO_ITEM_END) {
|
||||
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
|
||||
if (
|
||||
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||
targetScrollTop >= maxScrollTop
|
||||
) {
|
||||
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
|
||||
} else {
|
||||
virtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
|
||||
}
|
||||
@@ -168,8 +175,11 @@ function ScrollableList<T>(
|
||||
ease;
|
||||
|
||||
if (progress >= 1) {
|
||||
if (targetScrollTop === SCROLL_TO_ITEM_END) {
|
||||
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
|
||||
if (
|
||||
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||
targetScrollTop >= maxScrollTop
|
||||
) {
|
||||
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
|
||||
} else {
|
||||
virtualizedListRef.current?.scrollTo(Math.round(current));
|
||||
}
|
||||
@@ -200,9 +210,13 @@ function ScrollableList<T>(
|
||||
) {
|
||||
const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1;
|
||||
const scrollState = getScrollState();
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
scrollState.scrollHeight - scrollState.innerHeight,
|
||||
);
|
||||
const current = smoothScrollState.current.active
|
||||
? smoothScrollState.current.to
|
||||
: scrollState.scrollTop;
|
||||
: Math.min(scrollState.scrollTop, maxScroll);
|
||||
const innerHeight = scrollState.innerHeight;
|
||||
smoothScrollTo(current + direction * innerHeight);
|
||||
return true;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
|
||||
import { Text, Box } from 'ink';
|
||||
import {
|
||||
@@ -275,9 +276,11 @@ describe('<VirtualizedList />', () => {
|
||||
await waitUntilReady();
|
||||
|
||||
// Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px
|
||||
expect(lastFrame()).toContain('Item 0');
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
expect(lastFrame()).toContain('Item 9');
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Item 0');
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
expect(lastFrame()).toContain('Item 9');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
useLayoutEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
@@ -19,7 +18,7 @@ import { theme } from '../../semantic-colors.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
import { type DOMElement, measureElement, Box } from 'ink';
|
||||
import { type DOMElement, Box, ResizeObserver } from 'ink';
|
||||
|
||||
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
@@ -81,7 +80,7 @@ function VirtualizedList<T>(
|
||||
} = props;
|
||||
const { copyModeEnabled } = useUIState();
|
||||
const dataRef = useRef(data);
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
@@ -108,6 +107,7 @@ function VirtualizedList<T>(
|
||||
|
||||
return { index: 0, offset: 0 };
|
||||
});
|
||||
|
||||
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
@@ -116,73 +116,75 @@ function VirtualizedList<T>(
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
return scrollToEnd;
|
||||
});
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const containerRef = useRef<DOMElement | null>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const itemRefs = useRef<Array<DOMElement | null>>([]);
|
||||
const [heights, setHeights] = useState<number[]>([]);
|
||||
const [heights, setHeights] = useState<Record<string, number>>({});
|
||||
const isInitialScrollSet = useRef(false);
|
||||
|
||||
const containerObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
|
||||
|
||||
const containerRefCallback = useCallback((node: DOMElement | null) => {
|
||||
containerObserverRef.current?.disconnect();
|
||||
containerRef.current = node;
|
||||
if (node) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setContainerHeight(Math.round(entry.contentRect.height));
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
containerObserverRef.current = observer;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const itemsObserver = useMemo(
|
||||
() =>
|
||||
new ResizeObserver((entries) => {
|
||||
setHeights((prev) => {
|
||||
let next: Record<string, number> | null = null;
|
||||
for (const entry of entries) {
|
||||
const key = nodeToKeyRef.current.get(entry.target);
|
||||
if (key !== undefined) {
|
||||
const height = Math.round(entry.contentRect.height);
|
||||
if (prev[key] !== height) {
|
||||
if (!next) {
|
||||
next = { ...prev };
|
||||
}
|
||||
next[key] = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next ?? prev;
|
||||
});
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useLayoutEffect(
|
||||
() => () => {
|
||||
containerObserverRef.current?.disconnect();
|
||||
itemsObserver.disconnect();
|
||||
},
|
||||
[itemsObserver],
|
||||
);
|
||||
|
||||
const { totalHeight, offsets } = useMemo(() => {
|
||||
const offsets: number[] = [0];
|
||||
let totalHeight = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const height = heights[i] ?? estimatedItemHeight(i);
|
||||
const key = keyExtractor(data[i], i);
|
||||
const height = heights[key] ?? estimatedItemHeight(i);
|
||||
totalHeight += height;
|
||||
offsets.push(totalHeight);
|
||||
}
|
||||
return { totalHeight, offsets };
|
||||
}, [heights, data, estimatedItemHeight]);
|
||||
}, [heights, data, estimatedItemHeight, keyExtractor]);
|
||||
|
||||
useEffect(() => {
|
||||
setHeights((prevHeights) => {
|
||||
if (data.length === prevHeights.length) {
|
||||
return prevHeights;
|
||||
}
|
||||
|
||||
const newHeights = [...prevHeights];
|
||||
if (data.length < prevHeights.length) {
|
||||
newHeights.length = data.length;
|
||||
} else {
|
||||
for (let i = prevHeights.length; i < data.length; i++) {
|
||||
newHeights[i] = estimatedItemHeight(i);
|
||||
}
|
||||
}
|
||||
return newHeights;
|
||||
});
|
||||
}, [data, estimatedItemHeight]);
|
||||
|
||||
// This layout effect needs to run on every render to correctly measure the
|
||||
// container and ensure we recompute the layout if it has changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useLayoutEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const height = Math.round(measureElement(containerRef.current).height);
|
||||
if (containerHeight !== height) {
|
||||
setContainerHeight(height);
|
||||
}
|
||||
}
|
||||
|
||||
let newHeights: number[] | null = null;
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const itemRef = itemRefs.current[i];
|
||||
if (itemRef) {
|
||||
const height = Math.round(measureElement(itemRef).height);
|
||||
if (height !== heights[i]) {
|
||||
if (!newHeights) {
|
||||
newHeights = [...heights];
|
||||
}
|
||||
newHeights[i] = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newHeights) {
|
||||
setHeights(newHeights);
|
||||
}
|
||||
});
|
||||
|
||||
const scrollableContainerHeight = containerRef.current
|
||||
? Math.round(measureElement(containerRef.current).height)
|
||||
: containerHeight;
|
||||
const scrollableContainerHeight = containerHeight;
|
||||
|
||||
const getAnchorForScrollTop = useCallback(
|
||||
(
|
||||
@@ -199,23 +201,36 @@ function VirtualizedList<T>(
|
||||
[],
|
||||
);
|
||||
|
||||
const scrollTop = useMemo(() => {
|
||||
const actualScrollTop = useMemo(() => {
|
||||
const offset = offsets[scrollAnchor.index];
|
||||
if (typeof offset !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
|
||||
const itemHeight = heights[scrollAnchor.index] ?? 0;
|
||||
const item = data[scrollAnchor.index];
|
||||
const key = item ? keyExtractor(item, scrollAnchor.index) : '';
|
||||
const itemHeight = heights[key] ?? 0;
|
||||
return offset + itemHeight - scrollableContainerHeight;
|
||||
}
|
||||
|
||||
return offset + scrollAnchor.offset;
|
||||
}, [scrollAnchor, offsets, heights, scrollableContainerHeight]);
|
||||
}, [
|
||||
scrollAnchor,
|
||||
offsets,
|
||||
heights,
|
||||
scrollableContainerHeight,
|
||||
data,
|
||||
keyExtractor,
|
||||
]);
|
||||
|
||||
const scrollTop = isStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: actualScrollTop;
|
||||
|
||||
const prevDataLength = useRef(data.length);
|
||||
const prevTotalHeight = useRef(totalHeight);
|
||||
const prevScrollTop = useRef(scrollTop);
|
||||
const prevScrollTop = useRef(actualScrollTop);
|
||||
const prevContainerHeight = useRef(scrollableContainerHeight);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -226,9 +241,7 @@ function VirtualizedList<T>(
|
||||
prevTotalHeight.current - prevContainerHeight.current - 1;
|
||||
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
|
||||
|
||||
// If the user was at the bottom, they are now sticking. This handles
|
||||
// manually scrolling back to the bottom.
|
||||
if (wasAtBottom && scrollTop >= prevScrollTop.current) {
|
||||
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
|
||||
@@ -236,9 +249,6 @@ function VirtualizedList<T>(
|
||||
const containerChanged =
|
||||
prevContainerHeight.current !== scrollableContainerHeight;
|
||||
|
||||
// We scroll to the end if:
|
||||
// 1. The list grew AND we were already at the bottom (or sticking).
|
||||
// 2. We are sticking to the bottom AND the container size changed.
|
||||
if (
|
||||
(listGrew && (isStickingToBottom || wasAtBottom)) ||
|
||||
(isStickingToBottom && containerChanged)
|
||||
@@ -247,34 +257,28 @@ function VirtualizedList<T>(
|
||||
index: data.length > 0 ? data.length - 1 : 0,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
// If we are scrolling to the bottom, we are by definition sticking.
|
||||
if (!isStickingToBottom) {
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
}
|
||||
// Scenario 2: The list has changed (shrunk) in a way that our
|
||||
// current scroll position or anchor is invalid. We should adjust to the bottom.
|
||||
else if (
|
||||
} else if (
|
||||
(scrollAnchor.index >= data.length ||
|
||||
scrollTop > totalHeight - scrollableContainerHeight) &&
|
||||
actualScrollTop > totalHeight - scrollableContainerHeight) &&
|
||||
data.length > 0
|
||||
) {
|
||||
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
} else if (data.length === 0) {
|
||||
// List is now empty, reset scroll to top.
|
||||
setScrollAnchor({ index: 0, offset: 0 });
|
||||
}
|
||||
|
||||
// Update refs for the next render cycle.
|
||||
prevDataLength.current = data.length;
|
||||
prevTotalHeight.current = totalHeight;
|
||||
prevScrollTop.current = scrollTop;
|
||||
prevScrollTop.current = actualScrollTop;
|
||||
prevContainerHeight.current = scrollableContainerHeight;
|
||||
}, [
|
||||
data.length,
|
||||
totalHeight,
|
||||
scrollTop,
|
||||
actualScrollTop,
|
||||
scrollableContainerHeight,
|
||||
scrollAnchor.index,
|
||||
getAnchorForScrollTop,
|
||||
@@ -334,10 +338,10 @@ function VirtualizedList<T>(
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
findLastIndex(offsets, (offset) => offset <= scrollTop) - 1,
|
||||
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
|
||||
);
|
||||
const endIndexOffset = offsets.findIndex(
|
||||
(offset) => offset > scrollTop + scrollableContainerHeight,
|
||||
(offset) => offset > actualScrollTop + scrollableContainerHeight,
|
||||
);
|
||||
const endIndex =
|
||||
endIndexOffset === -1
|
||||
@@ -348,6 +352,32 @@ function VirtualizedList<T>(
|
||||
const bottomSpacerHeight =
|
||||
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
|
||||
|
||||
// Maintain a stable set of observed nodes using useLayoutEffect
|
||||
const observedNodes = useRef<Set<DOMElement>>(new Set());
|
||||
useLayoutEffect(() => {
|
||||
const currentNodes = new Set<DOMElement>();
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const node = itemRefs.current[i];
|
||||
const item = data[i];
|
||||
if (node && item) {
|
||||
currentNodes.add(node);
|
||||
const key = keyExtractor(item, i);
|
||||
// Always update the key mapping because React can reuse nodes at different indices/keys
|
||||
nodeToKeyRef.current.set(node, key);
|
||||
if (!observedNodes.current.has(node)) {
|
||||
itemsObserver.observe(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const node of observedNodes.current) {
|
||||
if (!currentNodes.has(node)) {
|
||||
itemsObserver.unobserve(node);
|
||||
nodeToKeyRef.current.delete(node);
|
||||
}
|
||||
}
|
||||
observedNodes.current = currentNodes;
|
||||
});
|
||||
|
||||
const renderedItems = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const item = data[i];
|
||||
@@ -356,6 +386,8 @@ function VirtualizedList<T>(
|
||||
<Box
|
||||
key={keyExtractor(item, i)}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
ref={(el) => {
|
||||
itemRefs.current[i] = el;
|
||||
}}
|
||||
@@ -376,27 +408,39 @@ function VirtualizedList<T>(
|
||||
setIsStickingToBottom(false);
|
||||
}
|
||||
const currentScrollTop = getScrollTop();
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
totalHeight - scrollableContainerHeight,
|
||||
currentScrollTop + delta,
|
||||
),
|
||||
);
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const actualCurrent = Math.min(currentScrollTop, maxScroll);
|
||||
let newScrollTop = Math.max(0, actualCurrent + delta);
|
||||
if (newScrollTop >= maxScroll) {
|
||||
setIsStickingToBottom(true);
|
||||
newScrollTop = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
setScrollAnchor(
|
||||
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets),
|
||||
);
|
||||
},
|
||||
scrollTo: (offset: number) => {
|
||||
setIsStickingToBottom(false);
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(totalHeight - scrollableContainerHeight, offset),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) {
|
||||
setIsStickingToBottom(true);
|
||||
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
if (data.length > 0) {
|
||||
setScrollAnchor({
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setIsStickingToBottom(false);
|
||||
const newScrollTop = Math.max(0, offset);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
}
|
||||
},
|
||||
scrollToEnd: () => {
|
||||
setIsStickingToBottom(true);
|
||||
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
if (data.length > 0) {
|
||||
setScrollAnchor({
|
||||
index: data.length - 1,
|
||||
@@ -416,10 +460,14 @@ function VirtualizedList<T>(
|
||||
setIsStickingToBottom(false);
|
||||
const offset = offsets[index];
|
||||
if (offset !== undefined) {
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
totalHeight - scrollableContainerHeight,
|
||||
);
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
totalHeight - scrollableContainerHeight,
|
||||
maxScroll,
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
@@ -441,10 +489,14 @@ function VirtualizedList<T>(
|
||||
if (index !== -1) {
|
||||
const offset = offsets[index];
|
||||
if (offset !== undefined) {
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
totalHeight - scrollableContainerHeight,
|
||||
);
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
totalHeight - scrollableContainerHeight,
|
||||
maxScroll,
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
@@ -454,11 +506,14 @@ function VirtualizedList<T>(
|
||||
}
|
||||
},
|
||||
getScrollIndex: () => scrollAnchor.index,
|
||||
getScrollState: () => ({
|
||||
scrollTop: getScrollTop(),
|
||||
scrollHeight: totalHeight,
|
||||
innerHeight: containerHeight,
|
||||
}),
|
||||
getScrollState: () => {
|
||||
const maxScroll = Math.max(0, totalHeight - containerHeight);
|
||||
return {
|
||||
scrollTop: Math.min(getScrollTop(), maxScroll),
|
||||
scrollHeight: totalHeight,
|
||||
innerHeight: containerHeight,
|
||||
};
|
||||
},
|
||||
}),
|
||||
[
|
||||
offsets,
|
||||
@@ -475,7 +530,7 @@ function VirtualizedList<T>(
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
ref={containerRefCallback}
|
||||
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
|
||||
overflowX="hidden"
|
||||
scrollTop={copyModeEnabled ? 0 : scrollTop}
|
||||
@@ -489,7 +544,7 @@ function VirtualizedList<T>(
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
marginTop={copyModeEnabled ? -scrollTop : 0}
|
||||
marginTop={copyModeEnabled ? -actualScrollTop : 0}
|
||||
>
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
{renderedItems}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<MaxSizedBox /> > accounts for additionalHiddenLinesCount 1`] = `
|
||||
"... first 7 lines hidden ...
|
||||
"... first 7 lines hidden (Ctrl+O to show) ...
|
||||
Line 3
|
||||
"
|
||||
`;
|
||||
@@ -16,12 +16,12 @@ Line 6
|
||||
Line 7
|
||||
Line 8
|
||||
Line 9
|
||||
... last 21 lines hidden ...
|
||||
... last 21 lines hidden (Ctrl+O to show) ...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > clips a long single text child from the top 1`] = `
|
||||
"... first 21 lines hidden ...
|
||||
"... first 21 lines hidden (Ctrl+O to show) ...
|
||||
Line 22
|
||||
Line 23
|
||||
Line 24
|
||||
@@ -39,7 +39,7 @@ exports[`<MaxSizedBox /> > does not leak content after hidden indicator with bot
|
||||
|
||||
- Step 1: Do something important
|
||||
- Step 2: Do something important
|
||||
... last 18 lines hidden ...
|
||||
... last 18 lines hidden (Ctrl+O to show) ...
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -58,12 +58,12 @@ Line 3 direct child
|
||||
|
||||
exports[`<MaxSizedBox /> > hides lines at the end when content exceeds maxHeight and overflowDirection is bottom 1`] = `
|
||||
"Line 1
|
||||
... last 2 lines hidden ...
|
||||
... last 2 lines hidden (Ctrl+O to show) ...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > hides lines when content exceeds maxHeight 1`] = `
|
||||
"... first 2 lines hidden ...
|
||||
"... first 2 lines hidden (Ctrl+O to show) ...
|
||||
Line 3
|
||||
"
|
||||
`;
|
||||
@@ -74,13 +74,13 @@ exports[`<MaxSizedBox /> > renders children without truncation when they fit 1`]
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > shows plural "lines" when more than one line is hidden 1`] = `
|
||||
"... first 2 lines hidden ...
|
||||
"... first 2 lines hidden (Ctrl+O to show) ...
|
||||
Line 3
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > shows singular "line" when exactly one line is hidden 1`] = `
|
||||
"... first 1 line hidden ...
|
||||
"... first 1 line hidden (Ctrl+O to show) ...
|
||||
Line 1
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import {
|
||||
useAlternateBuffer,
|
||||
isAlternateBufferEnabled,
|
||||
} from './useAlternateBuffer.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../contexts/ConfigContext.js', () => ({
|
||||
useConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseConfig = vi.mocked(
|
||||
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
|
||||
);
|
||||
|
||||
describe('useAlternateBuffer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return false when config.getUseAlternateBuffer returns false', () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => false,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>);
|
||||
|
||||
const { result } = renderHook(() => useAlternateBuffer());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when config.getUseAlternateBuffer returns true', () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>);
|
||||
|
||||
const { result } = renderHook(() => useAlternateBuffer());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return the immutable config value, not react to settings changes', () => {
|
||||
const mockConfig = {
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as ReturnType<typeof mockUseConfig>;
|
||||
|
||||
mockUseConfig.mockReturnValue(mockConfig);
|
||||
|
||||
const { result, rerender } = renderHook(() => useAlternateBuffer());
|
||||
|
||||
// Value should remain true even after rerender
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAlternateBufferEnabled', () => {
|
||||
it('should return true when config.getUseAlternateBuffer returns true', () => {
|
||||
const config = {
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as Config;
|
||||
|
||||
expect(isAlternateBufferEnabled(config)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when config.getUseAlternateBuffer returns false', () => {
|
||||
const config = {
|
||||
getUseAlternateBuffer: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
expect(isAlternateBufferEnabled(config)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
export const isAlternateBufferEnabled = (settings: LoadedSettings): boolean =>
|
||||
settings.merged.ui.useAlternateBuffer === true;
|
||||
export const isAlternateBufferEnabled = (config: Config): boolean =>
|
||||
config.getUseAlternateBuffer();
|
||||
|
||||
// This is read from Config so that the UI reads the same value per application session
|
||||
export const useAlternateBuffer = (): boolean => {
|
||||
const settings = useSettings();
|
||||
return isAlternateBufferEnabled(settings);
|
||||
const config = useConfig();
|
||||
return isAlternateBufferEnabled(config);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type CodeAssistServer,
|
||||
UserTierId,
|
||||
getCodeAssistServer,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export interface PrivacyState {
|
||||
@@ -103,7 +104,12 @@ async function getRemoteDataCollectionOptIn(
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await server.getCodeAssistGlobalUserSetting();
|
||||
return resp.freeTierDataCollectionOptin;
|
||||
if (resp.freeTierDataCollectionOptin === undefined) {
|
||||
debugLogger.warn(
|
||||
'Warning: Code Assist API did not return freeTierDataCollectionOptin. Defaulting to true.',
|
||||
);
|
||||
}
|
||||
return resp.freeTierDataCollectionOptin ?? true;
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
@@ -128,5 +134,10 @@ async function setRemoteDataCollectionOptIn(
|
||||
cloudaicompanionProject: server.projectId,
|
||||
freeTierDataCollectionOptin: optIn,
|
||||
});
|
||||
return resp.freeTierDataCollectionOptin;
|
||||
if (resp.freeTierDataCollectionOptin === undefined) {
|
||||
debugLogger.warn(
|
||||
`Warning: Code Assist API did not return freeTierDataCollectionOptin. Defaulting to ${optIn}.`,
|
||||
);
|
||||
}
|
||||
return resp.freeTierDataCollectionOptin ?? optIn;
|
||||
}
|
||||
|
||||
@@ -15,14 +15,16 @@ export function useTimedMessage<T>(durationMs: number) {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const showMessage = useCallback(
|
||||
(msg: T) => {
|
||||
(msg: T | null) => {
|
||||
setMessage(msg);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setMessage(null);
|
||||
}, durationMs);
|
||||
if (msg !== null) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setMessage(null);
|
||||
}, durationMs);
|
||||
}
|
||||
},
|
||||
[durationMs],
|
||||
);
|
||||
|
||||
@@ -6,223 +6,14 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
resolveColor,
|
||||
INK_SUPPORTED_NAMES,
|
||||
INK_NAME_TO_HEX_MAP,
|
||||
} from '../themes/color-utils.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
|
||||
import { stripUnsafeCharacters } from './textUtils.js';
|
||||
|
||||
// Constants for Markdown parsing
|
||||
const BOLD_MARKER_LENGTH = 2; // For "**"
|
||||
const ITALIC_MARKER_LENGTH = 1; // For "*" or "_"
|
||||
const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~")
|
||||
const INLINE_CODE_MARKER_LENGTH = 1; // For "`"
|
||||
const UNDERLINE_TAG_START_LENGTH = 3; // For "<u>"
|
||||
const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
|
||||
|
||||
interface RenderInlineProps {
|
||||
text: string;
|
||||
defaultColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to apply color to a string using ANSI escape codes,
|
||||
* consistent with how Ink's colorize works.
|
||||
*/
|
||||
const ansiColorize = (str: string, color: string | undefined): string => {
|
||||
if (!color) return str;
|
||||
const resolved = resolveColor(color);
|
||||
if (!resolved) return str;
|
||||
|
||||
if (resolved.startsWith('#')) {
|
||||
return chalk.hex(resolved)(str);
|
||||
}
|
||||
|
||||
const mappedHex = INK_NAME_TO_HEX_MAP[resolved];
|
||||
if (mappedHex) {
|
||||
return chalk.hex(mappedHex)(str);
|
||||
}
|
||||
|
||||
if (INK_SUPPORTED_NAMES.has(resolved)) {
|
||||
switch (resolved) {
|
||||
case 'black':
|
||||
return chalk.black(str);
|
||||
case 'red':
|
||||
return chalk.red(str);
|
||||
case 'green':
|
||||
return chalk.green(str);
|
||||
case 'yellow':
|
||||
return chalk.yellow(str);
|
||||
case 'blue':
|
||||
return chalk.blue(str);
|
||||
case 'magenta':
|
||||
return chalk.magenta(str);
|
||||
case 'cyan':
|
||||
return chalk.cyan(str);
|
||||
case 'white':
|
||||
return chalk.white(str);
|
||||
case 'gray':
|
||||
case 'grey':
|
||||
return chalk.gray(str);
|
||||
default:
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts markdown text into a string with ANSI escape codes.
|
||||
* This mirrors the parsing logic in InlineMarkdownRenderer.tsx
|
||||
*/
|
||||
export const parseMarkdownToANSI = (
|
||||
text: string,
|
||||
defaultColor?: string,
|
||||
): string => {
|
||||
const baseColor = defaultColor ?? theme.text.primary;
|
||||
// Early return for plain text without markdown or URLs
|
||||
if (!/[*_~`<[https?:]/.test(text)) {
|
||||
return ansiColorize(text, baseColor);
|
||||
}
|
||||
|
||||
let result = '';
|
||||
const inlineRegex =
|
||||
/(\*\*\*.*?\*\*\*|\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = inlineRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
result += ansiColorize(text.slice(lastIndex, match.index), baseColor);
|
||||
}
|
||||
|
||||
const fullMatch = match[0];
|
||||
let styledPart = '';
|
||||
|
||||
try {
|
||||
if (
|
||||
fullMatch.endsWith('***') &&
|
||||
fullMatch.startsWith('***') &&
|
||||
fullMatch.length > (BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH) * 2
|
||||
) {
|
||||
styledPart = chalk.bold(
|
||||
chalk.italic(
|
||||
parseMarkdownToANSI(
|
||||
fullMatch.slice(
|
||||
BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH,
|
||||
-BOLD_MARKER_LENGTH - ITALIC_MARKER_LENGTH,
|
||||
),
|
||||
baseColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
fullMatch.endsWith('**') &&
|
||||
fullMatch.startsWith('**') &&
|
||||
fullMatch.length > BOLD_MARKER_LENGTH * 2
|
||||
) {
|
||||
styledPart = chalk.bold(
|
||||
parseMarkdownToANSI(
|
||||
fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH),
|
||||
baseColor,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
fullMatch.length > ITALIC_MARKER_LENGTH * 2 &&
|
||||
((fullMatch.startsWith('*') && fullMatch.endsWith('*')) ||
|
||||
(fullMatch.startsWith('_') && fullMatch.endsWith('_'))) &&
|
||||
!/\w/.test(text.substring(match.index - 1, match.index)) &&
|
||||
!/\w/.test(
|
||||
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1),
|
||||
) &&
|
||||
!/\S[./\\]/.test(text.substring(match.index - 2, match.index)) &&
|
||||
!/[./\\]\S/.test(
|
||||
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2),
|
||||
)
|
||||
) {
|
||||
styledPart = chalk.italic(
|
||||
parseMarkdownToANSI(
|
||||
fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH),
|
||||
baseColor,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
fullMatch.startsWith('~~') &&
|
||||
fullMatch.endsWith('~~') &&
|
||||
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2
|
||||
) {
|
||||
styledPart = chalk.strikethrough(
|
||||
parseMarkdownToANSI(
|
||||
fullMatch.slice(
|
||||
STRIKETHROUGH_MARKER_LENGTH,
|
||||
-STRIKETHROUGH_MARKER_LENGTH,
|
||||
),
|
||||
baseColor,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
fullMatch.startsWith('`') &&
|
||||
fullMatch.endsWith('`') &&
|
||||
fullMatch.length > INLINE_CODE_MARKER_LENGTH
|
||||
) {
|
||||
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
|
||||
if (codeMatch && codeMatch[2]) {
|
||||
styledPart = ansiColorize(codeMatch[2], theme.text.accent);
|
||||
}
|
||||
} else if (
|
||||
fullMatch.startsWith('[') &&
|
||||
fullMatch.includes('](') &&
|
||||
fullMatch.endsWith(')')
|
||||
) {
|
||||
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
|
||||
if (linkMatch) {
|
||||
const linkText = linkMatch[1];
|
||||
const url = linkMatch[2];
|
||||
styledPart =
|
||||
parseMarkdownToANSI(linkText, baseColor) +
|
||||
ansiColorize(' (', baseColor) +
|
||||
ansiColorize(url, theme.text.link) +
|
||||
ansiColorize(')', baseColor);
|
||||
}
|
||||
} else if (
|
||||
fullMatch.startsWith('<u>') &&
|
||||
fullMatch.endsWith('</u>') &&
|
||||
fullMatch.length >
|
||||
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1
|
||||
) {
|
||||
styledPart = chalk.underline(
|
||||
parseMarkdownToANSI(
|
||||
fullMatch.slice(
|
||||
UNDERLINE_TAG_START_LENGTH,
|
||||
-UNDERLINE_TAG_END_LENGTH,
|
||||
),
|
||||
baseColor,
|
||||
),
|
||||
);
|
||||
} else if (fullMatch.match(/^https?:\/\//)) {
|
||||
styledPart = ansiColorize(fullMatch, theme.text.link);
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.warn('Error parsing inline markdown part:', fullMatch, e);
|
||||
styledPart = '';
|
||||
}
|
||||
|
||||
result += styledPart || ansiColorize(fullMatch, baseColor);
|
||||
lastIndex = inlineRegex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
result += ansiColorize(text.slice(lastIndex), baseColor);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const RenderInlineInternal: React.FC<RenderInlineProps> = ({
|
||||
text: rawText,
|
||||
defaultColor,
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
widestLineFromStyledChars,
|
||||
} from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { parseMarkdownToANSI } from './InlineMarkdownRenderer.js';
|
||||
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
|
||||
import { stripUnsafeCharacters } from './textUtils.js';
|
||||
|
||||
interface TableRendererProps {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import chalk from 'chalk';
|
||||
import { parseMarkdownToANSI } from './InlineMarkdownRenderer.js';
|
||||
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
|
||||
|
||||
// Mock the theme to use explicit colors instead of empty strings from the default theme.
|
||||
// This ensures that ansiColorize actually applies ANSI codes that we can verify.
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
resolveColor,
|
||||
INK_SUPPORTED_NAMES,
|
||||
INK_NAME_TO_HEX_MAP,
|
||||
} from '../themes/color-utils.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
// Constants for Markdown parsing
|
||||
const BOLD_MARKER_LENGTH = 2; // For "**"
|
||||
const ITALIC_MARKER_LENGTH = 1; // For "*" or "_"
|
||||
const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~")
|
||||
const INLINE_CODE_MARKER_LENGTH = 1; // For "`"
|
||||
const UNDERLINE_TAG_START_LENGTH = 3; // For "<u>"
|
||||
const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
|
||||
|
||||
/**
|
||||
* Helper to apply color to a string using ANSI escape codes,
|
||||
* consistent with how Ink's colorize works.
|
||||
*/
|
||||
const ansiColorize = (str: string, color: string | undefined): string => {
|
||||
if (!color) return str;
|
||||
const resolved = resolveColor(color);
|
||||
if (!resolved) return str;
|
||||
|
||||
if (resolved.startsWith('#')) {
|
||||
return chalk.hex(resolved)(str);
|
||||
}
|
||||
|
||||
const mappedHex = INK_NAME_TO_HEX_MAP[resolved];
|
||||
if (mappedHex) {
|
||||
return chalk.hex(mappedHex)(str);
|
||||
}
|
||||
|
||||
if (INK_SUPPORTED_NAMES.has(resolved)) {
|
||||
switch (resolved) {
|
||||
case 'black':
|
||||
return chalk.black(str);
|
||||
case 'red':
|
||||
return chalk.red(str);
|
||||
case 'green':
|
||||
return chalk.green(str);
|
||||
case 'yellow':
|
||||
return chalk.yellow(str);
|
||||
case 'blue':
|
||||
return chalk.blue(str);
|
||||
case 'magenta':
|
||||
return chalk.magenta(str);
|
||||
case 'cyan':
|
||||
return chalk.cyan(str);
|
||||
case 'white':
|
||||
return chalk.white(str);
|
||||
case 'gray':
|
||||
case 'grey':
|
||||
return chalk.gray(str);
|
||||
default:
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts markdown text into a string with ANSI escape codes.
|
||||
* This mirrors the parsing logic in InlineMarkdownRenderer.tsx
|
||||
*/
|
||||
export const parseMarkdownToANSI = (
|
||||
text: string,
|
||||
defaultColor?: string,
|
||||
): string => {
|
||||
const baseColor = defaultColor ?? theme.text.primary;
|
||||
// Early return for plain text without markdown or URLs
|
||||
if (!/[*_~`<[https?:]/.test(text)) {
|
||||
return ansiColorize(text, baseColor);
|
||||
}
|
||||
|
||||
let result = '';
|
||||
const inlineRegex =
|
||||
/(\*\*\*.*?\*\*\*|\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = inlineRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
result += ansiColorize(text.slice(lastIndex, match.index), baseColor);
|
||||
}
|
||||
|
||||
const fullMatch = match[0];
|
||||
let styledPart = '';
|
||||
|
||||
try {
|
||||
if (
|
||||
fullMatch.endsWith('***') &&
|
||||
fullMatch.startsWith('***') &&
|
||||
fullMatch.length > (BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH) * 2
|
||||
) {
|
||||
styledPart = chalk.bold(
|
||||
chalk.italic(
|
||||
parseMarkdownToANSI(
|
||||
fullMatch.slice(
|
||||
BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH,
|
||||
-BOLD_MARKER_LENGTH - ITALIC_MARKER_LENGTH,
|
||||
),
|
||||
baseColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
fullMatch.endsWith('**') &&
|
||||
fullMatch.startsWith('**') &&
|
||||
fullMatch.length > BOLD_MARKER_LENGTH * 2
|
||||
) {
|
||||
styledPart = chalk.bold(
|
||||
parseMarkdownToANSI(
|
||||
fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH),
|
||||
baseColor,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
fullMatch.length > ITALIC_MARKER_LENGTH * 2 &&
|
||||
((fullMatch.startsWith('*') && fullMatch.endsWith('*')) ||
|
||||
(fullMatch.startsWith('_') && fullMatch.endsWith('_'))) &&
|
||||
!/\w/.test(text.substring(match.index - 1, match.index)) &&
|
||||
!/\w/.test(
|
||||
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1),
|
||||
) &&
|
||||
!/\S[./\\]/.test(text.substring(match.index - 2, match.index)) &&
|
||||
!/[./\\]\S/.test(
|
||||
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2),
|
||||
)
|
||||
) {
|
||||
styledPart = chalk.italic(
|
||||
parseMarkdownToANSI(
|
||||
fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH),
|
||||
baseColor,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
fullMatch.startsWith('~~') &&
|
||||
fullMatch.endsWith('~~') &&
|
||||
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2
|
||||
) {
|
||||
styledPart = chalk.strikethrough(
|
||||
parseMarkdownToANSI(
|
||||
fullMatch.slice(
|
||||
STRIKETHROUGH_MARKER_LENGTH,
|
||||
-STRIKETHROUGH_MARKER_LENGTH,
|
||||
),
|
||||
baseColor,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
fullMatch.startsWith('`') &&
|
||||
fullMatch.endsWith('`') &&
|
||||
fullMatch.length > INLINE_CODE_MARKER_LENGTH
|
||||
) {
|
||||
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
|
||||
if (codeMatch && codeMatch[2]) {
|
||||
styledPart = ansiColorize(codeMatch[2], theme.text.accent);
|
||||
}
|
||||
} else if (
|
||||
fullMatch.startsWith('[') &&
|
||||
fullMatch.includes('](') &&
|
||||
fullMatch.endsWith(')')
|
||||
) {
|
||||
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
|
||||
if (linkMatch) {
|
||||
const linkText = linkMatch[1];
|
||||
const url = linkMatch[2];
|
||||
styledPart =
|
||||
parseMarkdownToANSI(linkText, baseColor) +
|
||||
ansiColorize(' (', baseColor) +
|
||||
ansiColorize(url, theme.text.link) +
|
||||
ansiColorize(')', baseColor);
|
||||
}
|
||||
} else if (
|
||||
fullMatch.startsWith('<u>') &&
|
||||
fullMatch.endsWith('</u>') &&
|
||||
fullMatch.length >
|
||||
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1
|
||||
) {
|
||||
styledPart = chalk.underline(
|
||||
parseMarkdownToANSI(
|
||||
fullMatch.slice(
|
||||
UNDERLINE_TAG_START_LENGTH,
|
||||
-UNDERLINE_TAG_END_LENGTH,
|
||||
),
|
||||
baseColor,
|
||||
),
|
||||
);
|
||||
} else if (fullMatch.match(/^https?:\/\//)) {
|
||||
styledPart = ansiColorize(fullMatch, theme.text.link);
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.warn('Error parsing inline markdown part:', fullMatch, e);
|
||||
styledPart = '';
|
||||
}
|
||||
|
||||
result += styledPart || ansiColorize(fullMatch, baseColor);
|
||||
lastIndex = inlineRegex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
result += ansiColorize(text.slice(lastIndex), baseColor);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -4,29 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateMainAreaWidth } from './ui-sizing.js';
|
||||
import { type LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isAlternateBufferEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useAlternateBuffer.js', () => ({
|
||||
isAlternateBufferEnabled: mocks.isAlternateBufferEnabled,
|
||||
}));
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
describe('ui-sizing', () => {
|
||||
const createSettings = (useFullWidth?: boolean): LoadedSettings =>
|
||||
({
|
||||
merged: {
|
||||
ui: {
|
||||
useFullWidth,
|
||||
},
|
||||
},
|
||||
}) as unknown as LoadedSettings;
|
||||
|
||||
describe('calculateMainAreaWidth', () => {
|
||||
it.each([
|
||||
// expected, width, altBuffer
|
||||
@@ -37,10 +19,10 @@ describe('ui-sizing', () => {
|
||||
])(
|
||||
'should return %i when width=%i and altBuffer=%s',
|
||||
(expected, width, altBuffer) => {
|
||||
mocks.isAlternateBufferEnabled.mockReturnValue(altBuffer);
|
||||
const settings = createSettings();
|
||||
|
||||
expect(calculateMainAreaWidth(width, settings)).toBe(expected);
|
||||
const mockConfig = {
|
||||
getUseAlternateBuffer: () => altBuffer,
|
||||
} as unknown as Config;
|
||||
expect(calculateMainAreaWidth(width, mockConfig)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type LoadedSettings } from '../../config/settings.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
export const calculateMainAreaWidth = (
|
||||
terminalWidth: number,
|
||||
settings: LoadedSettings,
|
||||
config: Config,
|
||||
): number => {
|
||||
if (isAlternateBufferEnabled(settings)) {
|
||||
if (isAlternateBufferEnabled(config)) {
|
||||
return terminalWidth - 1;
|
||||
}
|
||||
return terminalWidth;
|
||||
|
||||
@@ -246,7 +246,7 @@ describe('converter', () => {
|
||||
};
|
||||
const genaiRes = fromGenerateContentResponse(codeAssistRes);
|
||||
expect(genaiRes).toBeInstanceOf(GenerateContentResponse);
|
||||
expect(genaiRes.candidates).toEqual(codeAssistRes.response.candidates);
|
||||
expect(genaiRes.candidates).toEqual(codeAssistRes.response!.candidates);
|
||||
});
|
||||
|
||||
it('should handle prompt feedback and usage metadata', () => {
|
||||
@@ -266,10 +266,10 @@ describe('converter', () => {
|
||||
};
|
||||
const genaiRes = fromGenerateContentResponse(codeAssistRes);
|
||||
expect(genaiRes.promptFeedback).toEqual(
|
||||
codeAssistRes.response.promptFeedback,
|
||||
codeAssistRes.response!.promptFeedback,
|
||||
);
|
||||
expect(genaiRes.usageMetadata).toEqual(
|
||||
codeAssistRes.response.usageMetadata,
|
||||
codeAssistRes.response!.usageMetadata,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -296,7 +296,7 @@ describe('converter', () => {
|
||||
};
|
||||
const genaiRes = fromGenerateContentResponse(codeAssistRes);
|
||||
expect(genaiRes.automaticFunctionCallingHistory).toEqual(
|
||||
codeAssistRes.response.automaticFunctionCallingHistory,
|
||||
codeAssistRes.response!.automaticFunctionCallingHistory,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
ToolConfig,
|
||||
} from '@google/genai';
|
||||
import { GenerateContentResponse } from '@google/genai';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export interface CAGenerateContentRequest {
|
||||
model: string;
|
||||
@@ -72,12 +73,12 @@ interface VertexGenerationConfig {
|
||||
}
|
||||
|
||||
export interface CaGenerateContentResponse {
|
||||
response: VertexGenerateContentResponse;
|
||||
response?: VertexGenerateContentResponse;
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
interface VertexGenerateContentResponse {
|
||||
candidates: Candidate[];
|
||||
candidates?: Candidate[];
|
||||
automaticFunctionCallingHistory?: Content[];
|
||||
promptFeedback?: GenerateContentResponsePromptFeedback;
|
||||
usageMetadata?: GenerateContentResponseUsageMetadata;
|
||||
@@ -94,7 +95,7 @@ interface VertexCountTokenRequest {
|
||||
}
|
||||
|
||||
export interface CaCountTokenResponse {
|
||||
totalTokens: number;
|
||||
totalTokens?: number;
|
||||
}
|
||||
|
||||
export function toCountTokenRequest(
|
||||
@@ -111,8 +112,13 @@ export function toCountTokenRequest(
|
||||
export function fromCountTokenResponse(
|
||||
res: CaCountTokenResponse,
|
||||
): CountTokensResponse {
|
||||
if (res.totalTokens === undefined) {
|
||||
debugLogger.warn(
|
||||
'Warning: Code Assist API did not return totalTokens. Defaulting to 0.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
totalTokens: res.totalTokens,
|
||||
totalTokens: res.totalTokens ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -73,19 +73,26 @@ describe('CodeAssistServer', () => {
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining(':generateContent'),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-custom-header': 'test-value',
|
||||
},
|
||||
responseType: 'json',
|
||||
body: expect.any(String),
|
||||
signal: undefined,
|
||||
}),
|
||||
);
|
||||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
url: expect.stringContaining(':generateContent'),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-custom-header': 'test-value',
|
||||
},
|
||||
responseType: 'json',
|
||||
body: expect.any(String),
|
||||
signal: undefined,
|
||||
retryConfig: {
|
||||
retry: 3,
|
||||
noResponseRetries: 3,
|
||||
statusCodesToRetry: [
|
||||
[429, 429],
|
||||
[499, 499],
|
||||
[500, 599],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const requestBody = JSON.parse(mockRequest.mock.calls[0][0].body);
|
||||
expect(requestBody.user_prompt_id).toBe('user-prompt-id');
|
||||
@@ -393,19 +400,26 @@ describe('CodeAssistServer', () => {
|
||||
results.push(res);
|
||||
}
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining(':streamGenerateContent'),
|
||||
method: 'POST',
|
||||
params: { alt: 'sse' },
|
||||
responseType: 'stream',
|
||||
body: expect.any(String),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: undefined,
|
||||
}),
|
||||
);
|
||||
expect(mockRequest).toHaveBeenCalledWith({
|
||||
url: expect.stringContaining(':streamGenerateContent'),
|
||||
method: 'POST',
|
||||
params: { alt: 'sse' },
|
||||
responseType: 'stream',
|
||||
body: expect.any(String),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: undefined,
|
||||
retryConfig: {
|
||||
retry: 3,
|
||||
noResponseRetries: 3,
|
||||
statusCodesToRetry: [
|
||||
[429, 429],
|
||||
[499, 499],
|
||||
[500, 599],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].candidates?.[0].content?.parts?.[0].text).toBe('Hello');
|
||||
|
||||
@@ -305,6 +305,15 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
responseType: 'json',
|
||||
body: JSON.stringify(req),
|
||||
signal,
|
||||
retryConfig: {
|
||||
retry: 3,
|
||||
noResponseRetries: 3,
|
||||
statusCodesToRetry: [
|
||||
[429, 429],
|
||||
[499, 499],
|
||||
[500, 599],
|
||||
],
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
@@ -352,6 +361,15 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
responseType: 'stream',
|
||||
body: JSON.stringify(req),
|
||||
signal,
|
||||
retryConfig: {
|
||||
retry: 3,
|
||||
noResponseRetries: 3,
|
||||
statusCodesToRetry: [
|
||||
[429, 429],
|
||||
[499, 499],
|
||||
[500, 599],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return (async function* (): AsyncGenerator<T> {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { AuthClient } from 'google-auth-library';
|
||||
import type { ValidationHandler } from '../fallback/types.js';
|
||||
import { ChangeAuthRequestedError } from '../utils/errors.js';
|
||||
import { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export class ProjectIdRequiredError extends Error {
|
||||
constructor() {
|
||||
@@ -130,11 +131,20 @@ export async function setupUser(
|
||||
}
|
||||
|
||||
if (loadRes.currentTier) {
|
||||
if (!loadRes.paidTier?.id && !loadRes.currentTier.id) {
|
||||
debugLogger.warn(
|
||||
'Warning: Code Assist API did not return a user tier ID. Defaulting to STANDARD tier.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!loadRes.cloudaicompanionProject) {
|
||||
if (projectId) {
|
||||
return {
|
||||
projectId,
|
||||
userTier: loadRes.paidTier?.id ?? loadRes.currentTier.id,
|
||||
userTier:
|
||||
loadRes.paidTier?.id ??
|
||||
loadRes.currentTier.id ??
|
||||
UserTierId.STANDARD,
|
||||
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
|
||||
};
|
||||
}
|
||||
@@ -144,13 +154,20 @@ export async function setupUser(
|
||||
}
|
||||
return {
|
||||
projectId: loadRes.cloudaicompanionProject,
|
||||
userTier: loadRes.paidTier?.id ?? loadRes.currentTier.id,
|
||||
userTier:
|
||||
loadRes.paidTier?.id ?? loadRes.currentTier.id ?? UserTierId.STANDARD,
|
||||
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
|
||||
};
|
||||
}
|
||||
|
||||
const tier = getOnboardTier(loadRes);
|
||||
|
||||
if (!tier.id) {
|
||||
debugLogger.warn(
|
||||
'Warning: Code Assist API did not return an onboarding tier ID. Defaulting to STANDARD tier.',
|
||||
);
|
||||
}
|
||||
|
||||
let onboardReq: OnboardUserRequest;
|
||||
if (tier.id === UserTierId.FREE) {
|
||||
// The free tier uses a managed google cloud project. Setting a project in the `onboardUser` request causes a `Precondition Failed` error.
|
||||
@@ -183,7 +200,7 @@ export async function setupUser(
|
||||
if (projectId) {
|
||||
return {
|
||||
projectId,
|
||||
userTier: tier.id,
|
||||
userTier: tier.id ?? UserTierId.STANDARD,
|
||||
userTierName: tier.name,
|
||||
};
|
||||
}
|
||||
@@ -193,7 +210,7 @@ export async function setupUser(
|
||||
|
||||
return {
|
||||
projectId: lroRes.response.cloudaicompanionProject.id,
|
||||
userTier: tier.id,
|
||||
userTier: tier.id ?? UserTierId.STANDARD,
|
||||
userTierName: tier.name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface LoadCodeAssistResponse {
|
||||
* GeminiUserTier reflects the structure received from the CodeAssist when calling LoadCodeAssist.
|
||||
*/
|
||||
export interface GeminiUserTier {
|
||||
id: UserTierId;
|
||||
id?: UserTierId;
|
||||
name?: string;
|
||||
description?: string;
|
||||
// This value is used to declare whether a given tier requires the user to configure the project setting on the IDE settings or not.
|
||||
@@ -79,10 +79,10 @@ export interface GeminiUserTier {
|
||||
* @param tierName name of the tier.
|
||||
*/
|
||||
export interface IneligibleTier {
|
||||
reasonCode: IneligibleTierReasonCode;
|
||||
reasonMessage: string;
|
||||
tierId: UserTierId;
|
||||
tierName: string;
|
||||
reasonCode?: IneligibleTierReasonCode;
|
||||
reasonMessage?: string;
|
||||
tierId?: UserTierId;
|
||||
tierName?: string;
|
||||
validationErrorMessage?: string;
|
||||
validationUrl?: string;
|
||||
validationUrlLinkText?: string;
|
||||
@@ -127,7 +127,7 @@ export type UserTierId = (typeof UserTierId)[keyof typeof UserTierId] | string;
|
||||
* privacy notice.
|
||||
*/
|
||||
export interface PrivacyNotice {
|
||||
showNotice: boolean;
|
||||
showNotice?: boolean;
|
||||
noticeText?: string;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export interface OnboardUserRequest {
|
||||
* http://google3/google/longrunning/operations.proto;rcl=698857719;l=107
|
||||
*/
|
||||
export interface LongRunningOperationResponse {
|
||||
name: string;
|
||||
name?: string;
|
||||
done?: boolean;
|
||||
response?: OnboardUserResponse;
|
||||
}
|
||||
@@ -157,8 +157,8 @@ export interface LongRunningOperationResponse {
|
||||
export interface OnboardUserResponse {
|
||||
// tslint:disable-next-line:enforce-name-casing This is the name of the field in the proto.
|
||||
cloudaicompanionProject?: {
|
||||
id: string;
|
||||
name: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ export interface SetCodeAssistGlobalUserSettingRequest {
|
||||
|
||||
export interface CodeAssistGlobalUserSettingResponse {
|
||||
cloudaicompanionProject?: string;
|
||||
freeTierDataCollectionOptin: boolean;
|
||||
freeTierDataCollectionOptin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -941,6 +941,31 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('UseAlternateBuffer Configuration', () => {
|
||||
it('should default useAlternateBuffer to false when not provided', () => {
|
||||
const config = new Config(baseParams);
|
||||
expect(config.getUseAlternateBuffer()).toBe(false);
|
||||
});
|
||||
|
||||
it('should set useAlternateBuffer to true when provided as true', () => {
|
||||
const paramsWithAlternateBuffer: ConfigParameters = {
|
||||
...baseParams,
|
||||
useAlternateBuffer: true,
|
||||
};
|
||||
const config = new Config(paramsWithAlternateBuffer);
|
||||
expect(config.getUseAlternateBuffer()).toBe(true);
|
||||
});
|
||||
|
||||
it('should set useAlternateBuffer to false when explicitly provided as false', () => {
|
||||
const paramsWithAlternateBuffer: ConfigParameters = {
|
||||
...baseParams,
|
||||
useAlternateBuffer: false,
|
||||
};
|
||||
const config = new Config(paramsWithAlternateBuffer);
|
||||
expect(config.getUseAlternateBuffer()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UseWriteTodos Configuration', () => {
|
||||
it('should default useWriteTodos to true when not provided', () => {
|
||||
const config = new Config(baseParams);
|
||||
|
||||
@@ -519,6 +519,7 @@ export interface ConfigParameters {
|
||||
interactive?: boolean;
|
||||
trustedFolder?: boolean;
|
||||
useBackgroundColor?: boolean;
|
||||
useAlternateBuffer?: boolean;
|
||||
useRipgrep?: boolean;
|
||||
enableInteractiveShell?: boolean;
|
||||
skipNextSpeakerCheck?: boolean;
|
||||
@@ -702,6 +703,7 @@ export class Config {
|
||||
private readonly enableInteractiveShell: boolean;
|
||||
private readonly skipNextSpeakerCheck: boolean;
|
||||
private readonly useBackgroundColor: boolean;
|
||||
private readonly useAlternateBuffer: boolean;
|
||||
private shellExecutionConfig: ShellExecutionConfig;
|
||||
private readonly extensionManagement: boolean = true;
|
||||
private readonly truncateToolOutputThreshold: number;
|
||||
@@ -900,6 +902,7 @@ export class Config {
|
||||
this.directWebFetch = params.directWebFetch ?? false;
|
||||
this.useRipgrep = params.useRipgrep ?? true;
|
||||
this.useBackgroundColor = params.useBackgroundColor ?? true;
|
||||
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
|
||||
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
|
||||
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
|
||||
this.shellExecutionConfig = {
|
||||
@@ -2521,6 +2524,10 @@ export class Config {
|
||||
return this.useBackgroundColor;
|
||||
}
|
||||
|
||||
getUseAlternateBuffer(): boolean {
|
||||
return this.useAlternateBuffer;
|
||||
}
|
||||
|
||||
getEnableInteractiveShell(): boolean {
|
||||
return this.enableInteractiveShell;
|
||||
}
|
||||
|
||||
@@ -169,10 +169,7 @@ export class Storage {
|
||||
}
|
||||
|
||||
getAutoSavedPolicyPath(): string {
|
||||
return path.join(
|
||||
this.getWorkspacePoliciesDir(),
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
return path.join(Storage.getUserPoliciesDir(), AUTO_SAVED_POLICY_FILENAME);
|
||||
}
|
||||
|
||||
ensureProjectTempDirExists(): void {
|
||||
|
||||
@@ -109,7 +109,7 @@ The following tools are available in Plan Mode:
|
||||
## Rules
|
||||
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
|
||||
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
|
||||
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify.
|
||||
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
|
||||
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
|
||||
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
|
||||
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
|
||||
@@ -277,7 +277,7 @@ The following tools are available in Plan Mode:
|
||||
## Rules
|
||||
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
|
||||
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
|
||||
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify.
|
||||
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
|
||||
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
|
||||
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
|
||||
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
|
||||
@@ -564,7 +564,7 @@ The following tools are available in Plan Mode:
|
||||
## Rules
|
||||
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/project-temp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
|
||||
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/project-temp/plans/\`. They cannot modify source code.
|
||||
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify.
|
||||
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
|
||||
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
|
||||
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
|
||||
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
|
||||
|
||||
@@ -516,9 +516,8 @@ export function createPolicyUpdater(
|
||||
if (message.persist) {
|
||||
persistenceQueue = persistenceQueue.then(async () => {
|
||||
try {
|
||||
const workspacePoliciesDir = storage.getWorkspacePoliciesDir();
|
||||
await fs.mkdir(workspacePoliciesDir, { recursive: true });
|
||||
const policyFile = storage.getAutoSavedPolicyPath();
|
||||
await fs.mkdir(path.dirname(policyFile), { recursive: true });
|
||||
|
||||
// Read existing file
|
||||
let existingData: { rule?: TomlRule[] } = {};
|
||||
|
||||
@@ -48,14 +48,8 @@ describe('createPolicyUpdater', () => {
|
||||
it('should persist policy when persist flag is true', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
const userPoliciesDir = '/mock/user/.gemini/policies';
|
||||
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
|
||||
(fs.readFile as unknown as Mock).mockRejectedValue(
|
||||
@@ -79,8 +73,7 @@ describe('createPolicyUpdater', () => {
|
||||
// Wait for async operations (microtasks)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(mockStorage.getWorkspacePoliciesDir).toHaveBeenCalled();
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(workspacePoliciesDir, {
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(userPoliciesDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
@@ -115,14 +108,8 @@ describe('createPolicyUpdater', () => {
|
||||
it('should persist policy with commandPrefix when provided', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
const userPoliciesDir = '/mock/user/.gemini/policies';
|
||||
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
|
||||
(fs.readFile as unknown as Mock).mockRejectedValue(
|
||||
@@ -168,14 +155,8 @@ describe('createPolicyUpdater', () => {
|
||||
it('should persist policy with mcpName and toolName when provided', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
const userPoliciesDir = '/mock/user/.gemini/policies';
|
||||
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
|
||||
(fs.readFile as unknown as Mock).mockRejectedValue(
|
||||
@@ -214,14 +195,8 @@ describe('createPolicyUpdater', () => {
|
||||
it('should escape special characters in toolName and mcpName', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
||||
const policyFile = path.join(
|
||||
workspacePoliciesDir,
|
||||
AUTO_SAVED_POLICY_FILENAME,
|
||||
);
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
const userPoliciesDir = '/mock/user/.gemini/policies';
|
||||
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
|
||||
(fs.readFile as unknown as Mock).mockRejectedValue(
|
||||
|
||||
@@ -50,8 +50,8 @@ describe('createPolicyUpdater', () => {
|
||||
|
||||
messageBus = new MessageBus(policyEngine);
|
||||
mockStorage = new Storage('/mock/project');
|
||||
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
|
||||
'/mock/project/.gemini/policies',
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(
|
||||
'/mock/user/.gemini/policies/auto-saved.toml',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -472,7 +472,7 @@ ${options.planModeToolsList}
|
||||
## Rules
|
||||
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`${options.plansDir}/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
|
||||
2. **Write Constraint:** ${formatToolName(WRITE_FILE_TOOL_NAME)} and ${formatToolName(EDIT_TOOL_NAME)} may ONLY be used to write .md plan files to \`${options.plansDir}/\`. They cannot modify source code.
|
||||
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use ${formatToolName(ASK_USER_TOOL_NAME)} to clarify.
|
||||
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use ${formatToolName(ASK_USER_TOOL_NAME)} to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
|
||||
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
|
||||
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
|
||||
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
|
||||
|
||||
+1
-1
@@ -859,7 +859,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
|
||||
|
||||
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: ask_user 1`] = `
|
||||
{
|
||||
"description": "Ask the user one or more questions to gather preferences, clarify requirements, or make decisions.",
|
||||
"description": "Ask the user one or more questions to gather preferences, clarify requirements, or make decisions. When using this tool, prefer providing multiple-choice options with detailed descriptions and enable multi-select where appropriate to provide maximum flexibility.",
|
||||
"name": "ask_user",
|
||||
"parametersJsonSchema": {
|
||||
"properties": {
|
||||
|
||||
@@ -558,7 +558,7 @@ The agent did not use the todo list because this task could be completed by a ti
|
||||
ask_user: {
|
||||
name: ASK_USER_TOOL_NAME,
|
||||
description:
|
||||
'Ask the user one or more questions to gather preferences, clarify requirements, or make decisions.',
|
||||
'Ask the user one or more questions to gather preferences, clarify requirements, or make decisions. When using this tool, prefer providing multiple-choice options with detailed descriptions and enable multi-select where appropriate to provide maximum flexibility.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
required: ['questions'],
|
||||
|
||||
@@ -1792,6 +1792,80 @@ describe('mcp-client', () => {
|
||||
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include extension settings with defined values in environment', async () => {
|
||||
const mockedTransport = vi
|
||||
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
||||
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
|
||||
|
||||
await createTransport(
|
||||
'test-server',
|
||||
{
|
||||
command: 'test-command',
|
||||
extension: {
|
||||
name: 'test-ext',
|
||||
resolvedSettings: [
|
||||
{
|
||||
envVar: 'GEMINI_CLI_EXT_VAR',
|
||||
value: 'defined-value',
|
||||
sensitive: false,
|
||||
name: 'ext-setting',
|
||||
},
|
||||
],
|
||||
version: '',
|
||||
isActive: false,
|
||||
path: '',
|
||||
contextFiles: [],
|
||||
id: '',
|
||||
},
|
||||
},
|
||||
false,
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
const callArgs = mockedTransport.mock.calls[0][0];
|
||||
expect(callArgs.env).toBeDefined();
|
||||
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBe('defined-value');
|
||||
});
|
||||
|
||||
it('should resolve environment variables in mcpServerConfig.env using extension settings', async () => {
|
||||
const mockedTransport = vi
|
||||
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
||||
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
|
||||
|
||||
await createTransport(
|
||||
'test-server',
|
||||
{
|
||||
command: 'test-command',
|
||||
env: {
|
||||
RESOLVED_VAR: '$GEMINI_CLI_EXT_VAR',
|
||||
},
|
||||
extension: {
|
||||
name: 'test-ext',
|
||||
resolvedSettings: [
|
||||
{
|
||||
envVar: 'GEMINI_CLI_EXT_VAR',
|
||||
value: 'ext-value',
|
||||
sensitive: false,
|
||||
name: 'ext-setting',
|
||||
},
|
||||
],
|
||||
version: '',
|
||||
isActive: false,
|
||||
path: '',
|
||||
contextFiles: [],
|
||||
id: '',
|
||||
},
|
||||
},
|
||||
false,
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
const callArgs = mockedTransport.mock.calls[0][0];
|
||||
expect(callArgs.env).toBeDefined();
|
||||
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBe('ext-value');
|
||||
expect(callArgs.env!['RESOLVED_VAR']).toBe('ext-value');
|
||||
});
|
||||
|
||||
it('should expand environment variables in mcpServerConfig.env and not redact them', async () => {
|
||||
const mockedTransport = vi
|
||||
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
||||
|
||||
@@ -34,7 +34,11 @@ import {
|
||||
ProgressNotificationSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { parse } from 'shell-quote';
|
||||
import type { Config, MCPServerConfig } from '../config/config.js';
|
||||
import type {
|
||||
Config,
|
||||
MCPServerConfig,
|
||||
GeminiCLIExtension,
|
||||
} from '../config/config.js';
|
||||
import { AuthProviderType } from '../config/config.js';
|
||||
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
|
||||
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
|
||||
@@ -778,15 +782,25 @@ async function handleAutomaticOAuth(
|
||||
*
|
||||
* @param mcpServerConfig The MCP server configuration
|
||||
* @param headers Additional headers
|
||||
* @param sanitizationConfig Configuration for environment sanitization
|
||||
*/
|
||||
function createTransportRequestInit(
|
||||
mcpServerConfig: MCPServerConfig,
|
||||
headers: Record<string, string>,
|
||||
sanitizationConfig: EnvironmentSanitizationConfig,
|
||||
): RequestInit {
|
||||
const extensionEnv = getExtensionEnvironment(mcpServerConfig.extension);
|
||||
const expansionEnv = { ...process.env, ...extensionEnv };
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(expansionEnv, {
|
||||
...sanitizationConfig,
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
});
|
||||
|
||||
const expandedHeaders: Record<string, string> = {};
|
||||
if (mcpServerConfig.headers) {
|
||||
for (const [key, value] of Object.entries(mcpServerConfig.headers)) {
|
||||
expandedHeaders[key] = expandEnvVars(value, process.env);
|
||||
expandedHeaders[key] = expandEnvVars(value, sanitizedEnv);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,12 +840,14 @@ function createAuthProvider(
|
||||
* @param mcpServerName The name of the MCP server
|
||||
* @param mcpServerConfig The MCP server configuration
|
||||
* @param accessToken The OAuth access token
|
||||
* @param sanitizationConfig Configuration for environment sanitization
|
||||
* @returns The transport with OAuth token, or null if creation fails
|
||||
*/
|
||||
async function createTransportWithOAuth(
|
||||
mcpServerName: string,
|
||||
mcpServerConfig: MCPServerConfig,
|
||||
accessToken: string,
|
||||
sanitizationConfig: EnvironmentSanitizationConfig,
|
||||
): Promise<StreamableHTTPClientTransport | SSEClientTransport | null> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -840,7 +856,11 @@ async function createTransportWithOAuth(
|
||||
const transportOptions:
|
||||
| StreamableHTTPClientTransportOptions
|
||||
| SSEClientTransportOptions = {
|
||||
requestInit: createTransportRequestInit(mcpServerConfig, headers),
|
||||
requestInit: createTransportRequestInit(
|
||||
mcpServerConfig,
|
||||
headers,
|
||||
sanitizationConfig,
|
||||
),
|
||||
};
|
||||
|
||||
return createUrlTransport(mcpServerName, mcpServerConfig, transportOptions);
|
||||
@@ -1435,6 +1455,7 @@ async function showAuthRequiredMessage(serverName: string): Promise<never> {
|
||||
* @param config The MCP server configuration
|
||||
* @param accessToken The OAuth access token to use
|
||||
* @param httpReturned404 Whether the HTTP transport returned 404 (indicating SSE-only server)
|
||||
* @param sanitizationConfig Configuration for environment sanitization
|
||||
*/
|
||||
async function retryWithOAuth(
|
||||
client: Client,
|
||||
@@ -1442,6 +1463,7 @@ async function retryWithOAuth(
|
||||
config: MCPServerConfig,
|
||||
accessToken: string,
|
||||
httpReturned404: boolean,
|
||||
sanitizationConfig: EnvironmentSanitizationConfig,
|
||||
): Promise<void> {
|
||||
if (httpReturned404) {
|
||||
// HTTP returned 404, only try SSE
|
||||
@@ -1462,6 +1484,7 @@ async function retryWithOAuth(
|
||||
serverName,
|
||||
config,
|
||||
accessToken,
|
||||
sanitizationConfig,
|
||||
);
|
||||
if (!httpTransport) {
|
||||
throw new Error(
|
||||
@@ -1741,6 +1764,7 @@ export async function connectToMcpServer(
|
||||
mcpServerConfig,
|
||||
accessToken,
|
||||
httpReturned404,
|
||||
sanitizationConfig,
|
||||
);
|
||||
return mcpClient;
|
||||
} else {
|
||||
@@ -1813,6 +1837,7 @@ export async function connectToMcpServer(
|
||||
mcpServerName,
|
||||
mcpServerConfig,
|
||||
accessToken,
|
||||
sanitizationConfig,
|
||||
);
|
||||
if (!oauthTransport) {
|
||||
throw new Error(
|
||||
@@ -1960,7 +1985,11 @@ export async function createTransport(
|
||||
const transportOptions:
|
||||
| StreamableHTTPClientTransportOptions
|
||||
| SSEClientTransportOptions = {
|
||||
requestInit: createTransportRequestInit(mcpServerConfig, headers),
|
||||
requestInit: createTransportRequestInit(
|
||||
mcpServerConfig,
|
||||
headers,
|
||||
sanitizationConfig,
|
||||
),
|
||||
authProvider,
|
||||
};
|
||||
|
||||
@@ -1968,8 +1997,11 @@ export async function createTransport(
|
||||
}
|
||||
|
||||
if (mcpServerConfig.command) {
|
||||
const extensionEnv = getExtensionEnvironment(mcpServerConfig.extension);
|
||||
const expansionEnv = { ...process.env, ...extensionEnv };
|
||||
|
||||
// 1. Sanitize the base process environment to prevent unintended leaks of system-wide secrets.
|
||||
const sanitizedEnv = sanitizeEnvironment(process.env, {
|
||||
const sanitizedEnv = sanitizeEnvironment(expansionEnv, {
|
||||
...sanitizationConfig,
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
});
|
||||
@@ -1977,6 +2009,7 @@ export async function createTransport(
|
||||
const finalEnv: Record<string, string> = {
|
||||
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
|
||||
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
|
||||
...extensionEnv,
|
||||
};
|
||||
for (const [key, value] of Object.entries(sanitizedEnv)) {
|
||||
if (value !== undefined) {
|
||||
@@ -1987,7 +2020,7 @@ export async function createTransport(
|
||||
// Expand and merge explicit environment variables from the MCP configuration.
|
||||
if (mcpServerConfig.env) {
|
||||
for (const [key, value] of Object.entries(mcpServerConfig.env)) {
|
||||
finalEnv[key] = expandEnvVars(value, process.env);
|
||||
finalEnv[key] = expandEnvVars(value, expansionEnv);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2045,6 +2078,20 @@ interface NamedTool {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
function getExtensionEnvironment(
|
||||
extension?: GeminiCLIExtension,
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
if (extension?.resolvedSettings) {
|
||||
for (const setting of extension.resolvedSettings) {
|
||||
if (setting.value !== undefined) {
|
||||
env[setting.envVar] = setting.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/** Visible for testing */
|
||||
export function isEnabled(
|
||||
funcDecl: NamedTool,
|
||||
|
||||
Reference in New Issue
Block a user