Compare commits

..

15 Commits

Author SHA1 Message Date
Christian Gunderman e1a7ecba9c chore(evals): add tool-log-formatter to tsconfig includes 2026-08-07 10:05:25 -07:00
ved015 7f9b99bb64 chore(evals): address review feedback and fix formatter edge cases 2026-07-10 01:09:01 +05:30
ved015 dd8dfd91f9 feat(evals): add tool call formatter and integrate failure summaries 2026-07-06 01:06:20 +05:30
Chad f7af4e5180 feat(caretaker): egress cloud run service skeleton (#28167) 2026-07-02 00:44:38 +00:00
luisfelipe-alt ff00dacd9f fix(core): resolve symbolic link directory escape in memory import processor (#28233) 2026-07-01 19:23:32 +00:00
Chad 7f00c5fe59 feat(caretaker): implement Cloud Run webhook ingestion service (#28015)
Co-authored-by: Christian Gunderman <gundermanc@google.com>
2026-06-30 23:34:31 +00:00
luisfelipe-alt b5fc06ee33 fix(core-tools): resolve defensive path resolution for at-reference files and fix macOS tests (#28053)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-06-30 19:45:32 +00:00
luisfelipe-alt ae0a3aa7b9 fix(security): enforce case-insensitive sensitive path blocklist and vscode hitl (#27966)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-06-26 19:36:00 +00:00
David Pierce b14416447e Vertex base url update (#28145) 2026-06-25 20:40:55 +00:00
Jerry Lin 8cd5c0f71f Fix no_proxy test (#28131)
Co-authored-by: Jerry Lin <jerrysf@google.com>
2026-06-25 20:35:41 +00:00
gemini-cli-robot df997354c8 chore(release): bump version to 0.51.0-nightly.20260625.g3fbf93e26 (#28151) 2026-06-25 20:34:00 +00:00
gemini-cli-robot 19ad71b903 Changelog for v0.50.0-preview.1 (#28150)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-06-25 20:14:42 +00:00
Gal Zahavi 3fbf93e26f fix(ci): prevent bad NPM releases and promote job crashes (#28147) 2026-06-25 18:22:56 +00:00
Vedant Mahajan d845bc5d45 Feat/tool registry discovery (#28113) 2026-06-24 23:51:30 +00:00
Gal Zahavi 02c6c77324 fix(ci): prevent workspace binary shadowing in release verification (#28132) 2026-06-24 22:04:47 +00:00
127 changed files with 4733 additions and 3501 deletions
+31 -13
View File
@@ -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:
+4 -2
View File
@@ -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
+3 -1
View File
@@ -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
+2 -1
View File
@@ -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 != '' }}"
+11 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.48.0-preview.0
# Preview release: v0.50.0-preview.1
Released: June 17, 2026
Released: June 25, 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).
@@ -27,6 +27,14 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix/verify release npm ci ignore scripts by @rmedranollamas in
[#28116](https://github.com/google-gemini/gemini-cli/pull/28116)
- fix(ci): prevent workspace binary shadowing in release verification by @galz10
in [#28132](https://github.com/google-gemini/gemini-cli/pull/28132)
- Feat/tool registry discovery by @ved015 in
[#28113](https://github.com/google-gemini/gemini-cli/pull/28113)
- fix(ci): prevent bad NPM releases and promote job crashes by @galz10 in
[#28147](https://github.com/google-gemini/gemini-cli/pull/28147)
- 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)
@@ -67,4 +75,4 @@ npm install -g @google/gemini-cli@preview
[#27992](https://github.com/google-gemini/gemini-cli/pull/27992)
**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.47.0-preview.0...v0.50.0-preview.1
-1
View File
@@ -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"` |
-6
View File
@@ -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.
+11 -3
View File
@@ -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
+105
View File
@@ -216,4 +216,109 @@ describe('evalTest reliability logic', () => {
}
}
});
it('should append tool call chain to assertion failure error messages', async () => {
const mockRig = {
setup: vi.fn(),
run: vi.fn(),
cleanup: vi.fn(),
readToolLogs: vi.fn().mockReturnValue([]),
_lastRunStderr: '',
} as any;
(TestRig as any).mockReturnValue(mockRig);
mockRig.run.mockResolvedValue('Success');
mockRig.readToolLogs.mockReturnValue([
{
toolRequest: {
name: 'grep_search',
args: '{"query":"TODO"}',
success: true,
duration_ms: 42,
},
},
{
toolRequest: {
name: 'read_file',
args: '{"path":"/src/foo.ts"}',
success: false,
duration_ms: 15,
error: 'File not found',
error_type: 'ENOENT',
},
},
]);
const assertionError = new Error('Expected tool to be called');
try {
await internalEvalTest({
suiteName: 'test',
suiteType: 'behavioral',
name: 'test-tool-chain',
prompt: 'do something',
assert: async () => {
throw assertionError;
},
});
expect.unreachable('Expected internalEvalTest to throw');
} catch (error: unknown) {
expect(error).toBeInstanceOf(Error);
const msg = (error as Error).message;
expect(msg).toContain('Expected tool to be called');
expect(msg).toContain('Tool Call Chain (2 calls)');
expect(msg).toContain('grep_search');
expect(msg).toContain('read_file');
expect(msg).toContain('[ENOENT] File not found');
}
});
it('should not crash when error.message is read-only (frozen error)', async () => {
const mockRig = {
setup: vi.fn(),
run: vi.fn(),
cleanup: vi.fn(),
readToolLogs: vi.fn(),
_lastRunStderr: '',
} as any;
(TestRig as any).mockReturnValue(mockRig);
mockRig.run.mockResolvedValue('Success');
mockRig.readToolLogs.mockReturnValue([
{
toolRequest: {
name: 'read_file',
args: '{"path":"/foo.ts"}',
success: true,
duration_ms: 10,
},
},
]);
// Simulate a frozen error whose message property cannot be mutated
const frozenError = Object.freeze(new Error('Frozen assertion error'));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
await expect(
internalEvalTest({
suiteName: 'test',
suiteType: 'behavioral',
name: 'test-frozen-error',
prompt: 'do something',
assert: async () => {
throw frozenError;
},
}),
).rejects.toThrow('Frozen assertion error');
// Should have warned that the message could not be mutated
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Could not append tool call chain'),
);
} finally {
warnSpy.mockRestore();
}
});
});
+18
View File
@@ -10,6 +10,7 @@ import path from 'node:path';
import crypto from 'node:crypto';
import { execSync } from 'node:child_process';
import { TestRig } from '@google/gemini-cli-test-utils';
import { formatToolLogChain } from '../scripts/utils/tool-log-formatter.js';
import {
createUnauthorizedToolError,
parseAgentMarkdown,
@@ -186,6 +187,23 @@ export async function internalEvalTest(evalCase: EvalCase) {
await evalCase.assert(rig, result);
isSuccess = true;
} catch (error: unknown) {
const toolLogs = rig.readToolLogs();
if (toolLogs && toolLogs.length > 0) {
const summary = formatToolLogChain(toolLogs);
if (error instanceof Error) {
try {
error.message = `${error.message}\n\nTool Call Chain (${toolLogs.length} calls):\n${summary}`;
} catch {
// Error object may be frozen or have a read-only message property.
// The original error is still re-thrown, so no failure is hidden.
console.warn(
`[eval] Could not append tool call chain to error message (${toolLogs.length} calls)`,
);
}
}
}
throw error;
} finally {
if (isSuccess) {
await fs.promises.unlink(activityLogFile).catch((err) => {
+1 -1
View File
@@ -7,7 +7,7 @@
"@google/gemini-cli": ["../packages/cli/index.ts"]
}
},
"include": ["**/*.ts"],
"include": ["**/*.ts", "../scripts/utils/tool-log-formatter.ts"],
"exclude": ["logs"],
"references": [{ "path": "../packages/core" }, { "path": "../packages/cli" }]
}
+14 -14
View File
@@ -1,17 +1,17 @@
{
"name": "@google/gemini-cli",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"workspaces": [
"packages/*"
],
"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",
@@ -9996,9 +9996,9 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-7.1.0.tgz",
"integrity": "sha512-OM49V37BUVfbG77zyT3YIDvwKosEOz1fBIBY5FI00DefrJGcfDc4GUVynb9GdjwwJwSNMe39VLH3VdG4bJ718A==",
"version": "6.6.9",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
"license": "MIT",
"dependencies": {
"ansi-escapes": "^7.0.0",
@@ -17969,7 +17969,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "7.19.0",
@@ -18471,7 +18471,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
@@ -18493,7 +18493,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",
@@ -19091,7 +19091,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -20367,7 +20367,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"license": "Apache-2.0",
"dependencies": {
"ws": "8.16.0"
@@ -20403,7 +20403,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -20742,7 +20742,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -20760,7 +20760,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "1.23.0",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"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.51.0-nightly.20260625.g3fbf93e26"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -75,7 +75,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"
@@ -145,7 +145,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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
-4
View File
@@ -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
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"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.51.0-nightly.20260625.g3fbf93e26"
},
"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",
-10
View File
@@ -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',
-1
View File
@@ -167,7 +167,6 @@ export async function startInteractiveUI(
useAlternateBuffer &&
!isShpool,
debugRainbow: settings.merged.ui.debugRainbow === true,
maxScrollbackLength: settings.merged.ui.maxScrollbackLength,
},
);
+2 -5
View File
@@ -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';
};
+4 -9
View File
@@ -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">&gt; </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();
});
+9 -21
View File
@@ -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();
});
});
File diff suppressed because it is too large Load Diff
@@ -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
│ │
│ │
│ │
+13 -18
View File
@@ -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.
@@ -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 />
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -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);
@@ -341,6 +350,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);
@@ -24,7 +24,7 @@ describe('PolicyEngine - Core Tools Mapping', () => {
vi.restoreAllMocks();
});
it('should map tools listed in settings.tools.core to ALLOW with correct priority and fallback to default policies', async () => {
it('should allow tools explicitly listed in settings.tools.core', async () => {
const settings = {
tools: {
core: ['run_shell_command(ls)', 'run_shell_command(git status)'],
@@ -63,7 +63,7 @@ describe('PolicyEngine - Core Tools Mapping', () => {
expect(result3.decision).toBe(PolicyDecision.DENY);
});
it('should map tools in tools.core with higher priority than default policies', async () => {
it('should allow tools in tools.core even if they are restricted by default policies', async () => {
// By default run_shell_command is ASK_USER.
// Putting it in tools.core should make it ALLOW.
const settings = {
+68
View File
@@ -240,4 +240,72 @@ describe('AllowedPathChecker', () => {
const result = await checker.check(input);
expect(result.decision).toBe(SafetyCheckDecision.ALLOW);
});
describe('Security Regression: Case-Insensitive Blocklist & .vscode HITL', () => {
it('should deny sensitive paths like .git, .env, and node_modules case-insensitively, including Windows trailing character and NTFS ADS bypasses', async () => {
const sensitivePaths = [
path.join(mockCwd, '.git', 'config'),
path.join(mockCwd, '.GIT', 'config'),
path.join(mockCwd, '.Git', 'config'),
path.join(mockCwd, '.env'),
path.join(mockCwd, '.Env'),
path.join(mockCwd, '.ENV'),
path.join(mockCwd, 'node_modules', 'package', 'index.js'),
path.join(mockCwd, 'NODE_MODULES', 'package', 'index.js'),
// Windows trailing character bypasses
path.join(mockCwd, '.git ', 'config'),
path.join(mockCwd, '.git.', 'config'),
path.join(mockCwd, '.env ', 'config'),
path.join(mockCwd, '.env.', 'config'),
path.join(mockCwd, 'node_modules ', 'package', 'index.js'),
// NTFS Alternate Data Stream bypasses
path.join(mockCwd, '.git::$DATA', 'config'),
path.join(mockCwd, '.env::$DATA'),
path.join(mockCwd, 'node_modules::$DATA', 'package', 'index.js'),
];
for (const p of sensitivePaths) {
const input = createInput({ path: p });
const result = await checker.check(input);
expect(result.decision).toBe(SafetyCheckDecision.DENY);
expect(result.reason).toContain('Access to sensitive path');
}
});
it('should require ASK_USER for .vscode configuration files inside workspace, but deny them if outside, including NTFS ADS bypasses', async () => {
const vscodePaths = [
path.join(mockCwd, '.vscode', 'settings.json'),
path.join(mockCwd, '.vscode', 'settings.JSON'),
path.join(mockCwd, '.VSCODE', 'settings.json'),
path.join(mockCwd, '.vscode', 'launch.json'),
// Windows trailing character bypasses
path.join(mockCwd, '.vscode ', 'settings.json'),
path.join(mockCwd, '.vscode.', 'settings.json'),
// NTFS Alternate Data Stream bypasses
path.join(mockCwd, '.vscode::$DATA', 'settings.json'),
];
for (const p of vscodePaths) {
const input = createInput({ path: p });
const result = await checker.check(input);
expect(result.decision).toBe(SafetyCheckDecision.ASK_USER);
expect(result.reason).toContain(
'Modifying .vscode configuration files requires explicit user confirmation',
);
}
// Verify that paths outside the workspace containing .vscode are strictly denied
const outsideVscodePaths = [
path.join(testRootDir, 'outside', '.vscode', 'settings.json'),
path.join(testRootDir, 'outside', '.VSCODE', 'settings.json'),
];
for (const p of outsideVscodePaths) {
const input = createInput({ path: p });
const result = await checker.check(input);
expect(result.decision).toBe(SafetyCheckDecision.DENY);
expect(result.reason).toContain('outside of the allowed workspace');
}
});
});
});
+68 -13
View File
@@ -5,13 +5,13 @@
*/
import * as path from 'node:path';
import * as fs from 'node:fs';
import {
SafetyCheckDecision,
type SafetyCheckInput,
type SafetyCheckResult,
} from './protocol.js';
import type { AllowedPathConfig } from '../policy/types.js';
import { resolveToRealPath } from '../utils/paths.js';
/**
* Interface for all in-process safety checkers.
@@ -45,6 +45,11 @@ export class AllowedPathChecker implements InProcessChecker {
excludedArgs,
);
// Resolve allowed directories once outside the loop to avoid redundant filesystem calls
const resolvedAllowedDirs = allowedDirs
.map((dir) => this.safelyResolvePath(dir, context.environment.cwd))
.filter((resolvedDir): resolvedDir is string => resolvedDir !== null);
// Check each path
for (const { path: p, argName } of pathsToCheck) {
const resolvedPath = this.safelyResolvePath(p, context.environment.cwd);
@@ -57,15 +62,52 @@ export class AllowedPathChecker implements InProcessChecker {
};
}
const isAllowed = allowedDirs.some((dir) => {
// Also resolve allowed directories to handle symlinks
const resolvedDir = this.safelyResolvePath(
dir,
context.environment.cwd,
);
if (!resolvedDir) return false;
return this.isPathAllowed(resolvedPath, resolvedDir);
});
// Check for blocked segments case-insensitively
let hasBlockedSegment = false;
let isVscodePath = false;
for (const resolvedDir of resolvedAllowedDirs) {
if (!this.isPathAllowed(resolvedPath, resolvedDir)) continue;
const relative = path.relative(resolvedDir, resolvedPath);
const segments = relative.split(path.sep);
for (const segment of segments) {
const clean = trimTrailingSpacesAndDots(
segment.split(':')[0],
).toLowerCase();
if (
clean === '.git' ||
clean === '.env' ||
clean === 'node_modules'
) {
hasBlockedSegment = true;
}
if (clean === '.vscode') {
isVscodePath = true;
}
}
}
if (hasBlockedSegment) {
return {
decision: SafetyCheckDecision.DENY,
reason: `Access to sensitive path "${p}" in argument "${argName}" is blocked.`,
};
}
if (isVscodePath) {
return {
decision: SafetyCheckDecision.ASK_USER,
reason: `Modifying .vscode configuration files requires explicit user confirmation.`,
};
}
let isAllowed = false;
for (const resolvedDir of resolvedAllowedDirs) {
if (this.isPathAllowed(resolvedPath, resolvedDir)) {
isAllowed = true;
break;
}
}
if (!isAllowed) {
return {
@@ -84,14 +126,15 @@ export class AllowedPathChecker implements InProcessChecker {
// Walk up the directory tree until we find a path that exists
let current = resolved;
// Stop at root (dirname(root) === root on many systems, or it becomes empty/'.' depending on implementation)
while (current && current !== path.dirname(current)) {
if (fs.existsSync(current)) {
const canonical = fs.realpathSync(current);
try {
const canonical = resolveToRealPath(current);
// Re-construct the full path from this canonical base
const relative = path.relative(current, resolved);
// path.join handles empty relative paths correctly (returns canonical)
return path.join(canonical, relative);
} catch {
// Path does not exist, continue walking up
}
current = path.dirname(current);
}
@@ -156,3 +199,15 @@ export class AllowedPathChecker implements InProcessChecker {
return paths;
}
}
/**
* Trims trailing spaces and dots from a string without using regular expressions
* to completely eliminate any potential ReDoS (Regular Expression Denial of Service) risk.
*/
function trimTrailingSpacesAndDots(str: string): string {
let end = str.length - 1;
while (end >= 0 && (str[end] === ' ' || str[end] === '.')) {
end--;
}
return str.slice(0, end + 1);
}
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import { vi } from 'vitest';
import type { WorkspaceContext } from '../utils/workspaceContext.js';
@@ -17,7 +18,17 @@ export function createMockWorkspaceContext(
rootDir: string,
additionalDirs: string[] = [],
): WorkspaceContext {
const allDirs = [rootDir, ...additionalDirs];
const resolveToRealPathSafe = (p: string) => {
try {
return fs.realpathSync(p);
} catch {
return p;
}
};
const resolvedRootDir = resolveToRealPathSafe(rootDir);
const resolvedAdditionalDirs = additionalDirs.map(resolveToRealPathSafe);
const allDirs = [resolvedRootDir, ...resolvedAdditionalDirs];
const mockWorkspaceContext = {
addDirectory: vi.fn(),
@@ -0,0 +1,684 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ReadFileTool } from './read-file.js';
import { WriteFileTool, getCorrectedFileContent } from './write-file.js';
import { EditTool } from './edit.js';
import { correctPath } from '../utils/pathCorrector.js';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import type { Config } from '../config/config.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { isSubpath } from '../utils/paths.js';
vi.mock('../telemetry/loggers.js', () => ({
logFileOperation: vi.fn(),
logEditStrategy: vi.fn(),
logEditCorrectionEvent: vi.fn(),
}));
vi.mock('./jit-context.js', () => ({
discoverJitContext: vi.fn().mockResolvedValue(''),
appendJitContext: vi.fn().mockImplementation((content) => content),
appendJitContextToParts: vi.fn().mockImplementation((content) => content),
}));
describe('Consolidated At-Reference Path Resolution Tests (b-495551283)', () => {
let tempRootDir: string;
let mockConfigInstance: Config;
const abortSignal = new AbortController().signal;
beforeEach(async () => {
// Create a unique temporary root directory for each test run
const realTmp = await fsp.realpath(os.tmpdir());
tempRootDir = await fsp.mkdtemp(
path.join(realTmp, 'at-ref-resolution-root-'),
);
mockConfigInstance = {
getFileService: () => new FileDiscoveryService(tempRootDir),
getFileSystemService: () => new StandardFileSystemService(),
getTargetDir: () => tempRootDir,
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
getFileFilteringOptions: () => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
}),
storage: {
getProjectTempDir: () => path.join(tempRootDir, '.temp'),
},
isInteractive: () => false,
isPlanMode: () => false,
getActiveModel: () => undefined,
getBaseLlmClient: () => undefined,
getDisableLLMCorrection: () => true,
isPathAllowed(this: Config, absolutePath: string): boolean {
const workspaceContext = this.getWorkspaceContext();
if (workspaceContext.isPathWithinWorkspace(absolutePath)) {
return true;
}
const projectTempDir = this.storage.getProjectTempDir();
return isSubpath(path.resolve(projectTempDir), absolutePath);
},
validatePathAccess(this: Config, absolutePath: string): string | null {
if (this.isPathAllowed(absolutePath)) {
return null;
}
const workspaceDirs = this.getWorkspaceContext().getDirectories();
const projectTempDir = this.storage.getProjectTempDir();
return `Path not in workspace: Attempted path "${absolutePath}" resolves outside the allowed workspace directories: ${workspaceDirs.join(', ')} or the project temp directory: ${projectTempDir}`;
},
} as unknown as Config;
// Create the policies directory and new-policies.txt file
await fsp.mkdir(path.join(tempRootDir, 'policies'), { recursive: true });
await fsp.writeFile(
path.join(tempRootDir, 'policies', 'new-policies.txt'),
'[[rule]]\ntoolName = "run_shell_command"\ndecision = "allow"\n',
'utf8',
);
});
afterEach(async () => {
// Clean up the temporary root directory
if (fs.existsSync(tempRootDir)) {
await fsp.rm(tempRootDir, { recursive: true, force: true });
}
});
it('ReadFileTool successfully reads a file when the path is prefixed with @', async () => {
const readFileTool = new ReadFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = readFileTool.build({
file_path: '@policies/new-policies.txt',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed because it defensively strips the leading '@'
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('toolName = "run_shell_command"');
});
it('ReadFileTool successfully reads a file when the path is prefixed with @/', async () => {
const readFileTool = new ReadFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = readFileTool.build({
file_path: '@/policies/new-policies.txt',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed because it defensively strips the leading '@/'
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('toolName = "run_shell_command"');
});
it('WriteFileTool successfully writes to/updates a file when the path is prefixed with @', async () => {
const writeFileTool = new WriteFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = writeFileTool.build({
file_path: '@policies/new-policies.txt',
content: '[[rule]]\nupdated_content = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and update the correct file
expect(result.error).toBeUndefined();
const incorrectFilePath = path.join(
tempRootDir,
'@policies',
'new-policies.txt',
);
const correctFilePath = path.join(
tempRootDir,
'policies',
'new-policies.txt',
);
// It should NOT have created a literal "@policies" directory
expect(fs.existsSync(incorrectFilePath)).toBe(false);
// It should have updated the correct file under "policies"
const updatedContent = await fsp.readFile(correctFilePath, 'utf8');
expect(updatedContent).toContain('updated_content = true');
});
it('WriteFileTool successfully creates a new file when the path is prefixed with @ and the parent directory exists', async () => {
const writeFileTool = new WriteFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = writeFileTool.build({
file_path: '@policies/brand-new-file.txt',
content: '[[rule]]\nbrand_new_file = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const incorrectFilePath = path.join(
tempRootDir,
'@policies',
'brand-new-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'policies',
'brand-new-file.txt',
);
// It should NOT have created a literal "@policies" directory
expect(fs.existsSync(incorrectFilePath)).toBe(false);
// It should have created the correct file under "policies"
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('brand_new_file = true');
});
it('WriteFileTool successfully creates a new file in a nested subdirectory when the path is prefixed with @ and the first segment exists', async () => {
const writeFileTool = new WriteFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = writeFileTool.build({
file_path: '@policies/sub/brand-new-file.txt',
content: '[[rule]]\nnested_brand_new_file = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const incorrectFilePath = path.join(
tempRootDir,
'@policies',
'sub',
'brand-new-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'policies',
'sub',
'brand-new-file.txt',
);
// It should NOT have created a literal "@policies" directory
expect(fs.existsSync(incorrectFilePath)).toBe(false);
// It should have created the correct file under "policies/sub"
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('nested_brand_new_file = true');
});
it('WriteFileTool successfully creates a new file in a nested subdirectory when the path is prefixed with @ and the first segment does NOT exist', async () => {
const writeFileTool = new WriteFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = writeFileTool.build({
file_path: '@new-policies/sub/brand-new-file.txt',
content: '[[rule]]\nnested_brand_new_file = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const incorrectFilePath = path.join(
tempRootDir,
'@new-policies',
'sub',
'brand-new-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'new-policies',
'sub',
'brand-new-file.txt',
);
// It should NOT have created a literal "@new-policies" directory
expect(fs.existsSync(incorrectFilePath)).toBe(false);
// It SHOULD have created the file under "new-policies/sub"
expect(fs.existsSync(correctFilePath)).toBe(true);
// Verify the content of the created file
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('nested_brand_new_file = true');
});
it('WriteFileTool successfully creates a new file in a nested subdirectory when the path is prefixed with @/ and the first segment does NOT exist', async () => {
const writeFileTool = new WriteFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = writeFileTool.build({
file_path: '@/new-policies-alias/sub/brand-new-file.txt',
content: '[[rule]]\nnested_brand_new_file_alias = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const literalAtFilePath = path.join(
tempRootDir,
'@',
'new-policies-alias',
'sub',
'brand-new-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'new-policies-alias',
'sub',
'brand-new-file.txt',
);
// It should NOT have created a literal "@" directory
expect(fs.existsSync(literalAtFilePath)).toBe(false);
expect(fs.existsSync(path.join(tempRootDir, '@'))).toBe(false);
// It should have created the file under "new-policies-alias/sub"
expect(fs.existsSync(correctFilePath)).toBe(true);
// Verify the content of the created file
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('nested_brand_new_file_alias = true');
});
it('WriteFileTool successfully creates a new file in a nested subdirectory when the path is prefixed with @\\ and the first segment does NOT exist', async () => {
const writeFileTool = new WriteFileTool(
mockConfigInstance,
createMockMessageBus(),
);
const invocation = writeFileTool.build({
file_path: '@\\new-policies-alias-win\\sub\\brand-new-file.txt',
content: '[[rule]]\nnested_brand_new_file_alias_win = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const isWindows = process.platform === 'win32';
const literalAtFilePath = isWindows
? path.join(
tempRootDir,
'@',
'new-policies-alias-win',
'sub',
'brand-new-file.txt',
)
: path.join(
tempRootDir,
'@\\new-policies-alias-win\\sub\\brand-new-file.txt',
);
const correctFilePath = isWindows
? path.join(
tempRootDir,
'new-policies-alias-win',
'sub',
'brand-new-file.txt',
)
: path.join(
tempRootDir,
'new-policies-alias-win\\sub\\brand-new-file.txt',
);
// It should NOT have created a literal "@" directory
expect(fs.existsSync(literalAtFilePath)).toBe(false);
expect(fs.existsSync(path.join(tempRootDir, '@'))).toBe(false);
// It should have created the file under "new-policies-alias-win/sub"
expect(fs.existsSync(correctFilePath)).toBe(true);
// Verify the content of the created file
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('nested_brand_new_file_alias_win = true');
});
it('getCorrectedFileContent blocks path traversal outside the workspace', async () => {
const result = await getCorrectedFileContent(
mockConfigInstance,
'../../etc/passwd',
'malicious content',
abortSignal,
);
// The utility should fail with a path validation error
expect(result.error).toBeDefined();
expect(result.error?.message).toContain('Path not in workspace');
});
it('EditTool.getModifyContext blocks path traversal outside the workspace', async () => {
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const modifyContext = editTool.getModifyContext(abortSignal);
// The getCurrentContent method should throw a path validation error
await expect(
modifyContext.getCurrentContent({
file_path: '../../etc/passwd',
instruction: 'read file',
old_string: '',
new_string: '',
}),
).rejects.toThrow('Path not in workspace');
// The getProposedContent method should throw a path validation error
await expect(
modifyContext.getProposedContent({
file_path: '../../etc/passwd',
instruction: 'read file',
old_string: '',
new_string: '',
}),
).rejects.toThrow('Path not in workspace');
});
it('getCorrectedFileContent handles symlink loops gracefully', async () => {
const symlinkPath1 = path.join(tempRootDir, 'symlink1');
const symlinkPath2 = path.join(tempRootDir, 'symlink2');
await fsp.symlink(symlinkPath2, symlinkPath1);
await fsp.symlink(symlinkPath1, symlinkPath2);
const result = await getCorrectedFileContent(
mockConfigInstance,
'symlink1',
'content',
abortSignal,
);
// The utility should fail gracefully with a resolution error
expect(result.error).toBeDefined();
expect(result.error?.message).toContain('Failed to resolve path');
});
it('EditTool.getModifyContext handles symlink loops gracefully by throwing a descriptive error', async () => {
const symlinkPath1 = path.join(tempRootDir, 'symlink1');
const symlinkPath2 = path.join(tempRootDir, 'symlink2');
await fsp.symlink(symlinkPath2, symlinkPath1);
await fsp.symlink(symlinkPath1, symlinkPath2);
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const modifyContext = editTool.getModifyContext(abortSignal);
// The getCurrentContent method should throw a path resolution error
await expect(
modifyContext.getCurrentContent({
file_path: 'symlink1',
instruction: 'read file',
old_string: '',
new_string: '',
}),
).rejects.toThrow('Failed to resolve path');
// The getProposedContent method should throw a path resolution error
await expect(
modifyContext.getProposedContent({
file_path: 'symlink1',
instruction: 'read file',
old_string: '',
new_string: '',
}),
).rejects.toThrow('Failed to resolve path');
});
it('getCorrectedFileContent successfully resolves paths in Plan Mode', async () => {
const plansDir = path.join(tempRootDir, '.plans');
await fsp.mkdir(plansDir, { recursive: true });
await fsp.writeFile(
path.join(plansDir, 'plan-file.txt'),
'plan content',
'utf8',
);
const planConfigInstance = Object.assign({}, mockConfigInstance, {
isPlanMode: () => true,
getProjectRoot: () => tempRootDir,
storage: {
getProjectTempDir: () => path.join(tempRootDir, '.temp'),
getPlansDir: () => plansDir,
},
}) as unknown as Config;
const result = await getCorrectedFileContent(
planConfigInstance,
'plan-file.txt',
'new plan content',
abortSignal,
);
expect(result.error).toBeUndefined();
expect(result.originalContent).toBe('plan content');
});
it('EditTool successfully edits an existing file when the path is prefixed with @', async () => {
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const invocation = editTool.build({
file_path: '@policies/new-policies.txt',
instruction: 'update decision rule',
old_string: 'decision = "allow"',
new_string: 'decision = "deny"',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and update the correct file
expect(result.error).toBeUndefined();
const correctFilePath = path.join(
tempRootDir,
'policies',
'new-policies.txt',
);
const updatedContent = await fsp.readFile(correctFilePath, 'utf8');
expect(updatedContent).toContain('decision = "deny"');
});
it('EditTool successfully creates a new file when the path is prefixed with @ and the parent directory exists', async () => {
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const invocation = editTool.build({
file_path: '@policies/brand-new-edit-file.txt',
instruction: 'create new file',
old_string: '',
new_string: '[[rule]]\nbrand_new_edit_file = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const incorrectFilePath = path.join(
tempRootDir,
'@policies',
'brand-new-edit-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'policies',
'brand-new-edit-file.txt',
);
// It should NOT have created a literal "@policies" directory
expect(fs.existsSync(incorrectFilePath)).toBe(false);
// It should have created the correct file under "policies"
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('brand_new_edit_file = true');
});
it('EditTool successfully creates a new file in a nested subdirectory when the path is prefixed with @ and the first segment does NOT exist', async () => {
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const invocation = editTool.build({
file_path: '@new-policies-edit/sub/brand-new-file.txt',
instruction: 'create new file in nested subdirectory',
old_string: '',
new_string: '[[rule]]\nnested_brand_new_edit_file = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const incorrectFilePath = path.join(
tempRootDir,
'@new-policies-edit',
'sub',
'brand-new-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'new-policies-edit',
'sub',
'brand-new-file.txt',
);
// It should NOT have created a literal "@new-policies-edit" directory
expect(fs.existsSync(incorrectFilePath)).toBe(false);
// It SHOULD have created the file under "new-policies-edit/sub"
expect(fs.existsSync(correctFilePath)).toBe(true);
// Verify the content of the created file
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('nested_brand_new_edit_file = true');
});
it('EditTool successfully creates a new file in a nested subdirectory when the path is prefixed with @/ and the first segment does NOT exist', async () => {
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const invocation = editTool.build({
file_path: '@/new-policies-edit-alias/sub/brand-new-file.txt',
instruction: 'create new file in nested subdirectory',
old_string: '',
new_string: '[[rule]]\nnested_brand_new_edit_file_alias = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const literalAtFilePath = path.join(
tempRootDir,
'@',
'new-policies-edit-alias',
'sub',
'brand-new-file.txt',
);
const correctFilePath = path.join(
tempRootDir,
'new-policies-edit-alias',
'sub',
'brand-new-file.txt',
);
// It should NOT have created a literal "@" directory
expect(fs.existsSync(literalAtFilePath)).toBe(false);
expect(fs.existsSync(path.join(tempRootDir, '@'))).toBe(false);
// It should have created the file under "new-policies-edit-alias/sub"
expect(fs.existsSync(correctFilePath)).toBe(true);
// Verify the content of the created file
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain('nested_brand_new_edit_file_alias = true');
});
it('EditTool successfully creates a new file in a nested subdirectory when the path is prefixed with @\\ and the first segment does NOT exist', async () => {
const editTool = new EditTool(mockConfigInstance, createMockMessageBus());
const invocation = editTool.build({
file_path: '@\\new-policies-edit-alias-win\\sub\\brand-new-file.txt',
instruction: 'create new file in nested subdirectory',
old_string: '',
new_string: '[[rule]]\nnested_brand_new_edit_file_alias_win = true\n',
});
const result = await invocation.execute({ abortSignal });
// The tool should succeed and create the correct file
expect(result.error).toBeUndefined();
const isWindows = process.platform === 'win32';
const literalAtFilePath = isWindows
? path.join(
tempRootDir,
'@',
'new-policies-edit-alias-win',
'sub',
'brand-new-file.txt',
)
: path.join(
tempRootDir,
'@\\new-policies-edit-alias-win\\sub\\brand-new-file.txt',
);
const correctFilePath = isWindows
? path.join(
tempRootDir,
'new-policies-edit-alias-win',
'sub',
'brand-new-file.txt',
)
: path.join(
tempRootDir,
'new-policies-edit-alias-win\\sub\\brand-new-file.txt',
);
// It should NOT have created a literal "@" directory
expect(fs.existsSync(literalAtFilePath)).toBe(false);
expect(fs.existsSync(path.join(tempRootDir, '@'))).toBe(false);
// It should have created the file under "new-policies-edit-alias-win/sub"
expect(fs.existsSync(correctFilePath)).toBe(true);
// Verify the content of the created file
const createdContent = await fsp.readFile(correctFilePath, 'utf8');
expect(createdContent).toContain(
'nested_brand_new_edit_file_alias_win = true',
);
});
it('correctPath successfully resolves a path prefixed with @ to its clean counterpart', () => {
const result = correctPath(
'@policies/new-policies.txt',
mockConfigInstance,
);
expect(result.success).toBe(true);
if (result.success) {
const expectedPath = path.join(
tempRootDir,
'policies',
'new-policies.txt',
);
expect(result.correctedPath).toBe(expectedPath);
}
});
});
+28 -1
View File
@@ -84,7 +84,10 @@ describe('EditTool', () => {
beforeEach(() => {
vi.restoreAllMocks();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'edit-tool-test-'));
const rawTempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'edit-tool-test-'),
);
tempDir = fs.realpathSync(rawTempDir);
rootDir = path.join(tempDir, 'root');
fs.mkdirSync(rootDir);
@@ -701,6 +704,30 @@ function doIt() {
};
expect(tool.validateToolParams(params)).toBeNull();
});
it('should sanitize null bytes in absolute path during validation', () => {
const badPath = path.resolve(rootDir, 'test\0.txt');
const params: EditToolParams = {
file_path: badPath,
instruction: 'An instruction',
old_string: 'old',
new_string: 'new',
};
expect(tool.validateToolParams(params)).toBeNull();
});
it('should sanitize null bytes in absolute path during invocation setup', () => {
const badPath = path.resolve(rootDir, 'test\0.txt');
const invocation = tool.build({
file_path: badPath,
instruction: 'test',
old_string: 'old',
new_string: 'new',
});
expect((invocation as any).resolvedPath).toBe(
path.resolve(rootDir, 'test.txt'),
);
});
});
describe('execute', () => {
+114 -17
View File
@@ -27,7 +27,12 @@ import {
import { buildFilePathArgsPattern } from '../policy/utils.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import {
makeRelative,
shortenPath,
resolveDefensiveToolPath,
resolveToRealPath,
} from '../utils/paths.js';
import { isNodeError } from '../utils/errors.js';
import { correctPath } from '../utils/pathCorrector.js';
import type { Config } from '../config/config.js';
@@ -478,11 +483,13 @@ class EditToolInvocation
);
if (this.config.isPlanMode()) {
try {
this.resolvedPath = resolveAndValidatePlanPath(
this.params.file_path,
const cleanFilePath = this.params.file_path.replace(/\0/g, '');
const planPath = resolveAndValidatePlanPath(
cleanFilePath,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
this.resolvedPath = resolveToRealPath(planPath);
} catch (e) {
debugLogger.error(
'Failed to resolve plan path during EditTool invocation setup',
@@ -490,20 +497,39 @@ class EditToolInvocation
);
// Validation fails, set resolvedPath to something that will fail validation downstream or just the raw path.
// It's safer to store it so validation in execute() or getConfirmationDetails() catches it.
this.resolvedPath = this.params.file_path;
this.resolvedPath = this.params.file_path.replace(/\0/g, '');
}
} else if (!path.isAbsolute(this.params.file_path)) {
const result = correctPath(this.params.file_path, this.config);
if (result.success) {
this.resolvedPath = result.correctedPath;
try {
this.resolvedPath = resolveToRealPath(result.correctedPath);
} catch {
this.resolvedPath = result.correctedPath;
}
} else {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
const sanitizedPath = resolveDefensiveToolPath(
this.params.file_path,
this.config.getTargetDir(),
);
try {
this.resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), sanitizedPath),
);
} catch {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
sanitizedPath,
);
}
}
} else {
this.resolvedPath = this.params.file_path;
const cleanPath = this.params.file_path.replace(/\0/g, '');
try {
this.resolvedPath = resolveToRealPath(cleanPath);
} catch {
this.resolvedPath = cleanPath;
}
}
}
@@ -1094,28 +1120,45 @@ export class EditTool
let resolvedPath: string;
if (this.config.isPlanMode()) {
try {
resolvedPath = resolveAndValidatePlanPath(
params.file_path,
const cleanFilePath = params.file_path.replace(/\0/g, '');
const planPath = resolveAndValidatePlanPath(
cleanFilePath,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
resolvedPath = resolveToRealPath(planPath);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
} else if (!path.isAbsolute(params.file_path)) {
const result = correctPath(params.file_path, this.config);
if (result.success) {
resolvedPath = result.correctedPath;
try {
resolvedPath = resolveToRealPath(result.correctedPath);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
} else {
resolvedPath = path.resolve(
this.config.getTargetDir(),
const sanitizedPath = resolveDefensiveToolPath(
params.file_path,
this.config.getTargetDir(),
);
try {
resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), sanitizedPath),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
}
} else {
resolvedPath = params.file_path;
const cleanPath = params.file_path.replace(/\0/g, '');
try {
resolvedPath = resolveToRealPath(cleanPath);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
}
const newPlaceholders = detectOmissionPlaceholders(params.new_string);
if (newPlaceholders.length > 0) {
const oldPlaceholders = new Set(
@@ -1150,13 +1193,66 @@ export class EditTool
}
getModifyContext(_: AbortSignal): ModifyContext<EditToolParams> {
const resolvePath = (params: EditToolParams): string => {
let pathBeforeRealResolve: string;
try {
if (this.config.isPlanMode()) {
const cleanFilePath = params.file_path.replace(/\0/g, '');
pathBeforeRealResolve = resolveAndValidatePlanPath(
cleanFilePath,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
} else if (!path.isAbsolute(params.file_path)) {
const result = correctPath(params.file_path, this.config);
if (result.success) {
pathBeforeRealResolve = result.correctedPath;
} else {
const sanitizedPath = resolveDefensiveToolPath(
params.file_path,
this.config.getTargetDir(),
);
pathBeforeRealResolve = path.resolve(
this.config.getTargetDir(),
sanitizedPath,
);
}
} else {
pathBeforeRealResolve = params.file_path.replace(/\0/g, '');
}
} catch (err) {
throw new Error(
'Failed to resolve path: ' +
(err instanceof Error ? err.message : String(err)),
);
}
let resolved: string;
try {
resolved = resolveToRealPath(pathBeforeRealResolve);
} catch (err) {
throw new Error(
'Failed to resolve path: ' +
(err instanceof Error ? err.message : String(err)),
);
}
const validationError = this.config.validatePathAccess(resolved);
if (validationError) {
throw new Error(validationError);
}
return resolved;
};
return {
getFilePath: (params: EditToolParams) => params.file_path,
getCurrentContent: async (params: EditToolParams): Promise<string> => {
try {
const resolvedPath = resolvePath(params);
return await this.config
.getFileSystemService()
.readTextFile(params.file_path);
.readTextFile(resolvedPath);
} catch (err) {
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
return '';
@@ -1164,9 +1260,10 @@ export class EditTool
},
getProposedContent: async (params: EditToolParams): Promise<string> => {
try {
const resolvedPath = resolvePath(params);
const currentContent = await this.config
.getFileSystemService()
.readTextFile(params.file_path);
.readTextFile(resolvedPath);
return applyReplacement(
currentContent,
params.old_string,
+4 -1
View File
@@ -37,7 +37,10 @@ describe('GlobTool', () => {
beforeEach(async () => {
// Create a unique root directory for each test run
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-tool-root-'));
const rawTempRootDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'glob-tool-root-'),
);
tempRootDir = await fs.realpath(rawTempRootDir);
await fs.writeFile(path.join(tempRootDir, '.git'), ''); // Fake git repo
const rootDir = tempRootDir;
+45 -12
View File
@@ -18,7 +18,11 @@ import {
type ToolConfirmationOutcome,
type ExecuteOptions,
} from './tools.js';
import { shortenPath, makeRelative } from '../utils/paths.js';
import {
shortenPath,
makeRelative,
resolveToRealPath,
} from '../utils/paths.js';
import { type Config } from '../config/config.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { ToolErrorType } from './tool-error.js';
@@ -138,10 +142,22 @@ class GlobToolInvocation extends BaseToolInvocation<
// If a specific path is provided, resolve it and check if it's within workspace
let searchDirectories: readonly string[];
if (this.params.dir_path) {
const searchDirAbsolute = path.resolve(
this.config.getTargetDir(),
this.params.dir_path,
);
let searchDirAbsolute: string;
try {
searchDirAbsolute = resolveToRealPath(
path.resolve(this.config.getTargetDir(), this.params.dir_path),
);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
return {
llmContent: errMsg,
returnDisplay: 'Path resolution failed.',
error: {
message: errMsg,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
}
const validationError = this.config.validatePathAccess(
searchDirAbsolute,
'read',
@@ -189,9 +205,22 @@ class GlobToolInvocation extends BaseToolInvocation<
allEntries.push(...entries);
}
const relativePaths = allEntries.map((p) =>
path.relative(this.config.getTargetDir(), p.fullpath()),
);
let realTargetDir = this.config.getTargetDir();
try {
realTargetDir = resolveToRealPath(realTargetDir);
} catch {
// Ignore and use raw targetDir
}
const relativePaths = allEntries.map((p) => {
let realFullPath = p.fullpath();
try {
realFullPath = resolveToRealPath(realFullPath);
} catch {
// Ignore and use raw fullpath
}
return path.relative(realTargetDir, realFullPath);
});
const { filteredPaths, ignoredCount } =
fileDiscovery.filterFilesWithReport(relativePaths, {
@@ -304,10 +333,14 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
protected override validateToolParamValues(
params: GlobToolParams,
): string | null {
const searchDirAbsolute = path.resolve(
this.config.getTargetDir(),
params.dir_path || '.',
);
let searchDirAbsolute: string;
try {
searchDirAbsolute = resolveToRealPath(
path.resolve(this.config.getTargetDir(), params.dir_path || '.'),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
const validationError = this.config.validatePathAccess(
searchDirAbsolute,
+2 -2
View File
@@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { GrepTool, type GrepToolParams } from './grep.js';
import type { ToolResult, GrepResult, ExecuteOptions } from './tools.js';
import path from 'node:path';
import { isSubpath } from '../utils/paths.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import fs from 'node:fs/promises';
import os from 'node:os';
import type { Config } from '../config/config.js';
@@ -156,7 +156,7 @@ describe('GrepTool', () => {
});
it('should return error if path is a file, not a directory', async () => {
const filePath = path.join(tempRootDir, 'fileA.txt');
const filePath = resolveToRealPath(path.join(tempRootDir, 'fileA.txt'));
const params: GrepToolParams = { pattern: 'hello', dir_path: filePath };
expect(grepTool.validateToolParams(params)).toContain(
`Path is not a directory: ${filePath}`,
+28 -6
View File
@@ -25,7 +25,11 @@ import {
type ToolConfirmationOutcome,
type ExecuteOptions,
} from './tools.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import {
makeRelative,
shortenPath,
resolveToRealPath,
} from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { isGitRepository } from '../utils/gitUtils.js';
import type { Config } from '../config/config.js';
@@ -146,7 +150,21 @@ class GrepToolInvocation extends BaseToolInvocation<
let searchDirAbs: string | null = null;
if (pathParam) {
searchDirAbs = path.resolve(this.config.getTargetDir(), pathParam);
try {
searchDirAbs = resolveToRealPath(
path.resolve(this.config.getTargetDir(), pathParam),
);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
return {
llmContent: errMsg,
returnDisplay: 'Error: Path resolution failed.',
error: {
message: errMsg,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
}
const validationError = this.config.validatePathAccess(
searchDirAbs,
'read',
@@ -722,10 +740,14 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
// Only validate dir_path if one is provided
if (params.dir_path) {
const resolvedPath = path.resolve(
this.config.getTargetDir(),
params.dir_path,
);
let resolvedPath: string;
try {
resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), params.dir_path),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
const validationError = this.config.validatePathAccess(
resolvedPath,
'read',
+29 -5
View File
@@ -6,7 +6,12 @@
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import path from 'node:path';
import { makeRelative, shortenPath } from '../utils/paths.js';
import {
makeRelative,
shortenPath,
resolveDefensiveToolPath,
resolveToRealPath,
} from '../utils/paths.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
@@ -74,10 +79,20 @@ class ReadFileToolInvocation extends BaseToolInvocation<
_toolDisplayName?: string,
) {
super(params, messageBus, _toolName, _toolDisplayName);
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
const sanitizedPath = resolveDefensiveToolPath(
this.params.file_path,
this.config.getTargetDir(),
);
try {
this.resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), sanitizedPath),
);
} catch {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
sanitizedPath,
);
}
}
getDescription(): string {
@@ -242,11 +257,20 @@ export class ReadFileTool extends BaseDeclarativeTool<
return "The 'file_path' parameter must be non-empty.";
}
const resolvedPath = path.resolve(
this.config.getTargetDir(),
const sanitizedPath = resolveDefensiveToolPath(
params.file_path,
this.config.getTargetDir(),
);
let resolvedPath: string;
try {
resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), sanitizedPath),
);
} catch (err) {
return `Failed to resolve path: ${err instanceof Error ? err.message : String(err)}`;
}
const validationError = this.config.validatePathAccess(
resolvedPath,
'read',
@@ -398,7 +398,7 @@ describe('ReadManyFilesTool', () => {
});
it('should NOT use default excludes if useDefaultExcludes is false', async () => {
createFile('node_modules/some-lib/index.js', 'lib code');
createFile('dist/some-lib/index.js', 'lib code');
createFile('src/app.js', 'app code');
const params = { include: ['**/*.js'], useDefaultExcludes: false };
const invocation = tool.build(params);
@@ -406,10 +406,7 @@ describe('ReadManyFilesTool', () => {
abortSignal: new AbortController().signal,
});
const content = result.llmContent as string[];
const expectedPath1 = path.join(
tempRootDir,
'node_modules/some-lib/index.js',
);
const expectedPath1 = path.join(tempRootDir, 'dist/some-lib/index.js');
const expectedPath2 = path.join(tempRootDir, 'src/app.js');
expect(
content.some((c) =>
+7 -3
View File
@@ -46,7 +46,7 @@ vi.mock('../utils/paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/paths.js')>();
return {
...actual,
resolveToRealPath: vi.fn((p) => p),
resolveToRealPath: vi.fn((p) => actual.resolveToRealPath(p)),
normalizePath: vi.fn((p) =>
typeof p === 'string' ? p.replace(/\\/g, '/') : p,
),
@@ -1351,7 +1351,9 @@ describe('RipGrepTool', () => {
});
it('should add .geminiignore when enabled and patterns exist', async () => {
const geminiIgnorePath = path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME);
const geminiIgnorePath = resolveToRealPath(
path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME),
);
await fs.writeFile(geminiIgnorePath, 'ignored.log');
const configWithGeminiIgnore = createMockConfig(tempRootDir);
@@ -1395,7 +1397,9 @@ describe('RipGrepTool', () => {
});
it('should skip .geminiignore when disabled', async () => {
const geminiIgnorePath = path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME);
const geminiIgnorePath = resolveToRealPath(
path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME),
);
await fs.writeFile(geminiIgnorePath, 'ignored.log');
const configWithoutGeminiIgnore = createMockConfig(tempRootDir);
vi.spyOn(
+31 -6
View File
@@ -185,7 +185,22 @@ class GrepToolInvocation extends BaseToolInvocation<
// This forces CWD search instead of 'all workspaces' search by default.
const pathParam = this.params.dir_path || '.';
const searchDirAbs = path.resolve(this.config.getTargetDir(), pathParam);
let searchDirAbs: string;
try {
searchDirAbs = resolveToRealPath(
path.resolve(this.config.getTargetDir(), pathParam),
);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
return {
llmContent: errMsg,
returnDisplay: 'Error: Path resolution failed.',
error: {
message: errMsg,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
}
const validationError = this.config.validatePathAccess(
searchDirAbs,
'read',
@@ -624,8 +639,14 @@ export class RipGrepTool extends BaseDeclarativeTool<
true, // isOutputMarkdown
false, // canUpdateOutput
);
let targetDir = config.getTargetDir();
try {
targetDir = resolveToRealPath(targetDir);
} catch {
// Ignore and use raw targetDir
}
this.fileDiscoveryService = new FileDiscoveryService(
config.getTargetDir(),
targetDir,
config.getFileFilteringOptions(),
);
}
@@ -670,10 +691,14 @@ export class RipGrepTool extends BaseDeclarativeTool<
// Only validate path if one is provided
if (params.dir_path) {
const resolvedPath = path.resolve(
this.config.getTargetDir(),
params.dir_path,
);
let resolvedPath: string;
try {
resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), params.dir_path),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
const validationError = this.config.validatePathAccess(
resolvedPath,
'read',
+1 -1
View File
@@ -31,7 +31,7 @@ describe('Tracker Tools Integration', () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tracker-tools-test-'));
config = new Config({
sessionId: 'test-session',
sessionId: `test-session-${Math.random().toString(36).substring(7)}`,
targetDir: tempDir,
cwd: tempDir,
model: 'gemini-3-flash',
+21 -14
View File
@@ -30,7 +30,7 @@ import type { Config } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import type { ToolRegistry } from './tool-registry.js';
import path from 'node:path';
import { isSubpath } from '../utils/paths.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import fs from 'node:fs';
import os from 'node:os';
import { GeminiClient } from '../core/client.js';
@@ -44,8 +44,8 @@ import {
getMockMessageBusInstance,
} from '../test-utils/mock-message-bus.js';
const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root');
const plansDir = path.resolve(os.tmpdir(), 'gemini-cli-test-plans');
let rootDir: string;
let plansDir: string;
// --- MOCKS ---
vi.mock('../core/client.js');
@@ -134,16 +134,20 @@ describe('WriteFileTool', () => {
beforeEach(() => {
vi.clearAllMocks();
// Create a unique temporary directory for files created outside the root
tempDir = fs.mkdtempSync(
const rawTempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'write-file-test-external-'),
);
// Ensure the rootDir and plansDir for the tool exists
if (!fs.existsSync(rootDir)) {
fs.mkdirSync(rootDir, { recursive: true });
}
if (!fs.existsSync(plansDir)) {
fs.mkdirSync(plansDir, { recursive: true });
}
tempDir = fs.realpathSync(rawTempDir);
const rawRootDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-root-'),
);
rootDir = fs.realpathSync(rawRootDir);
const rawPlansDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-plans-'),
);
plansDir = fs.realpathSync(rawPlansDir);
const workspaceContext = new WorkspaceContext(rootDir, [plansDir]);
const mockStorage = {
@@ -272,8 +276,9 @@ describe('WriteFileTool', () => {
file_path: dirAsFilePath,
content: 'hello',
};
const realDirAsFilePath = resolveToRealPath(dirAsFilePath);
expect(() => tool.build(params)).toThrow(
`Path is a directory, not a file: ${dirAsFilePath}`,
`Path is a directory, not a file: ${realDirAsFilePath}`,
);
});
@@ -441,7 +446,8 @@ describe('WriteFileTool', () => {
abortSignal,
);
expect(fsService.readTextFile).toHaveBeenCalledWith(filePath);
const realFilePath = resolveToRealPath(filePath);
expect(fsService.readTextFile).toHaveBeenCalledWith(realFilePath);
expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
expect(result.correctedContent).toBe(proposedContent);
expect(result.originalContent).toBe('');
@@ -1014,8 +1020,9 @@ describe('WriteFileTool', () => {
expect(result.error?.type).toBe(errorType);
const errorSuffix = errorCode ? ` (${errorCode})` : '';
const realFilePath = resolveToRealPath(filePath);
const expectedMessage = errorCode
? `${expectedMessagePrefix}: ${filePath}${errorSuffix}`
? `${expectedMessagePrefix}: ${realFilePath}${errorSuffix}`
: `${expectedMessagePrefix}: ${errorMessage}`;
expect(result.llmContent).toContain(expectedMessage);
expect(result.returnDisplay).toContain(expectedMessage);
+96 -10
View File
@@ -28,7 +28,12 @@ import {
} from './tools.js';
import { buildFilePathArgsPattern } from '../policy/utils.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import {
makeRelative,
shortenPath,
resolveDefensiveToolPath,
resolveToRealPath,
} from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { ensureCorrectFileContent } from '../utils/editCorrector.js';
import { detectLineEnding } from '../utils/textUtils.js';
@@ -109,10 +114,67 @@ export async function getCorrectedFileContent(
let fileExists = false;
let correctedContent = proposedContent;
let resolvedPath: string;
if (config.isPlanMode()) {
try {
const cleanFilePath = filePath.replace(/\0/g, '');
const planPath = resolveAndValidatePlanPath(
cleanFilePath,
config.storage.getPlansDir(),
config.getProjectRoot(),
);
resolvedPath = resolveToRealPath(planPath);
} catch (err) {
return {
originalContent: '',
correctedContent: proposedContent,
fileExists: false,
error: {
message:
'Failed to resolve plan path: ' +
(err instanceof Error ? err.message : String(err)),
code: 'EINVAL',
},
};
}
} else {
const sanitizedPath = resolveDefensiveToolPath(
filePath,
config.getTargetDir(),
);
try {
resolvedPath = resolveToRealPath(
path.resolve(config.getTargetDir(), sanitizedPath),
);
} catch (err) {
return {
originalContent: '',
correctedContent: proposedContent,
fileExists: false,
error: {
message:
'Failed to resolve path: ' +
(err instanceof Error ? err.message : String(err)),
code: 'EINVAL',
},
};
}
}
const validationError = config.validatePathAccess(resolvedPath);
if (validationError) {
return {
originalContent: '',
correctedContent: proposedContent,
fileExists: false,
error: { message: validationError, code: 'EACCES' },
};
}
try {
originalContent = await config
.getFileSystemService()
.readTextFile(filePath);
.readTextFile(resolvedPath);
fileExists = true; // File exists and was read
} catch (err) {
if (isNodeError(err) && err.code === 'ENOENT') {
@@ -170,24 +232,36 @@ class WriteFileToolInvocation extends BaseToolInvocation<
if (this.config.isPlanMode()) {
try {
this.resolvedPath = resolveAndValidatePlanPath(
this.params.file_path,
const cleanFilePath = this.params.file_path.replace(/\0/g, '');
const planPath = resolveAndValidatePlanPath(
cleanFilePath,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
this.resolvedPath = resolveToRealPath(planPath);
} catch (e) {
debugLogger.error(
'Failed to resolve plan path during WriteFileTool invocation setup',
e,
);
// Validation fails, set resolvedPath to something that will fail validation downstream or just the raw path.
this.resolvedPath = this.params.file_path;
this.resolvedPath = this.params.file_path.replace(/\0/g, '');
}
} else {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
const sanitizedPath = resolveDefensiveToolPath(
this.params.file_path,
this.config.getTargetDir(),
);
try {
this.resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), sanitizedPath),
);
} catch {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
sanitizedPath,
);
}
}
}
@@ -525,16 +599,28 @@ export class WriteFileTool
let resolvedPath: string;
if (this.config.isPlanMode()) {
try {
resolvedPath = resolveAndValidatePlanPath(
filePath,
const cleanFilePath = filePath.replace(/\0/g, '');
const planPath = resolveAndValidatePlanPath(
cleanFilePath,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
resolvedPath = resolveToRealPath(planPath);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
} else {
resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
const sanitizedPath = resolveDefensiveToolPath(
filePath,
this.config.getTargetDir(),
);
try {
resolvedPath = resolveToRealPath(
path.resolve(this.config.getTargetDir(), sanitizedPath),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
}
const validationError = this.config.validatePathAccess(resolvedPath);
+1
View File
@@ -261,6 +261,7 @@ describe('fetch utils', () => {
it('should fall back to no_proxy if NO_PROXY is not set', () => {
const proxyUrl = 'http://proxy.example.com';
const noProxyValue = 'localhost,127.0.0.1';
vi.stubEnv('NO_PROXY', undefined);
vi.stubEnv('no_proxy', noProxyValue);
setGlobalProxy(proxyUrl);
@@ -6,6 +6,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { marked } from 'marked';
import { processImports, validateImportPath } from './memoryImportProcessor.js';
@@ -867,5 +869,46 @@ describe('memoryImportProcessor', () => {
);
expect(validateImportPath(dotPath, basePath, [allowedPath])).toBe(true);
});
it('should reject paths that escape allowed directories via symbolic links', () => {
const tmpDir = fsSync.realpathSync(os.tmpdir());
const testRoot = fsSync.mkdtempSync(path.join(tmpDir, 'gemini-test-'));
const allowedDir = path.join(testRoot, 'allowed');
const outsideDir = path.join(testRoot, 'outside');
const symlinkDir = path.join(allowedDir, 'sym_outside');
try {
// Create real directories and files on disk
fsSync.mkdirSync(allowedDir, { recursive: true });
fsSync.mkdirSync(outsideDir, { recursive: true });
fsSync.writeFileSync(path.join(outsideDir, 'sensitive.md'), 'secret');
// Create a symbolic link pointing outside the allowed directory
try {
fsSync.symlinkSync(outsideDir, symlinkDir, 'dir');
} catch (err: unknown) {
if (
process.platform === 'win32' &&
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'EPERM'
) {
// Skip the test if the user lacks symlink creation privileges on Windows
return;
}
throw err;
}
const importPath = 'sym_outside/sensitive.md';
expect(validateImportPath(importPath, allowedDir, [allowedDir])).toBe(
false,
);
} finally {
// Cleanup
fsSync.rmSync(testRoot, { recursive: true, force: true });
}
});
});
});
@@ -6,7 +6,7 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { isSubpath } from './paths.js';
import { isSubpath, resolveToRealPath } from './paths.js';
import { debugLogger } from './debugLogger.js';
// Simple console logger for import processing
@@ -397,9 +397,28 @@ export function validateImportPath(
return false;
}
const resolvedPath = path.resolve(basePath, importPath);
let resolvedPath: string;
try {
// Canonicalize the path on the actual physical disk to resolve symlinks
resolvedPath = resolveToRealPath(path.resolve(basePath, importPath));
} catch {
// If path resolution fails (e.g., infinite recursion or invalid path), fail-closed and reject it
return false;
}
return allowedDirectories.some((allowedDir) =>
isSubpath(allowedDir, resolvedPath),
const realAllowedDirs = allowedDirectories
.map((dir) => {
const trimmed = dir.trim();
if (!trimmed) return null;
try {
return resolveToRealPath(trimmed);
} catch {
return null;
}
})
.filter((dir): dir is string => dir !== null);
return realAllowedDirs.some((realAllowedDir) =>
isSubpath(realAllowedDir, resolvedPath),
);
}
@@ -20,7 +20,10 @@ describe('pathCorrector', () => {
let mockConfig: Config;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-corrector-test-'));
const rawTempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'path-corrector-test-'),
);
tempDir = fs.realpathSync(rawTempDir);
rootDir = path.join(tempDir, 'root');
otherWorkspaceDir = path.join(tempDir, 'other');
fs.mkdirSync(rootDir, { recursive: true });
+9 -3
View File
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Config } from '../config/config.js';
import { bfsFileSearchSync } from './bfsFileSearch.js';
import { resolveDefensiveToolPath } from './paths.js';
type SuccessfulPathCorrection = {
success: true;
@@ -34,8 +35,13 @@ export function correctPath(
filePath: string,
config: Config,
): PathCorrectionResult {
const sanitizedPath = resolveDefensiveToolPath(
filePath,
config.getTargetDir(),
);
// Check for direct path relative to the primary target directory.
const directPath = path.join(config.getTargetDir(), filePath);
const directPath = path.join(config.getTargetDir(), sanitizedPath);
if (fs.existsSync(directPath)) {
return { success: true, correctedPath: directPath };
}
@@ -43,8 +49,8 @@ export function correctPath(
// If not found directly, search across all workspace directories for ambiguous matches.
const workspaceContext = config.getWorkspaceContext();
const searchPaths = workspaceContext.getDirectories();
const basename = path.basename(filePath);
const normalizedTarget = filePath.replace(/\\/g, '/');
const basename = path.basename(sanitizedPath);
const normalizedTarget = sanitizedPath.replace(/\\/g, '/');
// Normalize path for matching and check if it ends with the provided relative path
const foundFiles = searchPaths
+17
View File
@@ -20,6 +20,7 @@ import {
toAbsolutePath,
toPathKey,
isTrustedSystemPath,
resolveDefensiveToolPath,
} from './paths.js';
vi.mock('node:fs', async (importOriginal) => {
@@ -918,4 +919,20 @@ describe('normalizePath', () => {
});
});
});
describe('resolveDefensiveToolPath', () => {
it('should sanitize paths by stripping null bytes', () => {
const targetDir = '/workspace';
const filePathWithNull = 'src/index.ts\0.exe';
const result = resolveDefensiveToolPath(filePathWithNull, targetDir);
expect(result).toBe('src/index.ts.exe');
});
it('should sanitize @ prefixed paths by stripping null bytes', () => {
const targetDir = '/workspace';
const filePathWithNull = '@/components/Button.tsx\0';
const result = resolveDefensiveToolPath(filePathWithNull, targetDir);
expect(result).toBe('components/Button.tsx');
});
});
});
+51
View File
@@ -572,3 +572,54 @@ export function isTrustedSystemPath(filePath: string): boolean {
);
}
}
/**
* Defensively resolves and sanitizes a file path generated by the LLM,
* stripping user-facing reference prefixes if necessary.
*/
export function resolveDefensiveToolPath(
filePath: string,
targetDir: string,
): string {
const cleanPath = filePath.replace(/\0/g, '');
try {
const literalPath = path.resolve(targetDir, cleanPath);
// If the file literally exists on disk as-is, return the resolved literal path immediately
if (fs.existsSync(literalPath)) {
return cleanPath;
}
// If the model supplied a leading @ prefix and the literal path doesn't exist:
if (cleanPath.startsWith('@') && cleanPath.length > 1) {
if (cleanPath.startsWith('@/') || cleanPath.startsWith('@\\')) {
const stripped = cleanPath.substring(1).replace(/^[\\/]+/, '');
return stripped.length > 0 ? stripped : cleanPath;
}
const strippedPath = cleanPath.substring(1).replace(/^[\\/]+/, '');
// Check if a literal directory/file starting with '@' exists for the first segment.
// If it does, we should preserve the '@' prefix.
const parts = strippedPath.split(/[\\/]/);
const firstSegment = parts[0];
if (firstSegment) {
const literalFirstSegment = path.resolve(targetDir, '@' + firstSegment);
if (fs.existsSync(literalFirstSegment)) {
return cleanPath;
}
// Otherwise, strip the '@' prefix to resolve to the standard directory name,
// preventing the accidental creation of literal '@'-prefixed directories (e.g. '@src', '@policies')
// when creating new files or directories.
return strippedPath;
}
}
} catch {
// Fallback to original path if any filesystem or resolution error occurs
}
// Fallback: return the original path
return cleanPath;
}
@@ -492,4 +492,50 @@ describe('WorkspaceContext with optional directories', () => {
expect(directories).toEqual([cwd, existingDir1]);
expect(debugLogger.warn).not.toHaveBeenCalled();
});
describe('Security Regression: Case-Insensitive Sensitive Path Blocklist', () => {
it('should reject sensitive paths like .git, .env, and node_modules case-insensitively, including Windows trailing character and NTFS ADS bypasses', () => {
const workspaceContext = new WorkspaceContext(cwd);
const sensitivePaths = [
path.join(cwd, '.git', 'config'),
path.join(cwd, '.GIT', 'config'),
path.join(cwd, '.Git', 'config'),
path.join(cwd, '.env'),
path.join(cwd, '.Env'),
path.join(cwd, '.ENV'),
path.join(cwd, 'node_modules', 'package', 'index.js'),
path.join(cwd, 'NODE_MODULES', 'package', 'index.js'),
// Windows trailing character bypasses
path.join(cwd, '.git ', 'config'),
path.join(cwd, '.git.', 'config'),
path.join(cwd, '.env ', 'config'),
path.join(cwd, '.env.', 'config'),
path.join(cwd, 'node_modules ', 'package', 'index.js'),
// NTFS Alternate Data Stream bypasses
path.join(cwd, '.git::$DATA', 'config'),
path.join(cwd, '.env::$DATA'),
path.join(cwd, 'node_modules::$DATA', 'package', 'index.js'),
];
for (const p of sensitivePaths) {
expect(workspaceContext.isPathWithinWorkspace(p)).toBe(false);
}
});
it('should allow standard non-sensitive paths', () => {
const workspaceContext = new WorkspaceContext(cwd);
const safePaths = [
path.join(cwd, 'src', 'index.ts'),
path.join(cwd, '.gitignore'),
path.join(cwd, '.env.example'),
path.join(cwd, 'package.json'),
];
for (const p of safePaths) {
expect(workspaceContext.isPathWithinWorkspace(p)).toBe(true);
}
});
});
});
@@ -184,6 +184,20 @@ export class WorkspaceContext {
for (const dir of this.directories) {
if (this.isPathWithinRoot(fullyResolvedPath, dir)) {
// Check for blocked segments case-insensitively
const relative = path.relative(dir, fullyResolvedPath);
const segments = relative.split(path.sep);
const hasBlockedSegment = segments.some((segment) => {
const clean = trimTrailingSpacesAndDots(
segment.split(':')[0],
).toLowerCase();
return (
clean === '.git' || clean === '.env' || clean === 'node_modules'
);
});
if (hasBlockedSegment) {
return false;
}
return true;
}
}
@@ -248,3 +262,15 @@ export class WorkspaceContext {
);
}
}
/**
* Trims trailing spaces and dots from a string without using regular expressions
* to completely eliminate any potential ReDoS (Regular Expression Denial of Service) risk.
*/
function trimTrailingSpacesAndDots(str: string): string {
let end = str.length - 1;
while (end >= 0 && (str[end] === ' ' || str[end] === '.')) {
end--;
}
return str.slice(0, end + 1);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
"version": "0.51.0-nightly.20260625.g3fbf93e26",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
-7
View File
@@ -537,13 +537,6 @@
"default": true,
"type": "boolean"
},
"maxScrollbackLength": {
"title": "Max Scrollback Length",
"description": "Maximum number of lines to keep in the terminal scrollback buffer.",
"markdownDescription": "Maximum number of lines to keep in the terminal scrollback buffer.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `1000`",
"default": 1000,
"type": "number"
},
"showSpinner": {
"title": "Show Spinner",
"description": "Show the spinner during operations.",
+1 -1
View File
@@ -54,7 +54,7 @@ if (process.env.CI) {
.filter((name) => name !== '@google/gemini-cli-core');
execSync(
`npx npm-run-all --parallel ${parallelWorkspaces.map((w) => `"build -w ${w}"`).join(' ')}`,
`npx --no-install npm-run-all --parallel ${parallelWorkspaces.map((w) => `"build -w ${w}"`).join(' ')}`,
{ stdio: 'inherit', cwd: root },
);
}

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