Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44259a00e2 |
@@ -197,29 +197,6 @@ 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'
|
||||
@@ -236,19 +213,12 @@ 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}" \
|
||||
${PUBLISH_TARGET} \
|
||||
--workspace="${INPUTS_CLI_PACKAGE_NAME}" \
|
||||
--tag staging-tmp
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp
|
||||
@@ -282,6 +252,18 @@ runs:
|
||||
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} staging-tmp
|
||||
fi
|
||||
|
||||
- name: '🔬 Verify NPM release by version'
|
||||
uses: './.github/actions/verify-release'
|
||||
if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}"
|
||||
with:
|
||||
npm-package: '${{ inputs.cli-package-name }}@${{ inputs.release-version }}'
|
||||
expected-version: '${{ inputs.release-version }}'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
gemini_api_key: '${{ inputs.gemini_api_key }}'
|
||||
github-token: '${{ inputs.github-token }}'
|
||||
npm-registry-url: '${{ inputs.npm-registry-url }}'
|
||||
npm-registry-scope: '${{ inputs.npm-registry-scope }}'
|
||||
|
||||
- name: '🏷️ Tag release'
|
||||
uses: './.github/actions/tag-npm-release'
|
||||
with:
|
||||
|
||||
@@ -74,7 +74,7 @@ runs:
|
||||
shell: 'bash'
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: |-
|
||||
gemini_version=$(npx --yes --prefer-online "${INPUTS_NPM_PACKAGE}" --version)
|
||||
gemini_version=$(npx --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,6 +98,4 @@ runs:
|
||||
# See https://github.com/google-gemini/gemini-cli/issues/10517
|
||||
CI: 'false'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
export INTEGRATION_TEST_GEMINI_BINARY_PATH=$(which gemini)
|
||||
npm run test:integration:sandbox:none
|
||||
run: 'npm run test:integration:sandbox:none'
|
||||
|
||||
@@ -106,9 +106,7 @@ jobs:
|
||||
echo "NIGHTLY_JSON: ${NIGHTLY_JSON}"
|
||||
echo "STABLE_VERSION=${STABLE_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
# shellcheck disable=SC1083
|
||||
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 "STABLE_SHA=$(git rev-parse "$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)"^{commit})" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIOUS_STABLE_TAG=$(echo "${STABLE_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
|
||||
echo "PREVIEW_VERSION=$(echo "${PREVIEW_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
|
||||
# shellcheck disable=SC1083
|
||||
|
||||
@@ -82,8 +82,7 @@ jobs:
|
||||
ORIGIN_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
ORIGIN_HASH=$(git rev-parse "${ORIGIN_TAG}")
|
||||
echo "ORIGIN_HASH=${ORIGIN_HASH}" >> "$GITHUB_OUTPUT"
|
||||
echo "ORIGIN_HASH=$(git rev-parse "${ORIGIN_TAG}")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Change tag'
|
||||
if: "${{ github.event.inputs.rollback_destination != '' }}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.50.0-preview.1
|
||||
# Preview release: v0.48.0-preview.0
|
||||
|
||||
Released: June 25, 2026
|
||||
Released: June 17, 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,14 +27,6 @@ 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)
|
||||
@@ -75,4 +67,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.50.0-preview.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.47.0-preview.0...v0.48.0-preview.0
|
||||
|
||||
@@ -81,6 +81,7 @@ they appear in the UI.
|
||||
| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Max Scrollback Length | `ui.maxScrollbackLength` | Maximum number of lines to keep in the terminal scrollback buffer. | `1000` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, all, or off. | `"off"` |
|
||||
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
|
||||
|
||||
@@ -447,6 +447,12 @@ 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.
|
||||
|
||||
@@ -56,7 +56,6 @@ export default tseslint.config(
|
||||
'eslint.config.js',
|
||||
'**/coverage/**',
|
||||
'packages/**/dist/**',
|
||||
'tools/**/dist/**',
|
||||
'bundle/**',
|
||||
'package/bundle/**',
|
||||
'.integration-tests/**',
|
||||
@@ -81,8 +80,8 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
// Rules for packages/*/src and tools/caretaker-agent (TS/TSX)
|
||||
files: ['packages/*/src/**/*.{ts,tsx}', 'tools/caretaker-agent/**/*.{ts,tsx}'],
|
||||
// Rules for packages/*/src (TS/TSX)
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
@@ -285,7 +284,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}', 'tools/**/*.test.ts'],
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}'],
|
||||
plugins: {
|
||||
vitest,
|
||||
},
|
||||
@@ -411,13 +410,6 @@ 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
|
||||
|
||||
@@ -216,109 +216,4 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ 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,
|
||||
@@ -187,23 +186,6 @@ 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) => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"@google/gemini-cli": ["../packages/cli/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "../scripts/utils/tool-log-formatter.ts"],
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["logs"],
|
||||
"references": [{ "path": "../packages/core" }, { "path": "../packages/cli" }]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"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": "6.6.9",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
|
||||
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-7.1.0.tgz",
|
||||
"integrity": "sha512-OM49V37BUVfbG77zyT3YIDvwKosEOz1fBIBY5FI00DefrJGcfDc4GUVynb9GdjwwJwSNMe39VLH3VdG4bJ718A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -17969,7 +17969,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"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.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"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@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"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.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -20367,7 +20367,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "8.16.0"
|
||||
@@ -20403,7 +20403,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"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.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"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.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"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.51.0-nightly.20260625.g3fbf93e26"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.49.0-nightly.20260617.g4d3dcdce1"
|
||||
},
|
||||
"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@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -145,7 +145,7 @@
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"latest-version": "9.0.0",
|
||||
"node-fetch-native": "1.6.7",
|
||||
"proper-lockfile": "4.1.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
`MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the
|
||||
element is available, avoiding potential rendering timing issues.
|
||||
- Avoid prop drilling when at all possible.
|
||||
- **StaticRender**: Unlike Ink's native `<Static>` (which is printed above the
|
||||
application layout and takes no space in the flex container), the custom
|
||||
`<StaticRender>` component preserves its layout and _does_ take up its
|
||||
measured height in the active flex container.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"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.51.0-nightly.20260625.g3fbf93e26"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.49.0-nightly.20260617.g4d3dcdce1"
|
||||
},
|
||||
"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@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.1.0",
|
||||
"ink-gradient": "3.0.0",
|
||||
"ink-spinner": "5.0.0",
|
||||
"latest-version": "9.0.0",
|
||||
|
||||
@@ -842,6 +842,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
maxScrollbackLength: {
|
||||
type: 'number',
|
||||
label: 'Max Scrollback Length',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: 1000,
|
||||
description:
|
||||
'Maximum number of lines to keep in the terminal scrollback buffer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showSpinner: {
|
||||
type: 'boolean',
|
||||
label: 'Show Spinner',
|
||||
|
||||
@@ -167,6 +167,7 @@ export async function startInteractiveUI(
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
debugRainbow: settings.merged.ui.debugRainbow === true,
|
||||
maxScrollbackLength: settings.merged.ui.maxScrollbackLength,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 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';
|
||||
@@ -13,7 +14,7 @@ import { DefaultAppLayout } from './layouts/DefaultAppLayout.js';
|
||||
import { AlternateBufferQuittingDisplay } from './components/AlternateBufferQuittingDisplay.js';
|
||||
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
||||
|
||||
export const App = () => {
|
||||
export const App = React.memo(() => {
|
||||
const uiState = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
@@ -35,4 +36,6 @@ export const App = () => {
|
||||
{isScreenReaderEnabled ? <ScreenReaderAppLayout /> : <DefaultAppLayout />}
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
App.displayName = 'App';
|
||||
|
||||
@@ -1557,6 +1557,13 @@ 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(
|
||||
@@ -2488,7 +2495,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlDPressedOnce: ctrlDPressCount >= 1,
|
||||
shortcutsHelpVisible,
|
||||
cleanUiDetailsVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
currentTip,
|
||||
@@ -2502,7 +2508,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
currentModel,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
availableTerminalHeight: uiStateAvailableTerminalHeight,
|
||||
stableControlsHeight,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
@@ -2601,7 +2607,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlDPressCount,
|
||||
shortcutsHelpVisible,
|
||||
cleanUiDetailsVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
currentTip,
|
||||
@@ -2614,7 +2619,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
allowPlanMode,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
uiStateAvailableTerminalHeight,
|
||||
stableControlsHeight,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
|
||||
@@ -61,6 +61,28 @@ 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
|
||||
"
|
||||
`;
|
||||
@@ -109,42 +131,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,13 +4,6 @@
|
||||
</style>
|
||||
<rect width="920" height="666" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<rect x="0" y="0" width="9" height="17" fill="#141414" />
|
||||
<rect x="9" y="0" width="18" height="17" fill="#141414" />
|
||||
<text x="9" y="2" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<rect x="27" y="0" width="324" height="17" fill="#141414" />
|
||||
<text x="27" y="2" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
|
||||
<rect x="351" y="0" width="549" height="17" fill="#141414" />
|
||||
<text x="0" y="19" fill="#141414" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
|
||||
<text x="0" y="53" fill="#333333" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="70" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">? Edit </text>
|
||||
@@ -73,7 +66,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="18" lengthAdjust="spacingAndGlyphs">│▄</text>
|
||||
<text x="882" y="189" fill="#333333" textLength="9" 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>
|
||||
@@ -83,7 +76,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="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="882" y="206" fill="#333333" textLength="9" 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>
|
||||
@@ -93,7 +86,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="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="882" y="223" fill="#333333" textLength="9" 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>
|
||||
@@ -103,7 +96,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="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="882" y="240" fill="#333333" textLength="9" 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>
|
||||
@@ -113,7 +106,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="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="882" y="257" fill="#333333" textLength="9" 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>
|
||||
@@ -123,7 +116,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="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="882" y="274" fill="#333333" textLength="9" 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>
|
||||
@@ -133,7 +126,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="18" lengthAdjust="spacingAndGlyphs">│█</text>
|
||||
<text x="882" y="291" fill="#333333" textLength="9" 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: 29 KiB After Width: | Height: | Size: 28 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,6 +44,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
itemKey?: string;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
isPending: boolean;
|
||||
@@ -57,6 +58,7 @@ interface HistoryItemDisplayProps {
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
item,
|
||||
itemKey,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
isPending,
|
||||
@@ -102,6 +104,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini' && (
|
||||
<GeminiMessage
|
||||
itemKey={itemKey}
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
@@ -112,6 +115,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_content' && (
|
||||
<GeminiMessageContent
|
||||
itemKey={itemKey}
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
@@ -188,6 +192,7 @@ 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 {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
FixedScrollableList,
|
||||
type FixedScrollableListRef,
|
||||
} from './shared/FixedScrollableList.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<ScrollableListRef<ScrollableItem>>(null);
|
||||
const listRef = useRef<FixedScrollableListRef<ScrollableItem>>(null);
|
||||
|
||||
const { isRecording, handleVoiceInput, resetTurnBaseline } = useVoiceMode({
|
||||
buffer,
|
||||
@@ -1869,14 +1869,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
height={Math.min(buffer.viewportHeight, scrollableData.length)}
|
||||
width="100%"
|
||||
>
|
||||
{config.getUseTerminalBuffer() ? (
|
||||
<ScrollableList
|
||||
{isAlternateBuffer ? (
|
||||
<FixedScrollableList
|
||||
ref={listRef}
|
||||
hasFocus={focus}
|
||||
data={scrollableData}
|
||||
renderItem={renderItem}
|
||||
estimatedItemHeight={() => 1}
|
||||
fixedItemHeight={true}
|
||||
itemHeight={1}
|
||||
keyExtractor={(item) =>
|
||||
item.type === 'visualLine'
|
||||
? `line-${item.absoluteVisualIdx}`
|
||||
@@ -1884,7 +1883,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
width={inputWidth + SCROLLBAR_GUTTER_WIDTH}
|
||||
backgroundColor={listBackgroundColor}
|
||||
containerHeight={Math.min(
|
||||
maxHeight={Math.min(
|
||||
buffer.viewportHeight,
|
||||
scrollableData.length,
|
||||
)}
|
||||
|
||||
@@ -358,6 +358,7 @@ describe('MainContent', () => {
|
||||
bannerVisible: false,
|
||||
copyModeEnabled: false,
|
||||
terminalWidth: 100,
|
||||
mouseMode: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -803,7 +804,6 @@ describe('MainContent', () => {
|
||||
expect(output).toContain('Planning execution');
|
||||
expect(output).toContain('Refining approach');
|
||||
expect(output).toMatchSnapshot();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ 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);
|
||||
@@ -37,6 +38,7 @@ export const MainContent = () => {
|
||||
const config = useConfig();
|
||||
const useTerminalBuffer = config.getUseTerminalBuffer();
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
const { copyModeEnabled } = useInputState();
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showConfirmationQueue = confirmingTool !== null;
|
||||
@@ -114,6 +116,7 @@ export const MainContent = () => {
|
||||
isToolGroupBoundary,
|
||||
}) => (
|
||||
<MemoizedHistoryItemDisplay
|
||||
itemKey={item.id.toString()}
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !isExpandable
|
||||
@@ -202,19 +205,27 @@ export const MainContent = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
{ type: 'header' as const },
|
||||
...augmentedHistory.map((data, index) => ({
|
||||
const headerItem = useMemo(() => ({ type: 'header' as const }), []);
|
||||
|
||||
const historyVirtualizedItems = useMemo(
|
||||
() =>
|
||||
augmentedHistory.map((data, index) => ({
|
||||
type: 'history' as const,
|
||||
item: data.item,
|
||||
element: historyItems[index],
|
||||
})),
|
||||
{ type: 'pending' as const },
|
||||
],
|
||||
[augmentedHistory, historyItems],
|
||||
);
|
||||
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
headerItem,
|
||||
...historyVirtualizedItems,
|
||||
{ type: 'pending' as const, pendingHistoryItems },
|
||||
],
|
||||
[headerItem, historyVirtualizedItems, pendingHistoryItems],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: (typeof virtualizedData)[number] }) => {
|
||||
if (item.type === 'header') {
|
||||
@@ -234,7 +245,7 @@ export const MainContent = () => {
|
||||
[showHeaderDetails, version, pendingItems],
|
||||
);
|
||||
|
||||
const estimatedItemHeight = useCallback(() => 100, []);
|
||||
const estimatedItemHeight = useCallback(() => 10, []);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: (typeof virtualizedData)[number], _index: number) => {
|
||||
@@ -249,7 +260,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 === 'header',
|
||||
(item: (typeof virtualizedData)[number]) => item.type !== 'pending',
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -271,7 +282,7 @@ export const MainContent = () => {
|
||||
renderStatic={useTerminalBuffer}
|
||||
isStaticItem={useTerminalBuffer ? isStaticItem : undefined}
|
||||
overflowToBackbuffer={useTerminalBuffer && !isAlternateBuffer}
|
||||
scrollbar={mouseMode}
|
||||
scrollbar={mouseMode && !copyModeEnabled}
|
||||
/>
|
||||
// TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()}
|
||||
// as that will reduce the # of cases where we will have to clear the
|
||||
@@ -295,6 +306,7 @@ export const MainContent = () => {
|
||||
isStaticItem,
|
||||
mouseMode,
|
||||
isAlternateBuffer,
|
||||
copyModeEnabled,
|
||||
]);
|
||||
|
||||
if (!uiState.isConfigInitialized) {
|
||||
|
||||
@@ -213,24 +213,3 @@ 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 } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
SerializableConfirmationDetails,
|
||||
ToolResultDisplay,
|
||||
} from '../../types.js';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
|
||||
describe('DenseToolMessage', () => {
|
||||
const defaultProps = {
|
||||
@@ -563,6 +564,114 @@ 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, useState, useRef } from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import { useMemo, useContext, useCallback, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
type FileDiff,
|
||||
@@ -32,13 +32,14 @@ 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)
|
||||
@@ -46,6 +47,8 @@ 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;
|
||||
}
|
||||
@@ -260,6 +263,8 @@ function getGenericSuccessData(
|
||||
|
||||
export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
const {
|
||||
itemKey,
|
||||
groupKey,
|
||||
callId,
|
||||
name,
|
||||
status,
|
||||
@@ -274,15 +279,45 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
const settings = useSettings();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
// Handle optional context members
|
||||
const [localIsExpanded, setLocalIsExpanded] = useState(false);
|
||||
const isExpanded = isExpandedInContext
|
||||
? isExpandedInContext(callId)
|
||||
: localIsExpanded;
|
||||
const effectiveItemKey = groupKey ?? itemKey;
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const toggleRef = useRef<DOMElement>(null);
|
||||
// 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,
|
||||
);
|
||||
|
||||
// Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails)
|
||||
const diff = useMemo((): FileDiff | undefined => {
|
||||
@@ -301,25 +336,6 @@ 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) {
|
||||
@@ -449,7 +465,12 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginLeft={2} flexDirection="row" flexWrap="wrap">
|
||||
<Box
|
||||
ref={clickableProps.ref}
|
||||
marginLeft={2}
|
||||
flexDirection="row"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Box flexDirection="row" flexShrink={1}>
|
||||
<ToolStatusIndicator status={status} name={name} />
|
||||
<Box maxWidth={25} flexShrink={0} flexGrow={0}>
|
||||
@@ -463,12 +484,7 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
</Box>
|
||||
|
||||
{summary && (
|
||||
<Box
|
||||
key="tool-summary"
|
||||
ref={isAlternateBuffer && diff ? toggleRef : undefined}
|
||||
marginLeft={1}
|
||||
flexGrow={0}
|
||||
>
|
||||
<Box key="tool-summary" marginLeft={1} flexGrow={0}>
|
||||
{summary}
|
||||
</Box>
|
||||
)}
|
||||
@@ -489,23 +505,18 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
borderColor={theme.border.default}
|
||||
borderDimColor={true}
|
||||
maxWidth={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
PAYLOAD_MAX_WIDTH + PAYLOAD_BORDER_CHROME_WIDTH,
|
||||
terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
)}
|
||||
>
|
||||
<ScrollableList
|
||||
itemKey={itemKey}
|
||||
data={diffLines}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
hasFocus={isFocused}
|
||||
width={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
terminalWidth -
|
||||
PAYLOAD_MARGIN_LEFT -
|
||||
PAYLOAD_BORDER_CHROME_WIDTH -
|
||||
PAYLOAD_SCROLL_GUTTER,
|
||||
)}
|
||||
hasFocus={false}
|
||||
width="100%"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @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,6 +13,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface GeminiMessageProps {
|
||||
text: string;
|
||||
itemKey?: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
@@ -20,6 +21,7 @@ interface GeminiMessageProps {
|
||||
|
||||
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
text,
|
||||
itemKey,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -37,6 +39,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
itemKey={itemKey}
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface GeminiMessageContentProps {
|
||||
text: string;
|
||||
itemKey?: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
@@ -24,6 +25,7 @@ interface GeminiMessageContentProps {
|
||||
*/
|
||||
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
text,
|
||||
itemKey,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -35,6 +37,7 @@ 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 } from 'react';
|
||||
import { useMemo, Fragment, useContext } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type {
|
||||
HistoryItem,
|
||||
@@ -43,6 +43,7 @@ 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,
|
||||
@@ -94,6 +95,7 @@ export const hasDensePayload = (tool: IndividualToolCallDisplay): boolean => {
|
||||
};
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
itemKey?: string;
|
||||
item: HistoryItem | HistoryItemWithoutId;
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
availableTerminalHeight?: number;
|
||||
@@ -108,6 +110,7 @@ interface ToolGroupMessageProps {
|
||||
const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4;
|
||||
|
||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
itemKey,
|
||||
item,
|
||||
toolCalls: allToolCalls,
|
||||
availableTerminalHeight,
|
||||
@@ -141,6 +144,11 @@ 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(
|
||||
() =>
|
||||
@@ -425,13 +433,18 @@ 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: isCompact ? false : isFirstProp,
|
||||
isFirst: isFirstProp,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
export type { TextEmphasis };
|
||||
|
||||
export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||
itemKey?: string;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
emphasis?: TextEmphasis;
|
||||
@@ -44,6 +45,7 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||
|
||||
export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
callId,
|
||||
itemKey,
|
||||
name,
|
||||
description,
|
||||
resultDisplay,
|
||||
@@ -139,6 +141,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
/>
|
||||
)}
|
||||
<ToolResultDisplay
|
||||
itemKey={itemKey}
|
||||
resultDisplay={resultDisplay}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
|
||||
@@ -22,13 +22,14 @@ 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 { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { FixedScrollableList } from '../shared/FixedScrollableList.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;
|
||||
@@ -44,6 +45,7 @@ interface FileDiffResult {
|
||||
}
|
||||
|
||||
export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
itemKey,
|
||||
resultDisplay,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -194,6 +196,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
return (
|
||||
<Scrollable
|
||||
itemKey={itemKey}
|
||||
width={childWidth}
|
||||
maxHeight={effectiveMaxHeight}
|
||||
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
||||
@@ -213,12 +216,12 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const data = resultDisplay as AnsiOutput;
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
const initialScrollIndex =
|
||||
@@ -226,13 +229,13 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
||||
<ScrollableList
|
||||
<FixedScrollableList
|
||||
itemKey={itemKey}
|
||||
width={childWidth}
|
||||
containerHeight={listHeight}
|
||||
maxHeight={listHeight}
|
||||
data={data}
|
||||
renderItem={renderVirtualizedAnsiLine}
|
||||
estimatedItemHeight={() => 1}
|
||||
fixedItemHeight={true}
|
||||
itemHeight={1}
|
||||
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(17);
|
||||
listRef?.scrollBy(15);
|
||||
});
|
||||
await waitUntilReady();
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ 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 = {
|
||||
@@ -30,21 +35,86 @@ describe('<TopicMessage />', () => {
|
||||
isExpanded?: (callId: string) => boolean;
|
||||
toggleExpansion?: (callId: string) => void;
|
||||
},
|
||||
) =>
|
||||
renderWithProviders(
|
||||
<TopicMessage
|
||||
args={args}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={height}
|
||||
callId="test-topic"
|
||||
name={UPDATE_TOPIC_TOOL_NAME}
|
||||
description="Updating topic"
|
||||
status={CoreToolCallStatus.Success}
|
||||
confirmationDetails={undefined}
|
||||
resultDisplay={undefined}
|
||||
/>,
|
||||
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>,
|
||||
{ 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, useRef, useCallback } from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import { useEffect, useId, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
@@ -18,12 +18,14 @@ import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.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 =>
|
||||
@@ -34,6 +36,7 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
args,
|
||||
availableTerminalHeight,
|
||||
isExpandable = true,
|
||||
itemKey,
|
||||
}) => {
|
||||
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
|
||||
|
||||
@@ -47,7 +50,6 @@ 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;
|
||||
@@ -75,9 +77,14 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
}
|
||||
}, [toggleExpansion, hasExtraSummary, callId]);
|
||||
|
||||
useMouseClick(containerRef, handleToggle, {
|
||||
isActive: isExpandable && hasExtraSummary,
|
||||
});
|
||||
const clickableProps = useVirtualizedListClick(
|
||||
itemKey,
|
||||
'toggle',
|
||||
handleToggle,
|
||||
{
|
||||
isActive: isExpandable && hasExtraSummary,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Only register if there is more content (summary) and it's currently hidden
|
||||
@@ -95,7 +102,7 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
}, [isExpandable, hasExtraSummary, isExpanded, overflowActions, overflowId]);
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} flexDirection="column" marginLeft={2}>
|
||||
<Box ref={clickableProps.ref} 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-10 │
|
||||
╰────────────────────────────────────────────────────────────────────────╯ █
|
||||
│ ✓ tool-2 Description for tool-2 │ ▄
|
||||
│────────────────────────────────────────────────────────────────────────│ ▀
|
||||
│ c2-08 │
|
||||
│ c2-09 │
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* @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 };
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* @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,6 +13,7 @@ import {
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useId,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { Box, ResizeObserver, type DOMElement } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
@@ -22,9 +23,11 @@ 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;
|
||||
@@ -40,6 +43,7 @@ interface ScrollableProps {
|
||||
|
||||
export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
children,
|
||||
itemKey,
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
@@ -53,7 +57,16 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
stableScrollback,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
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 viewportRef = useRef<DOMElement | null>(null);
|
||||
const contentRef = useRef<DOMElement | null>(null);
|
||||
const overflowActions = useOverflowActions();
|
||||
@@ -73,6 +86,19 @@ 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,6 +11,8 @@ import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
@@ -25,17 +27,18 @@ 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;
|
||||
@@ -48,10 +51,44 @@ function ScrollableList<T>(
|
||||
ref: React.Ref<ScrollableListRef<T>>,
|
||||
) {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
|
||||
const settings = useSettings();
|
||||
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
|
||||
const {
|
||||
hasFocus,
|
||||
width,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
itemKey,
|
||||
} = 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,
|
||||
() => ({
|
||||
@@ -265,6 +302,7 @@ function ScrollableList<T>(
|
||||
scrollbar={scrollbar}
|
||||
scrollbarThumbColor={scrollbarColor}
|
||||
stableScrollback={stableScrollback}
|
||||
maxScrollbackLength={maxScrollbackLength}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @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,9 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
|
||||
import {
|
||||
SCROLL_TO_ITEM_END,
|
||||
VirtualizedList,
|
||||
type VirtualizedListRef,
|
||||
} from './VirtualizedList.js';
|
||||
import { Text, Box } from 'ink';
|
||||
import {
|
||||
createRef,
|
||||
@@ -115,6 +119,41 @@ 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(
|
||||
@@ -170,7 +209,7 @@ describe('<VirtualizedList />', () => {
|
||||
(_, i) => `Item ${i}`,
|
||||
);
|
||||
|
||||
const { lastFrame, unmount } = await render(
|
||||
const { lastFrame, unmount, waitUntilReady } = await render(
|
||||
<Box height={20} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={veryLongData}
|
||||
@@ -184,8 +223,12 @@ describe('<VirtualizedList />', () => {
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
await waitFor(() => {
|
||||
expect(mountedCount).toBe(expectedMountedCount);
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(mountedCount).toBe(expectedMountedCount);
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
},
|
||||
@@ -316,12 +359,69 @@ describe('<VirtualizedList />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders correctly in copyModeEnabled when scrolled', async () => {
|
||||
it('culls items that exceed maxScrollbackLength when overflowToBackbuffer is true', async () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
// Use copy mode
|
||||
const { lastFrame, unmount } = await render(
|
||||
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(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
@@ -330,18 +430,243 @@ describe('<VirtualizedList />', () => {
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={50}
|
||||
copyModeEnabled={true}
|
||||
initialScrollIndex={99}
|
||||
overflowToBackbuffer={true}
|
||||
maxScrollbackLength={10}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
// 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');
|
||||
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);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { VirtualizedList } from './VirtualizedList.js';
|
||||
import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useState } from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('VirtualizedList Interactivity', () => {
|
||||
const keyExtractor = (item: { id: string }) => item.id;
|
||||
|
||||
const InteractiveItem = ({
|
||||
id,
|
||||
onToggle,
|
||||
}: {
|
||||
id: string;
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
const { ref } = useVirtualizedListClick(id, 'toggle', onToggle);
|
||||
return (
|
||||
<Box height={1} width={80} ref={ref}>
|
||||
<Text>Item {id}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
it('triggers callback when tagged area is clicked', async () => {
|
||||
const onToggle = vi.fn();
|
||||
const data = [{ id: '1' }];
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(
|
||||
<Box height={10} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={({ item }) => (
|
||||
<InteractiveItem id={item.id} onToggle={onToggle} />
|
||||
)}
|
||||
/>
|
||||
</Box>,
|
||||
{ mouseEventsEnabled: true },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
|
||||
// Simulate click on the first line (Item 1)
|
||||
// VirtualizedList is at (0,0) and Item 1 is at (0,0) relative to list.
|
||||
// simulateClick expects absolute coordinates.
|
||||
// In renderWithProviders, the wrapper Box is at (0,0)?
|
||||
// Actually getBoundingBox(state.current.container) in VirtualizedList will give absolute coords.
|
||||
await simulateClick(1, 1);
|
||||
|
||||
await waitFor(() => expect(onToggle).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('wakes up static item and triggers callback on click', async () => {
|
||||
const onToggle = vi.fn();
|
||||
const data = [{ id: '1' }];
|
||||
|
||||
const TestComponent = () => {
|
||||
const [isStatic, setIsStatic] = useState(false);
|
||||
return (
|
||||
<Box height={10} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={({ item }) => (
|
||||
<InteractiveItem id={item.id} onToggle={onToggle} />
|
||||
)}
|
||||
isStaticItem={() => isStatic}
|
||||
/>
|
||||
<Box
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
setTimeout(() => setIsStatic(true), 100);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(<TestComponent />, {
|
||||
mouseEventsEnabled: true,
|
||||
});
|
||||
|
||||
await waitUntilReady();
|
||||
// Wait for the transition to static to happen and be recorded
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
|
||||
// Click to wake up and trigger
|
||||
await simulateClick(1, 1);
|
||||
|
||||
await waitFor(() => expect(onToggle).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -7,41 +7,41 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 1 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 2 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 3 │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 500) 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 500 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ ▄│
|
||||
│ ▀│
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 501 │
|
||||
│ │
|
||||
│ │
|
||||
│ ▄│
|
||||
│ ▀│
|
||||
│Item 502 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 503 │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -53,7 +53,7 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 997 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
@@ -35,6 +36,7 @@ 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);
|
||||
@@ -50,7 +52,7 @@ export function useMouseContext() {
|
||||
export function useMouse(handler: MouseHandler, { isActive = true } = {}) {
|
||||
const { subscribe, unsubscribe } = useMouseContext();
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
@@ -92,14 +94,8 @@ export function MouseProvider({
|
||||
[subscribers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mouseEventsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mouseBuffer = '';
|
||||
|
||||
const broadcast = (event: MouseEvent) => {
|
||||
const broadcast = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
let handled = false;
|
||||
for (const handler of subscribers) {
|
||||
if (handler(event) === true) {
|
||||
@@ -143,7 +139,16 @@ 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');
|
||||
@@ -190,11 +195,11 @@ export function MouseProvider({
|
||||
return () => {
|
||||
stdin.removeListener('data', handleData);
|
||||
};
|
||||
}, [stdin, mouseEventsEnabled, subscribers, debugKeystrokeLogging]);
|
||||
}, [stdin, mouseEventsEnabled, broadcast, debugKeystrokeLogging]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ subscribe, unsubscribe }),
|
||||
[subscribe, unsubscribe],
|
||||
() => ({ subscribe, unsubscribe, broadcast }),
|
||||
[subscribe, unsubscribe, broadcast],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -63,10 +63,8 @@ describe('handleAtCommand', () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
|
||||
testRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'folder-structure-test-'),
|
||||
),
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'folder-structure-test-'),
|
||||
);
|
||||
|
||||
abortController = new AbortController();
|
||||
@@ -1469,8 +1467,8 @@ describe('handleAtCommand', () => {
|
||||
});
|
||||
|
||||
it('should resolve files in multiple workspace directories', async () => {
|
||||
const secondRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(path.join(os.tmpdir(), 'second-root-')),
|
||||
const secondRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'second-root-'),
|
||||
);
|
||||
try {
|
||||
const fileContent = 'Second root content';
|
||||
@@ -1651,10 +1649,8 @@ describe('checkPermissions', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
testRootDir = await fsPromises.realpath(
|
||||
await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'check-permissions-test-'),
|
||||
),
|
||||
testRootDir = 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.realpath(
|
||||
await fsPromises.mkdtemp(path.join(os.tmpdir(), 'agent-test-')),
|
||||
testRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'agent-test-'),
|
||||
);
|
||||
|
||||
abortController = new AbortController();
|
||||
|
||||
@@ -30,6 +30,7 @@ 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,102 +525,6 @@ 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,20 +135,7 @@ export function useQuotaAndFallback({
|
||||
message = messageLines.join('\n');
|
||||
} else if (error instanceof ModelNotFoundError) {
|
||||
isModelNotFoundError = true;
|
||||
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)) {
|
||||
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.`,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @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="90%"
|
||||
height="100%"
|
||||
width={uiState.terminalWidth}
|
||||
height={uiState.terminalHeight}
|
||||
ref={uiState.rootUiRef}
|
||||
>
|
||||
<Notifications />
|
||||
|
||||
@@ -15,6 +15,7 @@ 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="360" viewBox="0 0 920 360">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="513" viewBox="0 0 920 513">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="360" fill="#000000" />
|
||||
<rect width="920" height="513" 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="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>
|
||||
<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>
|
||||
</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="360" viewBox="0 0 920 360">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="513" viewBox="0 0 920 513">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="360" fill="#000000" />
|
||||
<rect width="920" height="513" 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="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>
|
||||
<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>
|
||||
</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="360" viewBox="0 0 920 360">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="513" viewBox="0 0 920 513">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="360" fill="#000000" />
|
||||
<rect width="920" height="513" 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="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>
|
||||
<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>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -16,6 +16,15 @@ Tips for getting started:
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ google_web_search │
|
||||
│ │
|
||||
@@ -39,6 +48,15 @@ Tips for getting started:
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ run_shell_command │
|
||||
│ │
|
||||
@@ -62,6 +80,15 @@ Tips for getting started:
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ google_web_search │
|
||||
│ │
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -93,7 +93,6 @@ describe('createContentGenerator', () => {
|
||||
resetVersionCache();
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
vi.stubEnv('GOOGLE_CLOUD_LOCATION', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -484,82 +483,6 @@ 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,15 +121,6 @@ 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);
|
||||
@@ -350,13 +341,6 @@ 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 allow tools explicitly listed in settings.tools.core', async () => {
|
||||
it('should map tools listed in settings.tools.core to ALLOW with correct priority and fallback to default policies', 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 allow tools in tools.core even if they are restricted by default policies', async () => {
|
||||
it('should map tools in tools.core with higher priority than default policies', async () => {
|
||||
// By default run_shell_command is ASK_USER.
|
||||
// Putting it in tools.core should make it ALLOW.
|
||||
const settings = {
|
||||
|
||||
@@ -240,72 +240,4 @@ 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,11 +45,6 @@ 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);
|
||||
@@ -62,52 +57,15 @@ export class AllowedPathChecker implements InProcessChecker {
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
return {
|
||||
@@ -126,15 +84,14 @@ 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)) {
|
||||
try {
|
||||
const canonical = resolveToRealPath(current);
|
||||
if (fs.existsSync(current)) {
|
||||
const canonical = fs.realpathSync(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);
|
||||
}
|
||||
@@ -199,15 +156,3 @@ 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,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { vi } from 'vitest';
|
||||
import type { WorkspaceContext } from '../utils/workspaceContext.js';
|
||||
|
||||
@@ -18,17 +17,7 @@ export function createMockWorkspaceContext(
|
||||
rootDir: string,
|
||||
additionalDirs: string[] = [],
|
||||
): WorkspaceContext {
|
||||
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 allDirs = [rootDir, ...additionalDirs];
|
||||
|
||||
const mockWorkspaceContext = {
|
||||
addDirectory: vi.fn(),
|
||||
|
||||
@@ -1,684 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -84,10 +84,7 @@ describe('EditTool', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
const rawTempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'edit-tool-test-'),
|
||||
);
|
||||
tempDir = fs.realpathSync(rawTempDir);
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'edit-tool-test-'));
|
||||
rootDir = path.join(tempDir, 'root');
|
||||
fs.mkdirSync(rootDir);
|
||||
|
||||
@@ -704,30 +701,6 @@ 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', () => {
|
||||
|
||||
@@ -27,12 +27,7 @@ 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,
|
||||
resolveDefensiveToolPath,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
import { correctPath } from '../utils/pathCorrector.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -483,13 +478,11 @@ class EditToolInvocation
|
||||
);
|
||||
if (this.config.isPlanMode()) {
|
||||
try {
|
||||
const cleanFilePath = this.params.file_path.replace(/\0/g, '');
|
||||
const planPath = resolveAndValidatePlanPath(
|
||||
cleanFilePath,
|
||||
this.resolvedPath = resolveAndValidatePlanPath(
|
||||
this.params.file_path,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
this.resolvedPath = resolveToRealPath(planPath);
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
'Failed to resolve plan path during EditTool invocation setup',
|
||||
@@ -497,39 +490,20 @@ 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.replace(/\0/g, '');
|
||||
this.resolvedPath = this.params.file_path;
|
||||
}
|
||||
} else if (!path.isAbsolute(this.params.file_path)) {
|
||||
const result = correctPath(this.params.file_path, this.config);
|
||||
if (result.success) {
|
||||
try {
|
||||
this.resolvedPath = resolveToRealPath(result.correctedPath);
|
||||
} catch {
|
||||
this.resolvedPath = result.correctedPath;
|
||||
}
|
||||
this.resolvedPath = result.correctedPath;
|
||||
} else {
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
this.params.file_path,
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.file_path,
|
||||
);
|
||||
try {
|
||||
this.resolvedPath = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), sanitizedPath),
|
||||
);
|
||||
} catch {
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
sanitizedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const cleanPath = this.params.file_path.replace(/\0/g, '');
|
||||
try {
|
||||
this.resolvedPath = resolveToRealPath(cleanPath);
|
||||
} catch {
|
||||
this.resolvedPath = cleanPath;
|
||||
}
|
||||
this.resolvedPath = this.params.file_path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1120,45 +1094,28 @@ export class EditTool
|
||||
let resolvedPath: string;
|
||||
if (this.config.isPlanMode()) {
|
||||
try {
|
||||
const cleanFilePath = params.file_path.replace(/\0/g, '');
|
||||
const planPath = resolveAndValidatePlanPath(
|
||||
cleanFilePath,
|
||||
resolvedPath = resolveAndValidatePlanPath(
|
||||
params.file_path,
|
||||
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) {
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(result.correctedPath);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
resolvedPath = result.correctedPath;
|
||||
} else {
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
params.file_path,
|
||||
resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
params.file_path,
|
||||
);
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), sanitizedPath),
|
||||
);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const cleanPath = params.file_path.replace(/\0/g, '');
|
||||
try {
|
||||
resolvedPath = resolveToRealPath(cleanPath);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
resolvedPath = params.file_path;
|
||||
}
|
||||
|
||||
const newPlaceholders = detectOmissionPlaceholders(params.new_string);
|
||||
if (newPlaceholders.length > 0) {
|
||||
const oldPlaceholders = new Set(
|
||||
@@ -1193,66 +1150,13 @@ 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(resolvedPath);
|
||||
.readTextFile(params.file_path);
|
||||
} catch (err) {
|
||||
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
|
||||
return '';
|
||||
@@ -1260,10 +1164,9 @@ export class EditTool
|
||||
},
|
||||
getProposedContent: async (params: EditToolParams): Promise<string> => {
|
||||
try {
|
||||
const resolvedPath = resolvePath(params);
|
||||
const currentContent = await this.config
|
||||
.getFileSystemService()
|
||||
.readTextFile(resolvedPath);
|
||||
.readTextFile(params.file_path);
|
||||
return applyReplacement(
|
||||
currentContent,
|
||||
params.old_string,
|
||||
|
||||
@@ -37,10 +37,7 @@ describe('GlobTool', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a unique root directory for each test run
|
||||
const rawTempRootDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'glob-tool-root-'),
|
||||
);
|
||||
tempRootDir = await fs.realpath(rawTempRootDir);
|
||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-tool-root-'));
|
||||
await fs.writeFile(path.join(tempRootDir, '.git'), ''); // Fake git repo
|
||||
|
||||
const rootDir = tempRootDir;
|
||||
|
||||
@@ -18,11 +18,7 @@ import {
|
||||
type ToolConfirmationOutcome,
|
||||
type ExecuteOptions,
|
||||
} from './tools.js';
|
||||
import {
|
||||
shortenPath,
|
||||
makeRelative,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { shortenPath, makeRelative } 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';
|
||||
@@ -142,22 +138,10 @@ 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) {
|
||||
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 searchDirAbsolute = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.dir_path,
|
||||
);
|
||||
const validationError = this.config.validatePathAccess(
|
||||
searchDirAbsolute,
|
||||
'read',
|
||||
@@ -205,22 +189,9 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
allEntries.push(...entries);
|
||||
}
|
||||
|
||||
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 relativePaths = allEntries.map((p) =>
|
||||
path.relative(this.config.getTargetDir(), p.fullpath()),
|
||||
);
|
||||
|
||||
const { filteredPaths, ignoredCount } =
|
||||
fileDiscovery.filterFilesWithReport(relativePaths, {
|
||||
@@ -333,14 +304,10 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
protected override validateToolParamValues(
|
||||
params: GlobToolParams,
|
||||
): string | null {
|
||||
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 searchDirAbsolute = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
params.dir_path || '.',
|
||||
);
|
||||
|
||||
const validationError = this.config.validatePathAccess(
|
||||
searchDirAbsolute,
|
||||
|
||||
@@ -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, resolveToRealPath } from '../utils/paths.js';
|
||||
import { isSubpath } 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 = resolveToRealPath(path.join(tempRootDir, 'fileA.txt'));
|
||||
const filePath = path.join(tempRootDir, 'fileA.txt');
|
||||
const params: GrepToolParams = { pattern: 'hello', dir_path: filePath };
|
||||
expect(grepTool.validateToolParams(params)).toContain(
|
||||
`Path is not a directory: ${filePath}`,
|
||||
|
||||
@@ -25,11 +25,7 @@ import {
|
||||
type ToolConfirmationOutcome,
|
||||
type ExecuteOptions,
|
||||
} from './tools.js';
|
||||
import {
|
||||
makeRelative,
|
||||
shortenPath,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import { isGitRepository } from '../utils/gitUtils.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -150,21 +146,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
let searchDirAbs: string | null = null;
|
||||
if (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,
|
||||
},
|
||||
};
|
||||
}
|
||||
searchDirAbs = path.resolve(this.config.getTargetDir(), pathParam);
|
||||
const validationError = this.config.validatePathAccess(
|
||||
searchDirAbs,
|
||||
'read',
|
||||
@@ -740,14 +722,10 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
|
||||
// Only validate dir_path if one is provided
|
||||
if (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 resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
params.dir_path,
|
||||
);
|
||||
const validationError = this.config.validatePathAccess(
|
||||
resolvedPath,
|
||||
'read',
|
||||
|
||||
@@ -6,12 +6,7 @@
|
||||
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
makeRelative,
|
||||
shortenPath,
|
||||
resolveDefensiveToolPath,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
@@ -79,20 +74,10 @@ class ReadFileToolInvocation extends BaseToolInvocation<
|
||||
_toolDisplayName?: string,
|
||||
) {
|
||||
super(params, messageBus, _toolName, _toolDisplayName);
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
this.params.file_path,
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.file_path,
|
||||
);
|
||||
try {
|
||||
this.resolvedPath = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), sanitizedPath),
|
||||
);
|
||||
} catch {
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
sanitizedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
@@ -257,20 +242,11 @@ export class ReadFileTool extends BaseDeclarativeTool<
|
||||
return "The 'file_path' parameter must be non-empty.";
|
||||
}
|
||||
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
params.file_path,
|
||||
const resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
params.file_path,
|
||||
);
|
||||
|
||||
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('dist/some-lib/index.js', 'lib code');
|
||||
createFile('node_modules/some-lib/index.js', 'lib code');
|
||||
createFile('src/app.js', 'app code');
|
||||
const params = { include: ['**/*.js'], useDefaultExcludes: false };
|
||||
const invocation = tool.build(params);
|
||||
@@ -406,7 +406,10 @@ describe('ReadManyFilesTool', () => {
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
const content = result.llmContent as string[];
|
||||
const expectedPath1 = path.join(tempRootDir, 'dist/some-lib/index.js');
|
||||
const expectedPath1 = path.join(
|
||||
tempRootDir,
|
||||
'node_modules/some-lib/index.js',
|
||||
);
|
||||
const expectedPath2 = path.join(tempRootDir, 'src/app.js');
|
||||
expect(
|
||||
content.some((c) =>
|
||||
|
||||
@@ -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) => actual.resolveToRealPath(p)),
|
||||
resolveToRealPath: vi.fn((p) => p),
|
||||
normalizePath: vi.fn((p) =>
|
||||
typeof p === 'string' ? p.replace(/\\/g, '/') : p,
|
||||
),
|
||||
@@ -1351,9 +1351,7 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
|
||||
it('should add .geminiignore when enabled and patterns exist', async () => {
|
||||
const geminiIgnorePath = resolveToRealPath(
|
||||
path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME),
|
||||
);
|
||||
const geminiIgnorePath = path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME);
|
||||
await fs.writeFile(geminiIgnorePath, 'ignored.log');
|
||||
|
||||
const configWithGeminiIgnore = createMockConfig(tempRootDir);
|
||||
@@ -1397,9 +1395,7 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
|
||||
it('should skip .geminiignore when disabled', async () => {
|
||||
const geminiIgnorePath = resolveToRealPath(
|
||||
path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME),
|
||||
);
|
||||
const geminiIgnorePath = path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME);
|
||||
await fs.writeFile(geminiIgnorePath, 'ignored.log');
|
||||
const configWithoutGeminiIgnore = createMockConfig(tempRootDir);
|
||||
vi.spyOn(
|
||||
|
||||
@@ -185,22 +185,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
// This forces CWD search instead of 'all workspaces' search by default.
|
||||
const pathParam = this.params.dir_path || '.';
|
||||
|
||||
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 searchDirAbs = path.resolve(this.config.getTargetDir(), pathParam);
|
||||
const validationError = this.config.validatePathAccess(
|
||||
searchDirAbs,
|
||||
'read',
|
||||
@@ -639,14 +624,8 @@ 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(
|
||||
targetDir,
|
||||
config.getTargetDir(),
|
||||
config.getFileFilteringOptions(),
|
||||
);
|
||||
}
|
||||
@@ -691,14 +670,10 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
|
||||
// Only validate path if one is provided
|
||||
if (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 resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
params.dir_path,
|
||||
);
|
||||
const validationError = this.config.validatePathAccess(
|
||||
resolvedPath,
|
||||
'read',
|
||||
|
||||
@@ -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-${Math.random().toString(36).substring(7)}`,
|
||||
sessionId: 'test-session',
|
||||
targetDir: tempDir,
|
||||
cwd: tempDir,
|
||||
model: 'gemini-3-flash',
|
||||
|
||||
@@ -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, resolveToRealPath } from '../utils/paths.js';
|
||||
import { isSubpath } 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';
|
||||
|
||||
let rootDir: string;
|
||||
let plansDir: string;
|
||||
const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root');
|
||||
const plansDir = path.resolve(os.tmpdir(), 'gemini-cli-test-plans');
|
||||
|
||||
// --- MOCKS ---
|
||||
vi.mock('../core/client.js');
|
||||
@@ -134,20 +134,16 @@ describe('WriteFileTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Create a unique temporary directory for files created outside the root
|
||||
const rawTempDir = fs.mkdtempSync(
|
||||
tempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'write-file-test-external-'),
|
||||
);
|
||||
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);
|
||||
// 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 });
|
||||
}
|
||||
|
||||
const workspaceContext = new WorkspaceContext(rootDir, [plansDir]);
|
||||
const mockStorage = {
|
||||
@@ -276,9 +272,8 @@ describe('WriteFileTool', () => {
|
||||
file_path: dirAsFilePath,
|
||||
content: 'hello',
|
||||
};
|
||||
const realDirAsFilePath = resolveToRealPath(dirAsFilePath);
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
`Path is a directory, not a file: ${realDirAsFilePath}`,
|
||||
`Path is a directory, not a file: ${dirAsFilePath}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -446,8 +441,7 @@ describe('WriteFileTool', () => {
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
const realFilePath = resolveToRealPath(filePath);
|
||||
expect(fsService.readTextFile).toHaveBeenCalledWith(realFilePath);
|
||||
expect(fsService.readTextFile).toHaveBeenCalledWith(filePath);
|
||||
expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
|
||||
expect(result.correctedContent).toBe(proposedContent);
|
||||
expect(result.originalContent).toBe('');
|
||||
@@ -1020,9 +1014,8 @@ describe('WriteFileTool', () => {
|
||||
|
||||
expect(result.error?.type).toBe(errorType);
|
||||
const errorSuffix = errorCode ? ` (${errorCode})` : '';
|
||||
const realFilePath = resolveToRealPath(filePath);
|
||||
const expectedMessage = errorCode
|
||||
? `${expectedMessagePrefix}: ${realFilePath}${errorSuffix}`
|
||||
? `${expectedMessagePrefix}: ${filePath}${errorSuffix}`
|
||||
: `${expectedMessagePrefix}: ${errorMessage}`;
|
||||
expect(result.llmContent).toContain(expectedMessage);
|
||||
expect(result.returnDisplay).toContain(expectedMessage);
|
||||
|
||||
@@ -28,12 +28,7 @@ import {
|
||||
} from './tools.js';
|
||||
import { buildFilePathArgsPattern } from '../policy/utils.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import {
|
||||
makeRelative,
|
||||
shortenPath,
|
||||
resolveDefensiveToolPath,
|
||||
resolveToRealPath,
|
||||
} from '../utils/paths.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import { ensureCorrectFileContent } from '../utils/editCorrector.js';
|
||||
import { detectLineEnding } from '../utils/textUtils.js';
|
||||
@@ -114,67 +109,10 @@ 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(resolvedPath);
|
||||
.readTextFile(filePath);
|
||||
fileExists = true; // File exists and was read
|
||||
} catch (err) {
|
||||
if (isNodeError(err) && err.code === 'ENOENT') {
|
||||
@@ -232,36 +170,24 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
|
||||
if (this.config.isPlanMode()) {
|
||||
try {
|
||||
const cleanFilePath = this.params.file_path.replace(/\0/g, '');
|
||||
const planPath = resolveAndValidatePlanPath(
|
||||
cleanFilePath,
|
||||
this.resolvedPath = resolveAndValidatePlanPath(
|
||||
this.params.file_path,
|
||||
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.replace(/\0/g, '');
|
||||
this.resolvedPath = this.params.file_path;
|
||||
}
|
||||
} else {
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
this.params.file_path,
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.file_path,
|
||||
);
|
||||
try {
|
||||
this.resolvedPath = resolveToRealPath(
|
||||
path.resolve(this.config.getTargetDir(), sanitizedPath),
|
||||
);
|
||||
} catch {
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
sanitizedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,28 +525,16 @@ export class WriteFileTool
|
||||
let resolvedPath: string;
|
||||
if (this.config.isPlanMode()) {
|
||||
try {
|
||||
const cleanFilePath = filePath.replace(/\0/g, '');
|
||||
const planPath = resolveAndValidatePlanPath(
|
||||
cleanFilePath,
|
||||
resolvedPath = resolveAndValidatePlanPath(
|
||||
filePath,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
resolvedPath = resolveToRealPath(planPath);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
|
||||
}
|
||||
|
||||
const validationError = this.config.validatePathAccess(resolvedPath);
|
||||
|
||||
@@ -261,7 +261,6 @@ 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,8 +6,6 @@
|
||||
|
||||
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';
|
||||
@@ -869,46 +867,5 @@ 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, resolveToRealPath } from './paths.js';
|
||||
import { isSubpath } from './paths.js';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
|
||||
// Simple console logger for import processing
|
||||
@@ -397,28 +397,9 @@ export function validateImportPath(
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const resolvedPath = path.resolve(basePath, importPath);
|
||||
|
||||
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),
|
||||
return allowedDirectories.some((allowedDir) =>
|
||||
isSubpath(allowedDir, resolvedPath),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,10 +20,7 @@ describe('pathCorrector', () => {
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
const rawTempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'path-corrector-test-'),
|
||||
);
|
||||
tempDir = fs.realpathSync(rawTempDir);
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-corrector-test-'));
|
||||
rootDir = path.join(tempDir, 'root');
|
||||
otherWorkspaceDir = path.join(tempDir, 'other');
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
|
||||
@@ -8,7 +8,6 @@ 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;
|
||||
@@ -35,13 +34,8 @@ 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(), sanitizedPath);
|
||||
const directPath = path.join(config.getTargetDir(), filePath);
|
||||
if (fs.existsSync(directPath)) {
|
||||
return { success: true, correctedPath: directPath };
|
||||
}
|
||||
@@ -49,8 +43,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(sanitizedPath);
|
||||
const normalizedTarget = sanitizedPath.replace(/\\/g, '/');
|
||||
const basename = path.basename(filePath);
|
||||
const normalizedTarget = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Normalize path for matching and check if it ends with the provided relative path
|
||||
const foundFiles = searchPaths
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
toAbsolutePath,
|
||||
toPathKey,
|
||||
isTrustedSystemPath,
|
||||
resolveDefensiveToolPath,
|
||||
} from './paths.js';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
@@ -919,20 +918,4 @@ 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -572,54 +572,3 @@ 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,50 +492,4 @@ 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,20 +184,6 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -262,15 +248,3 @@ 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -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.51.0-nightly.20260625.g3fbf93e26",
|
||||
"version": "0.49.0-nightly.20260617.g4d3dcdce1",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -537,6 +537,13 @@
|
||||
"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.",
|
||||
|
||||
@@ -54,7 +54,7 @@ if (process.env.CI) {
|
||||
.filter((name) => name !== '@google/gemini-cli-core');
|
||||
|
||||
execSync(
|
||||
`npx --no-install npm-run-all --parallel ${parallelWorkspaces.map((w) => `"build -w ${w}"`).join(' ')}`,
|
||||
`npx npm-run-all --parallel ${parallelWorkspaces.map((w) => `"build -w ${w}"`).join(' ')}`,
|
||||
{ stdio: 'inherit', cwd: root },
|
||||
);
|
||||
}
|
||||
|
||||