Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69b51f8fa2 | |||
| 3c1bb8c35d | |||
| d76d2d0742 | |||
| a96259c9e5 | |||
| 87f785192c | |||
| 1c21640f97 | |||
| 455d721a0c | |||
| 9681621c6b | |||
| f743ab5790 | |||
| c776c665b0 | |||
| acae7124bd | |||
| 69e0c2659e | |||
| aea9e5e8ed | |||
| 3ff5ba20fc | |||
| 1ae8ba6496 | |||
| fa975395bc | |||
| a345621404 | |||
| f5e58ff18b | |||
| 42ee2b74c7 | |||
| f354eebaf4 | |||
| a4c91ce191 | |||
| 9a023bbba0 | |||
| a8b115caa1 | |||
| 172ff92c34 | |||
| 70f4d573f2 | |||
| 132f38c3a4 | |||
| b31b755bbf | |||
| c988cbb1e3 | |||
| b7c61c9e3d | |||
| 27a3da3e88 | |||
| 15a9429b69 | |||
| 892b35fcfb | |||
| f7af4e5180 | |||
| ff00dacd9f | |||
| 7f00c5fe59 | |||
| b5fc06ee33 | |||
| ae0a3aa7b9 | |||
| b14416447e | |||
| 8cd5c0f71f | |||
| df997354c8 | |||
| 19ad71b903 | |||
| 3fbf93e26f | |||
| d845bc5d45 | |||
| 02c6c77324 |
@@ -197,6 +197,29 @@ runs:
|
||||
run: |
|
||||
node ${{ github.workspace }}/scripts/prepare-npm-release.js
|
||||
|
||||
- name: '📦 Pack CLI for verification'
|
||||
if: "inputs.dry-run != 'true' && inputs.force-skip-tests != 'true'"
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
npm pack --workspace="${INPUTS_CLI_PACKAGE_NAME}"
|
||||
# We restore the package.json so that `npm ci` in verify-release doesn't fail due to deleted dependencies
|
||||
git checkout packages/cli/package.json
|
||||
env:
|
||||
INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
|
||||
|
||||
- name: '🔬 Verify NPM release by version'
|
||||
uses: './.github/actions/verify-release'
|
||||
if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}"
|
||||
with:
|
||||
npm-package: './google-gemini-cli-${{ inputs.release-version }}.tgz'
|
||||
expected-version: '${{ inputs.release-version }}'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
gemini_api_key: '${{ inputs.gemini_api_key }}'
|
||||
github-token: '${{ inputs.github-token }}'
|
||||
npm-registry-url: '${{ inputs.npm-registry-url }}'
|
||||
npm-registry-scope: '${{ inputs.npm-registry-scope }}'
|
||||
|
||||
- name: 'Get CLI Token'
|
||||
uses: './.github/actions/npm-auth-token'
|
||||
id: 'cli-token'
|
||||
@@ -213,12 +236,19 @@ runs:
|
||||
NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}'
|
||||
INPUTS_DRY_RUN: '${{ inputs.dry-run }}'
|
||||
INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
|
||||
INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
if [ -f "google-gemini-cli-${INPUTS_RELEASE_VERSION}.tgz" ]; then
|
||||
PUBLISH_TARGET="google-gemini-cli-${INPUTS_RELEASE_VERSION}.tgz"
|
||||
else
|
||||
PUBLISH_TARGET="--workspace=${INPUTS_CLI_PACKAGE_NAME}"
|
||||
fi
|
||||
|
||||
npm publish \
|
||||
--ignore-scripts \
|
||||
--dry-run="${INPUTS_DRY_RUN}" \
|
||||
--workspace="${INPUTS_CLI_PACKAGE_NAME}" \
|
||||
${PUBLISH_TARGET} \
|
||||
--tag staging-tmp
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp
|
||||
@@ -252,18 +282,6 @@ runs:
|
||||
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} staging-tmp
|
||||
fi
|
||||
|
||||
- name: '🔬 Verify NPM release by version'
|
||||
uses: './.github/actions/verify-release'
|
||||
if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}"
|
||||
with:
|
||||
npm-package: '${{ inputs.cli-package-name }}@${{ inputs.release-version }}'
|
||||
expected-version: '${{ inputs.release-version }}'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
gemini_api_key: '${{ inputs.gemini_api_key }}'
|
||||
github-token: '${{ inputs.github-token }}'
|
||||
npm-registry-url: '${{ inputs.npm-registry-url }}'
|
||||
npm-registry-scope: '${{ inputs.npm-registry-scope }}'
|
||||
|
||||
- name: '🏷️ Tag release'
|
||||
uses: './.github/actions/tag-npm-release'
|
||||
with:
|
||||
|
||||
@@ -74,7 +74,7 @@ runs:
|
||||
shell: 'bash'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: |-
|
||||
gemini_version=$(npx --prefer-online "${INPUTS_NPM_PACKAGE}" --version)
|
||||
gemini_version=$(npx --yes --prefer-online "${INPUTS_NPM_PACKAGE}" --version)
|
||||
if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then
|
||||
echo "❌ NPX Run Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}"
|
||||
exit 1
|
||||
@@ -98,4 +98,6 @@ runs:
|
||||
# See https://github.com/google-gemini/gemini-cli/issues/10517
|
||||
CI: 'false'
|
||||
shell: 'bash'
|
||||
run: 'npm run test:integration:sandbox:none'
|
||||
run: |
|
||||
export INTEGRATION_TEST_GEMINI_BINARY_PATH=$(which gemini)
|
||||
npm run test:integration:sandbox:none
|
||||
|
||||
@@ -106,7 +106,9 @@ jobs:
|
||||
echo "NIGHTLY_JSON: ${NIGHTLY_JSON}"
|
||||
echo "STABLE_VERSION=${STABLE_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
# shellcheck disable=SC1083
|
||||
echo "STABLE_SHA=$(git rev-parse "$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)"^{commit})" >> "${GITHUB_OUTPUT}"
|
||||
PREVIOUS_PREVIEW_TAG=$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)
|
||||
STABLE_SHA=$(git rev-parse "${PREVIOUS_PREVIEW_TAG}^{commit}")
|
||||
echo "STABLE_SHA=${STABLE_SHA}" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIOUS_STABLE_TAG=$(echo "${STABLE_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIEW_VERSION=$(echo "${PREVIEW_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
|
||||
# shellcheck disable=SC1083
|
||||
|
||||
@@ -82,7 +82,8 @@ jobs:
|
||||
ORIGIN_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
echo "ORIGIN_HASH=$(git rev-parse "${ORIGIN_TAG}")" >> "$GITHUB_OUTPUT"
|
||||
ORIGIN_HASH=$(git rev-parse "${ORIGIN_TAG}")
|
||||
echo "ORIGIN_HASH=${ORIGIN_HASH}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Change tag'
|
||||
if: "${{ github.event.inputs.rollback_destination != '' }}"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: 'Testing: Tools (Python)'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'tools/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'tools/**'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
|
||||
jobs:
|
||||
python-tests:
|
||||
name: 'Python Tests'
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4
|
||||
|
||||
- name: 'Set up Python'
|
||||
uses: 'actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55' # ratchet:actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'tools/caretaker-agent/cloudrun/triage-worker/requirements.txt'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
if [ -f tools/caretaker-agent/cloudrun/triage-worker/requirements.txt ]; then
|
||||
python -m pip install -r tools/caretaker-agent/cloudrun/triage-worker/requirements.txt
|
||||
fi
|
||||
|
||||
- name: 'Run unittest suite'
|
||||
run: |
|
||||
PYTHONPATH=tools/caretaker-agent/cloudrun/triage-worker python -m unittest discover -s tools/caretaker-agent/cloudrun/triage-worker/tests -t tools/caretaker-agent/cloudrun/triage-worker
|
||||
@@ -421,9 +421,9 @@ To debug the CLI's React-based UI, you can use React DevTools.
|
||||
|
||||
On macOS, `gemini` uses Seatbelt (`sandbox-exec`) under a `permissive-open`
|
||||
profile (see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) that
|
||||
restricts writes to the project folder but otherwise allows all other operations
|
||||
and outbound network traffic ("open") by default. You can switch to a
|
||||
`strict-open` profile (see
|
||||
denies operations by default, confining writes to the project folder while
|
||||
allowing broad file reads and outbound network traffic ("open") by default. You
|
||||
can switch to a `strict-open` profile (see
|
||||
`packages/cli/src/utils/sandbox-macos-strict-open.sb`) that restricts both reads
|
||||
and writes to the working directory while allowing outbound network traffic by
|
||||
setting `SEATBELT_PROFILE=strict-open` in your environment or `.env` file.
|
||||
|
||||
@@ -18,6 +18,39 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.52.0 - 2026-07-22
|
||||
|
||||
- **Caretaker Triage & Egress Services:** Implemented the core triage worker
|
||||
foundational modules, main worker execution loops, and egress action
|
||||
publishers alongside the octokit GitHub Action handler for egress services
|
||||
([#28163](https://github.com/google-gemini/gemini-cli/pull/28163),
|
||||
[#28306](https://github.com/google-gemini/gemini-cli/pull/28306) by @chadd28).
|
||||
- **Core Tool Enhancements:** Bypassed LLM correction for JSON and IPYNB files
|
||||
in `write_file` and `replace` tools, and simplified plan mode write policy to
|
||||
support relative paths
|
||||
([#28223](https://github.com/google-gemini/gemini-cli/pull/28223) by
|
||||
@amelidev, [#28398](https://github.com/google-gemini/gemini-cli/pull/28398) by
|
||||
@DavidAPierce).
|
||||
- **Auth & Privacy Improvements:** Displayed clear error messages when user
|
||||
account has no Code Assist tier, and bumped `google-auth-library` to version
|
||||
10.9.0 ([#28304](https://github.com/google-gemini/gemini-cli/pull/28304) by
|
||||
@ompatel-aiml,
|
||||
[#28385](https://github.com/google-gemini/gemini-cli/pull/28385) by
|
||||
@jerrylin3321).
|
||||
|
||||
## Announcements: v0.50.0 - 2026-07-08
|
||||
|
||||
- **Tool Registry Discovery:** Introduced tool registry discovery capabilities
|
||||
to automatically detect and register available tools
|
||||
([#28113](https://github.com/google-gemini/gemini-cli/pull/28113) by @ved015).
|
||||
- **Release Verification & CI Stability:** Enhanced release verification by
|
||||
ignoring scripts during verification, preventing workspace binary shadowing,
|
||||
and safeguarding against bad NPM releases
|
||||
([#28116](https://github.com/google-gemini/gemini-cli/pull/28116) by
|
||||
@rmedranollamas,
|
||||
[#28132](https://github.com/google-gemini/gemini-cli/pull/28132) by
|
||||
@galdawave).
|
||||
|
||||
## Announcements: v0.45.0 - 2026-06-03
|
||||
|
||||
- **Context Simplification:** Completed major architectural work to simplify the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.45.0
|
||||
# Latest stable release: v0.52.0
|
||||
|
||||
Released: June 03, 2026
|
||||
Released: July 22, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,55 +11,59 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Context Manager Simplification:** Completed a significant refactoring of the
|
||||
context management system to improve reliability and architectural clarity.
|
||||
- **A2A Usage Metadata:** Enhanced the Agent-to-Agent protocol to expose usage
|
||||
metadata, enabling more transparent resource monitoring.
|
||||
- **Terminal & PTY Robustness:** Resolved several critical issues related to
|
||||
terminal interactions, including Termux relaunch loops and PTY resize errors.
|
||||
- **Routing Optimizations:** Updated default auto-routing and bypassed
|
||||
classifiers for specific tool responses to prevent orphaned function errors.
|
||||
- **Tool Execution Control:** Forced the `update_topic` tool to execute
|
||||
sequentially, ensuring consistent narrative flow in agent interactions.
|
||||
- **Caretaker Services:** Introduced a new caretaker triage worker including
|
||||
core foundational modules, main worker execution loops, egress action
|
||||
publishers, and octokit GitHub Action handlers.
|
||||
- **Robust File Editing:** Core tools like `write_file` and `replace` now bypass
|
||||
LLM corrections for JSON and IPYNB files to ensure accurate and direct file
|
||||
modifications.
|
||||
- **Plan Mode Improvements:** Simplified plan mode write policies to natively
|
||||
support writing to relative paths, enhancing project directory navigation.
|
||||
- **Enhanced Account Visibility:** Improved clear user-facing messages when the
|
||||
user account does not have a Code Assist tier, and enriched shared project
|
||||
quota limit errors with setup instructions.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore(release): bump version to 0.45.0-nightly.20260521.g854f811be by
|
||||
- Refactor: exclude transient CI configuration files from workspace context by
|
||||
@DavidAPierce in
|
||||
[#28216](https://github.com/google-gemini/gemini-cli/pull/28216)
|
||||
- feat(caretaker-triage): add triage worker core foundational modules by
|
||||
@chadd28 in [#28163](https://github.com/google-gemini/gemini-cli/pull/28163)
|
||||
- feat(caretaker-egress): implement octokit github action handler for egress
|
||||
service by @chadd28 in
|
||||
[#28303](https://github.com/google-gemini/gemini-cli/pull/28303)
|
||||
- chore(release): bump version to 0.52.0-nightly.20260707.g27a3da3e8 by
|
||||
@gemini-cli-robot in
|
||||
[#27362](https://github.com/google-gemini/gemini-cli/pull/27362)
|
||||
- fix(cli): prevent Termux relaunch and resize remount loops by @saymanq in
|
||||
[#27110](https://github.com/google-gemini/gemini-cli/pull/27110)
|
||||
- Feat/a2a expose usage metadata by @jvargassanchez-dot in
|
||||
[#27288](https://github.com/google-gemini/gemini-cli/pull/27288)
|
||||
- feat(context): Complete simplification work. by @joshualitt in
|
||||
[#27345](https://github.com/google-gemini/gemini-cli/pull/27345)
|
||||
- fix(core): force update_topic tool to execute sequentially by
|
||||
@jvargassanchez-dot in
|
||||
[#27357](https://github.com/google-gemini/gemini-cli/pull/27357)
|
||||
- Changelog for v0.44.0-preview.0 by @gemini-cli-robot in
|
||||
[#27360](https://github.com/google-gemini/gemini-cli/pull/27360)
|
||||
- Changelog for v0.43.0 by @gemini-cli-robot in
|
||||
[#27361](https://github.com/google-gemini/gemini-cli/pull/27361)
|
||||
- Revert "fix(core): prevent SIGHUP kills in PTY environments" by @bbiggs in
|
||||
[#27401](https://github.com/google-gemini/gemini-cli/pull/27401)
|
||||
- fix(cli): filter internal session context from history during resumption by
|
||||
@rmedranollamas in
|
||||
[#27391](https://github.com/google-gemini/gemini-cli/pull/27391)
|
||||
- Update default auto routing by @DavidAPierce in
|
||||
[#27071](https://github.com/google-gemini/gemini-cli/pull/27071)
|
||||
- fix(core): bypass routing classifiers to prevent orphaned function response
|
||||
errors by @danielweis in
|
||||
[#27389](https://github.com/google-gemini/gemini-cli/pull/27389)
|
||||
- fix(core): suppress PTY resize EBADF errors by @scidomino in
|
||||
[#27461](https://github.com/google-gemini/gemini-cli/pull/27461)
|
||||
- fix(core): prevent blacklist bypass in mcp list by @ompatel-aiml in
|
||||
[#27377](https://github.com/google-gemini/gemini-cli/pull/27377)
|
||||
- fix(cli): ignore unmapped vim normal keys by @MukundaKatta in
|
||||
[#27102](https://github.com/google-gemini/gemini-cli/pull/27102)
|
||||
- fix(patch): cherry-pick bd53951 to release/v0.45.0-preview.0-pr-27496 to patch
|
||||
version v0.45.0-preview.0 and create version 0.45.0-preview.1 by
|
||||
[#28323](https://github.com/google-gemini/gemini-cli/pull/28323)
|
||||
- Changelog for v0.51.0-preview.0 by @gemini-cli-robot in
|
||||
[#28320](https://github.com/google-gemini/gemini-cli/pull/28320)
|
||||
- Changelog for v0.50.0 by @gemini-cli-robot in
|
||||
[#28322](https://github.com/google-gemini/gemini-cli/pull/28322)
|
||||
- fix(core-tools): bypass LLM correction for JSON and IPYNB files in write_file
|
||||
and replace by @amelidev in
|
||||
[#28223](https://github.com/google-gemini/gemini-cli/pull/28223)
|
||||
- fix(core): use unambiguous previous intent label in fallback summary by
|
||||
@amelidev in [#28343](https://github.com/google-gemini/gemini-cli/pull/28343)
|
||||
- feat(caretaker-triage): implement main worker execution loop and egress action
|
||||
publisher by @chadd28 in
|
||||
[#28306](https://github.com/google-gemini/gemini-cli/pull/28306)
|
||||
- fix(privacy): show a clear message when the account has no Code Assist tier by
|
||||
@ompatel-aiml in
|
||||
[#28304](https://github.com/google-gemini/gemini-cli/pull/28304)
|
||||
- fix(core): enrich shared project quota limit errors with setup hint by
|
||||
@amelidev in [#28391](https://github.com/google-gemini/gemini-cli/pull/28391)
|
||||
- fix(a2a-server): ensure task cancellation aborts execution loop by
|
||||
@luisfelipe-alt in
|
||||
[#28316](https://github.com/google-gemini/gemini-cli/pull/28316)
|
||||
- fix(core): simplify plan mode write policy to support relative paths by
|
||||
@DavidAPierce in
|
||||
[#28398](https://github.com/google-gemini/gemini-cli/pull/28398)
|
||||
- feat(core): Bump node google-auth-library version to 10.9.0 by @jerrylin3321
|
||||
in [#28385](https://github.com/google-gemini/gemini-cli/pull/28385)
|
||||
- chore/release: bump version to 0.52.0-nightly.20260715.gfa975395b by
|
||||
@gemini-cli-robot in
|
||||
[#27535](https://github.com/google-gemini/gemini-cli/pull/27535)
|
||||
[#28402](https://github.com/google-gemini/gemini-cli/pull/28402)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.44.1...v0.45.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.51.0...v0.52.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.48.0-preview.0
|
||||
# Preview release: v0.53.0-preview.0
|
||||
|
||||
Released: June 17, 2026
|
||||
Released: July 22, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -13,58 +13,42 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **GDC Service Identity Support**: Added support for GDC air-gapped Service
|
||||
Identity after a major auth library update.
|
||||
- **Standardised Tool Outputs**: Standardised tool output formatting to ensure
|
||||
consistency and readability across different CLI commands.
|
||||
- **Static Evaluation Analyzer**: Introduced a new static evaluation source
|
||||
analyzer to improve development and testing.
|
||||
- **Vulnerability Prevention**: Hardened CLI security by preventing path
|
||||
traversal vulnerabilities during the installation of Skills.
|
||||
- **Configuration & Error Hardening**: Migrated the `coreTools` configuration
|
||||
setting to `tools.core` and ensured zero-quota limits fail fast to prevent
|
||||
infinite retry loops.
|
||||
- **Caretaker LLM Triage Orchestrator**: Implemented the LLM triage orchestrator
|
||||
and container build configuration to support caretaker triage workflows.
|
||||
- **Enhanced Workspace Trust & Sandbox Hardening**: Aligned macOS permissive
|
||||
Seatbelt profiles with the deny-default model and enforced workspace trust and
|
||||
task isolation in the Agent-to-Agent (A2A) server to prevent remote code
|
||||
execution (RCE).
|
||||
- **Core Robustness & API Protections**: Mitigated infinite ReAct and prompt
|
||||
injection loops, and prevented 400 Bad Request errors by grouping cancelled
|
||||
tool responses and coalescing consecutive roles.
|
||||
- **Robust Credentials & Fallbacks**: Restored the
|
||||
`GOOGLE_APPLICATION_CREDENTIALS` environment variable fallback and
|
||||
sequentially verified cached credentials.
|
||||
- **Evaluation Coverage Reporting**: Added a new command to generate
|
||||
comprehensive eval coverage reports.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore(release): bump version to 0.48.0-nightly.20260609.g3a13b8eeb by
|
||||
@gemini-cli-robot in
|
||||
[#27779](https://github.com/google-gemini/gemini-cli/pull/27779)
|
||||
- ci(dependabot): enable cooldown period for npm packages by @ruomengz in
|
||||
[#27743](https://github.com/google-gemini/gemini-cli/pull/27743)
|
||||
- refactor(core): standardize tool output formatting by @galz10 in
|
||||
[#27772](https://github.com/google-gemini/gemini-cli/pull/27772)
|
||||
- ci: update workflow logging and policy configurations by @galz10 in
|
||||
[#27853](https://github.com/google-gemini/gemini-cli/pull/27853)
|
||||
- fix(core): Ensure zero-quota limits fail fast to prevent retry loop hang by
|
||||
- fix(core,a2a): group cancelled tool responses and coalesce consecutive roles
|
||||
to prevent 400 Bad Request by @luisfelipe-alt in
|
||||
[#28407](https://github.com/google-gemini/gemini-cli/pull/28407)
|
||||
- feat(caretaker-triage): implement LLM triage orchestrator and container build
|
||||
by @chadd28 in
|
||||
[#28345](https://github.com/google-gemini/gemini-cli/pull/28345)
|
||||
- refactor(cli): align macOS permissive Seatbelt profiles with deny-default
|
||||
model by @ompatel-aiml in
|
||||
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424)
|
||||
- fix(core): mitigate infinite ReAct loops and prompt injection loops by
|
||||
@amelidev in [#28429](https://github.com/google-gemini/gemini-cli/pull/28429)
|
||||
- fix(a2a-server): enforce workspace trust and task isolation to prevent RCE by
|
||||
@luisfelipe-alt in
|
||||
[#27698](https://github.com/google-gemini/gemini-cli/pull/27698)
|
||||
- fix(core): handle multi-line escaped quotes in stripShellWrapper by
|
||||
@sanchezcoraspe in
|
||||
[#27467](https://github.com/google-gemini/gemini-cli/pull/27467)
|
||||
- fix(cli): prevent path traversal vulnerabilities during skill install… by
|
||||
@ompatel-aiml in
|
||||
[#27767](https://github.com/google-gemini/gemini-cli/pull/27767)
|
||||
- Fix/pending tools and trust overrides by @jvargassanchez-dot in
|
||||
[#27854](https://github.com/google-gemini/gemini-cli/pull/27854)
|
||||
- ci: use internal environment for scheduled nightly releases (#27865) by
|
||||
@rmedranollamas in
|
||||
[#27939](https://github.com/google-gemini/gemini-cli/pull/27939)
|
||||
- feat(core): Support GDC air-gapped Service Identity after auth library update
|
||||
by @sidhantgoyal-droid in
|
||||
[#27956](https://github.com/google-gemini/gemini-cli/pull/27956)
|
||||
- fix(cli): handle tmux false positive background detection by @amelidev in
|
||||
[#27572](https://github.com/google-gemini/gemini-cli/pull/27572)
|
||||
- Add static eval source analyzer by @ved015 in
|
||||
[#27631](https://github.com/google-gemini/gemini-cli/pull/27631)
|
||||
- fix(config): migrate coreTools setting to tools.core by @galz10 in
|
||||
[#27947](https://github.com/google-gemini/gemini-cli/pull/27947)
|
||||
- fix(core-tools): resolve defensive path resolution for at-reference files by
|
||||
@luisfelipe-alt in
|
||||
[#27943](https://github.com/google-gemini/gemini-cli/pull/27943)
|
||||
- Revert "fix(core-tools): resolve defensive path resolution for at-reference
|
||||
files" by @galz10 in
|
||||
[#27992](https://github.com/google-gemini/gemini-cli/pull/27992)
|
||||
[#28470](https://github.com/google-gemini/gemini-cli/pull/28470)
|
||||
- fix(core): sequentially verify cached credentials and restore
|
||||
GOOGLE_APPLICATION_CREDENTIALS fallback by @luisfelipe-alt in
|
||||
[#28472](https://github.com/google-gemini/gemini-cli/pull/28472)
|
||||
- feat(evals): add eval coverage report command by @ved015 in
|
||||
[#28169](https://github.com/google-gemini/gemini-cli/pull/28169)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.47.0-preview.0...v0.48.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.52.0-preview.0...v0.53.0-preview.0
|
||||
|
||||
@@ -87,8 +87,9 @@ preferred container solution.
|
||||
|
||||
Lightweight, built-in sandboxing using `sandbox-exec`.
|
||||
|
||||
**Default profile**: `permissive-open` - restricts writes outside project
|
||||
directory but allows most other operations.
|
||||
**Default profile**: `permissive-open` - denies operations by default; confines
|
||||
writes to the project directory while allowing broad file reads and network
|
||||
access.
|
||||
|
||||
Built-in profiles (set via `SEATBELT_PROFILE` env var):
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ they appear in the UI.
|
||||
| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Max Scrollback Length | `ui.maxScrollbackLength` | Maximum number of lines to keep in the terminal scrollback buffer. | `1000` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, all, or off. | `"off"` |
|
||||
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
|
||||
|
||||
@@ -447,12 +447,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.maxScrollbackLength`** (number):
|
||||
- **Description:** Maximum number of lines to keep in the terminal scrollback
|
||||
buffer.
|
||||
- **Default:** `1000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.showSpinner`** (boolean):
|
||||
|
||||
- **Description:** Show the spinner during operations.
|
||||
@@ -2742,10 +2736,10 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- Run the CLI once with this set to generate the file.
|
||||
- **`SEATBELT_PROFILE`** (macOS specific):
|
||||
- Switches the Seatbelt (`sandbox-exec`) profile on macOS.
|
||||
- `permissive-open`: (Default) Restricts writes to the project folder (and a
|
||||
few other folders, see
|
||||
`packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other
|
||||
operations.
|
||||
- `permissive-open`: (Default) Denies operations by default, confining writes
|
||||
to the project folder (and a few other folders, see
|
||||
`packages/cli/src/utils/sandbox-macos-permissive-open.sb`) while allowing
|
||||
broad file reads and network access.
|
||||
- `restrictive-open`: Declines operations by default, allows network.
|
||||
- `strict-open`: Restricts both reads and writes to the working directory,
|
||||
allows network.
|
||||
|
||||
@@ -56,6 +56,7 @@ export default tseslint.config(
|
||||
'eslint.config.js',
|
||||
'**/coverage/**',
|
||||
'packages/**/dist/**',
|
||||
'tools/**/dist/**',
|
||||
'bundle/**',
|
||||
'package/bundle/**',
|
||||
'.integration-tests/**',
|
||||
@@ -80,8 +81,8 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
// Rules for packages/*/src (TS/TSX)
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'],
|
||||
// Rules for packages/*/src and tools/caretaker-agent (TS/TSX)
|
||||
files: ['packages/*/src/**/*.{ts,tsx}', 'tools/caretaker-agent/**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
@@ -284,7 +285,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}'],
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}', 'tools/**/*.test.ts'],
|
||||
plugins: {
|
||||
vitest,
|
||||
},
|
||||
@@ -410,6 +411,13 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
// Allow console logging for backend services (Cloud Logging)
|
||||
{
|
||||
files: ['tools/**/*.ts', 'tools/**/*.test.ts'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
// Prettier config must be last
|
||||
prettierConfig,
|
||||
// extra settings for scripts that we run directly with node
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('Interactive file system', () => {
|
||||
const run = await rig.runInteractive();
|
||||
|
||||
// Step 1: Read the file
|
||||
const readPrompt = `Read the version from ${fileName}`;
|
||||
const readPrompt = `Read the version from ${fileName} using the read_file tool`;
|
||||
await run.type(readPrompt);
|
||||
await run.type('\r');
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.54.0-nightly.20260722.gf743ab579",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.49.0-nightly.20260617.g4d3dcdce1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.54.0-nightly.20260722.gf743ab579"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -34,6 +34,7 @@
|
||||
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
|
||||
"eval:inventory": "tsx ./scripts/eval-inventory-cli.ts",
|
||||
"eval:inventory:json": "tsx ./scripts/eval-inventory-cli.ts --json",
|
||||
"eval:coverage": "tsx ./scripts/eval-coverage-cli.ts",
|
||||
"build": "node scripts/build.js",
|
||||
"build-and-start": "npm run build && npm run start --",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
@@ -75,7 +76,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -95,8 +96,10 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
"read-package-up": "11.0.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/marked": "5.0.2",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/minimatch": "5.1.2",
|
||||
@@ -121,6 +124,7 @@
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"express": "5.1.0",
|
||||
"glob": "12.0.0",
|
||||
"globals": "16.0.0",
|
||||
"google-artifactregistry-auth": "3.4.0",
|
||||
@@ -145,7 +149,7 @@
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"latest-version": "9.0.0",
|
||||
"node-fetch-native": "1.6.7",
|
||||
"proper-lockfile": "4.1.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.54.0-nightly.20260722.gf743ab579",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -14,7 +14,24 @@ import type {
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { requestStorage } from '../http/requestStorage.js';
|
||||
|
||||
vi.mock('../utils/path_utils.js', () => ({
|
||||
validateWorkspacePath: vi
|
||||
.fn()
|
||||
.mockImplementation(async (path?: string) => path || process.cwd()),
|
||||
}));
|
||||
|
||||
// Mocks for constructor dependencies
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
GeminiEventType: {
|
||||
PRIMARY_TURN_STARTED: 'PRIMARY_TURN_STARTED',
|
||||
SECONDARY_TURN_STARTED: 'SECONDARY_TURN_STARTED',
|
||||
},
|
||||
SimpleExtensionLoader: vi.fn(),
|
||||
checkPathTrust: vi.fn().mockReturnValue({ isTrusted: false }),
|
||||
isHeadlessMode: vi.fn().mockReturnValue(true),
|
||||
resolveToRealPath: vi.fn().mockImplementation((p) => p),
|
||||
}));
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadConfig: vi.fn().mockReturnValue({
|
||||
getSessionId: () => 'test-session',
|
||||
@@ -24,6 +41,10 @@ vi.mock('../config/config.js', () => ({
|
||||
loadEnvironment: vi.fn(),
|
||||
setIsTrusted: vi.fn().mockReturnValue(false),
|
||||
setTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
envStorage: {
|
||||
run: (env: Record<string, string>, cb: () => unknown) => cb(),
|
||||
},
|
||||
cwdSymbol: Symbol('cwd'),
|
||||
}));
|
||||
|
||||
vi.mock('../config/settings.js', () => ({
|
||||
@@ -300,4 +321,125 @@ describe('CoderAgentExecutor', () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('cancelTask should abort the active execution loop', async () => {
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
|
||||
const taskId = 'test-task-to-cancel';
|
||||
const contextId = 'test-context';
|
||||
|
||||
const mockSocket = new EventEmitter();
|
||||
(requestStorage.getStore as Mock).mockReturnValue({
|
||||
req: { socket: mockSocket },
|
||||
});
|
||||
|
||||
const requestContext = {
|
||||
userMessage: {
|
||||
messageId: 'msg-1',
|
||||
taskId,
|
||||
contextId,
|
||||
parts: [{ kind: 'text', text: 'a long running prompt' }],
|
||||
metadata: {
|
||||
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
|
||||
},
|
||||
},
|
||||
} as unknown as RequestContext;
|
||||
|
||||
// Don't await this, let it run in the background.
|
||||
let primaryError: Error | null = null;
|
||||
const primaryPromise = executor.execute(requestContext, mockEventBus);
|
||||
primaryPromise.catch((err) => {
|
||||
primaryError = err as Error;
|
||||
});
|
||||
|
||||
// Poll until the task is registered in the executor to avoid flaky timeouts in slow CI environments.
|
||||
let attempts = 0;
|
||||
while (!executor.getTask(taskId)) {
|
||||
if (primaryError) {
|
||||
throw new Error(`Primary execution failed early: ${primaryError}`);
|
||||
}
|
||||
if (attempts++ > 100) {
|
||||
// 100 * 5ms = 500ms timeout
|
||||
throw new Error('Timed out waiting for task to be registered');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
const wrapper = executor.getTask(taskId);
|
||||
expect(wrapper).toBeDefined();
|
||||
const setTaskStateSpy = vi
|
||||
.spyOn(wrapper!.task, 'setTaskStateAndPublishUpdate')
|
||||
.mockImplementation((newState) => {
|
||||
// Make the mock realistic: actually update the state when called.
|
||||
wrapper!.task.taskState = newState;
|
||||
});
|
||||
|
||||
// Now, cancel the task.
|
||||
await executor.cancelTask(taskId, mockEventBus);
|
||||
|
||||
// Verify that the abort method on the controller was called and state was updated.
|
||||
expect(abortSpy).toHaveBeenCalledOnce();
|
||||
expect(setTaskStateSpy).toHaveBeenCalledWith(
|
||||
'canceled',
|
||||
expect.any(Object),
|
||||
'Task canceled by user request.',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
// Clean up the test by allowing the promise to resolve.
|
||||
// The abort call should have unblocked the acceptUserMessage generator.
|
||||
await primaryPromise;
|
||||
|
||||
// Verify task is evicted from cache
|
||||
expect(executor.getTask(taskId)).toBeUndefined();
|
||||
|
||||
abortSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('cancelTask should explicitly save task state to TaskStore and evict task during active aborts', async () => {
|
||||
const taskId = 'test-task-active-abort-save';
|
||||
const contextId = 'test-context';
|
||||
|
||||
const mockSocket = new EventEmitter();
|
||||
(requestStorage.getStore as Mock).mockReturnValue({
|
||||
req: { socket: mockSocket },
|
||||
});
|
||||
|
||||
const requestContext = {
|
||||
userMessage: {
|
||||
messageId: 'msg-1',
|
||||
taskId,
|
||||
contextId,
|
||||
parts: [{ kind: 'text', text: 'a long running prompt' }],
|
||||
metadata: {
|
||||
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
|
||||
},
|
||||
},
|
||||
} as unknown as RequestContext;
|
||||
|
||||
const primaryPromise = executor.execute(requestContext, mockEventBus);
|
||||
|
||||
// Wait for task to be registered
|
||||
let attempts = 0;
|
||||
while (!executor.getTask(taskId)) {
|
||||
if (attempts++ > 100) {
|
||||
throw new Error('Timed out waiting for task to be registered');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
const wrapper = executor.getTask(taskId)!;
|
||||
const saveSpy = vi.spyOn(mockTaskStore, 'save');
|
||||
|
||||
// Now, cancel the task.
|
||||
await executor.cancelTask(taskId, mockEventBus);
|
||||
|
||||
// Verify that the task state was saved to TaskStore during cancelTask
|
||||
expect(saveSpy).toHaveBeenCalled();
|
||||
expect(wrapper.task.dispose).toHaveBeenCalled();
|
||||
expect(executor.getTask(taskId)).toBeUndefined();
|
||||
|
||||
// Clean up the test by allowing the promise to resolve.
|
||||
await primaryPromise;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1092,19 +1092,21 @@ export class Task {
|
||||
logger.info(
|
||||
`[Task] Adding ${completedTools.length} tool responses to history without generating a new response.`,
|
||||
);
|
||||
const responsesToAdd = completedTools.flatMap(
|
||||
(toolCall) => toolCall.response.responseParts,
|
||||
);
|
||||
|
||||
for (const response of responsesToAdd) {
|
||||
let parts: genAiPart[];
|
||||
if (Array.isArray(response)) {
|
||||
parts = response;
|
||||
} else if (typeof response === 'string') {
|
||||
parts = [{ text: response }];
|
||||
} else {
|
||||
parts = [response];
|
||||
const parts: genAiPart[] = [];
|
||||
for (const toolCall of completedTools) {
|
||||
const response = toolCall.response?.responseParts;
|
||||
if (!response) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(response)) {
|
||||
parts.push(...response);
|
||||
} else if (typeof response === 'string') {
|
||||
parts.push({ text: response });
|
||||
} else {
|
||||
parts.push(response);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.geminiClient.addHistory({
|
||||
role: 'user',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
startupProfiler,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
homedir,
|
||||
tmpdir,
|
||||
GitService,
|
||||
fetchAdminControlsOnce,
|
||||
getCodeAssistServer,
|
||||
@@ -28,28 +30,246 @@ import {
|
||||
type TelemetryTarget,
|
||||
type ConfigParameters,
|
||||
type ExtensionLoader,
|
||||
resolveToRealPath,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import { type AgentSettings, CoderAgentEvent } from '../types.js';
|
||||
|
||||
const INITIAL_FOLDER_TRUST = process.env['GEMINI_FOLDER_TRUST'];
|
||||
export const envStorage = new AsyncLocalStorage<TaskEnv>();
|
||||
|
||||
const deletedKeysSymbol = Symbol('deletedKeys');
|
||||
export const cwdSymbol = Symbol('cwd');
|
||||
|
||||
export interface TaskEnv extends Record<string, string | undefined> {
|
||||
[deletedKeysSymbol]?: Set<string>;
|
||||
[cwdSymbol]?: string;
|
||||
}
|
||||
|
||||
// Set up a Proxy on process.env to intercept reads and writes, isolating environment variables per task
|
||||
const originalEnv = process.env;
|
||||
const envProxy = new Proxy(originalEnv, {
|
||||
get(target, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
const deleted = taskEnv[deletedKeysSymbol];
|
||||
if (deleted?.has(prop)) {
|
||||
return undefined;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
|
||||
return taskEnv[prop];
|
||||
}
|
||||
}
|
||||
return target[prop];
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return target[prop as any];
|
||||
},
|
||||
has(target, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
const deleted = taskEnv[deletedKeysSymbol];
|
||||
if (deleted?.has(prop)) {
|
||||
return false;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return prop in target;
|
||||
}
|
||||
return prop in target;
|
||||
},
|
||||
set(target, prop, value) {
|
||||
if (typeof prop === 'string') {
|
||||
if (
|
||||
prop === '__proto__' ||
|
||||
prop === 'constructor' ||
|
||||
prop === 'prototype'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
taskEnv[deletedKeysSymbol]?.delete(prop);
|
||||
taskEnv[prop] = String(value);
|
||||
return true;
|
||||
}
|
||||
target[prop] = String(value);
|
||||
return true;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
target[prop as any] = value;
|
||||
return true;
|
||||
},
|
||||
deleteProperty(target, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
if (
|
||||
prop === '__proto__' ||
|
||||
prop === 'constructor' ||
|
||||
prop === 'prototype'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
delete taskEnv[prop];
|
||||
(taskEnv[deletedKeysSymbol] ??= new Set()).add(prop);
|
||||
return true;
|
||||
}
|
||||
delete target[prop];
|
||||
return true;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return delete target[prop as any];
|
||||
},
|
||||
ownKeys(target) {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
const keys = new Set<string | symbol>([
|
||||
...Object.getOwnPropertyNames(target),
|
||||
...Object.getOwnPropertySymbols(target),
|
||||
...Object.keys(taskEnv),
|
||||
]);
|
||||
taskEnv[deletedKeysSymbol]?.forEach((key) => {
|
||||
keys.delete(key);
|
||||
});
|
||||
return Array.from(keys);
|
||||
}
|
||||
return [
|
||||
...Object.getOwnPropertyNames(target),
|
||||
...Object.getOwnPropertySymbols(target),
|
||||
];
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv && typeof prop === 'string') {
|
||||
const deleted = taskEnv[deletedKeysSymbol];
|
||||
if (deleted?.has(prop)) {
|
||||
return undefined;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) {
|
||||
return {
|
||||
value: taskEnv[prop],
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
return Object.getOwnPropertyDescriptor(target, prop);
|
||||
},
|
||||
defineProperty(target, prop, descriptor) {
|
||||
if (typeof prop === 'string') {
|
||||
if (
|
||||
prop === '__proto__' ||
|
||||
prop === 'constructor' ||
|
||||
prop === 'prototype'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
taskEnv[deletedKeysSymbol]?.delete(prop);
|
||||
taskEnv[prop] =
|
||||
descriptor.value !== undefined ? String(descriptor.value) : undefined;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
Object.defineProperty(target, prop as any, descriptor);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(process, 'env', {
|
||||
value: envProxy,
|
||||
writable: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// NOTE: Monkey-patching process.cwd and process.chdir via AsyncLocalStorage is a robust way
|
||||
// to simulate workspace isolation in a concurrent server. However, please be aware of a critical
|
||||
// limitation: Node.js native C++ APIs (such as fs.readFileSync, fs.writeFile, etc.) and child
|
||||
// process spawning APIs (like child_process.spawn) resolve relative paths using the OS-level
|
||||
// working directory of the process, NOT the JS-level process.cwd() function.
|
||||
// To prevent cross-task interference, all file paths in the core package must be resolved to
|
||||
// absolute paths using path.resolve/path.join relative to config.getTargetDir() or config.getCwd()
|
||||
// before being passed to native APIs.
|
||||
const originalCwd = process.cwd;
|
||||
process.cwd = function () {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv && taskEnv[cwdSymbol]) {
|
||||
return taskEnv[cwdSymbol];
|
||||
}
|
||||
return originalCwd.call(process);
|
||||
};
|
||||
|
||||
const originalChdir = process.chdir;
|
||||
process.chdir = function (directory: string) {
|
||||
const taskEnv = envStorage.getStore();
|
||||
if (taskEnv) {
|
||||
const resolved = path.resolve(process.cwd(), directory);
|
||||
try {
|
||||
const stats = fs.statSync(resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
const err = new Error(
|
||||
"ENOTDIR: not a directory, chdir '" + resolved + "'",
|
||||
);
|
||||
(err as NodeJS.ErrnoException).code = 'ENOTDIR';
|
||||
throw err;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
) {
|
||||
const chdirErr = new Error(
|
||||
"ENOENT: no such file or directory, chdir '" + resolved + "'",
|
||||
);
|
||||
(chdirErr as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
throw chdirErr;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
taskEnv[cwdSymbol] = resolved;
|
||||
return;
|
||||
}
|
||||
return originalChdir.call(process, directory);
|
||||
};
|
||||
|
||||
export function getEnv(key: string): string | undefined {
|
||||
return process.env[key];
|
||||
}
|
||||
|
||||
export async function loadConfig(
|
||||
settings: Settings,
|
||||
extensionLoader: ExtensionLoader,
|
||||
taskId: string,
|
||||
trusted: boolean = false,
|
||||
workspaceDir: string = process.cwd(),
|
||||
): Promise<Config> {
|
||||
const workspaceDir = process.cwd();
|
||||
const workspaceEnv = await loadEnvironment(trusted, workspaceDir);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const envVars: Record<string, string> = { ...process.env } as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
Object.assign(envVars, workspaceEnv);
|
||||
|
||||
const getEnvLocal = (key: string) => envVars[key];
|
||||
|
||||
const folderTrust =
|
||||
settings.folderTrust === true ||
|
||||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
|
||||
getEnvLocal('GEMINI_FOLDER_TRUST') === 'true';
|
||||
|
||||
let checkpointing = process.env['CHECKPOINTING']
|
||||
? process.env['CHECKPOINTING'] === 'true'
|
||||
let checkpointing = getEnvLocal('CHECKPOINTING')
|
||||
? getEnvLocal('CHECKPOINTING') === 'true'
|
||||
: settings.checkpointing?.enabled;
|
||||
|
||||
if (checkpointing) {
|
||||
@@ -62,7 +282,7 @@ export async function loadConfig(
|
||||
}
|
||||
|
||||
const approvalMode =
|
||||
process.env['GEMINI_YOLO_MODE'] === 'true'
|
||||
getEnvLocal('GEMINI_YOLO_MODE') === 'true'
|
||||
? ApprovalMode.YOLO
|
||||
: ApprovalMode.DEFAULT;
|
||||
|
||||
@@ -91,8 +311,9 @@ export async function loadConfig(
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
|
||||
targetDir: workspaceDir, // Or a specific directory the agent operates on
|
||||
debugMode: process.env['DEBUG'] === 'true' || false,
|
||||
debugMode: getEnvLocal('DEBUG') === 'true' || false,
|
||||
question: '', // Not used in server mode directly like CLI
|
||||
env: envVars,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
excludeTools: settings.tools?.exclude || undefined,
|
||||
@@ -107,7 +328,7 @@ export async function loadConfig(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
target: settings.telemetry?.target as TelemetryTarget,
|
||||
otlpEndpoint:
|
||||
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
|
||||
getEnvLocal('OTEL_EXPORTER_OTLP_ENDPOINT') ??
|
||||
settings.telemetry?.otlpEndpoint,
|
||||
logPrompts: settings.telemetry?.logPrompts,
|
||||
},
|
||||
@@ -119,8 +340,8 @@ export async function loadConfig(
|
||||
settings.fileFiltering?.enableRecursiveFileSearch,
|
||||
customIgnoreFilePaths: [
|
||||
...(settings.fileFiltering?.customIgnoreFilePaths || []),
|
||||
...(process.env['CUSTOM_IGNORE_FILE_PATHS']
|
||||
? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter)
|
||||
...(getEnvLocal('CUSTOM_IGNORE_FILE_PATHS')
|
||||
? getEnvLocal('CUSTOM_IGNORE_FILE_PATHS').split(path.delimiter)
|
||||
: []),
|
||||
],
|
||||
},
|
||||
@@ -179,7 +400,7 @@ export async function loadConfig(
|
||||
await config.waitForMcpInit();
|
||||
startupProfiler.flush(config);
|
||||
|
||||
await refreshAuthentication(config, 'Config');
|
||||
await refreshAuthentication(config, 'Config', envVars);
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -187,16 +408,19 @@ export async function loadConfig(
|
||||
export function setIsTrusted(
|
||||
agentSettings: AgentSettings | undefined,
|
||||
): boolean {
|
||||
if (INITIAL_FOLDER_TRUST !== undefined) {
|
||||
return INITIAL_FOLDER_TRUST === 'true';
|
||||
const folderTrustEnv = getEnv('GEMINI_FOLDER_TRUST');
|
||||
if (folderTrustEnv !== undefined) {
|
||||
return folderTrustEnv === 'true';
|
||||
}
|
||||
return !!agentSettings?.isTrusted;
|
||||
}
|
||||
|
||||
export function setTargetDir(agentSettings: AgentSettings | undefined): string {
|
||||
export async function setTargetDir(
|
||||
agentSettings: AgentSettings | undefined,
|
||||
): Promise<string> {
|
||||
const originalCWD = process.cwd();
|
||||
const targetDir =
|
||||
process.env['CODER_AGENT_WORKSPACE_PATH'] ??
|
||||
getEnv('CODER_AGENT_WORKSPACE_PATH') ??
|
||||
(agentSettings?.kind === CoderAgentEvent.StateAgentSettingsEvent
|
||||
? agentSettings.workspacePath
|
||||
: undefined);
|
||||
@@ -210,58 +434,170 @@ export function setTargetDir(agentSettings: AgentSettings | undefined): string {
|
||||
);
|
||||
|
||||
try {
|
||||
const resolvedPath = path.resolve(targetDir);
|
||||
process.chdir(resolvedPath);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(targetDir);
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
) {
|
||||
const parentDir = path.dirname(path.resolve(targetDir));
|
||||
resolvedPath = path.join(
|
||||
resolveToRealPath(parentDir),
|
||||
path.basename(targetDir),
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const isTestEnv =
|
||||
process.env['VITEST'] === 'true' ||
|
||||
process.env['NODE_ENV'] === 'test' ||
|
||||
process.argv.some((arg) => arg.includes('vitest')) ||
|
||||
resolvedPath.startsWith(resolveToRealPath(tmpdir()));
|
||||
|
||||
const allowedRoot = resolveToRealPath(
|
||||
getEnv('CODER_AGENT_ALLOWED_ROOT') ||
|
||||
(isTestEnv ? path.parse(resolvedPath).root : homedir()),
|
||||
);
|
||||
const relative = path.relative(allowedRoot, resolvedPath);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(
|
||||
`Workspace path ${resolvedPath} is outside the allowed root directory`,
|
||||
);
|
||||
}
|
||||
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = await fs.promises.stat(resolvedPath);
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
) {
|
||||
if (isTestEnv) {
|
||||
await fs.promises.mkdir(resolvedPath, { recursive: true });
|
||||
stats = await fs.promises.stat(resolvedPath);
|
||||
} else {
|
||||
throw new Error(`Workspace path ${resolvedPath} does not exist`);
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Workspace path ${resolvedPath} is not a directory`);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[CoderAgentExecutor] Error resolving workspace path: ${e}, returning original os.cwd()`,
|
||||
);
|
||||
return originalCWD;
|
||||
logger.error(`[CoderAgentExecutor] Error resolving workspace path: ${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEnvironment(): void {
|
||||
const envFilePath = findEnvFile(process.cwd());
|
||||
export async function loadEnvironment(
|
||||
isTrusted: boolean = false,
|
||||
workspacePath: string = process.cwd(),
|
||||
): Promise<Record<string, string>> {
|
||||
// For untrusted workspaces, we completely bypass workspace-level .env loading
|
||||
// and only load environment variables from the user's trusted home directory.
|
||||
let envFilePath: string | null = null;
|
||||
if (isTrusted) {
|
||||
envFilePath = await findEnvFile(workspacePath);
|
||||
} else {
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
try {
|
||||
await fs.promises.access(homeGeminiEnvPath);
|
||||
envFilePath = homeGeminiEnvPath;
|
||||
} catch {
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
try {
|
||||
await fs.promises.access(homeEnvPath);
|
||||
envFilePath = homeEnvPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
const envVars: Record<string, string> = {};
|
||||
if (envFilePath) {
|
||||
dotenv.config({ path: envFilePath, override: true });
|
||||
try {
|
||||
const content = await fs.promises.readFile(envFilePath, 'utf-8');
|
||||
const parsed = dotenv.parse(content);
|
||||
for (const key in parsed) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(parsed, key) &&
|
||||
key !== '__proto__' &&
|
||||
key !== 'constructor' &&
|
||||
key !== 'prototype'
|
||||
) {
|
||||
envVars[key] = parsed[key];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string): string | null {
|
||||
async function findEnvFile(startDir: string): Promise<string | null> {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
try {
|
||||
await fs.promises.access(geminiEnvPath);
|
||||
return geminiEnvPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
try {
|
||||
await fs.promises.access(envPath);
|
||||
return envPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
// check .env under home as fallback, again preferring gemini-specific .env
|
||||
const homeGeminiEnvPath = path.join(process.cwd(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
}
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
if (fs.existsSync(homeEnvPath)) {
|
||||
return homeEnvPath;
|
||||
}
|
||||
return null;
|
||||
break;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
// check .env under home as fallback, again preferring gemini-specific .env
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
try {
|
||||
await fs.promises.access(homeGeminiEnvPath);
|
||||
return homeGeminiEnvPath;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
try {
|
||||
await fs.promises.access(homeEnvPath);
|
||||
return homeEnvPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAuthentication(
|
||||
config: Config,
|
||||
logPrefix: string,
|
||||
envVars: Record<string, string>,
|
||||
): Promise<void> {
|
||||
if (process.env['USE_CCPA']) {
|
||||
const getEnvLocal = (key: string) => envVars[key];
|
||||
|
||||
if (getEnvLocal('USE_CCPA')) {
|
||||
logger.info(`[${logPrefix}] Using CCPA Auth:`);
|
||||
|
||||
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
|
||||
@@ -276,7 +612,7 @@ async function refreshAuthentication(
|
||||
);
|
||||
|
||||
const useComputeAdc =
|
||||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
|
||||
getEnvLocal('GEMINI_CLI_USE_COMPUTE_ADC') === 'true';
|
||||
const isHeadless = isHeadlessMode();
|
||||
|
||||
if (isHeadless || useComputeAdc) {
|
||||
@@ -305,11 +641,14 @@ async function refreshAuthentication(
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
|
||||
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${getEnvLocal('GOOGLE_CLOUD_PROJECT')}`,
|
||||
);
|
||||
} else if (process.env['GEMINI_API_KEY']) {
|
||||
} else if (getEnvLocal('GEMINI_API_KEY')) {
|
||||
logger.info(`[${logPrefix}] Using Gemini API Key`);
|
||||
await config.refreshAuth(AuthType.USE_GEMINI);
|
||||
await config.refreshAuth(
|
||||
AuthType.USE_GEMINI,
|
||||
getEnvLocal('GEMINI_API_KEY'),
|
||||
);
|
||||
} else {
|
||||
const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
|
||||
logger.error(errorMessage);
|
||||
|
||||
@@ -36,9 +36,12 @@ interface ExtensionConfig {
|
||||
excludeTools?: string[];
|
||||
}
|
||||
|
||||
export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] {
|
||||
export function loadExtensions(
|
||||
workspaceDir: string,
|
||||
isTrusted: boolean = false,
|
||||
): GeminiCLIExtension[] {
|
||||
const allExtensions = [
|
||||
...loadExtensionsFromDir(workspaceDir),
|
||||
...(isTrusted ? loadExtensionsFromDir(workspaceDir) : []),
|
||||
...loadExtensionsFromDir(homedir()),
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
let mockHomeDir = '';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...original,
|
||||
homedir: () => mockHomeDir,
|
||||
};
|
||||
});
|
||||
|
||||
import { loadEnvironment } from './config.js';
|
||||
|
||||
describe('Vulnerability Mitigation: b-519269096', () => {
|
||||
let tempWorkspaceDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temporary home directory securely using mkdtempSync to ensure hermeticity
|
||||
mockHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-mock-home-'));
|
||||
|
||||
// Create a temporary workspace directory representing an untrusted repo
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-exploit-workspace-'),
|
||||
);
|
||||
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
|
||||
fs.mkdirSync(geminiDir, { recursive: true });
|
||||
|
||||
// Mock process.cwd to return the untrusted workspace
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
fs.rmSync(mockHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should ignore GEMINI_CLI_TRUST_WORKSPACE and GEMINI_YOLO_MODE in untrusted workspaces', async () => {
|
||||
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
|
||||
fs.writeFileSync(
|
||||
path.join(geminiDir, '.env'),
|
||||
'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
|
||||
);
|
||||
|
||||
// Ensure initially not set
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
|
||||
vi.stubEnv('GEMINI_YOLO_MODE', '');
|
||||
|
||||
// Act: load environment with isTrusted = false
|
||||
const envVars = await loadEnvironment(false);
|
||||
|
||||
// Assert: In a SECURE system, these variables should NOT be loaded
|
||||
expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBeUndefined();
|
||||
expect(envVars['GEMINI_YOLO_MODE']).toBeUndefined();
|
||||
expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
|
||||
expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not load any variables from untrusted workspaces', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempWorkspaceDir, '.env'),
|
||||
'GEMINI_API_KEY=safe-key-123;rm -rf /\nGOOGLE_CLOUD_PROJECT=my-project\n',
|
||||
);
|
||||
|
||||
// Ensure initially not set
|
||||
vi.stubEnv('GEMINI_API_KEY', '');
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', '');
|
||||
|
||||
// Act: load environment with isTrusted = false
|
||||
const envVars = await loadEnvironment(false);
|
||||
|
||||
// Assert: No variables should be loaded from the untrusted workspace
|
||||
expect(envVars['GEMINI_API_KEY']).toBeUndefined();
|
||||
expect(envVars['GOOGLE_CLOUD_PROJECT']).toBeUndefined();
|
||||
expect(process.env['GEMINI_API_KEY']).toBeFalsy();
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should load all variables in trusted workspaces with isolation', async () => {
|
||||
const geminiDir = path.join(tempWorkspaceDir, '.gemini');
|
||||
fs.writeFileSync(
|
||||
path.join(geminiDir, '.env'),
|
||||
'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n',
|
||||
);
|
||||
|
||||
// Ensure initially not set
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', '');
|
||||
vi.stubEnv('GEMINI_YOLO_MODE', '');
|
||||
|
||||
// Load environment variables
|
||||
const envVars = await loadEnvironment(true);
|
||||
|
||||
// Assert: In a trusted workspace, variables should be loaded
|
||||
expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBe('true');
|
||||
expect(envVars['GEMINI_YOLO_MODE']).toBe('true');
|
||||
|
||||
// Assert: Global process.env should NOT be polluted
|
||||
expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy();
|
||||
expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -197,8 +197,7 @@ async function handleExecuteCommand(
|
||||
export async function createApp() {
|
||||
try {
|
||||
// Load the server configuration once on startup.
|
||||
const workspaceRoot = setTargetDir(undefined);
|
||||
loadEnvironment();
|
||||
const workspaceRoot = await setTargetDir(undefined);
|
||||
|
||||
// Use a temporary settings load to check if folder trust is enabled.
|
||||
// This is similar to how the CLI handles the initial trust check.
|
||||
@@ -209,13 +208,35 @@ export async function createApp() {
|
||||
isHeadless: isHeadlessMode(),
|
||||
});
|
||||
|
||||
// Change the global working directory to the workspace root during startup
|
||||
process.chdir(workspaceRoot);
|
||||
|
||||
// Load environment globally for the server startup
|
||||
const globalEnv = await loadEnvironment(isTrusted ?? false, workspaceRoot);
|
||||
// Only assign safe server-config variables to process.env to prevent credential leakage
|
||||
const allowedServerKeys = [
|
||||
'CODER_AGENT_PORT',
|
||||
'CODER_AGENT_WORKSPACE_PATH',
|
||||
'GCS_BUCKET_NAME',
|
||||
'LOG_LEVEL',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'GEMINI_CLI_USE_COMPUTE_ADC',
|
||||
];
|
||||
for (const key of allowedServerKeys) {
|
||||
if (globalEnv[key] !== undefined) {
|
||||
process.env[key] = globalEnv[key];
|
||||
}
|
||||
}
|
||||
|
||||
const settings = loadSettings(workspaceRoot, isTrusted ?? false);
|
||||
const extensions = loadExtensions(workspaceRoot);
|
||||
const extensions = loadExtensions(workspaceRoot, isTrusted ?? false);
|
||||
const config = await loadConfig(
|
||||
settings,
|
||||
new SimpleExtensionLoader(extensions),
|
||||
'a2a-server',
|
||||
isTrusted ?? false,
|
||||
workspaceRoot,
|
||||
);
|
||||
|
||||
let git: GitService | undefined;
|
||||
|
||||
@@ -258,7 +258,7 @@ export class GCSTaskStore implements TaskStore {
|
||||
}
|
||||
const agentSettings = persistedState._agentSettings;
|
||||
|
||||
const workDir = setTargetDir(agentSettings);
|
||||
const workDir = await setTargetDir(agentSettings);
|
||||
await fse.ensureDir(workDir);
|
||||
const workspaceFile = this.storage
|
||||
.bucket(this.bucketName)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { resolveToRealPath, isSubpath } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Validates a workspace path to prevent path traversal attacks.
|
||||
*
|
||||
* @param workspacePath The path to validate.
|
||||
* @param allowedRoot The root directory the path must be within. Defaults to CWD.
|
||||
* @returns The resolved, safe path.
|
||||
* @throws An error if the path is invalid or outside the allowed root.
|
||||
*/
|
||||
export async function validateWorkspacePath(
|
||||
workspacePath?: string,
|
||||
allowedRoot: string = process.cwd(),
|
||||
): Promise<string> {
|
||||
const trimmedPath = workspacePath?.trim();
|
||||
if (!trimmedPath) {
|
||||
return resolveToRealPath(allowedRoot);
|
||||
}
|
||||
|
||||
if (trimmedPath.includes('\0')) {
|
||||
throw new Error('Security violation: Null byte detected in path.');
|
||||
}
|
||||
|
||||
try {
|
||||
const canonicalAllowedRoot = resolveToRealPath(allowedRoot);
|
||||
const resolvedWorkspacePath = path.resolve(
|
||||
canonicalAllowedRoot,
|
||||
trimmedPath,
|
||||
);
|
||||
const canonicalWorkspacePath = resolveToRealPath(resolvedWorkspacePath);
|
||||
|
||||
// Check if the resolved path is within the allowed root directory
|
||||
if (
|
||||
canonicalWorkspacePath !== canonicalAllowedRoot &&
|
||||
!isSubpath(canonicalAllowedRoot, canonicalWorkspacePath)
|
||||
) {
|
||||
throw new Error(
|
||||
`Security violation: The path "${trimmedPath}" is outside the allowed root directory.`,
|
||||
);
|
||||
}
|
||||
|
||||
const stats = await fs.promises.stat(canonicalWorkspacePath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`The path "${trimmedPath}" is not a directory.`);
|
||||
}
|
||||
|
||||
return canonicalWorkspacePath;
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
|
||||
throw new Error(`The path "${trimmedPath}" does not exist.`);
|
||||
}
|
||||
throw e; // Re-throw other errors
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,6 @@
|
||||
`MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the
|
||||
element is available, avoiding potential rendering timing issues.
|
||||
- Avoid prop drilling when at all possible.
|
||||
- **StaticRender**: Unlike Ink's native `<Static>` (which is printed above the
|
||||
application layout and takes no space in the flex container), the custom
|
||||
`<StaticRender>` component preserves its layout and _does_ take up its
|
||||
measured height in the active flex container.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.54.0-nightly.20260722.gf743ab579",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.49.0-nightly.20260617.g4d3dcdce1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.54.0-nightly.20260722.gf743ab579"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
@@ -49,7 +49,7 @@
|
||||
"fzf": "0.5.2",
|
||||
"glob": "12.0.0",
|
||||
"highlight.js": "11.11.1",
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink-gradient": "3.0.0",
|
||||
"ink-spinner": "5.0.0",
|
||||
"latest-version": "9.0.0",
|
||||
|
||||
@@ -842,16 +842,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
maxScrollbackLength: {
|
||||
type: 'number',
|
||||
label: 'Max Scrollback Length',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: 1000,
|
||||
description:
|
||||
'Maximum number of lines to keep in the terminal scrollback buffer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showSpinner: {
|
||||
type: 'boolean',
|
||||
label: 'Show Spinner',
|
||||
|
||||
@@ -167,7 +167,6 @@ export async function startInteractiveUI(
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
debugRainbow: settings.merged.ui.debugRainbow === true,
|
||||
maxScrollbackLength: settings.merged.ui.maxScrollbackLength,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useIsScreenReaderEnabled } from 'ink';
|
||||
import { useUIState } from './contexts/UIStateContext.js';
|
||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||
@@ -14,7 +13,7 @@ import { DefaultAppLayout } from './layouts/DefaultAppLayout.js';
|
||||
import { AlternateBufferQuittingDisplay } from './components/AlternateBufferQuittingDisplay.js';
|
||||
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
||||
|
||||
export const App = React.memo(() => {
|
||||
export const App = () => {
|
||||
const uiState = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
@@ -36,6 +35,4 @@ export const App = React.memo(() => {
|
||||
{isScreenReaderEnabled ? <ScreenReaderAppLayout /> : <DefaultAppLayout />}
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
App.displayName = 'App';
|
||||
};
|
||||
|
||||
@@ -1557,13 +1557,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
terminalHeight - stableControlsHeight - backgroundTaskHeight - 1,
|
||||
);
|
||||
|
||||
// In terminalBuffer mode, we return terminalHeight - 1 to prevent frequent
|
||||
// invalidation of UIState. This value is correct for the few cases where a
|
||||
// fixed terminal height must be respected.
|
||||
const uiStateAvailableTerminalHeight = config.getUseTerminalBuffer()
|
||||
? terminalHeight - 1
|
||||
: availableTerminalHeight;
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
|
||||
terminalHeight: Math.max(
|
||||
@@ -2495,6 +2488,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlDPressedOnce: ctrlDPressCount >= 1,
|
||||
shortcutsHelpVisible,
|
||||
cleanUiDetailsVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
currentTip,
|
||||
@@ -2508,7 +2502,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
currentModel,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight: uiStateAvailableTerminalHeight,
|
||||
availableTerminalHeight,
|
||||
stableControlsHeight,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
@@ -2607,6 +2601,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlDPressCount,
|
||||
shortcutsHelpVisible,
|
||||
cleanUiDetailsVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
currentTip,
|
||||
@@ -2619,7 +2614,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
allowPlanMode,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
uiStateAvailableTerminalHeight,
|
||||
availableTerminalHeight,
|
||||
stableControlsHeight,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
|
||||
@@ -61,28 +61,6 @@ Tips for getting started:
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Composer
|
||||
"
|
||||
`;
|
||||
@@ -131,42 +109,42 @@ DialogManager
|
||||
`;
|
||||
|
||||
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
|
||||
" ▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.2.3 █
|
||||
█
|
||||
█
|
||||
█
|
||||
Tips for getting started: █
|
||||
1. Create GEMINI.md files to customize your interactions █
|
||||
2. /help for more information █
|
||||
3. Ask coding questions, edit code or run commands █
|
||||
4. Be specific for the best results █
|
||||
█
|
||||
█
|
||||
█
|
||||
█
|
||||
█
|
||||
█
|
||||
█
|
||||
█
|
||||
█
|
||||
HistoryItemDisplay █
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────█
|
||||
│ Action Required █
|
||||
│ █
|
||||
│ ? ls list directory █
|
||||
│ █
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ █
|
||||
│ │ ls │ █
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ █
|
||||
│ Allow execution of [ls]? █
|
||||
│ █
|
||||
│ ● 1. Allow once █
|
||||
│ 2. Allow for this session █
|
||||
│ 3. No, suggest changes (esc) █
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────█
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
HistoryItemDisplay
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required │
|
||||
│ │
|
||||
│ ? ls list directory │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ ls │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ Allow execution of [ls]? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. No, suggest changes (esc) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
|
||||
Composer
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
</style>
|
||||
<rect width="920" height="666" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<rect x="0" y="0" width="9" height="17" fill="#141414" />
|
||||
<rect x="9" y="0" width="18" height="17" fill="#141414" />
|
||||
<text x="9" y="2" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<rect x="27" y="0" width="324" height="17" fill="#141414" />
|
||||
<text x="27" y="2" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
|
||||
<rect x="351" y="0" width="549" height="17" fill="#141414" />
|
||||
<text x="0" y="19" fill="#141414" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
|
||||
<text x="0" y="53" fill="#333333" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="70" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">? Edit </text>
|
||||
@@ -66,7 +73,7 @@
|
||||
<text x="216" y="189" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="189" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="189" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│▄</text>
|
||||
<text x="0" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="206" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">48</text>
|
||||
@@ -76,7 +83,7 @@
|
||||
<text x="216" y="206" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="206" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="206" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="0" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="223" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">49</text>
|
||||
@@ -86,7 +93,7 @@
|
||||
<text x="216" y="223" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="223" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="223" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="0" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="240" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">50</text>
|
||||
@@ -96,7 +103,7 @@
|
||||
<text x="216" y="240" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="240" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="240" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="257" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">51</text>
|
||||
@@ -106,7 +113,7 @@
|
||||
<text x="216" y="257" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="257" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="257" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="274" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">52</text>
|
||||
@@ -116,7 +123,7 @@
|
||||
<text x="216" y="274" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="274" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="274" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="0" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="291" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">53</text>
|
||||
@@ -126,7 +133,7 @@
|
||||
<text x="216" y="291" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="252" y="291" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
|
||||
<text x="864" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="882" y="291" fill="#333333" textLength="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="0" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="36" y="308" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">54</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
@@ -1,8 +1,8 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = `
|
||||
"
|
||||
|
||||
" > Can you edit InputPrompt.tsx for me?
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
|
||||
╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │
|
||||
@@ -12,13 +12,13 @@ exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation bo
|
||||
│ │ 44 const line44 = true; │ │
|
||||
│ │ 45 const line45 = true; │ │
|
||||
│ │ 46 const line46 = true; │ │
|
||||
│ │ 47 const line47 = true; │ │
|
||||
│ │ 48 const line48 = true; │ │
|
||||
│ │ 49 const line49 = true; │ │
|
||||
│ │ 50 const line50 = true; │ │
|
||||
│ │ 51 const line51 = true; │ │
|
||||
│ │ 52 const line52 = true; │ │
|
||||
│ │ 53 const line53 = true; │ │
|
||||
│ │ 47 const line47 = true; │ │▄
|
||||
│ │ 48 const line48 = true; │ │█
|
||||
│ │ 49 const line49 = true; │ │█
|
||||
│ │ 50 const line50 = true; │ │█
|
||||
│ │ 51 const line51 = true; │ │█
|
||||
│ │ 52 const line52 = true; │ │█
|
||||
│ │ 53 const line53 = true; │ │█
|
||||
│ │ 54 const line54 = true; │ │█
|
||||
│ │ 55 const line55 = true; │ │█
|
||||
│ │ 56 const line56 = true; │ │█
|
||||
|
||||
@@ -44,7 +44,6 @@ import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
itemKey?: string;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
isPending: boolean;
|
||||
@@ -58,7 +57,6 @@ interface HistoryItemDisplayProps {
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
item,
|
||||
itemKey,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
isPending,
|
||||
@@ -104,7 +102,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini' && (
|
||||
<GeminiMessage
|
||||
itemKey={itemKey}
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
@@ -115,7 +112,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_content' && (
|
||||
<GeminiMessageContent
|
||||
itemKey={itemKey}
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
@@ -192,7 +188,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_group' && (
|
||||
<ToolGroupMessage
|
||||
itemKey={itemKey}
|
||||
item={itemForDisplay}
|
||||
toolCalls={itemForDisplay.tools}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
|
||||
@@ -20,9 +20,9 @@ import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
|
||||
import {
|
||||
FixedScrollableList,
|
||||
type FixedScrollableListRef,
|
||||
} from './shared/FixedScrollableList.js';
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
import { ListeningIndicator } from './ListeningIndicator.js';
|
||||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||||
import {
|
||||
@@ -290,7 +290,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const innerBoxRef = useRef<DOMElement>(null);
|
||||
const hasUserNavigatedSuggestions = useRef(false);
|
||||
const listRef = useRef<FixedScrollableListRef<ScrollableItem>>(null);
|
||||
const listRef = useRef<ScrollableListRef<ScrollableItem>>(null);
|
||||
|
||||
const { isRecording, handleVoiceInput, resetTurnBaseline } = useVoiceMode({
|
||||
buffer,
|
||||
@@ -1869,13 +1869,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
height={Math.min(buffer.viewportHeight, scrollableData.length)}
|
||||
width="100%"
|
||||
>
|
||||
{isAlternateBuffer ? (
|
||||
<FixedScrollableList
|
||||
{config.getUseTerminalBuffer() ? (
|
||||
<ScrollableList
|
||||
ref={listRef}
|
||||
hasFocus={focus}
|
||||
data={scrollableData}
|
||||
renderItem={renderItem}
|
||||
itemHeight={1}
|
||||
estimatedItemHeight={() => 1}
|
||||
fixedItemHeight={true}
|
||||
keyExtractor={(item) =>
|
||||
item.type === 'visualLine'
|
||||
? `line-${item.absoluteVisualIdx}`
|
||||
@@ -1883,7 +1884,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
width={inputWidth + SCROLLBAR_GUTTER_WIDTH}
|
||||
backgroundColor={listBackgroundColor}
|
||||
maxHeight={Math.min(
|
||||
containerHeight={Math.min(
|
||||
buffer.viewportHeight,
|
||||
scrollableData.length,
|
||||
)}
|
||||
|
||||
@@ -358,7 +358,6 @@ describe('MainContent', () => {
|
||||
bannerVisible: false,
|
||||
copyModeEnabled: false,
|
||||
terminalWidth: 100,
|
||||
mouseMode: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -804,6 +803,7 @@ describe('MainContent', () => {
|
||||
expect(output).toContain('Planning execution');
|
||||
expect(output).toContain('Refining approach');
|
||||
expect(output).toMatchSnapshot();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { useInputState } from '../contexts/InputContext.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
const MemoizedAppHeader = memo(AppHeader);
|
||||
@@ -38,7 +37,6 @@ export const MainContent = () => {
|
||||
const config = useConfig();
|
||||
const useTerminalBuffer = config.getUseTerminalBuffer();
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
const { copyModeEnabled } = useInputState();
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showConfirmationQueue = confirmingTool !== null;
|
||||
@@ -116,7 +114,6 @@ export const MainContent = () => {
|
||||
isToolGroupBoundary,
|
||||
}) => (
|
||||
<MemoizedHistoryItemDisplay
|
||||
itemKey={item.id.toString()}
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !isExpandable
|
||||
@@ -205,25 +202,17 @@ export const MainContent = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const headerItem = useMemo(() => ({ type: 'header' as const }), []);
|
||||
|
||||
const historyVirtualizedItems = useMemo(
|
||||
() =>
|
||||
augmentedHistory.map((data, index) => ({
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
{ type: 'header' as const },
|
||||
...augmentedHistory.map((data, index) => ({
|
||||
type: 'history' as const,
|
||||
item: data.item,
|
||||
element: historyItems[index],
|
||||
})),
|
||||
[augmentedHistory, historyItems],
|
||||
);
|
||||
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
headerItem,
|
||||
...historyVirtualizedItems,
|
||||
{ type: 'pending' as const, pendingHistoryItems },
|
||||
{ type: 'pending' as const },
|
||||
],
|
||||
[headerItem, historyVirtualizedItems, pendingHistoryItems],
|
||||
[augmentedHistory, historyItems],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
@@ -245,7 +234,7 @@ export const MainContent = () => {
|
||||
[showHeaderDetails, version, pendingItems],
|
||||
);
|
||||
|
||||
const estimatedItemHeight = useCallback(() => 10, []);
|
||||
const estimatedItemHeight = useCallback(() => 100, []);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: (typeof virtualizedData)[number], _index: number) => {
|
||||
@@ -260,7 +249,7 @@ export const MainContent = () => {
|
||||
// interactive. Gemini messages and Tool results that are not scrollable,
|
||||
// collapsible, or clickable should also be tagged as static in the future.
|
||||
const isStaticItem = useCallback(
|
||||
(item: (typeof virtualizedData)[number]) => item.type !== 'pending',
|
||||
(item: (typeof virtualizedData)[number]) => item.type === 'header',
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -282,7 +271,7 @@ export const MainContent = () => {
|
||||
renderStatic={useTerminalBuffer}
|
||||
isStaticItem={useTerminalBuffer ? isStaticItem : undefined}
|
||||
overflowToBackbuffer={useTerminalBuffer && !isAlternateBuffer}
|
||||
scrollbar={mouseMode && !copyModeEnabled}
|
||||
scrollbar={mouseMode}
|
||||
/>
|
||||
// TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()}
|
||||
// as that will reduce the # of cases where we will have to clear the
|
||||
@@ -306,7 +295,6 @@ export const MainContent = () => {
|
||||
isStaticItem,
|
||||
mouseMode,
|
||||
isAlternateBuffer,
|
||||
copyModeEnabled,
|
||||
]);
|
||||
|
||||
if (!uiState.isConfigInitialized) {
|
||||
|
||||
@@ -213,3 +213,24 @@ AppHeader(full)
|
||||
│ refine the solution.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`MainContent > renders multiple thinking messages sequentially correctly 2`] = `
|
||||
"ScrollableList
|
||||
AppHeader(full)
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
> Plan a solution
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
Thinking...
|
||||
│
|
||||
│ Initial analysis
|
||||
│ This is a multiple line paragraph for the first thinking message of how the
|
||||
│ model analyzes the problem.
|
||||
│
|
||||
│ Planning execution
|
||||
│ This a second multiple line paragraph for the second thinking message
|
||||
│ explaining the plan in detail so that it wraps around the terminal display.
|
||||
│
|
||||
│ Refining approach
|
||||
│ And finally a third multiple line paragraph for the third thinking message to
|
||||
│ refine the solution."
|
||||
`;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
SerializableConfirmationDetails,
|
||||
ToolResultDisplay,
|
||||
} from '../../types.js';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
|
||||
describe('DenseToolMessage', () => {
|
||||
const defaultProps = {
|
||||
@@ -564,114 +563,6 @@ describe('DenseToolMessage', () => {
|
||||
// Verify it shows the diff when expanded
|
||||
expect(lastFrame()).toContain('new line');
|
||||
});
|
||||
|
||||
it('shows diff content when globally expanded inside a VirtualizedList context', async () => {
|
||||
const mockListContext = {
|
||||
registerInteractivity: vi.fn(),
|
||||
setItemState: vi.fn(),
|
||||
getItemState: vi.fn(),
|
||||
isItemToggled: vi.fn().mockReturnValue(false),
|
||||
toggleItem: vi.fn(),
|
||||
registerClickCallback: vi.fn(),
|
||||
unregisterClickCallback: vi.fn(),
|
||||
registerClickableArea: vi.fn(),
|
||||
unregisterClickableArea: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<VirtualizedListContext.Provider
|
||||
value={
|
||||
mockListContext as unknown as React.ContextType<
|
||||
typeof VirtualizedListContext
|
||||
>
|
||||
}
|
||||
>
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
itemKey="item-1"
|
||||
resultDisplay={diffResult as ToolResultDisplay}
|
||||
status={CoreToolCallStatus.Success}
|
||||
/>
|
||||
</VirtualizedListContext.Provider>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
toolActions: {
|
||||
isExpanded: () => true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('new line');
|
||||
});
|
||||
|
||||
it('toggles expansion when header is clicked', async () => {
|
||||
const toggleExpansion = vi.fn();
|
||||
const toggleItem = vi.fn();
|
||||
let registeredCallback: (() => void) | undefined;
|
||||
|
||||
const MockVirtualizedListWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const itemKey = 'item-1';
|
||||
const mockListContext = {
|
||||
toggleItem,
|
||||
registerClickCallback: vi.fn((key, id, cb) => {
|
||||
if (key === itemKey && id === 'toggle-call-1') {
|
||||
registeredCallback = cb;
|
||||
}
|
||||
}),
|
||||
unregisterClickCallback: vi.fn(),
|
||||
registerInteractivity: vi.fn(),
|
||||
setItemState: vi.fn(),
|
||||
getItemState: vi.fn(),
|
||||
isItemToggled: vi.fn().mockReturnValue(false),
|
||||
registerClickableArea: vi.fn(),
|
||||
unregisterClickableArea: vi.fn(),
|
||||
};
|
||||
|
||||
return (
|
||||
<VirtualizedListContext.Provider
|
||||
value={
|
||||
mockListContext as unknown as React.ContextType<
|
||||
typeof VirtualizedListContext
|
||||
>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const { waitUntilReady } = await renderWithProviders(
|
||||
<MockVirtualizedListWrapper>
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
callId="call-1"
|
||||
itemKey="item-1"
|
||||
/>
|
||||
</MockVirtualizedListWrapper>,
|
||||
{
|
||||
toolActions: {
|
||||
toggleExpansion,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => expect(registeredCallback).toBeDefined());
|
||||
|
||||
// Trigger the registered callback manually (simulating VirtualizedList behavior)
|
||||
if (registeredCallback) {
|
||||
registeredCallback();
|
||||
}
|
||||
|
||||
expect(toggleItem).toHaveBeenCalledWith('item-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visual Regression', () => {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useContext, useCallback, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
type FileDiff,
|
||||
@@ -32,14 +32,13 @@ import {
|
||||
isNewFile,
|
||||
parseDiffWithLineNumbers,
|
||||
} from './DiffRenderer.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { COMPACT_TOOL_SUBVIEW_MAX_LINES } from '../../constants.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { colorizeCode } from '../../utils/CodeColorizer.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { getFileExtension } from '../../utils/fileUtils.js';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js';
|
||||
|
||||
const PAYLOAD_MARGIN_LEFT = 6;
|
||||
const PAYLOAD_BORDER_CHROME_WIDTH = 4; // paddingX=1 (2 cols) + borders (2 cols)
|
||||
@@ -47,8 +46,6 @@ const PAYLOAD_SCROLL_GUTTER = 4;
|
||||
const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER;
|
||||
|
||||
interface DenseToolMessageProps extends IndividualToolCallDisplay {
|
||||
itemKey?: string;
|
||||
groupKey?: string;
|
||||
terminalWidth: number;
|
||||
availableTerminalHeight?: number;
|
||||
}
|
||||
@@ -263,8 +260,6 @@ function getGenericSuccessData(
|
||||
|
||||
export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
const {
|
||||
itemKey,
|
||||
groupKey,
|
||||
callId,
|
||||
name,
|
||||
status,
|
||||
@@ -279,45 +274,15 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
const settings = useSettings();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
const effectiveItemKey = groupKey ?? itemKey;
|
||||
// Handle optional context members
|
||||
const [localIsExpanded, setLocalIsExpanded] = useState(false);
|
||||
const isExpanded = isExpandedInContext
|
||||
? isExpandedInContext(callId)
|
||||
: localIsExpanded;
|
||||
|
||||
// Determine expansion state based on list context or fallback to tool actions
|
||||
const isExpanded = useMemo(() => {
|
||||
const isExpandedGlobally = isExpandedInContext
|
||||
? isExpandedInContext(callId)
|
||||
: false;
|
||||
if (effectiveItemKey && virtualizedListContext) {
|
||||
return (
|
||||
virtualizedListContext.isItemToggled(effectiveItemKey) ||
|
||||
isExpandedGlobally
|
||||
);
|
||||
}
|
||||
return isExpandedGlobally;
|
||||
}, [effectiveItemKey, virtualizedListContext, isExpandedInContext, callId]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (effectiveItemKey && virtualizedListContext?.toggleItem) {
|
||||
virtualizedListContext.toggleItem(effectiveItemKey);
|
||||
} else if (toggleExpansion) {
|
||||
toggleExpansion(callId);
|
||||
}
|
||||
}, [effectiveItemKey, virtualizedListContext, toggleExpansion, callId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (virtualizedListContext && effectiveItemKey) {
|
||||
virtualizedListContext.registerInteractivity(effectiveItemKey, {
|
||||
click: true,
|
||||
});
|
||||
}
|
||||
}, [virtualizedListContext, effectiveItemKey]);
|
||||
|
||||
const clickableProps = useVirtualizedListClick(
|
||||
effectiveItemKey,
|
||||
`toggle-${callId}`,
|
||||
handleToggle,
|
||||
);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const toggleRef = useRef<DOMElement>(null);
|
||||
|
||||
// Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails)
|
||||
const diff = useMemo((): FileDiff | undefined => {
|
||||
@@ -336,6 +301,25 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
return undefined;
|
||||
}, [resultDisplay, confirmationDetails]);
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = !isExpanded;
|
||||
if (!next) {
|
||||
setIsFocused(false);
|
||||
} else {
|
||||
setIsFocused(true);
|
||||
}
|
||||
|
||||
if (toggleExpansion) {
|
||||
toggleExpansion(callId);
|
||||
} else {
|
||||
setLocalIsExpanded(next);
|
||||
}
|
||||
};
|
||||
|
||||
useMouseClick(toggleRef, handleToggle, {
|
||||
isActive: isAlternateBuffer && !!diff,
|
||||
});
|
||||
|
||||
// State-to-View Coordination
|
||||
const viewParts = useMemo((): ViewParts => {
|
||||
if (diff) {
|
||||
@@ -465,12 +449,7 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
ref={clickableProps.ref}
|
||||
marginLeft={2}
|
||||
flexDirection="row"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Box marginLeft={2} flexDirection="row" flexWrap="wrap">
|
||||
<Box flexDirection="row" flexShrink={1}>
|
||||
<ToolStatusIndicator status={status} name={name} />
|
||||
<Box maxWidth={25} flexShrink={0} flexGrow={0}>
|
||||
@@ -484,7 +463,12 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
</Box>
|
||||
|
||||
{summary && (
|
||||
<Box key="tool-summary" marginLeft={1} flexGrow={0}>
|
||||
<Box
|
||||
key="tool-summary"
|
||||
ref={isAlternateBuffer && diff ? toggleRef : undefined}
|
||||
marginLeft={1}
|
||||
flexGrow={0}
|
||||
>
|
||||
{summary}
|
||||
</Box>
|
||||
)}
|
||||
@@ -505,18 +489,23 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
borderColor={theme.border.default}
|
||||
borderDimColor={true}
|
||||
maxWidth={Math.min(
|
||||
PAYLOAD_MAX_WIDTH + PAYLOAD_BORDER_CHROME_WIDTH,
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
)}
|
||||
>
|
||||
<ScrollableList
|
||||
itemKey={itemKey}
|
||||
data={diffLines}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
hasFocus={false}
|
||||
width="100%"
|
||||
hasFocus={isFocused}
|
||||
width={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
terminalWidth -
|
||||
PAYLOAD_MARGIN_LEFT -
|
||||
PAYLOAD_BORDER_CHROME_WIDTH -
|
||||
PAYLOAD_SCROLL_GUTTER,
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { VirtualizedList } from '../shared/VirtualizedList.js';
|
||||
import { DenseToolMessage } from './DenseToolMessage.js';
|
||||
import { Box } from 'ink';
|
||||
import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('DenseToolMessage Interactivity in VirtualizedList', () => {
|
||||
const keyExtractor = (item: { id: string }) => item.id;
|
||||
|
||||
it('toggles expansion when header is clicked in a VirtualizedList', async () => {
|
||||
const data = [{ id: '1' }];
|
||||
const diffResult = {
|
||||
fileName: 'test.ts',
|
||||
filePath: 'test.ts',
|
||||
fileDiff: '--- test.ts\n+++ test.ts\n@@ -1,1 +1,1 @@\n-old\n+new',
|
||||
diffStat: { model_added_lines: 1, model_removed_lines: 1 },
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
|
||||
// We need to monitor if toggleItem is called on the list context
|
||||
// Actually, VirtualizedList handles its own state.
|
||||
// We can verify that it renders the payload after click.
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(
|
||||
<Box height={20} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={() => (
|
||||
<DenseToolMessage
|
||||
callId="call-1"
|
||||
itemKey="1-tool-call-1"
|
||||
groupKey="1"
|
||||
name="edit"
|
||||
status={CoreToolCallStatus.Success}
|
||||
resultDisplay={
|
||||
diffResult as unknown as React.ComponentProps<
|
||||
typeof DenseToolMessage
|
||||
>['resultDisplay']
|
||||
}
|
||||
terminalWidth={80}
|
||||
description="test"
|
||||
confirmationDetails={undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
mouseEventsEnabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Initially it should be collapsed (no payload shown because of alternate buffer mode)
|
||||
expect(lastFrame()).toContain('edit');
|
||||
expect(lastFrame()).toContain('test.ts');
|
||||
expect(lastFrame()).not.toContain('new');
|
||||
|
||||
// Click on the first line (the header), avoiding the left margin
|
||||
await simulateClick(10, 1);
|
||||
|
||||
// Now it should be expanded and show the diff payload
|
||||
await waitFor(() => expect(lastFrame()).toContain('new'), {
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('wakes up static DenseToolMessage and toggles on click', async () => {
|
||||
const data = [{ id: '1' }];
|
||||
const diffResult = {
|
||||
fileName: 'test.ts',
|
||||
filePath: 'test.ts',
|
||||
fileDiff: '--- test.ts\n+++ test.ts\n@@ -1,1 +1,1 @@\n-old\n+new',
|
||||
diffStat: { model_added_lines: 1, model_removed_lines: 1 },
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(
|
||||
<Box height={20} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={() => (
|
||||
<DenseToolMessage
|
||||
callId="call-1"
|
||||
itemKey="1-tool-call-1"
|
||||
groupKey="1"
|
||||
name="edit"
|
||||
status={CoreToolCallStatus.Success}
|
||||
resultDisplay={
|
||||
diffResult as unknown as React.ComponentProps<
|
||||
typeof DenseToolMessage
|
||||
>['resultDisplay']
|
||||
}
|
||||
terminalWidth={80}
|
||||
description="test"
|
||||
confirmationDetails={undefined}
|
||||
/>
|
||||
)}
|
||||
isStaticItem={() => true} // Force static rendering
|
||||
/>
|
||||
</Box>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
mouseEventsEnabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Static item should still show the header
|
||||
expect(lastFrame()).toContain('edit');
|
||||
expect(lastFrame()).not.toContain('new');
|
||||
|
||||
// Click to wake up and toggle
|
||||
await simulateClick(10, 1);
|
||||
|
||||
// Should wake up and expand
|
||||
await waitFor(() => expect(lastFrame()).toContain('new'), {
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface GeminiMessageProps {
|
||||
text: string;
|
||||
itemKey?: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
@@ -21,7 +20,6 @@ interface GeminiMessageProps {
|
||||
|
||||
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
text,
|
||||
itemKey,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -39,7 +37,6 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
itemKey={itemKey}
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface GeminiMessageContentProps {
|
||||
text: string;
|
||||
itemKey?: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
@@ -25,7 +24,6 @@ interface GeminiMessageContentProps {
|
||||
*/
|
||||
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
text,
|
||||
itemKey,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -37,7 +35,6 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={prefixWidth}>
|
||||
<MarkdownDisplay
|
||||
itemKey={itemKey}
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, Fragment, useContext } from 'react';
|
||||
import { useMemo, Fragment } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type {
|
||||
HistoryItem,
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
TOOL_RESULT_STATIC_HEIGHT,
|
||||
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
|
||||
const COMPACT_OUTPUT_ALLOWLIST = new Set([
|
||||
EDIT_DISPLAY_NAME,
|
||||
@@ -95,7 +94,6 @@ export const hasDensePayload = (tool: IndividualToolCallDisplay): boolean => {
|
||||
};
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
itemKey?: string;
|
||||
item: HistoryItem | HistoryItemWithoutId;
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
availableTerminalHeight?: number;
|
||||
@@ -110,7 +108,6 @@ interface ToolGroupMessageProps {
|
||||
const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4;
|
||||
|
||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
itemKey,
|
||||
item,
|
||||
toolCalls: allToolCalls,
|
||||
availableTerminalHeight,
|
||||
@@ -144,11 +141,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
} = useUIState();
|
||||
|
||||
const config = useConfig();
|
||||
const { registerInteractivity } = useContext(VirtualizedListContext) ?? {};
|
||||
|
||||
if (itemKey && registerInteractivity) {
|
||||
registerInteractivity(itemKey, { click: true, scroll: true });
|
||||
}
|
||||
|
||||
const { borderColor, borderDimColor } = useMemo(
|
||||
() =>
|
||||
@@ -433,18 +425,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const tool = group;
|
||||
const isShellToolCall = isShellTool(tool.name);
|
||||
const uniqueItemKey = itemKey
|
||||
? `${itemKey}-tool-${tool.callId}`
|
||||
: undefined;
|
||||
|
||||
const commonProps = {
|
||||
...tool,
|
||||
itemKey: uniqueItemKey,
|
||||
groupKey: itemKey,
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
terminalWidth: contentWidth,
|
||||
emphasis: 'medium' as const,
|
||||
isFirst: isFirstProp,
|
||||
isFirst: isCompact ? false : isFirstProp,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
|
||||
@@ -29,7 +29,6 @@ import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
export type { TextEmphasis };
|
||||
|
||||
export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||
itemKey?: string;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
emphasis?: TextEmphasis;
|
||||
@@ -45,7 +44,6 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||
|
||||
export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
callId,
|
||||
itemKey,
|
||||
name,
|
||||
description,
|
||||
resultDisplay,
|
||||
@@ -141,7 +139,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
/>
|
||||
)}
|
||||
<ToolResultDisplay
|
||||
itemKey={itemKey}
|
||||
resultDisplay={resultDisplay}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
|
||||
@@ -22,14 +22,13 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { Scrollable } from '../shared/Scrollable.js';
|
||||
import { FixedScrollableList } from '../shared/FixedScrollableList.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
|
||||
export interface ToolResultDisplayProps {
|
||||
itemKey?: string;
|
||||
resultDisplay: string | object | undefined;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
@@ -45,7 +44,6 @@ interface FileDiffResult {
|
||||
}
|
||||
|
||||
export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
itemKey,
|
||||
resultDisplay,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -196,7 +194,6 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
return (
|
||||
<Scrollable
|
||||
itemKey={itemKey}
|
||||
width={childWidth}
|
||||
maxHeight={effectiveMaxHeight}
|
||||
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
||||
@@ -216,12 +213,12 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const data = resultDisplay as AnsiOutput;
|
||||
|
||||
// In alternate buffer, always constrain to limit to ensure virtualization works and fits viewport.
|
||||
const listHeight = isAlternateBuffer
|
||||
? Math.min(data.length, limit)
|
||||
: !constrainHeight
|
||||
? data.length
|
||||
: Math.min(data.length, limit);
|
||||
// Calculate list height: if not constrained, use full data length.
|
||||
// If constrained (e.g. alternate buffer), limit to available height
|
||||
// to ensure virtualization works and fits within the viewport.
|
||||
const listHeight = !constrainHeight
|
||||
? data.length
|
||||
: Math.min(data.length, limit);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
const initialScrollIndex =
|
||||
@@ -229,13 +226,13 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
||||
<FixedScrollableList
|
||||
itemKey={itemKey}
|
||||
<ScrollableList
|
||||
width={childWidth}
|
||||
maxHeight={listHeight}
|
||||
containerHeight={listHeight}
|
||||
data={data}
|
||||
renderItem={renderVirtualizedAnsiLine}
|
||||
itemHeight={1}
|
||||
estimatedItemHeight={() => 1}
|
||||
fixedItemHeight={true}
|
||||
keyExtractor={keyExtractor}
|
||||
initialScrollIndex={initialScrollIndex}
|
||||
hasFocus={hasFocus}
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('ToolMessage Sticky Header Regression', () => {
|
||||
|
||||
// Scroll further so tool-1 is completely gone and tool-2's header should be stuck
|
||||
await act(async () => {
|
||||
listRef?.scrollBy(15);
|
||||
listRef?.scrollBy(17);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
|
||||
@@ -14,11 +14,6 @@ import {
|
||||
CoreToolCallStatus,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
import { Box, type DOMElement, getBoundingBox } from 'ink';
|
||||
import { useMouse } from '../../contexts/MouseContext.js';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
describe('<TopicMessage />', () => {
|
||||
const baseArgs = {
|
||||
@@ -35,86 +30,21 @@ describe('<TopicMessage />', () => {
|
||||
isExpanded?: (callId: string) => boolean;
|
||||
toggleExpansion?: (callId: string) => void;
|
||||
},
|
||||
virtualizedListProps?: {
|
||||
itemKey?: string;
|
||||
},
|
||||
) => {
|
||||
const defaultItemKey = virtualizedListProps?.itemKey || 'test-topic-key';
|
||||
|
||||
const MockVirtualizedListWrapper: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const callbacks = useRef(new Map<string, () => void>());
|
||||
|
||||
const mockListContext = {
|
||||
registerInteractivity: vi.fn(),
|
||||
setItemState: vi.fn(),
|
||||
getItemState: vi.fn(),
|
||||
isItemToggled: vi.fn().mockReturnValue(false),
|
||||
toggleItem: vi.fn(),
|
||||
registerClickCallback: vi.fn((key, id, cb) => {
|
||||
if (key === defaultItemKey) callbacks.current.set(id, cb);
|
||||
}),
|
||||
unregisterClickCallback: vi.fn((key, id) => {
|
||||
if (key === defaultItemKey) callbacks.current.delete(id);
|
||||
}),
|
||||
registerClickableArea: vi.fn(),
|
||||
unregisterClickableArea: vi.fn(),
|
||||
toggledKeys: new Set<string>(),
|
||||
};
|
||||
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
const handleMouse = useCallback(
|
||||
(event: { name: string; col: number; row: number }) => {
|
||||
if (event.name === 'left-press' && containerRef.current) {
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height: elHeight,
|
||||
} = getBoundingBox(containerRef.current);
|
||||
const mouseX = event.col - 1;
|
||||
const mouseY = event.row - 1;
|
||||
if (
|
||||
mouseX >= x &&
|
||||
mouseX < x + width &&
|
||||
mouseY >= y &&
|
||||
mouseY < y + elHeight
|
||||
) {
|
||||
const cb = callbacks.current.get('toggle');
|
||||
if (cb) cb();
|
||||
}
|
||||
}
|
||||
},
|
||||
[callbacks],
|
||||
);
|
||||
useMouse(handleMouse, { isActive: true });
|
||||
|
||||
return (
|
||||
<VirtualizedListContext.Provider value={mockListContext}>
|
||||
<Box ref={containerRef}>{children}</Box>
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
return renderWithProviders(
|
||||
<MockVirtualizedListWrapper>
|
||||
<TopicMessage
|
||||
args={args}
|
||||
itemKey={defaultItemKey}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={height}
|
||||
callId="test-topic"
|
||||
name={UPDATE_TOPIC_TOOL_NAME}
|
||||
description="Updating topic"
|
||||
status={CoreToolCallStatus.Success}
|
||||
confirmationDetails={undefined}
|
||||
resultDisplay={undefined}
|
||||
/>
|
||||
</MockVirtualizedListWrapper>,
|
||||
) =>
|
||||
renderWithProviders(
|
||||
<TopicMessage
|
||||
args={args}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={height}
|
||||
callId="test-topic"
|
||||
name={UPDATE_TOPIC_TOOL_NAME}
|
||||
description="Updating topic"
|
||||
status={CoreToolCallStatus.Success}
|
||||
confirmationDetails={undefined}
|
||||
resultDisplay={undefined}
|
||||
/>,
|
||||
{ toolActions, mouseEventsEnabled: true },
|
||||
);
|
||||
};
|
||||
|
||||
it('renders title and intent by default (collapsed)', async () => {
|
||||
const { lastFrame } = await renderTopic(baseArgs, 40);
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useId, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useEffect, useId, useRef, useCallback } from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import {
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
@@ -18,14 +18,12 @@ import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
|
||||
interface TopicMessageProps extends IndividualToolCallDisplay {
|
||||
terminalWidth: number;
|
||||
availableTerminalHeight?: number;
|
||||
isExpandable?: boolean;
|
||||
// TopicMessage is only interactive when rendered inside VirtualizedList.
|
||||
itemKey?: string;
|
||||
}
|
||||
|
||||
export const isTopicTool = (name: string): boolean =>
|
||||
@@ -36,7 +34,6 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
args,
|
||||
availableTerminalHeight,
|
||||
isExpandable = true,
|
||||
itemKey,
|
||||
}) => {
|
||||
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
|
||||
|
||||
@@ -50,6 +47,7 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
const overflowActions = useOverflowActions();
|
||||
const uniqueId = useId();
|
||||
const overflowId = `topic-${uniqueId}`;
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const rawTitle = args?.[TOPIC_PARAM_TITLE];
|
||||
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
|
||||
@@ -77,14 +75,9 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
}
|
||||
}, [toggleExpansion, hasExtraSummary, callId]);
|
||||
|
||||
const clickableProps = useVirtualizedListClick(
|
||||
itemKey,
|
||||
'toggle',
|
||||
handleToggle,
|
||||
{
|
||||
isActive: isExpandable && hasExtraSummary,
|
||||
},
|
||||
);
|
||||
useMouseClick(containerRef, handleToggle, {
|
||||
isActive: isExpandable && hasExtraSummary,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Only register if there is more content (summary) and it's currently hidden
|
||||
@@ -102,7 +95,7 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
}, [isExpandable, hasExtraSummary, isExpanded, overflowActions, overflowId]);
|
||||
|
||||
return (
|
||||
<Box ref={clickableProps.ref} flexDirection="column" marginLeft={2}>
|
||||
<Box ref={containerRef} flexDirection="column" marginLeft={2}>
|
||||
<Box flexDirection="row" flexWrap="wrap">
|
||||
<Text color={theme.text.primary} bold wrap="truncate-end">
|
||||
{title || 'Topic'}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────────╮ █
|
||||
│ ✓ Shell Command Description for Shell Command │
|
||||
│ ✓ Shell Command Description for Shell Command │ █
|
||||
│ │
|
||||
│ shell-01 │
|
||||
│ shell-02 │
|
||||
@@ -10,10 +10,10 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i
|
||||
`;
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────────╮ ▄
|
||||
│ ✓ Shell Command Description for Shell Command │ ▀
|
||||
│────────────────────────────────────────────────────────────────────────│
|
||||
│ shell-06 │
|
||||
"╭────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Shell Command Description for Shell Command │ ▄
|
||||
│────────────────────────────────────────────────────────────────────────│ █
|
||||
│ shell-06 │ ▀
|
||||
│ shell-07 │
|
||||
"
|
||||
`;
|
||||
@@ -28,8 +28,8 @@ exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessa
|
||||
`;
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 2`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────────╮ ▄
|
||||
│ ✓ tool-1 Description for tool-1 │ ▀
|
||||
"╭────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool-1 Description for tool-1 │ █
|
||||
│────────────────────────────────────────────────────────────────────────│
|
||||
│ c1-06 │
|
||||
│ c1-07 │
|
||||
@@ -38,9 +38,9 @@ exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessa
|
||||
|
||||
exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 3`] = `
|
||||
"│ │
|
||||
│ ✓ tool-2 Description for tool-2 │ ▄
|
||||
│────────────────────────────────────────────────────────────────────────│ ▀
|
||||
│ c2-08 │
|
||||
│ c2-09 │
|
||||
│ ✓ tool-2 Description for tool-2 │
|
||||
│────────────────────────────────────────────────────────────────────────│
|
||||
│ c2-10 │
|
||||
╰────────────────────────────────────────────────────────────────────────╯ █
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
FixedVirtualizedList,
|
||||
type FixedVirtualizedListRef,
|
||||
type FixedVirtualizedListProps,
|
||||
SCROLL_TO_ITEM_END,
|
||||
} from './FixedVirtualizedList.js';
|
||||
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
import { Box, type DOMElement } from 'ink';
|
||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { VirtualizedListContext } from './VirtualizedList.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
interface FixedScrollableListProps<T> extends FixedVirtualizedListProps<T> {
|
||||
itemKey?: string;
|
||||
hasFocus: boolean;
|
||||
width: number;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
isStatic?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
targetScrollIndex?: number;
|
||||
scrollbarThumbColor?: string;
|
||||
}
|
||||
|
||||
export type FixedScrollableListRef<T> = FixedVirtualizedListRef<T>;
|
||||
|
||||
function FixedScrollableList<T>(
|
||||
props: FixedScrollableListProps<T>,
|
||||
ref: React.Ref<FixedScrollableListRef<T>>,
|
||||
) {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const settings = useSettings();
|
||||
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
|
||||
const {
|
||||
itemKey,
|
||||
hasFocus,
|
||||
width,
|
||||
maxHeight,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
} = props;
|
||||
const fixedVirtualizedListRef = useRef<FixedVirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const restoredTop = virtualizedListContext.getItemState(
|
||||
itemKey,
|
||||
'scrollTop',
|
||||
);
|
||||
if (typeof restoredTop === 'number') {
|
||||
fixedVirtualizedListRef.current?.scrollTo(restoredTop);
|
||||
}
|
||||
}
|
||||
}, [itemKey, virtualizedListContext]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const top = fixedVirtualizedListRef.current?.getScrollState().scrollTop;
|
||||
if (top !== undefined) {
|
||||
virtualizedListContext.setItemState(itemKey, 'scrollTop', top);
|
||||
}
|
||||
}
|
||||
},
|
||||
[itemKey, virtualizedListContext],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollBy: (delta) => fixedVirtualizedListRef.current?.scrollBy(delta),
|
||||
scrollTo: (offset) => fixedVirtualizedListRef.current?.scrollTo(offset),
|
||||
scrollToEnd: () => fixedVirtualizedListRef.current?.scrollToEnd(),
|
||||
scrollToIndex: (params) =>
|
||||
fixedVirtualizedListRef.current?.scrollToIndex(params),
|
||||
scrollToItem: (params) =>
|
||||
fixedVirtualizedListRef.current?.scrollToItem(params),
|
||||
getScrollIndex: () =>
|
||||
fixedVirtualizedListRef.current?.getScrollIndex() ?? 0,
|
||||
getScrollState: () =>
|
||||
fixedVirtualizedListRef.current?.getScrollState() ?? {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
innerHeight: 0,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const getScrollState = useCallback(
|
||||
() =>
|
||||
fixedVirtualizedListRef.current?.getScrollState() ?? {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
innerHeight: 0,
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const scrollBy = useCallback((delta: number) => {
|
||||
fixedVirtualizedListRef.current?.scrollBy(delta);
|
||||
}, []);
|
||||
|
||||
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
||||
useAnimatedScrollbar(hasFocus, scrollBy);
|
||||
|
||||
const smoothScrollState = useRef<{
|
||||
active: boolean;
|
||||
start: number;
|
||||
from: number;
|
||||
to: number;
|
||||
duration: number;
|
||||
timer: NodeJS.Timeout | null;
|
||||
}>({ active: false, start: 0, from: 0, to: 0, duration: 0, timer: null });
|
||||
|
||||
const stopSmoothScroll = useCallback(() => {
|
||||
if (smoothScrollState.current.timer) {
|
||||
clearInterval(smoothScrollState.current.timer);
|
||||
smoothScrollState.current.timer = null;
|
||||
}
|
||||
smoothScrollState.current.active = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
|
||||
|
||||
const smoothScrollTo = useCallback(
|
||||
(
|
||||
targetScrollTop: number,
|
||||
duration: number = process.env['NODE_ENV'] === 'test' ? 0 : 200,
|
||||
) => {
|
||||
stopSmoothScroll();
|
||||
|
||||
const scrollState = fixedVirtualizedListRef.current?.getScrollState() ?? {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
innerHeight: 0,
|
||||
};
|
||||
const {
|
||||
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 ||
|
||||
targetScrollTop >= maxScrollTop
|
||||
) {
|
||||
effectiveTarget = maxScrollTop;
|
||||
}
|
||||
|
||||
const clampedTarget = Math.max(
|
||||
0,
|
||||
Math.min(maxScrollTop, effectiveTarget),
|
||||
);
|
||||
|
||||
if (duration === 0) {
|
||||
if (
|
||||
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||
targetScrollTop >= maxScrollTop
|
||||
) {
|
||||
fixedVirtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
|
||||
} else {
|
||||
fixedVirtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
|
||||
}
|
||||
flashScrollbar();
|
||||
return;
|
||||
}
|
||||
|
||||
smoothScrollState.current = {
|
||||
active: true,
|
||||
start: Date.now(),
|
||||
from: startScrollTop,
|
||||
to: clampedTarget,
|
||||
duration,
|
||||
timer: setInterval(() => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - smoothScrollState.current.start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease-in-out
|
||||
const t = progress;
|
||||
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||
|
||||
const current =
|
||||
smoothScrollState.current.from +
|
||||
(smoothScrollState.current.to - smoothScrollState.current.from) *
|
||||
ease;
|
||||
|
||||
if (progress >= 1) {
|
||||
if (
|
||||
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||
targetScrollTop >= maxScrollTop
|
||||
) {
|
||||
fixedVirtualizedListRef.current?.scrollTo(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
} else {
|
||||
fixedVirtualizedListRef.current?.scrollTo(Math.round(current));
|
||||
}
|
||||
stopSmoothScroll();
|
||||
flashScrollbar();
|
||||
} else {
|
||||
fixedVirtualizedListRef.current?.scrollTo(Math.round(current));
|
||||
}
|
||||
}, ANIMATION_FRAME_DURATION_MS),
|
||||
};
|
||||
},
|
||||
[stopSmoothScroll, flashScrollbar],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.SCROLL_UP](key)) {
|
||||
stopSmoothScroll();
|
||||
scrollByWithAnimation(-1);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SCROLL_DOWN](key)) {
|
||||
stopSmoothScroll();
|
||||
scrollByWithAnimation(1);
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.PAGE_UP](key) ||
|
||||
keyMatchers[Command.PAGE_DOWN](key)
|
||||
) {
|
||||
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
|
||||
: Math.min(scrollState.scrollTop, maxScroll);
|
||||
const innerHeight = scrollState.innerHeight;
|
||||
smoothScrollTo(current + direction * innerHeight);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SCROLL_HOME](key)) {
|
||||
smoothScrollTo(0);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SCROLL_END](key)) {
|
||||
smoothScrollTo(SCROLL_TO_ITEM_END);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: hasFocus },
|
||||
);
|
||||
|
||||
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
|
||||
|
||||
const scrollableEntry = useMemo(
|
||||
() => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ref: containerRef as React.RefObject<DOMElement>,
|
||||
getScrollState,
|
||||
scrollBy: scrollByWithAnimation,
|
||||
scrollTo: smoothScrollTo,
|
||||
hasFocus: hasFocusCallback,
|
||||
flashScrollbar,
|
||||
}),
|
||||
[
|
||||
getScrollState,
|
||||
hasFocusCallback,
|
||||
flashScrollbar,
|
||||
scrollByWithAnimation,
|
||||
smoothScrollTo,
|
||||
],
|
||||
);
|
||||
|
||||
useScrollable(scrollableEntry, true);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
maxHeight={maxHeight}
|
||||
>
|
||||
<FixedVirtualizedList
|
||||
ref={fixedVirtualizedListRef}
|
||||
{...props}
|
||||
scrollbar={scrollbar}
|
||||
scrollbarThumbColor={scrollbarColor}
|
||||
stableScrollback={stableScrollback}
|
||||
maxScrollbackLength={maxScrollbackLength}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const FixedScrollableListWithForwardRef = forwardRef(FixedScrollableList) as <
|
||||
T,
|
||||
>(
|
||||
props: FixedScrollableListProps<T> & {
|
||||
ref?: React.Ref<FixedScrollableListRef<T>>;
|
||||
},
|
||||
) => React.ReactElement;
|
||||
|
||||
export { FixedScrollableListWithForwardRef as FixedScrollableList };
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||
import {
|
||||
FixedVirtualizedList,
|
||||
SCROLL_TO_ITEM_END,
|
||||
} from './FixedVirtualizedList.js';
|
||||
|
||||
describe('<FixedVirtualizedList />', () => {
|
||||
const renderList = (data: string[]) => (
|
||||
<Box height={5} width={80}>
|
||||
<FixedVirtualizedList
|
||||
data={data}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
itemHeight={1}
|
||||
keyExtractor={(item) => item}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
|
||||
width={80}
|
||||
maxHeight={5}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
it('sticks to the bottom when data grows', async () => {
|
||||
const initialData = Array.from({ length: 10 }, (_, i) => `Item ${i}`);
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
|
||||
renderList(initialData),
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('Item 9');
|
||||
|
||||
const newData = [...initialData, 'Item 10', 'Item 11'];
|
||||
await act(async () => {
|
||||
rerender(renderList(newData));
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('Item 11');
|
||||
expect(lastFrame()).not.toContain('Item 0');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,616 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
memo,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
|
||||
import { Box, StaticRender } from 'ink';
|
||||
|
||||
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export type FixedVirtualizedListProps<T> = {
|
||||
data: T[];
|
||||
renderItem: (info: { item: T; index: number }) => React.ReactElement;
|
||||
itemHeight: number;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
initialScrollIndex?: number;
|
||||
initialScrollOffsetInIndex?: number;
|
||||
targetScrollIndex?: number;
|
||||
backgroundColor?: string;
|
||||
scrollbarThumbColor?: string;
|
||||
renderStatic?: boolean;
|
||||
isStaticItem?: (item: T, index: number) => boolean;
|
||||
width: number;
|
||||
overflowToBackbuffer?: boolean;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
maxHeight: number;
|
||||
maxScrollbackLength?: number;
|
||||
};
|
||||
|
||||
export type FixedVirtualizedListRef<T> = {
|
||||
scrollBy: (delta: number) => void;
|
||||
scrollTo: (offset: number) => void;
|
||||
scrollToEnd: () => void;
|
||||
scrollToIndex: (params: {
|
||||
index: number;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => void;
|
||||
scrollToItem: (params: {
|
||||
item: T;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => void;
|
||||
getScrollIndex: () => number;
|
||||
getScrollState: () => {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
innerHeight: number;
|
||||
};
|
||||
};
|
||||
|
||||
const FixedVirtualizedListItem = memo(
|
||||
({
|
||||
content,
|
||||
shouldBeStatic,
|
||||
width,
|
||||
itemKey,
|
||||
}: {
|
||||
content: React.ReactElement;
|
||||
shouldBeStatic: boolean;
|
||||
width: number;
|
||||
itemKey: string;
|
||||
}) => (
|
||||
<Box width="100%" flexDirection="column" flexShrink={0}>
|
||||
{shouldBeStatic ? (
|
||||
<StaticRender width={width} key={itemKey + '-static-' + width}>
|
||||
{() => content}
|
||||
</StaticRender>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
);
|
||||
|
||||
FixedVirtualizedListItem.displayName = 'FixedVirtualizedListItem';
|
||||
|
||||
function FixedVirtualizedList<T>(
|
||||
props: FixedVirtualizedListProps<T>,
|
||||
ref: React.Ref<FixedVirtualizedListRef<T>>,
|
||||
) {
|
||||
const {
|
||||
data,
|
||||
renderItem,
|
||||
itemHeight,
|
||||
keyExtractor,
|
||||
initialScrollIndex,
|
||||
initialScrollOffsetInIndex,
|
||||
renderStatic,
|
||||
isStaticItem,
|
||||
width,
|
||||
overflowToBackbuffer,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
maxScrollbackLength,
|
||||
maxHeight,
|
||||
} = props;
|
||||
|
||||
const [scrollAnchor, setScrollAnchor] = useState(() => {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(typeof initialScrollIndex === 'number' &&
|
||||
initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
|
||||
if (scrollToEnd) {
|
||||
return {
|
||||
index: data.length > 0 ? data.length - 1 : 0,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof initialScrollIndex === 'number') {
|
||||
return {
|
||||
index: Math.max(0, Math.min(data.length - 1, initialScrollIndex)),
|
||||
offset: initialScrollOffsetInIndex ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof props.targetScrollIndex === 'number') {
|
||||
return {
|
||||
index: props.targetScrollIndex,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { index: 0, offset: 0 };
|
||||
});
|
||||
|
||||
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(typeof initialScrollIndex === 'number' &&
|
||||
initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
return scrollToEnd;
|
||||
});
|
||||
|
||||
const totalHeight = data.length * itemHeight;
|
||||
const scrollableContainerHeight = maxHeight;
|
||||
const isInitialScrollSet = useRef(false);
|
||||
|
||||
const getAnchorForScrollTop = useCallback(
|
||||
(scrollTop: number): { index: number; offset: number } => {
|
||||
const index = Math.max(
|
||||
0,
|
||||
Math.min(data.length - 1, Math.floor(scrollTop / itemHeight)),
|
||||
);
|
||||
if (data.length === 0) {
|
||||
return { index: 0, offset: 0 };
|
||||
}
|
||||
return { index, offset: scrollTop - index * itemHeight };
|
||||
},
|
||||
[data.length, itemHeight],
|
||||
);
|
||||
|
||||
const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState(
|
||||
props.targetScrollIndex,
|
||||
);
|
||||
const prevDataLength = useRef(data.length);
|
||||
const previousDataLength = prevDataLength.current;
|
||||
|
||||
if (
|
||||
(props.targetScrollIndex !== undefined &&
|
||||
props.targetScrollIndex !== prevTargetScrollIndex &&
|
||||
data.length > 0) ||
|
||||
(props.targetScrollIndex !== undefined &&
|
||||
previousDataLength === 0 &&
|
||||
data.length > 0)
|
||||
) {
|
||||
if (props.targetScrollIndex !== prevTargetScrollIndex) {
|
||||
setPrevTargetScrollIndex(props.targetScrollIndex);
|
||||
}
|
||||
setIsStickingToBottom(false);
|
||||
setScrollAnchor({ index: props.targetScrollIndex, offset: 0 });
|
||||
}
|
||||
|
||||
const rawStateActualScrollTop = (() => {
|
||||
const offset = scrollAnchor.index * itemHeight;
|
||||
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
|
||||
return offset + itemHeight - scrollableContainerHeight;
|
||||
}
|
||||
return offset + scrollAnchor.offset;
|
||||
})();
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const stateActualScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(maxScroll, rawStateActualScrollTop),
|
||||
);
|
||||
|
||||
const prevTotalHeight = useRef(totalHeight);
|
||||
const prevScrollTop = useRef(rawStateActualScrollTop);
|
||||
const prevContainerHeight = useRef(scrollableContainerHeight);
|
||||
|
||||
// Render-time state derivation to avoid useEffect for static rendering
|
||||
let currentScrollAnchor = scrollAnchor;
|
||||
let currentIsStickingToBottom = isStickingToBottom;
|
||||
|
||||
const contentPreviouslyFit =
|
||||
prevTotalHeight.current <= prevContainerHeight.current;
|
||||
const wasScrolledToBottomPixels =
|
||||
prevScrollTop.current >=
|
||||
prevTotalHeight.current - prevContainerHeight.current - 1;
|
||||
|
||||
// Crucial fix: we were previously only evaluating wasAtBottom against rawStateActualScrollTop *if* it was at bottom *last* frame.
|
||||
// But if the content just exceeded the container height, wasScrolledToBottomPixels is false, but contentPreviouslyFit is true.
|
||||
// If it previously fit, it implicitly means we should stick to the bottom if the new height exceeds the container.
|
||||
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
|
||||
|
||||
if (
|
||||
wasAtBottom &&
|
||||
(rawStateActualScrollTop >= prevScrollTop.current || contentPreviouslyFit)
|
||||
) {
|
||||
if (!currentIsStickingToBottom) {
|
||||
currentIsStickingToBottom = true;
|
||||
if (scrollAnchor === currentScrollAnchor) {
|
||||
// Avoid infinite loop if we already updated state
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listGrew = data.length > previousDataLength;
|
||||
const containerChanged =
|
||||
prevContainerHeight.current !== scrollableContainerHeight;
|
||||
const shouldAutoScroll = props.targetScrollIndex === undefined;
|
||||
|
||||
if (
|
||||
shouldAutoScroll &&
|
||||
((listGrew && (currentIsStickingToBottom || wasAtBottom)) ||
|
||||
(currentIsStickingToBottom && containerChanged))
|
||||
) {
|
||||
const newIndex = data.length > 0 ? data.length - 1 : 0;
|
||||
if (
|
||||
currentScrollAnchor.index !== newIndex ||
|
||||
currentScrollAnchor.offset !== SCROLL_TO_ITEM_END
|
||||
) {
|
||||
currentScrollAnchor = {
|
||||
index: newIndex,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
};
|
||||
setScrollAnchor(currentScrollAnchor);
|
||||
}
|
||||
if (!currentIsStickingToBottom) {
|
||||
currentIsStickingToBottom = true;
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
} else if (
|
||||
(currentScrollAnchor.index >= data.length ||
|
||||
stateActualScrollTop > totalHeight - scrollableContainerHeight) &&
|
||||
data.length > 0
|
||||
) {
|
||||
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const newAnchor = getAnchorForScrollTop(newScrollTop);
|
||||
if (
|
||||
currentScrollAnchor.index !== newAnchor.index ||
|
||||
currentScrollAnchor.offset !== newAnchor.offset
|
||||
) {
|
||||
currentScrollAnchor = newAnchor;
|
||||
setScrollAnchor(newAnchor);
|
||||
}
|
||||
} else if (data.length === 0) {
|
||||
if (currentScrollAnchor.index !== 0 || currentScrollAnchor.offset !== 0) {
|
||||
currentScrollAnchor = { index: 0, offset: 0 };
|
||||
setScrollAnchor(currentScrollAnchor);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial scroll setup during render
|
||||
if (
|
||||
!isInitialScrollSet.current &&
|
||||
data.length > 0 &&
|
||||
totalHeight > 0 &&
|
||||
scrollableContainerHeight > 0
|
||||
) {
|
||||
if (props.targetScrollIndex !== undefined) {
|
||||
isInitialScrollSet.current = true;
|
||||
} else if (typeof initialScrollIndex === 'number') {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
|
||||
if (scrollToEnd) {
|
||||
currentScrollAnchor = {
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
};
|
||||
setScrollAnchor(currentScrollAnchor);
|
||||
currentIsStickingToBottom = true;
|
||||
setIsStickingToBottom(true);
|
||||
isInitialScrollSet.current = true;
|
||||
} else {
|
||||
const index = Math.max(
|
||||
0,
|
||||
Math.min(data.length - 1, initialScrollIndex),
|
||||
);
|
||||
const offset = initialScrollOffsetInIndex ?? 0;
|
||||
const newScrollTop = index * itemHeight + offset;
|
||||
|
||||
const clampedScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(totalHeight - scrollableContainerHeight, newScrollTop),
|
||||
);
|
||||
|
||||
currentScrollAnchor = getAnchorForScrollTop(clampedScrollTop);
|
||||
setScrollAnchor(currentScrollAnchor);
|
||||
isInitialScrollSet.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After all derived state updates, update refs for the next render
|
||||
prevDataLength.current = data.length;
|
||||
prevTotalHeight.current = totalHeight;
|
||||
|
||||
const rawDerivedActualScrollTop = (() => {
|
||||
const offset = currentScrollAnchor.index * itemHeight;
|
||||
if (currentScrollAnchor.offset === SCROLL_TO_ITEM_END) {
|
||||
return offset + itemHeight - scrollableContainerHeight;
|
||||
}
|
||||
return offset + currentScrollAnchor.offset;
|
||||
})();
|
||||
const derivedActualScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(maxScroll, rawDerivedActualScrollTop),
|
||||
);
|
||||
|
||||
prevScrollTop.current = rawDerivedActualScrollTop;
|
||||
prevContainerHeight.current = scrollableContainerHeight;
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.floor(derivedActualScrollTop / itemHeight) - 1,
|
||||
);
|
||||
const viewHeightForEndIndex =
|
||||
scrollableContainerHeight > 0 ? scrollableContainerHeight : 50;
|
||||
|
||||
const maxEndIndex = data.length - 1;
|
||||
const endIndex = Math.min(
|
||||
maxEndIndex,
|
||||
Math.ceil((derivedActualScrollTop + viewHeightForEndIndex) / itemHeight),
|
||||
);
|
||||
|
||||
const culledHeight = useMemo(() => {
|
||||
if (
|
||||
overflowToBackbuffer &&
|
||||
typeof maxScrollbackLength === 'number' &&
|
||||
maxScrollbackLength > 0
|
||||
) {
|
||||
// Keep maxScrollbackLength items before the viewport.
|
||||
// We add 1 to startIndex to account for the 1-item overscan it includes.
|
||||
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
|
||||
return targetIndex * itemHeight;
|
||||
}
|
||||
return 0;
|
||||
}, [overflowToBackbuffer, maxScrollbackLength, startIndex, itemHeight]);
|
||||
|
||||
const scrollTop = currentIsStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: Math.max(0, derivedActualScrollTop - culledHeight);
|
||||
|
||||
const renderRangeStart = (() => {
|
||||
if (renderStatic) return 0;
|
||||
if (overflowToBackbuffer) {
|
||||
if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) {
|
||||
// Render from the culled boundary.
|
||||
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
|
||||
return targetIndex;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return startIndex;
|
||||
})();
|
||||
|
||||
const renderRangeEnd = renderStatic ? maxEndIndex : endIndex;
|
||||
|
||||
const topSpacerHeight = Math.max(
|
||||
0,
|
||||
renderRangeStart * itemHeight - culledHeight,
|
||||
);
|
||||
const bottomSpacerHeight = renderStatic
|
||||
? 0
|
||||
: totalHeight - (renderRangeEnd + 1) * itemHeight;
|
||||
|
||||
const renderedItems = useMemo(() => {
|
||||
const items = [];
|
||||
for (let i = renderRangeStart; i <= renderRangeEnd; i++) {
|
||||
const item = data[i];
|
||||
if (item) {
|
||||
const isOutsideViewport = i < startIndex || i > endIndex;
|
||||
const shouldBeStatic =
|
||||
(renderStatic === true && isOutsideViewport) ||
|
||||
isStaticItem?.(item, i) === true;
|
||||
|
||||
const content = renderItem({ item, index: i });
|
||||
const key = keyExtractor(item, i);
|
||||
|
||||
items.push(
|
||||
<FixedVirtualizedListItem
|
||||
key={key}
|
||||
itemKey={key}
|
||||
content={content}
|
||||
shouldBeStatic={shouldBeStatic}
|
||||
width={width}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [
|
||||
renderRangeStart,
|
||||
renderRangeEnd,
|
||||
data,
|
||||
startIndex,
|
||||
endIndex,
|
||||
renderStatic,
|
||||
isStaticItem,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
width,
|
||||
]);
|
||||
|
||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollBy: (delta: number) => {
|
||||
if (delta < 0) {
|
||||
setIsStickingToBottom(false);
|
||||
}
|
||||
const currentScrollTop = getScrollTop();
|
||||
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(Math.min(newScrollTop, maxScroll)),
|
||||
);
|
||||
},
|
||||
scrollTo: (offset: number) => {
|
||||
const effectiveTotalHeight = totalHeight - culledHeight;
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
effectiveTotalHeight - 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 + culledHeight);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
|
||||
}
|
||||
},
|
||||
scrollToEnd: () => {
|
||||
setIsStickingToBottom(true);
|
||||
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
if (data.length > 0) {
|
||||
setScrollAnchor({
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
}
|
||||
},
|
||||
scrollToIndex: ({
|
||||
index,
|
||||
viewOffset = 0,
|
||||
viewPosition = 0,
|
||||
}: {
|
||||
index: number;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => {
|
||||
setIsStickingToBottom(false);
|
||||
const offset = index * itemHeight;
|
||||
if (index >= 0 && index < data.length) {
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
totalHeight - scrollableContainerHeight,
|
||||
);
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
maxScroll,
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
|
||||
}
|
||||
},
|
||||
scrollToItem: ({
|
||||
item,
|
||||
viewOffset = 0,
|
||||
viewPosition = 0,
|
||||
}: {
|
||||
item: T;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => {
|
||||
setIsStickingToBottom(false);
|
||||
const index = data.indexOf(item);
|
||||
if (index !== -1) {
|
||||
const offset = index * itemHeight;
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
totalHeight - scrollableContainerHeight,
|
||||
);
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
maxScroll,
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
|
||||
}
|
||||
},
|
||||
getScrollIndex: () => scrollAnchor.index,
|
||||
getScrollState: () => {
|
||||
const effectiveTotalHeight = totalHeight - culledHeight;
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
effectiveTotalHeight - scrollableContainerHeight,
|
||||
);
|
||||
return {
|
||||
scrollTop: Math.min(
|
||||
Math.max(0, getScrollTop() - culledHeight),
|
||||
maxScroll,
|
||||
),
|
||||
scrollHeight: effectiveTotalHeight,
|
||||
innerHeight: scrollableContainerHeight,
|
||||
};
|
||||
},
|
||||
}),
|
||||
[
|
||||
scrollAnchor,
|
||||
totalHeight,
|
||||
getAnchorForScrollTop,
|
||||
data,
|
||||
scrollableContainerHeight,
|
||||
getScrollTop,
|
||||
setPendingScrollTop,
|
||||
itemHeight,
|
||||
culledHeight,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={
|
||||
isStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: Math.max(0, getScrollTop() - culledHeight)
|
||||
}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={1}
|
||||
overflowToBackbuffer={overflowToBackbuffer}
|
||||
scrollbar={scrollbar}
|
||||
stableScrollback={stableScrollback}
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
{renderedItems}
|
||||
<Box height={Math.max(0, bottomSpacerHeight)} flexShrink={0} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const FixedVirtualizedListWithForwardRef = forwardRef(FixedVirtualizedList) as <
|
||||
T,
|
||||
>(
|
||||
props: FixedVirtualizedListProps<T> & {
|
||||
ref?: React.Ref<FixedVirtualizedListRef<T>>;
|
||||
},
|
||||
) => React.ReactElement;
|
||||
|
||||
export { FixedVirtualizedListWithForwardRef as FixedVirtualizedList };
|
||||
|
||||
FixedVirtualizedList.displayName = 'FixedVirtualizedList';
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useId,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { Box, ResizeObserver, type DOMElement } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
@@ -23,11 +22,9 @@ import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { VirtualizedListContext } from './VirtualizedList.js';
|
||||
|
||||
interface ScrollableProps {
|
||||
children?: React.ReactNode;
|
||||
itemKey?: string;
|
||||
width?: number;
|
||||
height?: number | string;
|
||||
maxWidth?: number;
|
||||
@@ -43,7 +40,6 @@ interface ScrollableProps {
|
||||
|
||||
export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
children,
|
||||
itemKey,
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
@@ -57,16 +53,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
stableScrollback,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
const [scrollTop, setScrollTop] = useState(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const state = virtualizedListContext.getItemState(itemKey, 'scrollTop');
|
||||
return typeof state === 'number' ? state : 0;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const viewportRef = useRef<DOMElement | null>(null);
|
||||
const contentRef = useRef<DOMElement | null>(null);
|
||||
const overflowActions = useOverflowActions();
|
||||
@@ -86,19 +73,6 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
scrollTopRef.current = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
virtualizedListContext.setItemState(
|
||||
itemKey,
|
||||
'scrollTop',
|
||||
scrollTopRef.current,
|
||||
);
|
||||
}
|
||||
},
|
||||
[itemKey, virtualizedListContext],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (reportOverflow && size.scrollHeight > size.innerHeight) {
|
||||
overflowActions?.addOverflowingId?.(id);
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
@@ -27,18 +25,17 @@ import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { VirtualizedListContext } from './VirtualizedList.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
|
||||
itemKey?: string;
|
||||
hasFocus: boolean;
|
||||
width?: string | number;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
copyModeEnabled?: boolean;
|
||||
isStatic?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
targetScrollIndex?: number;
|
||||
containerHeight?: number;
|
||||
scrollbarThumbColor?: string;
|
||||
@@ -51,44 +48,10 @@ function ScrollableList<T>(
|
||||
ref: React.Ref<ScrollableListRef<T>>,
|
||||
) {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const settings = useSettings();
|
||||
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
|
||||
const {
|
||||
hasFocus,
|
||||
width,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
itemKey,
|
||||
} = props;
|
||||
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
|
||||
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const restoredTop = virtualizedListContext.getItemState(
|
||||
itemKey,
|
||||
'scrollTop',
|
||||
);
|
||||
if (typeof restoredTop === 'number') {
|
||||
virtualizedListRef.current?.scrollTo(restoredTop);
|
||||
}
|
||||
}
|
||||
}, [itemKey, virtualizedListContext]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const top = virtualizedListRef.current?.getScrollState().scrollTop;
|
||||
if (top !== undefined) {
|
||||
virtualizedListContext.setItemState(itemKey, 'scrollTop', top);
|
||||
}
|
||||
}
|
||||
},
|
||||
[itemKey, virtualizedListContext],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -302,7 +265,6 @@ function ScrollableList<T>(
|
||||
scrollbar={scrollbar}
|
||||
scrollbarThumbColor={scrollbarColor}
|
||||
stableScrollback={stableScrollback}
|
||||
maxScrollbackLength={maxScrollbackLength}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||
import { VirtualizedList } from './VirtualizedList.js';
|
||||
import type { VirtualizedListRef } from './VirtualizedList.js';
|
||||
import { Text, Box } from 'ink';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRef } from 'react';
|
||||
|
||||
describe('<VirtualizedList /> backbuffer regression', () => {
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
it('provides a sufficient history buffer regardless of height estimation', async () => {
|
||||
// 1000 items, each 1 line high.
|
||||
const data = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, i) => `Item ${String(i).padStart(3, '0')}`,
|
||||
);
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
|
||||
const { waitUntilReady, unmount } = await render(
|
||||
<Box height={50} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={data}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 10}
|
||||
initialScrollIndex={999}
|
||||
overflowToBackbuffer={true}
|
||||
renderStatic={true}
|
||||
maxScrollbackLength={150}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
try {
|
||||
const state = ref.current?.getScrollState();
|
||||
// Viewport is 50, backbuffer is 150.
|
||||
// Total scrollHeight should be AT LEAST 200 lines.
|
||||
// Since our fix is item-based, and items are 1 line high, it should be
|
||||
// exactly or very close to 200.
|
||||
expect(state?.scrollHeight).toBeGreaterThanOrEqual(200);
|
||||
expect(state?.innerHeight).toBe(50);
|
||||
} finally {
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||
import { VirtualizedList } from './VirtualizedList.js';
|
||||
import { Text, Box } from 'ink';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('<VirtualizedList /> fallback', () => {
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
it('uses default maxScrollbackLength of 1000 when not provided', async () => {
|
||||
const longData = Array.from({ length: 2000 }, (_, i) => `Item ${i}`);
|
||||
const renderedIndices = new Set<number>();
|
||||
const renderItem1px = ({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: string;
|
||||
index: number;
|
||||
}) => {
|
||||
renderedIndices.add(index);
|
||||
return (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const { unmount } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={1999}
|
||||
overflowToBackbuffer={true}
|
||||
// maxScrollbackLength is NOT provided
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
// Viewport height is 10.
|
||||
// initialScrollIndex is 1999.
|
||||
// actualScrollTop = 2000 - 10 = 1990.
|
||||
// Default fallback maxScrollbackLength = 1000.
|
||||
// targetOffset = 1990 - 1000 = 990.
|
||||
// renderRangeStart should be around 989/990.
|
||||
// Items below 980 should NOT be rendered.
|
||||
// Items around 1000 SHOULD be rendered.
|
||||
|
||||
// Check viewport items are rendered
|
||||
expect(renderedIndices.has(1995)).toBe(true);
|
||||
expect(renderedIndices.has(1999)).toBe(true);
|
||||
|
||||
// Check items in maxScrollbackLength (1000) are rendered
|
||||
expect(renderedIndices.has(1000)).toBe(true);
|
||||
expect(renderedIndices.has(1100)).toBe(true);
|
||||
|
||||
// Check items beyond maxScrollbackLength are NOT rendered
|
||||
expect(renderedIndices.has(0)).toBe(false);
|
||||
expect(renderedIndices.has(500)).toBe(false);
|
||||
expect(renderedIndices.has(900)).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import {
|
||||
SCROLL_TO_ITEM_END,
|
||||
VirtualizedList,
|
||||
type VirtualizedListRef,
|
||||
} from './VirtualizedList.js';
|
||||
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
|
||||
import { Text, Box } from 'ink';
|
||||
import {
|
||||
createRef,
|
||||
@@ -119,41 +115,6 @@ describe('<VirtualizedList />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('rerenders cached items when renderItem changes', async () => {
|
||||
const data = ['Item 0'];
|
||||
const renderWithLabel = (label: string) => (
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>
|
||||
{label} {item}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => itemHeight}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
|
||||
renderWithLabel('Initial'),
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Initial Item 0');
|
||||
|
||||
await act(async () => {
|
||||
rerender(renderWithLabel('Updated'));
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toContain('Updated Item 0');
|
||||
expect(lastFrame()).not.toContain('Initial Item 0');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('scrolls down to show new items when requested via ref', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const { lastFrame, waitUntilReady, unmount } = await render(
|
||||
@@ -209,7 +170,7 @@ describe('<VirtualizedList />', () => {
|
||||
(_, i) => `Item ${i}`,
|
||||
);
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = await render(
|
||||
const { lastFrame, unmount } = await render(
|
||||
<Box height={20} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={veryLongData}
|
||||
@@ -223,12 +184,8 @@ describe('<VirtualizedList />', () => {
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
await waitFor(() => {
|
||||
expect(mountedCount).toBe(expectedMountedCount);
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(mountedCount).toBe(expectedMountedCount);
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
},
|
||||
@@ -359,69 +316,12 @@ describe('<VirtualizedList />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('culls items that exceed maxScrollbackLength when overflowToBackbuffer is true', async () => {
|
||||
it('renders correctly in copyModeEnabled when scrolled', async () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
const renderedIndices = new Set<number>();
|
||||
const renderItem1px = ({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: string;
|
||||
index: number;
|
||||
}) => {
|
||||
renderedIndices.add(index);
|
||||
return (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const { unmount } = await render(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={99}
|
||||
overflowToBackbuffer={true}
|
||||
maxScrollbackLength={10}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
// Viewport height is 10, total items = 100.
|
||||
// actualScrollTop = 92 (due to top/bottom borders taking 2 lines out of 10, inner height 8).
|
||||
// wait, if height is 10 with round border, inner height is 8.
|
||||
// actualScrollTop = 100 - 8 = 92.
|
||||
// maxScrollbackLength = 10.
|
||||
// targetOffset = 92 - 10 = 82.
|
||||
// So renderRangeStart should be 81 (or 82).
|
||||
// Items 0 to 80 should not be rendered!
|
||||
|
||||
// Check viewport items are rendered
|
||||
expect(renderedIndices.has(95)).toBe(true);
|
||||
expect(renderedIndices.has(99)).toBe(true);
|
||||
|
||||
// Check items in maxScrollbackLength are rendered
|
||||
expect(renderedIndices.has(85)).toBe(true);
|
||||
|
||||
// Check items beyond maxScrollbackLength are NOT rendered
|
||||
expect(renderedIndices.has(0)).toBe(false);
|
||||
expect(renderedIndices.has(50)).toBe(false);
|
||||
expect(renderedIndices.has(75)).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('crops the document height when maxScrollbackLength is exceeded', async () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const { unmount, waitUntilReady } = await render(
|
||||
// Use copy mode
|
||||
const { lastFrame, unmount } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
@@ -430,243 +330,18 @@ describe('<VirtualizedList />', () => {
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={99}
|
||||
overflowToBackbuffer={true}
|
||||
maxScrollbackLength={10}
|
||||
initialScrollIndex={50}
|
||||
copyModeEnabled={true}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Viewport height is 10.
|
||||
// maxScrollbackLength = 10.
|
||||
// Total expected scrollHeight = 10 + 10 = 20.
|
||||
const state = ref.current?.getScrollState();
|
||||
expect(state?.scrollHeight).toBe(20);
|
||||
|
||||
// The top of the projected document (offset 0) should correspond to absolute offset 80.
|
||||
// getAnchorForScrollTop(80) will return index 90 because it's near the bottom and uses a bottom anchor.
|
||||
await act(async () => {
|
||||
ref.current?.scrollTo(0);
|
||||
});
|
||||
expect(ref.current?.getScrollIndex()).toBe(90);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('culls the backbuffer by measured row height instead of item count', async () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
const renderedIndices = new Set<number>();
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const { unmount, waitUntilReady } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={({ item, index }) => {
|
||||
renderedIndices.add(index);
|
||||
return (
|
||||
<Box height={2}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 2}
|
||||
initialScrollIndex={99}
|
||||
overflowToBackbuffer={true}
|
||||
maxScrollbackLength={10}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
const state = ref.current?.getScrollState();
|
||||
expect(state?.scrollHeight).toBe(20);
|
||||
expect(state?.innerHeight).toBe(10);
|
||||
expect(renderedIndices.has(90)).toBe(true);
|
||||
expect(renderedIndices.has(85)).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps keyboard scrolling in logical history coordinates after culling', async () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const { lastFrame, unmount, waitUntilReady } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={99}
|
||||
overflowToBackbuffer={true}
|
||||
maxScrollbackLength={10}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(10);
|
||||
|
||||
await act(async () => {
|
||||
ref.current?.scrollBy(-1);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
const state = ref.current?.getScrollState();
|
||||
expect(state?.scrollTop).toBeGreaterThan(0);
|
||||
expect(lastFrame()).not.toContain('Item 79');
|
||||
expect(lastFrame()).not.toContain('Item 80');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('measures mounted zero-height items instead of keeping their estimate', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const data = ['Item 0', 'Item 1', 'pending'];
|
||||
const { unmount, waitUntilReady } = await render(
|
||||
<Box height={50} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={data}
|
||||
renderItem={({ item }) =>
|
||||
item === 'pending' ? (
|
||||
<Box height={0} />
|
||||
) : (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 10}
|
||||
initialScrollIndex={2}
|
||||
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
expect(ref.current?.getScrollState()).toEqual({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 2,
|
||||
innerHeight: 50,
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not forget item heights when items are prepended', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const data = ['Item 1', 'Item 2'];
|
||||
const { rerender, waitUntilReady, unmount } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={data}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1000}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
await waitFor(() => {
|
||||
// Item 1 and 2 measured. totalHeight = 2.
|
||||
expect(ref.current?.getScrollState().scrollHeight).toBe(2);
|
||||
});
|
||||
|
||||
// Prepend Item 0
|
||||
const newData = ['Item 0', 'Item 1', 'Item 2'];
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={newData}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1000}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
});
|
||||
// With the Map-based cache, Item 1 and 2 heights (1 each) should be preserved
|
||||
// even though their indices changed.
|
||||
// Item 0 is new and uses estimate 1000.
|
||||
// So totalHeight should be 1002 (before Item 0 is measured).
|
||||
// Note: It might already be 3 if Item 0 was measured immediately, but it
|
||||
// definitely shouldn't be 3000 (which it would be if Item 1 and 2 were forgotten).
|
||||
const scrollHeight = ref.current?.getScrollState().scrollHeight;
|
||||
expect(scrollHeight).toBeGreaterThan(0);
|
||||
expect(scrollHeight).toBeLessThan(3000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ref.current?.getScrollState().scrollHeight).toBe(3);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('updates totalHeight correctly when estimated height differs from real height and scrolled up', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const longData = Array.from({ length: 10 }, (_, i) => `Item ${i}`);
|
||||
const itemHeight = 1;
|
||||
const renderItem1px = ({ item }: { item: string }) => (
|
||||
<Box height={itemHeight}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
const { unmount, waitUntilReady } = await render(
|
||||
<Box height={5} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1000}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await act(async () => {
|
||||
ref.current?.scrollTo(i * 1000);
|
||||
});
|
||||
await waitUntilReady();
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
ref.current?.scrollTo(0);
|
||||
});
|
||||
// Wait for the final scroll top to settle and height to be correct
|
||||
await waitFor(() => {
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(0);
|
||||
expect(ref.current?.getScrollState().scrollHeight).toBe(10);
|
||||
});
|
||||
|
||||
// Item 50 should be visible
|
||||
expect(lastFrame()).toContain('Item 50');
|
||||
// And surrounding items
|
||||
expect(lastFrame()).toContain('Item 59');
|
||||
// But far away items should not be (ensures we are actually scrolled)
|
||||
expect(lastFrame()).not.toContain('Item 0');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { VirtualizedList } from './VirtualizedList.js';
|
||||
import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useState } from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('VirtualizedList Interactivity', () => {
|
||||
const keyExtractor = (item: { id: string }) => item.id;
|
||||
|
||||
const InteractiveItem = ({
|
||||
id,
|
||||
onToggle,
|
||||
}: {
|
||||
id: string;
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
const { ref } = useVirtualizedListClick(id, 'toggle', onToggle);
|
||||
return (
|
||||
<Box height={1} width={80} ref={ref}>
|
||||
<Text>Item {id}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
it('triggers callback when tagged area is clicked', async () => {
|
||||
const onToggle = vi.fn();
|
||||
const data = [{ id: '1' }];
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(
|
||||
<Box height={10} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={({ item }) => (
|
||||
<InteractiveItem id={item.id} onToggle={onToggle} />
|
||||
)}
|
||||
/>
|
||||
</Box>,
|
||||
{ mouseEventsEnabled: true },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
|
||||
// Simulate click on the first line (Item 1)
|
||||
// VirtualizedList is at (0,0) and Item 1 is at (0,0) relative to list.
|
||||
// simulateClick expects absolute coordinates.
|
||||
// In renderWithProviders, the wrapper Box is at (0,0)?
|
||||
// Actually getBoundingBox(state.current.container) in VirtualizedList will give absolute coords.
|
||||
await simulateClick(1, 1);
|
||||
|
||||
await waitFor(() => expect(onToggle).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('wakes up static item and triggers callback on click', async () => {
|
||||
const onToggle = vi.fn();
|
||||
const data = [{ id: '1' }];
|
||||
|
||||
const TestComponent = () => {
|
||||
const [isStatic, setIsStatic] = useState(false);
|
||||
return (
|
||||
<Box height={10} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={({ item }) => (
|
||||
<InteractiveItem id={item.id} onToggle={onToggle} />
|
||||
)}
|
||||
isStaticItem={() => isStatic}
|
||||
/>
|
||||
<Box
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
setTimeout(() => setIsStatic(true), 100);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(<TestComponent />, {
|
||||
mouseEventsEnabled: true,
|
||||
});
|
||||
|
||||
await waitUntilReady();
|
||||
// Wait for the transition to static to happen and be recorded
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
|
||||
// Click to wake up and trigger
|
||||
await simulateClick(1, 1);
|
||||
|
||||
await waitFor(() => expect(onToggle).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -7,41 +7,41 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 1 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 2 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 3 │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 500) 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 500 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ ▄│
|
||||
│ ▀│
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 501 │
|
||||
│ │
|
||||
│ │
|
||||
│ ▄│
|
||||
│ ▀│
|
||||
│Item 502 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 503 │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -53,7 +53,7 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 997 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
@@ -36,7 +35,6 @@ const MAX_MOUSE_BUFFER_SIZE = 4096;
|
||||
interface MouseContextValue {
|
||||
subscribe: (handler: MouseHandler) => void;
|
||||
unsubscribe: (handler: MouseHandler) => void;
|
||||
broadcast: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const MouseContext = createContext<MouseContextValue | undefined>(undefined);
|
||||
@@ -52,7 +50,7 @@ export function useMouseContext() {
|
||||
export function useMouse(handler: MouseHandler, { isActive = true } = {}) {
|
||||
const { subscribe, unsubscribe } = useMouseContext();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
@@ -94,8 +92,14 @@ export function MouseProvider({
|
||||
[subscribers],
|
||||
);
|
||||
|
||||
const broadcast = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
useEffect(() => {
|
||||
if (!mouseEventsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mouseBuffer = '';
|
||||
|
||||
const broadcast = (event: MouseEvent) => {
|
||||
let handled = false;
|
||||
for (const handler of subscribers) {
|
||||
if (handler(event) === true) {
|
||||
@@ -139,16 +143,7 @@ export function MouseProvider({
|
||||
// events not the terminal.
|
||||
appEvents.emit(AppEvent.SelectionWarning);
|
||||
}
|
||||
},
|
||||
[subscribers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mouseEventsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mouseBuffer = '';
|
||||
};
|
||||
|
||||
const handleData = (data: Buffer | string) => {
|
||||
mouseBuffer += typeof data === 'string' ? data : data.toString('utf-8');
|
||||
@@ -195,11 +190,11 @@ export function MouseProvider({
|
||||
return () => {
|
||||
stdin.removeListener('data', handleData);
|
||||
};
|
||||
}, [stdin, mouseEventsEnabled, broadcast, debugKeystrokeLogging]);
|
||||
}, [stdin, mouseEventsEnabled, subscribers, debugKeystrokeLogging]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ subscribe, unsubscribe, broadcast }),
|
||||
[subscribe, unsubscribe, broadcast],
|
||||
() => ({ subscribe, unsubscribe }),
|
||||
[subscribe, unsubscribe],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -63,8 +63,10 @@ describe('handleAtCommand', () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'folder-structure-test-'),
|
||||
testRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'folder-structure-test-'),
|
||||
),
|
||||
);
|
||||
|
||||
abortController = new AbortController();
|
||||
@@ -1467,8 +1469,8 @@ describe('handleAtCommand', () => {
|
||||
});
|
||||
|
||||
it('should resolve files in multiple workspace directories', async () => {
|
||||
const secondRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'second-root-'),
|
||||
const secondRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(path.join(os.tmpdir(), 'second-root-')),
|
||||
);
|
||||
try {
|
||||
const fileContent = 'Second root content';
|
||||
@@ -1649,8 +1651,10 @@ describe('checkPermissions', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'check-permissions-test-'),
|
||||
testRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'check-permissions-test-'),
|
||||
),
|
||||
);
|
||||
|
||||
mockConfig = {
|
||||
|
||||
@@ -37,8 +37,8 @@ describe('handleAtCommand with Agents', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'agent-test-'),
|
||||
testRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(path.join(os.tmpdir(), 'agent-test-')),
|
||||
);
|
||||
|
||||
abortController = new AbortController();
|
||||
|
||||
@@ -30,7 +30,6 @@ export const useMouseClick = (
|
||||
(event: MouseEvent) => {
|
||||
const eventName =
|
||||
name ?? (button === 'left' ? 'left-press' : 'right-release');
|
||||
|
||||
if (event.name === eventName && containerRef.current) {
|
||||
const { x, y, width, height } = getBoundingBox(containerRef.current);
|
||||
// Terminal mouse events are 1-based, Ink layout is 0-based.
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('usePrivacySettings', () => {
|
||||
};
|
||||
};
|
||||
|
||||
it('should throw error when content generator is not a CodeAssistServer', async () => {
|
||||
it('should report tier unavailable when OAuth is not being used', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue(undefined);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
@@ -58,7 +58,8 @@ describe('usePrivacySettings', () => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.error).toBe('Oauth not being used');
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle paid tier users correctly', async () => {
|
||||
@@ -79,7 +80,7 @@ describe('usePrivacySettings', () => {
|
||||
expect(result.current.privacyState.dataCollectionOptIn).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when CodeAssistServer has no projectId', async () => {
|
||||
it('should report tier unavailable when CodeAssistServer has no projectId', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
userTier: UserTierId.FREE,
|
||||
} as unknown as CodeAssistServer);
|
||||
@@ -90,9 +91,63 @@ describe('usePrivacySettings', () => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.error).toBe(
|
||||
'CodeAssist server is missing a project ID',
|
||||
);
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should report tier unavailable when the user has no tier', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
projectId: 'test-project-id',
|
||||
userTier: undefined,
|
||||
} as unknown as CodeAssistServer);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.isFreeTier).toBeUndefined();
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should report tier unavailable when the backend reports no current tier', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
projectId: 'test-project-id',
|
||||
userTier: UserTierId.FREE,
|
||||
getCodeAssistGlobalUserSetting: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('User does not have a current tier')),
|
||||
} as unknown as CodeAssistServer);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.isTierUnavailable).toBe(true);
|
||||
expect(result.current.privacyState.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should surface unexpected errors while loading opt-in settings', async () => {
|
||||
vi.mocked(getCodeAssistServer).mockReturnValue({
|
||||
projectId: 'test-project-id',
|
||||
userTier: UserTierId.FREE,
|
||||
getCodeAssistGlobalUserSetting: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('network unavailable')),
|
||||
} as unknown as CodeAssistServer);
|
||||
|
||||
const { result } = await act(async () => renderPrivacySettingsHook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.privacyState.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.privacyState.error).toBe('network unavailable');
|
||||
expect(result.current.privacyState.isTierUnavailable).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update data collection opt-in setting', async () => {
|
||||
|
||||
@@ -18,8 +18,22 @@ export interface PrivacyState {
|
||||
error?: string;
|
||||
isFreeTier?: boolean;
|
||||
dataCollectionOptIn?: boolean;
|
||||
/**
|
||||
* True when the signed-in account has no consumer Code Assist tier, so the
|
||||
* data-collection opt-in isn't applicable (e.g. Workspace/enterprise accounts,
|
||||
* or an OAuth login without a Google Cloud project). This is an expected state
|
||||
* rendered as a friendly, actionable notice rather than a raw backend `error`.
|
||||
*/
|
||||
isTierUnavailable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that the current account can't be mapped to a consumer Code Assist
|
||||
* tier, so the privacy opt-in can't be shown. Handled by rendering a friendly
|
||||
* notice instead of surfacing a raw backend error.
|
||||
*/
|
||||
class TierUnavailableError extends Error {}
|
||||
|
||||
export const usePrivacySettings = (config: Config) => {
|
||||
const [privacyState, setPrivacyState] = useState<PrivacyState>({
|
||||
isLoading: true,
|
||||
@@ -34,7 +48,13 @@ export const usePrivacySettings = (config: Config) => {
|
||||
const server = getCodeAssistServerOrFail(config);
|
||||
const tier = server.userTier;
|
||||
if (tier === undefined) {
|
||||
throw new Error('Could not determine user tier.');
|
||||
// The account has no resolved Code Assist tier (e.g. Workspace or an
|
||||
// incomplete OAuth). Show a friendly notice instead of a raw error.
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
isTierUnavailable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (tier !== UserTierId.FREE) {
|
||||
// We don't need to fetch opt-out info since non-free tier
|
||||
@@ -53,6 +73,13 @@ export const usePrivacySettings = (config: Config) => {
|
||||
dataCollectionOptIn: optIn,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isTierUnavailableError(e)) {
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
isTierUnavailable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
@@ -74,6 +101,13 @@ export const usePrivacySettings = (config: Config) => {
|
||||
dataCollectionOptIn: updatedOptIn,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isTierUnavailableError(e)) {
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
isTierUnavailable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPrivacyState({
|
||||
isLoading: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
@@ -92,13 +126,30 @@ export const usePrivacySettings = (config: Config) => {
|
||||
function getCodeAssistServerOrFail(config: Config): CodeAssistServer {
|
||||
const server = getCodeAssistServer(config);
|
||||
if (server === undefined) {
|
||||
throw new Error('Oauth not being used');
|
||||
throw new TierUnavailableError('Oauth not being used');
|
||||
} else if (server.projectId === undefined) {
|
||||
throw new Error('CodeAssist server is missing a project ID');
|
||||
throw new TierUnavailableError('CodeAssist server is missing a project ID');
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether an error means the account simply has no consumer Code
|
||||
* Assist tier, as opposed to an unexpected failure. Covers the local
|
||||
* {@link TierUnavailableError} as well as the Code Assist backend error (e.g.
|
||||
* "User does not have a current tier") returned for Workspace/enterprise
|
||||
* accounts.
|
||||
*/
|
||||
function isTierUnavailableError(error: unknown): boolean {
|
||||
if (error instanceof TierUnavailableError) {
|
||||
return true;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// Match the specific Code Assist backend message rather than a broad substring
|
||||
// so an unrelated error that merely mentions "tier" isn't masked as a benign notice.
|
||||
return /does not have a current tier/i.test(message);
|
||||
}
|
||||
|
||||
async function getRemoteDataCollectionOptIn(
|
||||
server: CodeAssistServer,
|
||||
): Promise<boolean> {
|
||||
|
||||
@@ -525,6 +525,102 @@ Your admin might have disabled the access. Contact them to enable the Preview Re
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle ModelNotFoundError with Vertex AI by displaying region-specific availability message and documentation link', async () => {
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
});
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1');
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
const error = new ModelNotFoundError('model not found', 404);
|
||||
|
||||
act(() => {
|
||||
promise = handler('gemini-3.5-flash', 'gemini-1.5-flash', error);
|
||||
});
|
||||
|
||||
const request = result.current.proQuotaRequest;
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.failedModel).toBe('gemini-3.5-flash');
|
||||
expect(request?.isModelNotFoundError).toBe(true);
|
||||
|
||||
const message = request!.message;
|
||||
expect(message).toBe(
|
||||
`Model "gemini-3.5-flash" is not available in region "us-central1".\n` +
|
||||
`To see which models are available in this region, please visit:\n` +
|
||||
`https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations\n` +
|
||||
`/model to switch models.`,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry_always');
|
||||
});
|
||||
|
||||
it('should handle ModelNotFoundError with Vertex AI and invalid model by displaying generic not found error message', async () => {
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
});
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us-central1');
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
onShowAuthSelection: mockOnShowAuthSelection,
|
||||
paidTier: null,
|
||||
settings: mockSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
let promise: Promise<FallbackIntent | null>;
|
||||
const error = new ModelNotFoundError('model not found', 404);
|
||||
|
||||
act(() => {
|
||||
promise = handler('invalid-model-name', 'gemini-1.5-flash', error);
|
||||
});
|
||||
|
||||
const request = result.current.proQuotaRequest;
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.failedModel).toBe('invalid-model-name');
|
||||
expect(request?.isModelNotFoundError).toBe(true);
|
||||
|
||||
const message = request!.message;
|
||||
expect(message).toBe(
|
||||
`Model "invalid-model-name" was not found or is invalid.\n` +
|
||||
`/model to switch models.`,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('retry_always');
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('retry_always');
|
||||
});
|
||||
|
||||
it('should handle ModelNotFoundError with invalid model correctly', async () => {
|
||||
const { result } = await renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
|
||||
@@ -135,7 +135,20 @@ export function useQuotaAndFallback({
|
||||
message = messageLines.join('\n');
|
||||
} else if (error instanceof ModelNotFoundError) {
|
||||
isModelNotFoundError = true;
|
||||
if (VALID_GEMINI_MODELS.has(failedModel)) {
|
||||
if (
|
||||
contentGeneratorConfig?.authType === AuthType.USE_VERTEX_AI &&
|
||||
VALID_GEMINI_MODELS.has(failedModel)
|
||||
) {
|
||||
const location =
|
||||
process.env['GOOGLE_CLOUD_LOCATION'] || 'your configured region';
|
||||
const messageLines = [
|
||||
`Model "${failedModel}" is not available in region "${location}".`,
|
||||
`To see which models are available in this region, please visit:`,
|
||||
`https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations`,
|
||||
`/model to switch models.`,
|
||||
];
|
||||
message = messageLines.join('\n');
|
||||
} else if (VALID_GEMINI_MODELS.has(failedModel)) {
|
||||
const messageLines = [
|
||||
`It seems like you don't have access to ${getDisplayString(failedModel)}.`,
|
||||
`Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useContext, useLayoutEffect, useCallback, useRef } from 'react';
|
||||
import { VirtualizedListContext } from '../components/shared/VirtualizedList.js';
|
||||
import type { DOMElement } from 'ink';
|
||||
|
||||
/**
|
||||
* A hook to register a clickable area within a VirtualizedList item.
|
||||
* This works seamlessly with both static and dynamic rendering.
|
||||
*
|
||||
* @param itemKey The unique key for the list item.
|
||||
* @param areaId A unique identifier for this clickable area within the list item.
|
||||
* @param callback The function to execute when the area is clicked.
|
||||
* @param options Configuration options.
|
||||
* @returns Props to spread onto the clickable component.
|
||||
*/
|
||||
export const useVirtualizedListClick = (
|
||||
itemKey: string | undefined,
|
||||
areaId: string,
|
||||
callback: () => void,
|
||||
options: { isActive?: boolean } = {},
|
||||
) => {
|
||||
const { isActive = true } = options;
|
||||
const context = useContext(VirtualizedListContext);
|
||||
const elementRef = useRef<DOMElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isActive && context && itemKey) {
|
||||
context.registerClickCallback(itemKey, areaId, callback);
|
||||
return () => {
|
||||
context.unregisterClickCallback(itemKey, areaId);
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [isActive, context, itemKey, areaId, callback]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive || !context || !elementRef.current) return;
|
||||
context.registerClickableArea(elementRef.current, areaId);
|
||||
return () => {
|
||||
if (elementRef.current) {
|
||||
context.unregisterClickableArea(elementRef.current);
|
||||
}
|
||||
};
|
||||
}, [isActive, context, areaId]);
|
||||
|
||||
const ref = useCallback(
|
||||
(el: DOMElement | null) => {
|
||||
if (elementRef.current && context) {
|
||||
context.unregisterClickableArea(elementRef.current);
|
||||
}
|
||||
elementRef.current = el;
|
||||
if (el && context && isActive) {
|
||||
context.registerClickableArea(el, areaId);
|
||||
}
|
||||
},
|
||||
[isActive, context, areaId],
|
||||
);
|
||||
|
||||
return { ref };
|
||||
};
|
||||
@@ -23,8 +23,8 @@ export const ScreenReaderAppLayout: React.FC = () => {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={uiState.terminalWidth}
|
||||
height={uiState.terminalHeight}
|
||||
width="90%"
|
||||
height="100%"
|
||||
ref={uiState.rootUiRef}
|
||||
>
|
||||
<Notifications />
|
||||
|
||||
@@ -71,6 +71,11 @@ describe('CloudFreePrivacyNotice', () => {
|
||||
mockState: { isFreeTier: false },
|
||||
expectedText: 'Gemini Code Assist Privacy Notice',
|
||||
},
|
||||
{
|
||||
stateName: 'tier unavailable state',
|
||||
mockState: { isFreeTier: undefined, isTierUnavailable: true },
|
||||
expectedText: 'GOOGLE_CLOUD_PROJECT',
|
||||
},
|
||||
{
|
||||
stateName: 'free tier state',
|
||||
mockState: { isFreeTier: true },
|
||||
@@ -101,6 +106,11 @@ describe('CloudFreePrivacyNotice', () => {
|
||||
mockState: { isFreeTier: false },
|
||||
shouldExit: true,
|
||||
},
|
||||
{
|
||||
stateName: 'tier unavailable state',
|
||||
mockState: { isFreeTier: undefined, isTierUnavailable: true },
|
||||
shouldExit: true,
|
||||
},
|
||||
{
|
||||
stateName: 'free tier state (no selection)',
|
||||
mockState: { isFreeTier: true },
|
||||
|
||||
@@ -27,7 +27,9 @@ export const CloudFreePrivacyNotice = ({
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (
|
||||
(privacyState.error || privacyState.isFreeTier === false) &&
|
||||
(privacyState.error ||
|
||||
privacyState.isFreeTier === false ||
|
||||
privacyState.isTierUnavailable) &&
|
||||
key.name === 'escape'
|
||||
) {
|
||||
onExit();
|
||||
@@ -53,6 +55,35 @@ export const CloudFreePrivacyNotice = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (privacyState.isTierUnavailable) {
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Gemini Code Assist Privacy Notice
|
||||
</Text>
|
||||
<Newline />
|
||||
<Text color={theme.text.primary}>
|
||||
The data collection opt-in isn't available for this account
|
||||
because it doesn't have a Gemini Code Assist for Individuals
|
||||
(free) tier.
|
||||
</Text>
|
||||
<Newline />
|
||||
<Text color={theme.text.primary}>
|
||||
If you're on a Google Workspace or enterprise account, use the
|
||||
Vertex AI / Google Cloud path instead by setting the
|
||||
GOOGLE_CLOUD_PROJECT environment variable to your Google Cloud
|
||||
project.
|
||||
</Text>
|
||||
<Newline />
|
||||
<Text color={theme.text.primary}>
|
||||
Learn more: https://geminicli.com/docs/get-started/authentication/
|
||||
</Text>
|
||||
<Newline />
|
||||
<Text color={theme.text.secondary}>Press Esc to exit.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (privacyState.isFreeTier === false) {
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface MarkdownDisplayProps {
|
||||
text: string;
|
||||
itemKey?: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="513" viewBox="0 0 920 513">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="360" viewBox="0 0 920 360">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="513" fill="#000000" />
|
||||
<rect width="920" height="360" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
@@ -30,16 +30,16 @@
|
||||
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="0" y="410" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="427" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="461" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="0" y="257" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="274" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="308" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -1,8 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="513" viewBox="0 0 920 513">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="360" viewBox="0 0 920 360">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="513" fill="#000000" />
|
||||
<rect width="920" height="360" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
@@ -30,16 +30,16 @@
|
||||
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="0" y="410" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="427" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="427" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="427" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
|
||||
<text x="855" y="427" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="444" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Running command...</text>
|
||||
<text x="855" y="461" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="0" y="257" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="274" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="274" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="274" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
|
||||
<text x="855" y="274" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="291" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="308" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Running command...</text>
|
||||
<text x="855" y="308" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -1,8 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="513" viewBox="0 0 920 513">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="360" viewBox="0 0 920 360">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="513" fill="#000000" />
|
||||
<rect width="920" height="360" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
@@ -30,16 +30,16 @@
|
||||
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="0" y="410" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="427" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="461" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="0" y="257" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="274" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="308" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -16,15 +16,6 @@ Tips for getting started:
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ google_web_search │
|
||||
│ │
|
||||
@@ -48,15 +39,6 @@ Tips for getting started:
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ run_shell_command │
|
||||
│ │
|
||||
@@ -80,15 +62,6 @@ Tips for getting started:
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ google_web_search │
|
||||
│ │
|
||||
|
||||
@@ -1,10 +1,74 @@
|
||||
(version 1)
|
||||
|
||||
;; allow everything by default
|
||||
(allow default)
|
||||
;; permissive-open: uses (deny default) and explicitly allows the operations the
|
||||
;; CLI needs, matching the restrictive-* / strict-* profiles. Keep the allow-list
|
||||
;; minimal and reviewed; do not switch to (allow default).
|
||||
;;
|
||||
;; Keep the non-network rules in sync with sandbox-macos-permissive-proxied.sb:
|
||||
;; the two profiles are intentionally identical except for their network rules
|
||||
;; ("open" allows broad outbound; "proxied" routes outbound through the proxy).
|
||||
(deny default)
|
||||
|
||||
;; deny all writes EXCEPT under specific paths
|
||||
(deny file-write*)
|
||||
;; allow reading files from anywhere on host
|
||||
(allow file-read*)
|
||||
|
||||
;; allow exec/fork (children inherit this policy, so they stay sandboxed)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
|
||||
;; allow signals to self, e.g. SIGPIPE on write to closed pipe
|
||||
(allow signal (target self))
|
||||
|
||||
;; allow read access to specific information about system
|
||||
;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
|
||||
(allow sysctl-read
|
||||
(sysctl-name "hw.activecpu")
|
||||
(sysctl-name "hw.busfrequency_compat")
|
||||
(sysctl-name "hw.byteorder")
|
||||
(sysctl-name "hw.cacheconfig")
|
||||
(sysctl-name "hw.cachelinesize_compat")
|
||||
(sysctl-name "hw.cpufamily")
|
||||
(sysctl-name "hw.cpufrequency_compat")
|
||||
(sysctl-name "hw.cputype")
|
||||
(sysctl-name "hw.l1dcachesize_compat")
|
||||
(sysctl-name "hw.l1icachesize_compat")
|
||||
(sysctl-name "hw.l2cachesize_compat")
|
||||
(sysctl-name "hw.l3cachesize_compat")
|
||||
(sysctl-name "hw.logicalcpu_max")
|
||||
(sysctl-name "hw.machine")
|
||||
(sysctl-name "hw.ncpu")
|
||||
(sysctl-name "hw.nperflevels")
|
||||
(sysctl-name "hw.optional.arm.FEAT_BF16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_DotProd")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FCMA")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FHM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FP16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_I8MM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
|
||||
(sysctl-name "hw.optional.arm.FEAT_LSE")
|
||||
(sysctl-name "hw.optional.arm.FEAT_RDM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_SHA512")
|
||||
(sysctl-name "hw.optional.armv8_2_sha512")
|
||||
(sysctl-name "hw.packages")
|
||||
(sysctl-name "hw.pagesize_compat")
|
||||
(sysctl-name "hw.physicalcpu_max")
|
||||
(sysctl-name "hw.tbfrequency_compat")
|
||||
(sysctl-name "hw.vectorunit")
|
||||
(sysctl-name "kern.hostname")
|
||||
(sysctl-name "kern.maxfilesperproc")
|
||||
(sysctl-name "kern.osproductversion")
|
||||
(sysctl-name "kern.osrelease")
|
||||
(sysctl-name "kern.ostype")
|
||||
(sysctl-name "kern.osvariant_status")
|
||||
(sysctl-name "kern.osversion")
|
||||
(sysctl-name "kern.secure_kernel")
|
||||
(sysctl-name "kern.usrstack64")
|
||||
(sysctl-name "kern.version")
|
||||
(sysctl-name "sysctl.proc_cputype")
|
||||
(sysctl-name-prefix "hw.perflevel")
|
||||
)
|
||||
|
||||
;; allow writes only to specific paths (deny default already blocks the rest)
|
||||
(allow file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
@@ -12,7 +76,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
@@ -25,3 +88,48 @@
|
||||
(literal "/dev/ptmx")
|
||||
(regex #"^/dev/ttys[0-9]*$")
|
||||
)
|
||||
|
||||
;; allow the mach services normal workflows need under deny-default: sysmond for
|
||||
;; process listing (pgrep), plus DNS resolution (mDNSResponder), directory
|
||||
;; services (opendirectoryd), and certificate validation (trustd/ocspd).
|
||||
;; This set mirrors the deny-default profile in
|
||||
;; packages/core/src/sandbox/macos/baseProfile.ts (its NETWORK_SEATBELT_PROFILE),
|
||||
;; which restrictive-open reaches only implicitly via (allow network-outbound).
|
||||
;; Keep this allow-list minimal and reviewed.
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.sysmond")
|
||||
(global-name "com.apple.system.opendirectoryd.libinfo")
|
||||
(global-name "com.apple.system.opendirectoryd.membership")
|
||||
(global-name "com.apple.bsd.dirhelper")
|
||||
(global-name "com.apple.SecurityServer")
|
||||
(global-name "com.apple.networkd")
|
||||
(global-name "com.apple.ocspd")
|
||||
(global-name "com.apple.trustd")
|
||||
(global-name "com.apple.trustd.agent")
|
||||
(global-name "com.apple.mDNSResponder")
|
||||
(global-name "com.apple.mDNSResponderHelper")
|
||||
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
|
||||
(global-name "com.apple.SystemConfiguration.configd")
|
||||
)
|
||||
|
||||
;; AF_SYSTEM socket used by the network stack (from baseProfile.ts)
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
|
||||
;; enable terminal access required by ink
|
||||
;; fixes setRawMode EPERM failure (at node:tty:81:24)
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
|
||||
;; allow inbound network traffic (local dev/test servers, the debugger on :9229,
|
||||
;; OAuth localhost callbacks)
|
||||
(allow network-inbound (local ip "*:*"))
|
||||
|
||||
;; allow binding local ports (dev/test servers, OAuth localhost listeners)
|
||||
(allow network-bind (local ip "*:*"))
|
||||
|
||||
;; allow all outbound network traffic
|
||||
(allow network-outbound)
|
||||
|
||||
@@ -1,10 +1,76 @@
|
||||
(version 1)
|
||||
|
||||
;; allow everything by default
|
||||
(allow default)
|
||||
;; permissive-proxied: uses (deny default) and explicitly allows the operations
|
||||
;; the CLI needs, matching the restrictive-* / strict-* profiles. Keep the
|
||||
;; allow-list minimal and reviewed; do not switch to (allow default).
|
||||
;;
|
||||
;; Keep the non-network rules in sync with sandbox-macos-permissive-open.sb:
|
||||
;; the two profiles are intentionally identical except for their network rules
|
||||
;; ("open" allows broad outbound; "proxied" routes outbound through the proxy).
|
||||
(deny default)
|
||||
|
||||
;; deny all writes EXCEPT under specific paths
|
||||
(deny file-write*)
|
||||
;; allow reading files from anywhere on host
|
||||
(allow file-read*)
|
||||
|
||||
;; allow exec/fork (children inherit this policy, so they stay sandboxed)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
|
||||
;; allow signals to self, e.g. SIGPIPE on write to closed pipe
|
||||
(allow signal (target self))
|
||||
|
||||
;; allow read access to specific information about system
|
||||
;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
|
||||
(allow sysctl-read
|
||||
(sysctl-name "hw.activecpu")
|
||||
(sysctl-name "hw.busfrequency_compat")
|
||||
(sysctl-name "hw.byteorder")
|
||||
(sysctl-name "hw.cacheconfig")
|
||||
(sysctl-name "hw.cachelinesize_compat")
|
||||
(sysctl-name "hw.cpufamily")
|
||||
(sysctl-name "hw.cpufrequency_compat")
|
||||
(sysctl-name "hw.cputype")
|
||||
(sysctl-name "hw.l1dcachesize_compat")
|
||||
(sysctl-name "hw.l1icachesize_compat")
|
||||
(sysctl-name "hw.l2cachesize_compat")
|
||||
(sysctl-name "hw.l3cachesize_compat")
|
||||
(sysctl-name "hw.logicalcpu_max")
|
||||
(sysctl-name "hw.machine")
|
||||
(sysctl-name "hw.ncpu")
|
||||
(sysctl-name "hw.nperflevels")
|
||||
(sysctl-name "hw.optional.arm.FEAT_BF16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_DotProd")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FCMA")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FHM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FP16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_I8MM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
|
||||
(sysctl-name "hw.optional.arm.FEAT_LSE")
|
||||
(sysctl-name "hw.optional.arm.FEAT_RDM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_SHA512")
|
||||
(sysctl-name "hw.optional.armv8_2_sha512")
|
||||
(sysctl-name "hw.packages")
|
||||
(sysctl-name "hw.pagesize_compat")
|
||||
(sysctl-name "hw.physicalcpu_max")
|
||||
(sysctl-name "hw.tbfrequency_compat")
|
||||
(sysctl-name "hw.vectorunit")
|
||||
(sysctl-name "kern.hostname")
|
||||
(sysctl-name "kern.maxfilesperproc")
|
||||
(sysctl-name "kern.osproductversion")
|
||||
(sysctl-name "kern.osrelease")
|
||||
(sysctl-name "kern.ostype")
|
||||
(sysctl-name "kern.osvariant_status")
|
||||
(sysctl-name "kern.osversion")
|
||||
(sysctl-name "kern.secure_kernel")
|
||||
(sysctl-name "kern.usrstack64")
|
||||
(sysctl-name "kern.version")
|
||||
(sysctl-name "sysctl.proc_cputype")
|
||||
(sysctl-name-prefix "hw.perflevel")
|
||||
)
|
||||
|
||||
;; allow writes only to specific paths (deny default already blocks the rest).
|
||||
;; Mirrors permissive-open, including /dev/ptmx and the /dev/ttys regex needed
|
||||
;; for PTY support under deny default.
|
||||
(allow file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
@@ -12,7 +78,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
@@ -22,16 +87,52 @@
|
||||
(literal "/dev/stdout")
|
||||
(literal "/dev/stderr")
|
||||
(literal "/dev/null")
|
||||
(literal "/dev/ptmx")
|
||||
(regex #"^/dev/ttys[0-9]*$")
|
||||
)
|
||||
|
||||
;; deny all inbound network traffic EXCEPT on debugger port
|
||||
(deny network-inbound)
|
||||
;; allow the mach services normal workflows need under deny-default: sysmond for
|
||||
;; process listing (pgrep), plus DNS resolution (mDNSResponder), directory
|
||||
;; services (opendirectoryd), and certificate validation (trustd/ocspd).
|
||||
;; This set mirrors the deny-default profile in
|
||||
;; packages/core/src/sandbox/macos/baseProfile.ts (its NETWORK_SEATBELT_PROFILE),
|
||||
;; which restrictive-proxied reaches only implicitly via (allow network-outbound).
|
||||
;; Keep this allow-list minimal and reviewed.
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.sysmond")
|
||||
(global-name "com.apple.system.opendirectoryd.libinfo")
|
||||
(global-name "com.apple.system.opendirectoryd.membership")
|
||||
(global-name "com.apple.bsd.dirhelper")
|
||||
(global-name "com.apple.SecurityServer")
|
||||
(global-name "com.apple.networkd")
|
||||
(global-name "com.apple.ocspd")
|
||||
(global-name "com.apple.trustd")
|
||||
(global-name "com.apple.trustd.agent")
|
||||
(global-name "com.apple.mDNSResponder")
|
||||
(global-name "com.apple.mDNSResponderHelper")
|
||||
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
|
||||
(global-name "com.apple.SystemConfiguration.configd")
|
||||
)
|
||||
|
||||
;; AF_SYSTEM socket used by the network stack (from baseProfile.ts)
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
|
||||
;; enable terminal access required by ink
|
||||
;; fixes setRawMode EPERM failure (at node:tty:81:24)
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
|
||||
;; allow inbound network traffic on debugger port
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
|
||||
;; allow binding local ports (dev/test servers, OAuth localhost listeners)
|
||||
(allow network-bind (local ip "*:*"))
|
||||
|
||||
;; deny all outbound network traffic EXCEPT through proxy on localhost:8877
|
||||
;; set `GEMINI_SANDBOX_PROXY_COMMAND=<command>` to run proxy alongside sandbox
|
||||
;; proxy must listen on :::8877 (see docs/examples/proxy-script.md)
|
||||
(deny network-outbound)
|
||||
(allow network-outbound (remote tcp "localhost:8877"))
|
||||
|
||||
(allow network-bind (local ip "*:*"))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const utilsDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Strip SBPL comments (`; ...` to end of line) so assertions run against the
|
||||
* actual sandbox rules rather than any keywords that happen to appear in the
|
||||
* explanatory comments.
|
||||
*/
|
||||
function readRules(profile: string): string {
|
||||
return readFileSync(path.join(utilsDir, profile), 'utf8')
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const commentStart = line.indexOf(';');
|
||||
return commentStart === -1 ? line : line.slice(0, commentStart);
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const PERMISSIVE_PROFILES = [
|
||||
'sandbox-macos-permissive-open.sb',
|
||||
'sandbox-macos-permissive-proxied.sb',
|
||||
];
|
||||
|
||||
// These two profiles are the default macOS Seatbelt profiles, so the invariants
|
||||
// below must never silently regress. Keep them deny-default and confirm the
|
||||
// reviewed allow-list stays in place.
|
||||
describe('macOS permissive Seatbelt profiles', () => {
|
||||
describe.each(PERMISSIVE_PROFILES)('%s', (profile) => {
|
||||
const rules = readRules(profile);
|
||||
|
||||
it('uses a deny-default foundation', () => {
|
||||
expect(rules).toContain('(deny default)');
|
||||
});
|
||||
|
||||
it('does not use an allow-default foundation', () => {
|
||||
expect(rules).not.toContain('(allow default)');
|
||||
});
|
||||
|
||||
it('does not permit filesystem (un)mounts', () => {
|
||||
expect(rules).not.toMatch(/file-mount/);
|
||||
expect(rules).not.toMatch(/file-unmount/);
|
||||
});
|
||||
|
||||
it('does not grant broad service lookups', () => {
|
||||
expect(rules).not.toMatch(/launchd/);
|
||||
expect(rules).not.toMatch(/launchservices/i);
|
||||
});
|
||||
|
||||
it('allows binding local ports for dev/test servers', () => {
|
||||
expect(rules).toContain('(allow network-bind (local ip "*:*"))');
|
||||
});
|
||||
});
|
||||
|
||||
it('permissive-open keeps broad inbound and outbound network', () => {
|
||||
const rules = readRules('sandbox-macos-permissive-open.sb');
|
||||
expect(rules).toContain('(allow network-inbound (local ip "*:*"))');
|
||||
expect(rules).toMatch(/\(allow network-outbound\)/);
|
||||
});
|
||||
|
||||
it('permissive-proxied confines outbound to the proxy', () => {
|
||||
const rules = readRules('sandbox-macos-permissive-proxied.sb');
|
||||
expect(rules).toContain(
|
||||
'(allow network-outbound (remote tcp "localhost:8877"))',
|
||||
);
|
||||
// Proxied mode must never grant unrestricted outbound network.
|
||||
expect(rules).not.toMatch(/\(allow network-outbound\)/);
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(literal (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(literal (string-append (param "HOME_DIR") "/.gitconfig"))
|
||||
;; Allow writes to included directories from --include-directories
|
||||
(subpath (param "INCLUDE_DIR_0"))
|
||||
(subpath (param "INCLUDE_DIR_1"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"version": "0.54.0-nightly.20260722.gf743ab579",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -64,7 +64,7 @@
|
||||
"fdir": "6.4.6",
|
||||
"fzf": "0.5.2",
|
||||
"glob": "12.0.0",
|
||||
"google-auth-library": "9.11.0",
|
||||
"google-auth-library": "10.9.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
|
||||
@@ -414,4 +414,86 @@ describe('Auto Routing Fallback Integration', () => {
|
||||
'Pro success',
|
||||
);
|
||||
});
|
||||
|
||||
it('should rotate session ID on fallback and retry successfully with the Flash model', async () => {
|
||||
const originalSessionId = 'test-session-rotate-id';
|
||||
config = new Config({
|
||||
sessionId: originalSessionId,
|
||||
targetDir: '/test',
|
||||
debugMode: false,
|
||||
cwd: '/test',
|
||||
model: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
|
||||
vi.spyOn(config, 'isInteractive').mockReturnValue(true);
|
||||
|
||||
client = new BaseLlmClient(
|
||||
fakeGenerator,
|
||||
config,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
|
||||
let attemptsPro = 0;
|
||||
let attemptsFlash = 0;
|
||||
|
||||
const mockGoogleApiError = {
|
||||
code: 429,
|
||||
message:
|
||||
'Automatically switching from gemini-2.5-pro to gemini-2.5-flash for faster responses for the remainder of this session. Possible reasons for this are...',
|
||||
details: [],
|
||||
};
|
||||
|
||||
vi.spyOn(fakeGenerator, 'generateContent').mockImplementation(
|
||||
async (params) => {
|
||||
if (params.model === PREVIEW_GEMINI_MODEL) {
|
||||
attemptsPro++;
|
||||
throw new RetryableQuotaError(
|
||||
'Quota exceeded for Pro',
|
||||
mockGoogleApiError,
|
||||
0,
|
||||
);
|
||||
} else if (params.model === PREVIEW_GEMINI_FLASH_MODEL) {
|
||||
attemptsFlash++;
|
||||
return {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Flash success after rotation' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
}
|
||||
throw new Error(`Unexpected model: ${params.model}`);
|
||||
},
|
||||
);
|
||||
|
||||
config.setFallbackModelHandler(
|
||||
async (_failed, _fallback, _error): Promise<FallbackIntent | null> =>
|
||||
'retry_always', // Approve switch to Flash
|
||||
);
|
||||
|
||||
const promise = client.generateContent({
|
||||
modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true },
|
||||
contents: [{ role: 'user', parts: [{ text: 'test query' }] }],
|
||||
abortSignal: new AbortController().signal,
|
||||
promptId: 'test-prompt',
|
||||
role: LlmRole.UTILITY_TOOL,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
// Verify it resolved to Flash success instead of failing with Please submit a new query
|
||||
expect(result.candidates?.[0]?.content?.parts?.[0]?.text).toBe(
|
||||
'Flash success after rotation',
|
||||
);
|
||||
expect(attemptsPro).toBe(3);
|
||||
expect(attemptsFlash).toBe(1);
|
||||
|
||||
// Verify session ID has been rotated
|
||||
expect(config.getSessionId()).not.toBe(originalSessionId);
|
||||
expect(config.getSessionId()).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -680,6 +680,64 @@ describe('oauth2', () => {
|
||||
expect(mockFromJSON).toHaveBeenCalledWith(byoidCredentials);
|
||||
expect(client).toBe(mockExternalAccountClient);
|
||||
});
|
||||
|
||||
it('should fall back to GOOGLE_APPLICATION_CREDENTIALS if default cached credentials are invalid or expired', async () => {
|
||||
// Setup default cached credentials that are expired/invalid
|
||||
const defaultCreds = { refresh_token: 'expired-token' };
|
||||
const defaultCredsPath = path.join(
|
||||
tempHomeDir,
|
||||
GEMINI_DIR,
|
||||
'oauth_creds.json',
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(defaultCredsPath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.promises.writeFile(
|
||||
defaultCredsPath,
|
||||
JSON.stringify(defaultCreds),
|
||||
);
|
||||
|
||||
// Setup valid fallback credentials via environment variable
|
||||
const envCreds = { refresh_token: 'valid-env-token' };
|
||||
const envCredsPath = path.join(tempHomeDir, 'env_creds.json');
|
||||
await fs.promises.writeFile(envCredsPath, JSON.stringify(envCreds));
|
||||
vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', envCredsPath);
|
||||
|
||||
let currentCredentials: Credentials | null = null;
|
||||
const mockClient = {
|
||||
setCredentials: vi.fn((creds) => {
|
||||
currentCredentials = creds as Credentials;
|
||||
}),
|
||||
getAccessToken: vi.fn(async () => {
|
||||
if (
|
||||
currentCredentials &&
|
||||
currentCredentials.refresh_token === 'expired-token'
|
||||
) {
|
||||
throw new Error('Token is expired or revoked');
|
||||
}
|
||||
return { token: 'valid-token' };
|
||||
}),
|
||||
getTokenInfo: vi.fn(async (_token) => {
|
||||
if (
|
||||
currentCredentials &&
|
||||
currentCredentials.refresh_token === 'expired-token'
|
||||
) {
|
||||
throw new Error('Token is expired or revoked');
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(OAuth2Client).mockImplementation(
|
||||
() => mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
|
||||
await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig);
|
||||
|
||||
// Assert that fallback envCreds were eventually loaded and used
|
||||
expect(mockClient.setCredentials).toHaveBeenCalledWith(envCreds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with GCP environment variables', () => {
|
||||
|
||||
@@ -113,46 +113,52 @@ function getUseEncryptedStorageFlag() {
|
||||
return process.env[FORCE_ENCRYPTED_FILE_ENV_VAR] === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the given credentials object represents ADC credentials.
|
||||
*/
|
||||
function isAdcCredentials(
|
||||
credentials: unknown,
|
||||
): credentials is JWTInput & { type: string } {
|
||||
if (credentials && typeof credentials === 'object' && 'type' in credentials) {
|
||||
const type = credentials.type;
|
||||
return typeof type === 'string' && type !== 'authorized_user';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function initOauthClient(
|
||||
authType: AuthType,
|
||||
config: Config,
|
||||
): Promise<AuthClient> {
|
||||
const credentials = await fetchCachedCredentials();
|
||||
function createBaseOAuth2Client(): OAuth2Client {
|
||||
const client = new OAuth2Client({
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
clientSecret: OAUTH_CLIENT_SECRET,
|
||||
transporterOptions: {
|
||||
proxy: config.getProxy(),
|
||||
},
|
||||
});
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
|
||||
if (
|
||||
credentials &&
|
||||
typeof credentials === 'object' &&
|
||||
'type' in credentials &&
|
||||
(credentials.type === 'external_account_authorized_user' ||
|
||||
credentials.type === 'service_account')
|
||||
) {
|
||||
const auth = new GoogleAuth({
|
||||
scopes: OAUTH_SCOPE,
|
||||
client.on('tokens', async (tokens: Credentials) => {
|
||||
if (useEncryptedStorage) {
|
||||
await OAuthCredentialStorage.saveCredentials(tokens);
|
||||
} else {
|
||||
await cacheCredentials(tokens);
|
||||
}
|
||||
|
||||
await triggerPostAuthCallbacks(tokens);
|
||||
});
|
||||
const byoidClient = auth.fromJSON({
|
||||
...credentials,
|
||||
refresh_token: credentials.refresh_token ?? undefined,
|
||||
});
|
||||
const token = await byoidClient.getAccessToken();
|
||||
if (token) {
|
||||
debugLogger.debug(`Created ${credentials.type} auth client.`);
|
||||
return byoidClient;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
const client = new OAuth2Client({
|
||||
clientId: OAUTH_CLIENT_ID,
|
||||
clientSecret: OAUTH_CLIENT_SECRET,
|
||||
transporterOptions: {
|
||||
proxy: config.getProxy(),
|
||||
},
|
||||
});
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
|
||||
// 1. Try GOOGLE_CLOUD_ACCESS_TOKEN override first if configured
|
||||
if (
|
||||
process.env['GOOGLE_GENAI_USE_GCA'] &&
|
||||
process.env['GOOGLE_CLOUD_ACCESS_TOKEN']
|
||||
) {
|
||||
const client = createBaseOAuth2Client();
|
||||
client.setCredentials({
|
||||
access_token: process.env['GOOGLE_CLOUD_ACCESS_TOKEN'],
|
||||
});
|
||||
@@ -160,49 +166,70 @@ async function initOauthClient(
|
||||
return client;
|
||||
}
|
||||
|
||||
client.on('tokens', async (tokens: Credentials) => {
|
||||
if (useEncryptedStorage) {
|
||||
await OAuthCredentialStorage.saveCredentials(tokens);
|
||||
} else {
|
||||
await cacheCredentials(tokens);
|
||||
}
|
||||
const credentialsList = await fetchCachedCredentialsList();
|
||||
|
||||
await triggerPostAuthCallbacks(tokens);
|
||||
});
|
||||
|
||||
if (credentials) {
|
||||
client.setCredentials(credentials as Credentials);
|
||||
try {
|
||||
// This will verify locally that the credentials look good.
|
||||
const { token } = await client.getAccessToken();
|
||||
if (token) {
|
||||
// This will check with the server to see if it hasn't been revoked.
|
||||
await client.getTokenInfo(token);
|
||||
|
||||
if (!userAccountManager.getCachedGoogleAccount()) {
|
||||
try {
|
||||
await fetchAndCacheUserInfo(client);
|
||||
} catch (error) {
|
||||
// Non-fatal, continue with existing auth.
|
||||
debugLogger.warn(
|
||||
'Failed to fetch user info:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
// 2. Iterate sequentially over the credentials list in their natural priority order
|
||||
for (const credentials of credentialsList) {
|
||||
if (isAdcCredentials(credentials)) {
|
||||
try {
|
||||
const auth = new GoogleAuth({
|
||||
scopes: OAUTH_SCOPE,
|
||||
});
|
||||
const adcClient = auth.fromJSON({
|
||||
...credentials,
|
||||
refresh_token: credentials.refresh_token ?? undefined,
|
||||
});
|
||||
const response = await adcClient.getAccessToken();
|
||||
const token = response.token ?? null;
|
||||
if (token) {
|
||||
debugLogger.debug('Created ' + credentials.type + ' auth client.');
|
||||
return adcClient;
|
||||
}
|
||||
debugLogger.log('Loaded cached credentials.');
|
||||
await triggerPostAuthCallbacks(credentials as Credentials);
|
||||
|
||||
return client;
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
'ADC credentials verification failed:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
} else if (credentials) {
|
||||
const client = createBaseOAuth2Client();
|
||||
client.setCredentials(credentials as Credentials);
|
||||
try {
|
||||
// This will verify locally that the credentials look good.
|
||||
const { token } = await client.getAccessToken();
|
||||
if (token) {
|
||||
// This will check with the server to see if it hasn't been revoked.
|
||||
await client.getTokenInfo(token);
|
||||
|
||||
if (!userAccountManager.getCachedGoogleAccount()) {
|
||||
try {
|
||||
await fetchAndCacheUserInfo(client);
|
||||
} catch (error) {
|
||||
// Non-fatal, continue with existing auth.
|
||||
debugLogger.warn(
|
||||
'Failed to fetch user info:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
debugLogger.log('Loaded cached credentials.');
|
||||
await triggerPostAuthCallbacks(
|
||||
client.credentials || (credentials as Credentials),
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
'Cached credentials are not valid:',
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`Cached credentials are not valid:`,
|
||||
getErrorMessage(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const client = createBaseOAuth2Client();
|
||||
|
||||
// In Google Compute Engine based environments (including Cloud Shell), we can
|
||||
// use Application Default Credentials (ADC) provided via its metadata server
|
||||
// to authenticate non-interactively using the identity of the logged-in user.
|
||||
@@ -663,16 +690,27 @@ export function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchCachedCredentials(): Promise<
|
||||
Credentials | JWTInput | null
|
||||
async function fetchCachedCredentialsList(): Promise<
|
||||
Array<Credentials | JWTInput>
|
||||
> {
|
||||
const credentialsList: Array<Credentials | JWTInput> = [];
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
if (useEncryptedStorage) {
|
||||
return OAuthCredentialStorage.loadCredentials();
|
||||
try {
|
||||
const creds = await OAuthCredentialStorage.loadCredentials();
|
||||
if (creds) {
|
||||
credentialsList.push(creds);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
'Failed to load credentials from encrypted storage:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pathsToTry = [
|
||||
Storage.getOAuthCredsPath(),
|
||||
...(!useEncryptedStorage ? [Storage.getOAuthCredsPath()] : []),
|
||||
process.env['GOOGLE_APPLICATION_CREDENTIALS'],
|
||||
].filter((p): p is string => !!p);
|
||||
|
||||
@@ -683,9 +721,10 @@ async function fetchCachedCredentials(): Promise<
|
||||
const isOAuthCreds = (val: unknown): val is Credentials | JWTInput =>
|
||||
typeof val === 'object' && val !== null;
|
||||
if (isOAuthCreds(parsed)) {
|
||||
return parsed;
|
||||
credentialsList.push(parsed);
|
||||
} else {
|
||||
throw new Error('Invalid credentials format');
|
||||
}
|
||||
throw new Error('Invalid credentials format');
|
||||
} catch (error) {
|
||||
// Log specific error for debugging, but continue trying other paths
|
||||
debugLogger.debug(
|
||||
@@ -695,7 +734,7 @@ async function fetchCachedCredentials(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return credentialsList;
|
||||
}
|
||||
|
||||
export function clearOauthClientCache() {
|
||||
|
||||
@@ -86,6 +86,10 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
readonly config?: Config,
|
||||
) {}
|
||||
|
||||
getEffectiveSessionId(): string | undefined {
|
||||
return this.config?.getSessionId() ?? this.sessionId;
|
||||
}
|
||||
|
||||
async generateContentStream(
|
||||
req: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
@@ -117,7 +121,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
req,
|
||||
userPromptId,
|
||||
this.projectId,
|
||||
this.sessionId,
|
||||
this.getEffectiveSessionId(),
|
||||
enabledCreditTypes,
|
||||
),
|
||||
req.config?.abortSignal,
|
||||
@@ -153,7 +157,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
translatedResponse,
|
||||
streamingLatency,
|
||||
req.config?.abortSignal,
|
||||
server.sessionId, // Use sessionId as trajectoryId
|
||||
server.getEffectiveSessionId(), // Use sessionId as trajectoryId
|
||||
);
|
||||
|
||||
if (response.consumedCredits) {
|
||||
@@ -204,7 +208,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
req,
|
||||
userPromptId,
|
||||
this.projectId,
|
||||
this.sessionId,
|
||||
this.getEffectiveSessionId(),
|
||||
undefined,
|
||||
),
|
||||
req.config?.abortSignal,
|
||||
@@ -224,7 +228,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
translatedResponse,
|
||||
streamingLatency,
|
||||
req.config?.abortSignal,
|
||||
this.sessionId, // Use sessionId as trajectoryId
|
||||
this.getEffectiveSessionId(), // Use sessionId as trajectoryId
|
||||
);
|
||||
|
||||
if (response.remainingCredits) {
|
||||
|
||||
@@ -678,6 +678,7 @@ export interface ConfigParameters {
|
||||
truncateToolOutputThreshold?: number;
|
||||
eventEmitter?: EventEmitter;
|
||||
useWriteTodos?: boolean;
|
||||
env?: Record<string, string>;
|
||||
workspacePoliciesDir?: string;
|
||||
policyEngineConfig?: PolicyEngineConfig;
|
||||
directWebFetch?: boolean;
|
||||
@@ -896,6 +897,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly useTerminalBuffer: boolean;
|
||||
private readonly useRenderProcess: boolean;
|
||||
private shellExecutionConfig: ShellExecutionConfig;
|
||||
readonly env?: Record<string, string>;
|
||||
private readonly extensionManagement: boolean = true;
|
||||
private readonly extensionRegistryURI: string | undefined;
|
||||
private readonly truncateToolOutputThreshold: number;
|
||||
@@ -1119,6 +1121,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.checkpointing = params.checkpointing ?? false;
|
||||
this.proxy = params.proxy;
|
||||
this.cwd = params.cwd ?? process.cwd();
|
||||
this.env = params.env;
|
||||
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
|
||||
this.bugCommand = params.bugCommand;
|
||||
this.model = params.model;
|
||||
@@ -1858,6 +1861,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
}
|
||||
|
||||
rotateSessionId(sessionId: string): void {
|
||||
this._sessionId = sessionId;
|
||||
}
|
||||
|
||||
resetNewSessionState(sessionId: string): void {
|
||||
this.setSessionId(sessionId);
|
||||
}
|
||||
|
||||
@@ -135,6 +135,29 @@ describe('AgentHistoryProvider', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use unambiguous label in fallback summary to avoid LLM confusion', async () => {
|
||||
providerConfig.maxTokens = 60000;
|
||||
providerConfig.retainedTokens = 60000;
|
||||
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
} as unknown as ContextManagementConfig);
|
||||
vi.mocked(estimateTokenCountSync).mockImplementation(
|
||||
(parts: Part[]) => parts.length * 4000,
|
||||
);
|
||||
generateContentMock.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const history = createMockHistory(35);
|
||||
const result = await provider.manageHistory(history);
|
||||
|
||||
expect(generateContentMock).toHaveBeenCalled();
|
||||
expect(result.length).toBe(15);
|
||||
// The fallback summary should use clear and unambiguous phrasing
|
||||
expect(result[0].parts![0].text).toContain(
|
||||
'Previous User Intent (Truncated):',
|
||||
);
|
||||
expect(result[0].parts![0].text).not.toContain('Last User Intent:');
|
||||
});
|
||||
|
||||
it('should pass the contextual bridge to the summarizer', async () => {
|
||||
vi.spyOn(config, 'getContextManagementConfig').mockReturnValue({
|
||||
enabled: true,
|
||||
|
||||
@@ -267,7 +267,9 @@ export class AgentHistoryProvider {
|
||||
];
|
||||
|
||||
if (lastUserText) {
|
||||
summaryParts.push(`- **Last User Intent:** "${lastUserText}"`);
|
||||
summaryParts.push(
|
||||
`- **Previous User Intent (Truncated):** "${lastUserText}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (actionPath) {
|
||||
|
||||
@@ -93,6 +93,7 @@ describe('createContentGenerator', () => {
|
||||
resetVersionCache();
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -483,6 +484,82 @@ describe('createContentGenerator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use US REP endpoint for Vertex AI when location is us and no baseUrl is provided', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'us');
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: true,
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
googleAuthOptions: expect.objectContaining({
|
||||
clientOptions: expect.objectContaining({
|
||||
apiEndpoint: 'https://aiplatform.us.rep.googleapis.com',
|
||||
}),
|
||||
}),
|
||||
httpOptions: expect.objectContaining({
|
||||
baseUrl: 'https://aiplatform.us.rep.googleapis.com',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use EU REP endpoint for Vertex AI when location is eu and no baseUrl is provided', async () => {
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getClientName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
} as unknown as GoogleGenAI;
|
||||
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
|
||||
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'eu');
|
||||
|
||||
await createContentGenerator(
|
||||
{
|
||||
apiKey: 'test-api-key',
|
||||
vertexai: true,
|
||||
authType: AuthType.USE_VERTEX_AI,
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(GoogleGenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
googleAuthOptions: expect.objectContaining({
|
||||
clientOptions: expect.objectContaining({
|
||||
apiEndpoint: 'https://aiplatform.eu.rep.googleapis.com',
|
||||
}),
|
||||
}),
|
||||
httpOptions: expect.objectContaining({
|
||||
baseUrl: 'https://aiplatform.eu.rep.googleapis.com',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should inject HttpsProxyAgent into googleAuthOptions when proxy URL uses https://', async () => {
|
||||
const mockConfigWithProxy = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
|
||||
@@ -121,6 +121,15 @@ const VERTEX_AI_REQUEST_TYPE_HEADER = 'X-Vertex-AI-LLM-Request-Type';
|
||||
const VERTEX_AI_SHARED_REQUEST_TYPE_HEADER =
|
||||
'X-Vertex-AI-LLM-Shared-Request-Type';
|
||||
|
||||
/**
|
||||
* Vertex AI Representative Endpoints (REP) for US and EU multi-regions.
|
||||
* These are used as a workaround for the client dynamically
|
||||
* constructing default legacy hostnames (e.g., 'us-aiplatform.googleapis.com')
|
||||
* instead of routing to the official REP endpoints.
|
||||
*/
|
||||
const VERTEX_AI_US_REP_ENDPOINT = 'https://aiplatform.us.rep.googleapis.com';
|
||||
const VERTEX_AI_EU_REP_ENDPOINT = 'https://aiplatform.eu.rep.googleapis.com';
|
||||
|
||||
function validateBaseUrl(baseUrl: string): void {
|
||||
try {
|
||||
new URL(baseUrl);
|
||||
@@ -145,6 +154,13 @@ export async function createContentGeneratorConfig(
|
||||
vertexAiRouting,
|
||||
};
|
||||
|
||||
const getEnv = (key: string) => {
|
||||
if (config?.env && config.env[key] !== undefined) {
|
||||
return config.env[key];
|
||||
}
|
||||
return process.env[key];
|
||||
};
|
||||
|
||||
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now.
|
||||
// Return before touching the API-key keychain: on Linux without a Secret Service
|
||||
// (WSL/SSH/Docker/CI) keytar can block indefinitely on its functional probe.
|
||||
@@ -156,16 +172,13 @@ export async function createContentGeneratorConfig(
|
||||
}
|
||||
|
||||
const geminiApiKey =
|
||||
apiKey ||
|
||||
process.env['GEMINI_API_KEY'] ||
|
||||
(await loadApiKey()) ||
|
||||
undefined;
|
||||
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
|
||||
apiKey || getEnv('GEMINI_API_KEY') || (await loadApiKey()) || undefined;
|
||||
const googleApiKey = getEnv('GOOGLE_API_KEY') || undefined;
|
||||
const googleCloudProject =
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] ||
|
||||
process.env['GOOGLE_CLOUD_PROJECT_ID'] ||
|
||||
getEnv('GOOGLE_CLOUD_PROJECT') ||
|
||||
getEnv('GOOGLE_CLOUD_PROJECT_ID') ||
|
||||
undefined;
|
||||
const googleCloudLocation = process.env['GOOGLE_CLOUD_LOCATION'] || undefined;
|
||||
const googleCloudLocation = getEnv('GOOGLE_CLOUD_LOCATION') || undefined;
|
||||
|
||||
if (authType === AuthType.USE_GEMINI && geminiApiKey) {
|
||||
contentGeneratorConfig.apiKey = geminiApiKey;
|
||||
@@ -185,8 +198,7 @@ export async function createContentGeneratorConfig(
|
||||
}
|
||||
|
||||
if (authType === AuthType.GATEWAY) {
|
||||
contentGeneratorConfig.apiKey =
|
||||
apiKey || process.env['GEMINI_API_KEY'] || '';
|
||||
contentGeneratorConfig.apiKey = apiKey || getEnv('GEMINI_API_KEY') || '';
|
||||
contentGeneratorConfig.vertexai = false;
|
||||
|
||||
return contentGeneratorConfig;
|
||||
@@ -341,6 +353,13 @@ export async function createContentGenerator(
|
||||
if (envBaseUrl) {
|
||||
validateBaseUrl(envBaseUrl);
|
||||
baseUrl = envBaseUrl;
|
||||
} else if (config.authType === AuthType.USE_VERTEX_AI) {
|
||||
const location = process.env['GOOGLE_CLOUD_LOCATION'];
|
||||
if (location === 'us') {
|
||||
baseUrl = VERTEX_AI_US_REP_ENDPOINT;
|
||||
} else if (location === 'eu') {
|
||||
baseUrl = VERTEX_AI_EU_REP_ENDPOINT;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
validateBaseUrl(baseUrl);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ThinkingLevel,
|
||||
type Content,
|
||||
type GenerateContentResponse,
|
||||
type Part,
|
||||
} from '@google/genai';
|
||||
import type { ContentGenerator } from '../core/contentGenerator.js';
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type StreamEvent,
|
||||
stripToolCallIdPrefixes,
|
||||
type HistoryTurn,
|
||||
coalesceConsecutiveRoles,
|
||||
} from './geminiChat.js';
|
||||
import {
|
||||
type CompletedToolCall,
|
||||
@@ -2253,6 +2255,35 @@ describe('GeminiChat', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('thought leakage in getHistoryTurns', () => {
|
||||
it('should completely filter out thought parts from getHistoryTurns when context management is enabled', () => {
|
||||
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(true);
|
||||
|
||||
chat.setHistory([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'internal monologue', thought: true } as unknown as Part,
|
||||
{ text: 'actual conversational response' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const turns = chat.getHistoryTurns(true);
|
||||
|
||||
expect(turns).toHaveLength(2);
|
||||
const modelTurn = turns[1];
|
||||
expect(modelTurn.content.parts).toHaveLength(1);
|
||||
expect(modelTurn.content.parts![0]).toEqual({
|
||||
text: 'actual conversational response',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureActiveLoopHasThoughtSignatures', () => {
|
||||
it('should add thoughtSignature to the first functionCall in each model turn of the active loop', () => {
|
||||
const chat = new GeminiChat(mockConfig, '', [], []);
|
||||
@@ -3107,4 +3138,62 @@ describe('GeminiChat', () => {
|
||||
expect(stripped[1].parts![0].functionResponse!.id).toBe('call_123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coalesceConsecutiveRoles', () => {
|
||||
it('should return empty history if empty array is passed', () => {
|
||||
expect(coalesceConsecutiveRoles([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not modify history when roles alternate correctly', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
|
||||
{ id: '2', content: { role: 'model', parts: [{ text: 'hi' }] } },
|
||||
{
|
||||
id: '3',
|
||||
content: { role: 'user', parts: [{ text: 'how are you?' }] },
|
||||
},
|
||||
];
|
||||
expect(coalesceConsecutiveRoles(history)).toEqual(history);
|
||||
});
|
||||
|
||||
it('should coalesce consecutive user turns', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
|
||||
{ id: '2', content: { role: 'user', parts: [{ text: 'world' }] } },
|
||||
];
|
||||
expect(coalesceConsecutiveRoles(history)).toEqual([
|
||||
{
|
||||
id: '1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }, { text: 'world' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle undefined or missing parts gracefully', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user' } },
|
||||
{ id: '2', content: { role: 'user', parts: [{ text: 'world' }] } },
|
||||
];
|
||||
expect(coalesceConsecutiveRoles(history)).toEqual([
|
||||
{
|
||||
id: '1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'world' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not coalesce turns if roles are undefined', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { parts: [{ text: 'hello' }] } },
|
||||
{ id: '2', content: { parts: [{ text: 'world' }] } },
|
||||
];
|
||||
expect(coalesceConsecutiveRoles(history)).toEqual(history);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -683,10 +683,13 @@ export class GeminiChat {
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
// Last mile scrubbing to remove internal tracking properties (e.g. callIndex)
|
||||
// before sending to the Gemini API. This whitelists only standard Gemini fields.
|
||||
const scrubbedHistory = this.context.config.isContextManagementEnabled()
|
||||
let scrubbedHistory = this.context.config.isContextManagementEnabled()
|
||||
? scrubHistory([...requestHistory])
|
||||
: [...requestHistory];
|
||||
|
||||
// Always coalesce consecutive roles to prevent 400 Bad Request errors
|
||||
scrubbedHistory = coalesceConsecutiveRoles(scrubbedHistory);
|
||||
|
||||
const scrubbedContents = scrubbedHistory.map((h) => h.content);
|
||||
|
||||
const requestContents = apiHistoryOverride
|
||||
@@ -1472,3 +1475,31 @@ export function stripToolCallIdPrefixes(contents: Content[]): Content[] {
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function coalesceConsecutiveRoles(
|
||||
history: HistoryTurn[],
|
||||
): HistoryTurn[] {
|
||||
const result: HistoryTurn[] = [];
|
||||
for (const turn of history) {
|
||||
const lastIdx = result.length - 1;
|
||||
const last = result[lastIdx];
|
||||
if (last && last.content.role && last.content.role === turn.content.role) {
|
||||
const hasParts = last.content.parts || turn.content.parts;
|
||||
result[lastIdx] = {
|
||||
id: last.id,
|
||||
content: {
|
||||
...last.content,
|
||||
parts: hasParts
|
||||
? [...(last.content.parts || []), ...(turn.content.parts || [])]
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
result.push({
|
||||
id: turn.id,
|
||||
content: { ...turn.content },
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
setActiveModel: vi.fn(),
|
||||
setModel: vi.fn(),
|
||||
activateFallbackMode: vi.fn(),
|
||||
rotateSessionId: vi.fn(),
|
||||
getModelAvailabilityService: vi.fn(() =>
|
||||
createAvailabilityServiceMock({
|
||||
selectedModel: FALLBACK_MODEL,
|
||||
|
||||