mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 13:41:05 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e7e72d476 | |||
| 83a17cbf42 |
@@ -1,8 +1,8 @@
|
||||
name: 'Bug Report'
|
||||
description: 'Report a bug to help us improve Gemini CLI'
|
||||
labels:
|
||||
- 'kind/bug'
|
||||
- 'status/need-triage'
|
||||
type: 'Bug'
|
||||
body:
|
||||
- type: 'markdown'
|
||||
attributes:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: 'Feature Request'
|
||||
description: 'Suggest an idea for this project'
|
||||
labels:
|
||||
- 'kind/enhancement'
|
||||
- 'status/need-triage'
|
||||
type: 'Feature'
|
||||
body:
|
||||
- type: 'markdown'
|
||||
attributes:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
name: 'Website issue'
|
||||
description: 'Report an issue with the Gemini CLI Website and Gemini CLI Extensions Gallery'
|
||||
labels:
|
||||
- 'area/extensions'
|
||||
- 'area/website'
|
||||
- 'status/need-triage'
|
||||
body:
|
||||
- type: 'markdown'
|
||||
attributes:
|
||||
|
||||
@@ -92,9 +92,6 @@ jobs:
|
||||
fi
|
||||
NIGHTLY_COMMAND="node scripts/get-release-version.js --type=promote-nightly"
|
||||
STABLE_JSON=$(${STABLE_COMMAND})
|
||||
STABLE_VERSION=$(echo "${STABLE_JSON}" | jq -r .releaseVersion)
|
||||
PREVIEW_COMMAND+=" --stable-base-version=${STABLE_VERSION}"
|
||||
NIGHTLY_COMMAND+=" --stable-base-version=${STABLE_VERSION}"
|
||||
PREVIEW_JSON=$(${PREVIEW_COMMAND})
|
||||
NIGHTLY_JSON=$(${NIGHTLY_COMMAND})
|
||||
echo "STABLE_JSON_COMMAND=${STABLE_COMMAND}"
|
||||
@@ -103,14 +100,14 @@ jobs:
|
||||
echo "STABLE_JSON: ${STABLE_JSON}"
|
||||
echo "PREVIEW_JSON: ${PREVIEW_JSON}"
|
||||
echo "NIGHTLY_JSON: ${NIGHTLY_JSON}"
|
||||
echo "STABLE_VERSION=${STABLE_VERSION}" >> "${GITHUB_OUTPUT}"
|
||||
echo "STABLE_VERSION=$(echo "${STABLE_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
|
||||
# shellcheck disable=SC1083
|
||||
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
|
||||
REF="${REF_INPUT}"
|
||||
SHA=$(git ls-remote origin "$REF" | awk -v ref="$REF" '$2 == "refs/heads/"ref || $2 == "refs/tags/"ref || $2 == ref {print $1}' | head -n 1)
|
||||
SHA=$(git ls-remote origin "$REF" | awk '{print $1}')
|
||||
if [ -z "$SHA" ]; then
|
||||
if [[ "$REF" =~ ^[0-9a-f]{7,40}$ ]]; then
|
||||
SHA="$REF"
|
||||
|
||||
@@ -227,7 +227,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`ui.useFullWidth`** (boolean):
|
||||
- **Description:** Use the entire width of the terminal for output.
|
||||
- **Default:** `true`
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.useAlternateBuffer`** (boolean):
|
||||
- **Description:** Use an alternate screen buffer for the UI, preserving shell
|
||||
@@ -302,7 +302,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Named presets for model configs. Can be used in place of a
|
||||
model name and can inherit from other aliases using an `extends` property.
|
||||
- **Default:**
|
||||
`{"base":{"modelConfig":{"generateContentConfig":{"temperature":0,"topP":1}}},"chat-base":{"extends":"base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"includeThoughts":true,"thinkingBudget":-1}}}},"gemini-2.5-pro":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-pro"}},"gemini-2.5-flash":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-flash"}},"gemini-2.5-flash-lite":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-flash-lite"}},"gemini-2.5-flash-base":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash"}},"classifier":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":1024,"thinkingConfig":{"thinkingBudget":512}}}},"prompt-completion":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"temperature":0.3,"maxOutputTokens":16000,"thinkingConfig":{"thinkingBudget":0}}}},"edit-corrector":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"thinkingConfig":{"thinkingBudget":0}}}},"summarizer-default":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"summarizer-shell":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"web-search":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"googleSearch":{}}]}}},"web-fetch":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"urlContext":{}}]}}},"web-fetch-fallback":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"loop-detection":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"llm-edit-fixer":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"next-speaker-checker":{"extends":"gemini-2.5-flash-base","modelConfig":{}}}`
|
||||
`{"base":{"modelConfig":{"generateContentConfig":{"temperature":0,"topP":1}}},"chat-base":{"extends":"base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"includeThoughts":true,"thinkingBudget":-1}}}},"gemini-2.5-pro":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-pro"}},"gemini-2.5-flash":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-flash"}},"gemini-2.5-flash-lite":{"extends":"chat-base","modelConfig":{"model":"gemini-2.5-flash-lite"}},"classifier":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":1024,"thinkingConfig":{"thinkingBudget":512}}}},"prompt-completion":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"temperature":0.3,"maxOutputTokens":16000,"thinkingConfig":{"thinkingBudget":0}}}},"edit-corrector":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"thinkingConfig":{"thinkingBudget":0}}}},"summarizer-default":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"summarizer-shell":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"web-search-tool":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash","generateContentConfig":{"tools":[{"googleSearch":{}}]}}},"web-fetch-tool":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash","generateContentConfig":{"tools":[{"urlContext":{}}]}}}}`
|
||||
|
||||
- **`modelConfigs.overrides`** (array):
|
||||
- **Description:** Apply specific configuration overrides based on matches,
|
||||
@@ -480,8 +480,8 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
#### `useWriteTodos`
|
||||
|
||||
- **`useWriteTodos`** (boolean):
|
||||
- **Description:** Enable the write_todos tool.
|
||||
- **Default:** `true`
|
||||
- **Description:** Enable the write_todos_list tool.
|
||||
- **Default:** `false`
|
||||
|
||||
#### `security`
|
||||
|
||||
@@ -490,11 +490,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.blockGitExtensions`** (boolean):
|
||||
- **Description:** Blocks installing and loading extensions from Git.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.folderTrust.enabled`** (boolean):
|
||||
- **Description:** Setting to track whether Folder trust is enabled.
|
||||
- **Default:** `false`
|
||||
|
||||
+2
-3
@@ -82,9 +82,8 @@ Gemini CLI's built-in tools can be broadly categorized as follows:
|
||||
from URLs.
|
||||
- **[Web Search Tool](./web-search.md) (`google_web_search`):** For searching
|
||||
the web.
|
||||
- **[Multi-File Read Tool](./multi-file.md) (`read_many_files`):** (Deprecated,
|
||||
will be removed in v0.16.0) A specialized tool for reading content from
|
||||
multiple files or directories.
|
||||
- **[Multi-File Read Tool](./multi-file.md) (`read_many_files`):** A specialized
|
||||
tool for reading content from multiple files or directories.
|
||||
- **[Memory Tool](./memory.md) (`save_memory`):** For saving and recalling
|
||||
information across sessions.
|
||||
- **[Todo Tool](./todos.md) (`write_todos`):** For managing subtasks of complex
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# Multi File Read Tool (`read_many_files`)
|
||||
|
||||
> **Deprecated:** This tool is deprecated and will be removed in v0.16.0. Please
|
||||
> use `read_file` instead. If you need to read multiple files, you can make
|
||||
> multiple parallel calls to `read_file`.
|
||||
|
||||
This document describes the `read_many_files` tool for the Gemini CLI.
|
||||
|
||||
## Description
|
||||
|
||||
+3
-4
@@ -6,8 +6,7 @@ This document describes the `write_todos` tool for the Gemini CLI.
|
||||
|
||||
The `write_todos` tool allows the Gemini agent to create and manage a list of
|
||||
subtasks for complex user requests. This provides you, the user, with greater
|
||||
visibility into the agent's plan and its current progress. It also helps with
|
||||
alignment where the agent is less likely to lose track of its current goal.
|
||||
visibility into the agent's plan and its current progress.
|
||||
|
||||
### Arguments
|
||||
|
||||
@@ -50,8 +49,8 @@ write_todos({
|
||||
|
||||
## Important notes
|
||||
|
||||
- **Enabling:** This tool is enabled by default. You can disable it in your
|
||||
`settings.json` file by setting `"useWriteTodos": false`.
|
||||
- **Enabling:** This tool is disabled by default. To use it, you must enable it
|
||||
in your `settings.json` file by setting `"useWriteTodos": true`.
|
||||
|
||||
- **Intended Use:** This tool is primarily used by the agent for complex,
|
||||
multi-turn tasks. It is generally not used for simple, single-turn questions.
|
||||
|
||||
@@ -98,9 +98,7 @@ describe('file-system', () => {
|
||||
await rig.setup('should correctly handle file paths with spaces');
|
||||
const fileName = 'my test file.txt';
|
||||
|
||||
const result = await rig.run(
|
||||
`write "hello" to "${fileName}" and then stop. Do not perform any other actions.`,
|
||||
);
|
||||
const result = await rig.run(`write "hello" to "${fileName}"`);
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('write_file');
|
||||
if (!foundToolCall) {
|
||||
|
||||
@@ -12,7 +12,6 @@ if (process.env['NO_COLOR'] !== undefined) {
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
@@ -33,12 +32,6 @@ export async function setup() {
|
||||
// construct the path before the HOME env var is set.
|
||||
process.env['GEMINI_CONFIG_DIR'] = join(runDir, '.gemini');
|
||||
|
||||
// Download ripgrep to avoid race conditions in parallel tests
|
||||
const available = await canUseRipgrep();
|
||||
if (!available) {
|
||||
throw new Error('Failed to download ripgrep binary');
|
||||
}
|
||||
|
||||
// Clean up old test runs, but keep the latest few for debugging
|
||||
try {
|
||||
const testRuns = await readdir(integrationTestsDir);
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import { RipGrepTool } from '../packages/core/src/tools/ripGrep.js';
|
||||
import { Config } from '../packages/core/src/config/config.js';
|
||||
import { WorkspaceContext } from '../packages/core/src/utils/workspaceContext.js';
|
||||
|
||||
// Mock Config to provide necessary context
|
||||
class MockConfig {
|
||||
constructor(private targetDir: string) {}
|
||||
|
||||
getTargetDir() {
|
||||
return this.targetDir;
|
||||
}
|
||||
|
||||
getWorkspaceContext() {
|
||||
return new WorkspaceContext(this.targetDir, [this.targetDir]);
|
||||
}
|
||||
|
||||
getDebugMode() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ripgrep-real-direct', () => {
|
||||
let tempDir: string;
|
||||
let tool: RipGrepTool;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-real-test-'));
|
||||
|
||||
// Create test files
|
||||
await fs.writeFile(path.join(tempDir, 'file1.txt'), 'hello world\n');
|
||||
await fs.mkdir(path.join(tempDir, 'subdir'));
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'subdir', 'file2.txt'),
|
||||
'hello universe\n',
|
||||
);
|
||||
await fs.writeFile(path.join(tempDir, 'file3.txt'), 'goodbye moon\n');
|
||||
|
||||
const config = new MockConfig(tempDir) as unknown as Config;
|
||||
tool = new RipGrepTool(config);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should find matches using the real ripgrep binary', async () => {
|
||||
const invocation = tool.build({ pattern: 'hello' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 2 matches');
|
||||
expect(result.llmContent).toContain('file1.txt');
|
||||
expect(result.llmContent).toContain('L1: hello world');
|
||||
expect(result.llmContent).toContain('subdir'); // Should show path
|
||||
expect(result.llmContent).toContain('file2.txt');
|
||||
expect(result.llmContent).toContain('L1: hello universe');
|
||||
|
||||
expect(result.llmContent).not.toContain('goodbye moon');
|
||||
});
|
||||
|
||||
it('should handle no matches correctly', async () => {
|
||||
const invocation = tool.build({ pattern: 'nonexistent_pattern_123' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('No matches found');
|
||||
});
|
||||
|
||||
it('should respect include filters', async () => {
|
||||
// Create a .js file
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'script.js'),
|
||||
'console.log("hello");\n',
|
||||
);
|
||||
|
||||
const invocation = tool.build({ pattern: 'hello', include: '*.js' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 1 match');
|
||||
expect(result.llmContent).toContain('script.js');
|
||||
expect(result.llmContent).not.toContain('file1.txt');
|
||||
});
|
||||
});
|
||||
@@ -191,7 +191,7 @@ describe('run_shell_command', () => {
|
||||
expect(toolCall.toolRequest.success).toBe(true);
|
||||
});
|
||||
|
||||
it.skip('should succeed with no parens in non-interactive mode', async () => {
|
||||
it('should succeed with no parens in non-interactive mode', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should succeed with no parens in non-interactive mode');
|
||||
|
||||
|
||||
Generated
+12
-13
@@ -1,17 +1,16 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.15.0-preview.5",
|
||||
"version": "0.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.15.0-preview.5",
|
||||
"version": "0.14.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.2",
|
||||
"latest-version": "^9.0.0",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
@@ -43,6 +42,7 @@
|
||||
"globals": "^16.0.0",
|
||||
"google-artifactregistry-auth": "^3.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"ink": "npm:@jrichman/ink@6.4.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"json": "^11.0.0",
|
||||
"lint-staged": "^16.1.6",
|
||||
@@ -9886,9 +9886,9 @@
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"name": "@jrichman/ink",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.2.tgz",
|
||||
"integrity": "sha512-jfne1I/8+kVhzY/aoIWUKS0adPNRUhnN/wEsdBtSheyAp0b3c94zVsWWyDxnfXKL3RqOd40/H1FFaPLTUwjLXQ==",
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.0.tgz",
|
||||
"integrity": "sha512-RXr0+h414DfVRCOo7hLnGS8E83MV8AJ9qGUMR4/uGoqYID865O0p8NJ1+5QcAKzpP6ZotdIll5l9hvzalFYuDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
@@ -9903,7 +9903,6 @@
|
||||
"es-toolkit": "^1.39.10",
|
||||
"indent-string": "^5.0.0",
|
||||
"is-in-ci": "^2.0.0",
|
||||
"mnemonist": "^0.40.3",
|
||||
"patch-console": "^2.0.0",
|
||||
"react-reconciler": "^0.32.0",
|
||||
"signal-exit": "^3.0.7",
|
||||
@@ -16960,7 +16959,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.15.0-preview.5",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.2",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17250,7 +17249,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.15.0-preview.5",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -17266,7 +17265,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.4.5",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.2",
|
||||
"ink": "npm:@jrichman/ink@6.4.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
@@ -17350,7 +17349,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.15.0-preview.5",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@google-cloud/logging": "^11.2.1",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
|
||||
@@ -17494,7 +17493,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.15.0-preview.5",
|
||||
"version": "0.14.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
@@ -17505,7 +17504,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.15.0-preview.5",
|
||||
"version": "0.14.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.15.0-preview.5",
|
||||
"version": "0.14.0",
|
||||
"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.15.0-preview.5"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.14.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -61,7 +61,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.4.2",
|
||||
"ink": "npm:@jrichman/ink@6.4.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -100,6 +100,7 @@
|
||||
"globals": "^16.0.0",
|
||||
"google-artifactregistry-auth": "^3.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"ink": "npm:@jrichman/ink@6.4.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"json": "^11.0.0",
|
||||
"lint-staged": "^16.1.6",
|
||||
@@ -118,7 +119,6 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.2",
|
||||
"latest-version": "^9.0.0",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.15.0-preview.5",
|
||||
"version": "0.14.0",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -5,44 +5,27 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Command } from './types.js';
|
||||
|
||||
describe('CommandRegistry', () => {
|
||||
const mockListExtensionsCommandInstance: Command = {
|
||||
name: 'extensions list',
|
||||
description: 'Lists all installed extensions.',
|
||||
const mockListExtensionsCommandInstance = {
|
||||
names: ['extensions', 'extensions list'],
|
||||
execute: vi.fn(),
|
||||
};
|
||||
const mockListExtensionsCommand = vi.fn(
|
||||
() => mockListExtensionsCommandInstance,
|
||||
);
|
||||
|
||||
const mockExtensionsCommandInstance: Command = {
|
||||
name: 'extensions',
|
||||
description: 'Manage extensions.',
|
||||
execute: vi.fn(),
|
||||
subCommands: [mockListExtensionsCommandInstance],
|
||||
};
|
||||
const mockExtensionsCommand = vi.fn(() => mockExtensionsCommandInstance);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock('./extensions.js', () => ({
|
||||
ExtensionsCommand: mockExtensionsCommand,
|
||||
vi.doMock('./list-extensions', () => ({
|
||||
ListExtensionsCommand: mockListExtensionsCommand,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should register ExtensionsCommand on initialization', async () => {
|
||||
it('should register ListExtensionsCommand on initialization', async () => {
|
||||
const { commandRegistry } = await import('./command-registry.js');
|
||||
expect(mockExtensionsCommand).toHaveBeenCalled();
|
||||
expect(mockListExtensionsCommand).toHaveBeenCalled();
|
||||
const command = commandRegistry.get('extensions');
|
||||
expect(command).toBe(mockExtensionsCommandInstance);
|
||||
});
|
||||
|
||||
it('should register sub commands on initialization', async () => {
|
||||
const { commandRegistry } = await import('./command-registry.js');
|
||||
const command = commandRegistry.get('extensions list');
|
||||
expect(command).toBe(mockListExtensionsCommandInstance);
|
||||
});
|
||||
|
||||
@@ -54,65 +37,12 @@ describe('CommandRegistry', () => {
|
||||
|
||||
it('register() should register a new command', async () => {
|
||||
const { commandRegistry } = await import('./command-registry.js');
|
||||
const mockCommand: Command = {
|
||||
name: 'test-command',
|
||||
description: '',
|
||||
const mockCommand = {
|
||||
names: ['test-command'],
|
||||
execute: vi.fn(),
|
||||
};
|
||||
commandRegistry.register(mockCommand);
|
||||
const command = commandRegistry.get('test-command');
|
||||
expect(command).toBe(mockCommand);
|
||||
});
|
||||
|
||||
it('register() should register a nested command', async () => {
|
||||
const { commandRegistry } = await import('./command-registry.js');
|
||||
const mockSubSubCommand: Command = {
|
||||
name: 'test-command-sub-sub',
|
||||
description: '',
|
||||
execute: vi.fn(),
|
||||
};
|
||||
const mockSubCommand: Command = {
|
||||
name: 'test-command-sub',
|
||||
description: '',
|
||||
execute: vi.fn(),
|
||||
subCommands: [mockSubSubCommand],
|
||||
};
|
||||
const mockCommand: Command = {
|
||||
name: 'test-command',
|
||||
description: '',
|
||||
execute: vi.fn(),
|
||||
subCommands: [mockSubCommand],
|
||||
};
|
||||
commandRegistry.register(mockCommand);
|
||||
|
||||
const command = commandRegistry.get('test-command');
|
||||
const subCommand = commandRegistry.get('test-command-sub');
|
||||
const subSubCommand = commandRegistry.get('test-command-sub-sub');
|
||||
|
||||
expect(command).toBe(mockCommand);
|
||||
expect(subCommand).toBe(mockSubCommand);
|
||||
expect(subSubCommand).toBe(mockSubSubCommand);
|
||||
});
|
||||
|
||||
it('register() should not enter an infinite loop with a cyclic command', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { commandRegistry } = await import('./command-registry.js');
|
||||
const mockCommand: Command = {
|
||||
name: 'cyclic-command',
|
||||
description: '',
|
||||
subCommands: [],
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
mockCommand.subCommands?.push(mockCommand); // Create cycle
|
||||
|
||||
commandRegistry.register(mockCommand);
|
||||
|
||||
expect(commandRegistry.get('cyclic-command')).toBe(mockCommand);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Command cyclic-command already registered. Skipping.',
|
||||
);
|
||||
// If the test finishes, it means we didn't get into an infinite loop.
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,36 +4,30 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ExtensionsCommand } from './extensions.js';
|
||||
import type { Command } from './types.js';
|
||||
import { ListExtensionsCommand } from './list-extensions.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
export interface Command {
|
||||
readonly names: string[];
|
||||
execute(config: Config, args: string[]): Promise<unknown>;
|
||||
}
|
||||
|
||||
class CommandRegistry {
|
||||
private readonly commands = new Map<string, Command>();
|
||||
|
||||
constructor() {
|
||||
this.register(new ExtensionsCommand());
|
||||
this.register(new ListExtensionsCommand());
|
||||
}
|
||||
|
||||
register(command: Command) {
|
||||
if (this.commands.has(command.name)) {
|
||||
console.warn(`Command ${command.name} already registered. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.commands.set(command.name, command);
|
||||
|
||||
for (const subCommand of command.subCommands ?? []) {
|
||||
this.register(subCommand);
|
||||
for (const name of command.names) {
|
||||
this.commands.set(name, command);
|
||||
}
|
||||
}
|
||||
|
||||
get(commandName: string): Command | undefined {
|
||||
return this.commands.get(commandName);
|
||||
}
|
||||
|
||||
getAllCommands(): Command[] {
|
||||
return [...this.commands.values()];
|
||||
}
|
||||
}
|
||||
|
||||
export const commandRegistry = new CommandRegistry();
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ExtensionsCommand, ListExtensionsCommand } from './extensions.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
const mockListExtensions = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
|
||||
return {
|
||||
...original,
|
||||
listExtensions: mockListExtensions,
|
||||
};
|
||||
});
|
||||
|
||||
describe('ExtensionsCommand', () => {
|
||||
it('should have the correct name', () => {
|
||||
const command = new ExtensionsCommand();
|
||||
expect(command.name).toEqual('extensions');
|
||||
});
|
||||
|
||||
it('should have the correct description', () => {
|
||||
const command = new ExtensionsCommand();
|
||||
expect(command.description).toEqual('Manage extensions.');
|
||||
});
|
||||
|
||||
it('should have "extensions list" as a subcommand', () => {
|
||||
const command = new ExtensionsCommand();
|
||||
expect(command.subCommands.map((c) => c.name)).toContain('extensions list');
|
||||
});
|
||||
|
||||
it('should be a top-level command', () => {
|
||||
const command = new ExtensionsCommand();
|
||||
expect(command.topLevel).toBe(true);
|
||||
});
|
||||
|
||||
it('should default to listing extensions', async () => {
|
||||
const command = new ExtensionsCommand();
|
||||
const mockConfig = {} as Config;
|
||||
const mockExtensions = [{ name: 'ext1' }];
|
||||
mockListExtensions.mockReturnValue(mockExtensions);
|
||||
|
||||
const result = await command.execute(mockConfig, []);
|
||||
|
||||
expect(result).toEqual({ name: 'extensions list', data: mockExtensions });
|
||||
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListExtensionsCommand', () => {
|
||||
it('should have the correct name', () => {
|
||||
const command = new ListExtensionsCommand();
|
||||
expect(command.name).toEqual('extensions list');
|
||||
});
|
||||
|
||||
it('should call listExtensions with the provided config', async () => {
|
||||
const command = new ListExtensionsCommand();
|
||||
const mockConfig = {} as Config;
|
||||
const mockExtensions = [{ name: 'ext1' }];
|
||||
mockListExtensions.mockReturnValue(mockExtensions);
|
||||
|
||||
const result = await command.execute(mockConfig, []);
|
||||
|
||||
expect(result).toEqual({ name: 'extensions list', data: mockExtensions });
|
||||
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
|
||||
it('should return a message when no extensions are installed', async () => {
|
||||
const command = new ListExtensionsCommand();
|
||||
const mockConfig = {} as Config;
|
||||
mockListExtensions.mockReturnValue([]);
|
||||
|
||||
const result = await command.execute(mockConfig, []);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'extensions list',
|
||||
data: 'No extensions installed.',
|
||||
});
|
||||
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { listExtensions, type Config } from '@google/gemini-cli-core';
|
||||
import type { Command, CommandExecutionResponse } from './types.js';
|
||||
|
||||
export class ExtensionsCommand implements Command {
|
||||
readonly name = 'extensions';
|
||||
readonly description = 'Manage extensions.';
|
||||
readonly subCommands = [new ListExtensionsCommand()];
|
||||
readonly topLevel = true;
|
||||
|
||||
async execute(
|
||||
config: Config,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
return new ListExtensionsCommand().execute(config, _);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListExtensionsCommand implements Command {
|
||||
readonly name = 'extensions list';
|
||||
readonly description = 'Lists all installed extensions.';
|
||||
|
||||
async execute(
|
||||
config: Config,
|
||||
_: string[],
|
||||
): Promise<CommandExecutionResponse> {
|
||||
const extensions = listExtensions(config);
|
||||
const data = extensions.length ? extensions : 'No extensions installed.';
|
||||
|
||||
return { name: this.name, data };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ListExtensionsCommand } from './list-extensions.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
const mockListExtensions = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
|
||||
return {
|
||||
...original,
|
||||
listExtensions: mockListExtensions,
|
||||
};
|
||||
});
|
||||
|
||||
describe('ListExtensionsCommand', () => {
|
||||
it('should have the correct names', () => {
|
||||
const command = new ListExtensionsCommand();
|
||||
expect(command.names).toEqual(['extensions', 'extensions list']);
|
||||
});
|
||||
|
||||
it('should call listExtensions with the provided config', async () => {
|
||||
const command = new ListExtensionsCommand();
|
||||
const mockConfig = {} as Config;
|
||||
const mockExtensions = [{ name: 'ext1' }];
|
||||
mockListExtensions.mockReturnValue(mockExtensions);
|
||||
|
||||
const result = await command.execute(mockConfig, []);
|
||||
|
||||
expect(result).toEqual(mockExtensions);
|
||||
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { listExtensions, type Config } from '@google/gemini-cli-core';
|
||||
import type { Command } from './command-registry.js';
|
||||
|
||||
export class ListExtensionsCommand implements Command {
|
||||
readonly names = ['extensions', 'extensions list'];
|
||||
|
||||
async execute(config: Config, _: string[]): Promise<unknown> {
|
||||
return listExtensions(config);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
export interface CommandArgument {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly isRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly arguments?: CommandArgument[];
|
||||
readonly subCommands?: Command[];
|
||||
readonly topLevel?: boolean;
|
||||
|
||||
execute(config: Config, args: string[]): Promise<CommandExecutionResponse>;
|
||||
}
|
||||
|
||||
export interface CommandExecutionResponse {
|
||||
readonly name: string;
|
||||
readonly data: unknown;
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { createApp } from './app.js';
|
||||
import { commandRegistry } from '../commands/command-registry.js';
|
||||
import {
|
||||
assertUniqueFinalEventIsLast,
|
||||
assertTaskCreationAndWorkingStatus,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
createMockConfig,
|
||||
} from '../utils/testing_utils.js';
|
||||
import { MockTool } from '@google/gemini-cli-core';
|
||||
import type { Command } from '../commands/types.js';
|
||||
|
||||
const mockToolConfirmationFn = async () =>
|
||||
({}) as unknown as ToolCallConfirmationDetails;
|
||||
@@ -829,104 +827,6 @@ describe('E2E Tests', () => {
|
||||
expect(thoughtEvent.metadata?.['traceId']).toBe(traceId);
|
||||
});
|
||||
|
||||
describe('/listCommands', () => {
|
||||
it('should return a list of top-level commands', async () => {
|
||||
const mockCommands = [
|
||||
{
|
||||
name: 'test-command',
|
||||
description: 'A test command',
|
||||
topLevel: true,
|
||||
arguments: [{ name: 'arg1', description: 'Argument 1' }],
|
||||
subCommands: [
|
||||
{
|
||||
name: 'sub-command',
|
||||
description: 'A sub command',
|
||||
topLevel: false,
|
||||
execute: vi.fn(),
|
||||
},
|
||||
],
|
||||
execute: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'another-command',
|
||||
description: 'Another test command',
|
||||
topLevel: true,
|
||||
execute: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'not-top-level',
|
||||
description: 'Not a top level command',
|
||||
topLevel: false,
|
||||
execute: vi.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
const getAllCommandsSpy = vi
|
||||
.spyOn(commandRegistry, 'getAllCommands')
|
||||
.mockReturnValue(mockCommands);
|
||||
|
||||
const agent = request.agent(app);
|
||||
const res = await agent.get('/listCommands').expect(200);
|
||||
|
||||
expect(res.body).toEqual({
|
||||
commands: [
|
||||
{
|
||||
name: 'test-command',
|
||||
description: 'A test command',
|
||||
arguments: [{ name: 'arg1', description: 'Argument 1' }],
|
||||
subCommands: [
|
||||
{
|
||||
name: 'sub-command',
|
||||
description: 'A sub command',
|
||||
arguments: [],
|
||||
subCommands: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'another-command',
|
||||
description: 'Another test command',
|
||||
arguments: [],
|
||||
subCommands: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(getAllCommandsSpy).toHaveBeenCalledOnce();
|
||||
getAllCommandsSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle cyclic commands gracefully', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const cyclicCommand: Command = {
|
||||
name: 'cyclic-command',
|
||||
description: 'A cyclic command',
|
||||
topLevel: true,
|
||||
execute: vi.fn(),
|
||||
subCommands: [],
|
||||
};
|
||||
cyclicCommand.subCommands?.push(cyclicCommand); // Create cycle
|
||||
|
||||
const getAllCommandsSpy = vi
|
||||
.spyOn(commandRegistry, 'getAllCommands')
|
||||
.mockReturnValue([cyclicCommand]);
|
||||
|
||||
const agent = request.agent(app);
|
||||
const res = await agent.get('/listCommands').expect(200);
|
||||
|
||||
expect(res.body.commands[0].name).toBe('cyclic-command');
|
||||
expect(res.body.commands[0].subCommands).toEqual([]);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Command cyclic-command already inserted in the response, skipping',
|
||||
);
|
||||
|
||||
getAllCommandsSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('/executeCommand', () => {
|
||||
const mockExtensions = [{ name: 'test-extension', version: '0.0.1' }];
|
||||
|
||||
@@ -946,10 +846,7 @@ describe('E2E Tests', () => {
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
expect(res.body).toEqual({
|
||||
name: 'extensions list',
|
||||
data: mockExtensions,
|
||||
});
|
||||
expect(res.body).toEqual(mockExtensions);
|
||||
expect(getExtensionsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -21,14 +21,6 @@ import { loadSettings } from '../config/settings.js';
|
||||
import { loadExtensions } from '../config/extension.js';
|
||||
import { commandRegistry } from '../commands/command-registry.js';
|
||||
import { SimpleExtensionLoader } from '@google/gemini-cli-core';
|
||||
import type { Command, CommandArgument } from '../commands/types.js';
|
||||
|
||||
type CommandResponse = {
|
||||
name: string;
|
||||
description: string;
|
||||
arguments: CommandArgument[];
|
||||
subCommands: CommandResponse[];
|
||||
};
|
||||
|
||||
const coderAgentCard: AgentCard = {
|
||||
name: 'Gemini SDLC Agent',
|
||||
@@ -175,48 +167,6 @@ export async function createApp() {
|
||||
}
|
||||
});
|
||||
|
||||
expressApp.get('/listCommands', (req, res) => {
|
||||
try {
|
||||
const transformCommand = (
|
||||
command: Command,
|
||||
visited: string[],
|
||||
): CommandResponse | undefined => {
|
||||
const commandName = command.name;
|
||||
if (visited.includes(commandName)) {
|
||||
console.warn(
|
||||
`Command ${commandName} already inserted in the response, skipping`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
arguments: command.arguments ?? [],
|
||||
subCommands: (command.subCommands ?? [])
|
||||
.map((subCommand) =>
|
||||
transformCommand(subCommand, visited.concat(commandName)),
|
||||
)
|
||||
.filter(
|
||||
(subCommand): subCommand is CommandResponse => !!subCommand,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const commands = commandRegistry
|
||||
.getAllCommands()
|
||||
.filter((command) => command.topLevel)
|
||||
.map((command) => transformCommand(command, []));
|
||||
|
||||
return res.status(200).json({ commands });
|
||||
} catch (e) {
|
||||
logger.error('Error executing /listCommands:', e);
|
||||
const errorMessage =
|
||||
e instanceof Error ? e.message : 'Unknown error listing commands';
|
||||
return res.status(500).json({ error: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
expressApp.get('/tasks/metadata', async (req, res) => {
|
||||
// This endpoint is only meaningful if the task store is in-memory.
|
||||
if (!(taskStoreForExecutor instanceof InMemoryTaskStore)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.15.0-preview.5",
|
||||
"version": "0.14.0",
|
||||
"description": "Gemini CLI",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.0-preview.5"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.14.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -42,7 +42,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.4.5",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.2",
|
||||
"ink": "npm:@jrichman/ink@6.4.0",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -36,7 +36,7 @@ vi.mock('./sandboxConfig.js', () => ({
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actualFs = await importOriginal<typeof import('fs')>();
|
||||
const pathMod = await import('node:path');
|
||||
const mockHome = pathMod.resolve(pathMod.sep, 'mock', 'home', 'user');
|
||||
const mockHome = '/mock/home/user';
|
||||
const MOCK_CWD1 = process.cwd();
|
||||
const MOCK_CWD2 = pathMod.resolve(pathMod.sep, 'home', 'user', 'project');
|
||||
|
||||
@@ -69,7 +69,7 @@ vi.mock('os', async (importOriginal) => {
|
||||
const actualOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
...actualOs,
|
||||
homedir: vi.fn(() => path.resolve(path.sep, 'mock', 'home', 'user')),
|
||||
homedir: vi.fn(() => '/mock/home/user'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -451,36 +451,6 @@ describe('parseArguments', () => {
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('should throw an error when resuming a session without prompt in non-interactive mode', async () => {
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
process.stdin.isTTY = false;
|
||||
process.argv = ['node', 'script.js', '--resume', 'session-id'];
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
process.stdin.isTTY = originalIsTTY;
|
||||
}
|
||||
});
|
||||
|
||||
it('should support comma-separated values for --allowed-tools', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
@@ -782,11 +752,11 @@ describe('mergeMcpServers', () => {
|
||||
});
|
||||
|
||||
describe('mergeExcludeTools', () => {
|
||||
const defaultExcludes = new Set([
|
||||
const defaultExcludes = [
|
||||
SHELL_TOOL_NAME,
|
||||
EDIT_TOOL_NAME,
|
||||
WRITE_FILE_TOOL_NAME,
|
||||
]);
|
||||
];
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -829,7 +799,7 @@ describe('mergeExcludeTools', () => {
|
||||
argv,
|
||||
);
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
|
||||
expect.arrayContaining(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
|
||||
);
|
||||
expect(config.getExcludeTools()).toHaveLength(5);
|
||||
});
|
||||
@@ -851,7 +821,7 @@ describe('mergeExcludeTools', () => {
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
new Set(['tool1', 'tool2', 'tool3']),
|
||||
expect.arrayContaining(['tool1', 'tool2', 'tool3']),
|
||||
);
|
||||
expect(config.getExcludeTools()).toHaveLength(3);
|
||||
});
|
||||
@@ -882,7 +852,7 @@ describe('mergeExcludeTools', () => {
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
new Set(['tool1', 'tool2', 'tool3', 'tool4']),
|
||||
expect.arrayContaining(['tool1', 'tool2', 'tool3', 'tool4']),
|
||||
);
|
||||
expect(config.getExcludeTools()).toHaveLength(4);
|
||||
});
|
||||
@@ -893,7 +863,7 @@ describe('mergeExcludeTools', () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExcludeTools()).toEqual(new Set([]));
|
||||
expect(config.getExcludeTools()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
|
||||
@@ -911,7 +881,9 @@ describe('mergeExcludeTools', () => {
|
||||
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2']));
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
expect.arrayContaining(['tool1', 'tool2']),
|
||||
);
|
||||
expect(config.getExcludeTools()).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -931,7 +903,9 @@ describe('mergeExcludeTools', () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExcludeTools()).toEqual(new Set(['tool1', 'tool2']));
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
expect.arrayContaining(['tool1', 'tool2']),
|
||||
);
|
||||
expect(config.getExcludeTools()).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -1506,9 +1480,7 @@ describe('loadCliConfig folderTrust', () => {
|
||||
describe('loadCliConfig with includeDirectories', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue(
|
||||
path.resolve(path.sep, 'mock', 'home', 'user'),
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(
|
||||
path.resolve(path.sep, 'home', 'user', 'project'),
|
||||
|
||||
@@ -4,14 +4,21 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import yargs from 'yargs/yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import process from 'node:process';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
import type { OutputFormat } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
FileFilteringOptions,
|
||||
OutputFormat,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import {
|
||||
Config,
|
||||
loadServerHierarchicalMemory,
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
getCurrentGeminiMdFilename,
|
||||
ApprovalMode,
|
||||
@@ -29,7 +36,6 @@ import {
|
||||
getPty,
|
||||
EDIT_TOOL_NAME,
|
||||
debugLogger,
|
||||
loadServerHierarchicalMemory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
|
||||
@@ -41,7 +47,10 @@ import { appEvents } from '../utils/events.js';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { createPolicyEngineConfig } from './policy.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
|
||||
import type {
|
||||
ExtensionEvents,
|
||||
ExtensionLoader,
|
||||
} from '@google/gemini-cli-core/src/utils/extensionLoader.js';
|
||||
import { requestConsentNonInteractive } from './extensions/consent.js';
|
||||
import { promptForSetting } from './extensions/extensionSettings.js';
|
||||
import type { EventEmitter } from 'node:stream';
|
||||
@@ -61,9 +70,6 @@ export interface CliArgs {
|
||||
experimentalAcp: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
resume: string | 'latest' | undefined;
|
||||
listSessions: boolean | undefined;
|
||||
deleteSession: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
screenReader: boolean | undefined;
|
||||
useSmartEdit: boolean | undefined;
|
||||
@@ -175,35 +181,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
type: 'boolean',
|
||||
description: 'List all available extensions and exit.',
|
||||
})
|
||||
.option('resume', {
|
||||
alias: 'r',
|
||||
type: 'string',
|
||||
// `skipValidation` so that we can distinguish between it being passed with a value, without
|
||||
// one, and not being passed at all.
|
||||
skipValidation: true,
|
||||
description:
|
||||
'Resume a previous session. Use "latest" for most recent or index number (e.g. --resume 5)',
|
||||
coerce: (value: string): string => {
|
||||
// When --resume passed with a value (`gemini --resume 123`): value = "123" (string)
|
||||
// When --resume passed without a value (`gemini --resume`): value = "" (string)
|
||||
// When --resume not passed at all: this `coerce` function is not called at all, and
|
||||
// `yargsInstance.argv.resume` is undefined.
|
||||
if (value === '') {
|
||||
return 'latest';
|
||||
}
|
||||
return value;
|
||||
},
|
||||
})
|
||||
.option('list-sessions', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'List available sessions for the current project and exit.',
|
||||
})
|
||||
.option('delete-session', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Delete a session by index number (use --list-sessions to see available sessions).',
|
||||
})
|
||||
.option('include-directories', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
@@ -259,11 +236,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
if (argv['prompt'] && argv['promptInteractive']) {
|
||||
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
|
||||
}
|
||||
if (argv.resume && !argv.prompt && !process.stdin.isTTY) {
|
||||
throw new Error(
|
||||
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
|
||||
);
|
||||
}
|
||||
if (argv.yolo && argv['approvalMode']) {
|
||||
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
|
||||
}
|
||||
@@ -325,6 +297,49 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
return result as unknown as CliArgs;
|
||||
}
|
||||
|
||||
// This function is now a thin wrapper around the server's implementation.
|
||||
// It's kept in the CLI for now as App.tsx directly calls it for memory refresh.
|
||||
// TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself.
|
||||
export async function loadHierarchicalGeminiMemory(
|
||||
currentWorkingDirectory: string,
|
||||
includeDirectoriesToReadGemini: readonly string[] = [],
|
||||
debugMode: boolean,
|
||||
fileService: FileDiscoveryService,
|
||||
settings: Settings,
|
||||
extensionLoader: ExtensionLoader,
|
||||
folderTrust: boolean,
|
||||
memoryImportFormat: 'flat' | 'tree' = 'tree',
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
): Promise<{ memoryContent: string; fileCount: number; filePaths: string[] }> {
|
||||
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
|
||||
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
|
||||
const realHome = fs.realpathSync(path.resolve(homedir()));
|
||||
const isHomeDirectory = realCwd === realHome;
|
||||
|
||||
// If it is the home directory, pass an empty string to the core memory
|
||||
// function to signal that it should skip the workspace search.
|
||||
const effectiveCwd = isHomeDirectory ? '' : currentWorkingDirectory;
|
||||
|
||||
if (debugMode) {
|
||||
debugLogger.debug(
|
||||
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory} (memoryImportFormat: ${memoryImportFormat})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Directly call the server function with the corrected path.
|
||||
return loadServerHierarchicalMemory(
|
||||
effectiveCwd,
|
||||
includeDirectoriesToReadGemini,
|
||||
debugMode,
|
||||
fileService,
|
||||
extensionLoader,
|
||||
folderTrust,
|
||||
memoryImportFormat,
|
||||
fileFilteringOptions,
|
||||
settings.context?.discoveryMaxDirs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a filter function to determine if a tool should be excluded.
|
||||
*
|
||||
@@ -422,18 +437,18 @@ export async function loadCliConfig(
|
||||
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const { memoryContent, fileCount, filePaths } =
|
||||
await loadServerHierarchicalMemory(
|
||||
await loadHierarchicalGeminiMemory(
|
||||
cwd,
|
||||
settings.context?.loadMemoryFromIncludeDirectories
|
||||
? includeDirectories
|
||||
: [],
|
||||
debugMode,
|
||||
fileService,
|
||||
settings,
|
||||
extensionManager,
|
||||
trustedFolder,
|
||||
memoryImportFormat,
|
||||
memoryFileFiltering,
|
||||
settings.context?.discoveryMaxDirs,
|
||||
);
|
||||
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
@@ -622,8 +637,6 @@ export async function loadCliConfig(
|
||||
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
listSessions: argv.listSessions || false,
|
||||
deleteSession: argv.deleteSession,
|
||||
enabledExtensions: argv.extensions,
|
||||
extensionLoader: extensionManager,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
|
||||
@@ -128,15 +128,6 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
): Promise<GeminiCLIExtension> {
|
||||
if (
|
||||
(installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release') &&
|
||||
this.settings.security?.blockGitExtensions
|
||||
) {
|
||||
throw new Error(
|
||||
'Installing extensions from remote sources is disallowed by your current settings.',
|
||||
);
|
||||
}
|
||||
const isUpdate = !!previousExtensionConfig;
|
||||
let newExtensionConfig: ExtensionConfig | null = null;
|
||||
let localSourcePath: string | undefined;
|
||||
@@ -312,7 +303,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
throw new Error(`Extension not found`);
|
||||
}
|
||||
if (isUpdate) {
|
||||
await logExtensionUpdateEvent(
|
||||
logExtensionUpdateEvent(
|
||||
this.telemetryConfig,
|
||||
new ExtensionUpdateEvent(
|
||||
hashValue(newExtensionConfig.name),
|
||||
@@ -324,7 +315,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await logExtensionInstallEvent(
|
||||
logExtensionInstallEvent(
|
||||
this.telemetryConfig,
|
||||
new ExtensionInstallEvent(
|
||||
hashValue(newExtensionConfig.name),
|
||||
@@ -357,7 +348,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
? getExtensionId(config, installMetadata)
|
||||
: undefined;
|
||||
if (isUpdate) {
|
||||
await logExtensionUpdateEvent(
|
||||
logExtensionUpdateEvent(
|
||||
this.telemetryConfig,
|
||||
new ExtensionUpdateEvent(
|
||||
hashValue(config?.name ?? ''),
|
||||
@@ -369,7 +360,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await logExtensionInstallEvent(
|
||||
logExtensionInstallEvent(
|
||||
this.telemetryConfig,
|
||||
new ExtensionInstallEvent(
|
||||
hashValue(newExtensionConfig?.name ?? ''),
|
||||
@@ -399,11 +390,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
throw new Error(`Extension not found.`);
|
||||
}
|
||||
await this.unloadExtension(extension);
|
||||
const storage = new ExtensionStorage(
|
||||
extension.installMetadata?.type === 'link'
|
||||
? extension.name
|
||||
: path.basename(extension.path),
|
||||
);
|
||||
const storage = new ExtensionStorage(extension.name);
|
||||
|
||||
await fs.promises.rm(storage.getExtensionDir(), {
|
||||
recursive: true,
|
||||
@@ -416,7 +403,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
|
||||
this.extensionEnablementManager.remove(extension.name);
|
||||
|
||||
await logExtensionUninstall(
|
||||
logExtensionUninstall(
|
||||
this.telemetryConfig,
|
||||
new ExtensionUninstallEvent(
|
||||
hashValue(extension.name),
|
||||
@@ -458,13 +445,6 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
|
||||
const installMetadata = loadInstallMetadata(extensionDir);
|
||||
let effectiveExtensionPath = extensionDir;
|
||||
if (
|
||||
(installMetadata?.type === 'git' ||
|
||||
installMetadata?.type === 'github-release') &&
|
||||
this.settings.security?.blockGitExtensions
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (installMetadata?.type === 'link') {
|
||||
effectiveExtensionPath = installMetadata.source;
|
||||
@@ -589,8 +569,6 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
|
||||
let output = `${status} ${extension.name} (${extension.version})`;
|
||||
output += `\n ID: ${extension.id}`;
|
||||
output += `\n name: ${hashValue(extension.name)}`;
|
||||
|
||||
output += `\n Path: ${extension.path}`;
|
||||
if (extension.installMetadata) {
|
||||
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
|
||||
@@ -643,7 +621,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
scope === SettingScope.Workspace ? this.workspaceDir : os.homedir();
|
||||
this.extensionEnablementManager.disable(name, true, scopePath);
|
||||
}
|
||||
await logExtensionDisable(
|
||||
logExtensionDisable(
|
||||
this.telemetryConfig,
|
||||
new ExtensionDisableEvent(hashValue(name), extension.id, scope),
|
||||
);
|
||||
@@ -678,7 +656,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
scope === SettingScope.Workspace ? this.workspaceDir : os.homedir();
|
||||
this.extensionEnablementManager.enable(name, true, scopePath);
|
||||
}
|
||||
await logExtensionEnable(
|
||||
logExtensionEnable(
|
||||
this.telemetryConfig,
|
||||
new ExtensionEnableEvent(hashValue(name), extension.id, scope),
|
||||
);
|
||||
|
||||
@@ -594,34 +594,6 @@ describe('extension tests', () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not load github extensions if blockGitExtensions is set', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'http://somehost.com/foo/bar',
|
||||
},
|
||||
});
|
||||
|
||||
const blockGitExtensionsSetting = {
|
||||
security: {
|
||||
blockGitExtensions: true,
|
||||
},
|
||||
};
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: blockGitExtensionsSetting,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
|
||||
expect(extension).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('id generation', () => {
|
||||
it('should generate id from source for non-github git urls', async () => {
|
||||
createExtension({
|
||||
@@ -904,30 +876,6 @@ describe('extension tests', () => {
|
||||
fs.rmSync(targetExtDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should not install a github extension if blockGitExtensions is set', async () => {
|
||||
const gitUrl = 'https://somehost.com/somerepo.git';
|
||||
const blockGitExtensionsSetting = {
|
||||
security: {
|
||||
blockGitExtensions: true,
|
||||
},
|
||||
};
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: blockGitExtensionsSetting,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
await expect(
|
||||
extensionManager.installOrUpdateExtension({
|
||||
source: gitUrl,
|
||||
type: 'git',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Installing extensions from remote sources is disallowed by your current settings.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prompt for trust if workspace is not trusted', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
@@ -1607,34 +1555,6 @@ This extension will run the following MCP servers:
|
||||
expect(fs.existsSync(otherExtDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should uninstall an extension on non-matching extension directory name', async () => {
|
||||
// Create an extension with a name that differs from the directory name.
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'My-Local-Extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const newSourceExtDir = path.join(
|
||||
userExtensionsDir,
|
||||
'my-local-extension',
|
||||
);
|
||||
fs.renameSync(sourceExtDir, newSourceExtDir);
|
||||
|
||||
const otherExtDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'other-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
await extensionManager.uninstallExtension('my-local-extension', false);
|
||||
|
||||
expect(fs.existsSync(sourceExtDir)).toBe(false);
|
||||
expect(fs.existsSync(newSourceExtDir)).toBe(false);
|
||||
expect(extensionManager.getExtensions()).toHaveLength(1);
|
||||
expect(fs.existsSync(otherExtDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw an error if the extension does not exist', async () => {
|
||||
await extensionManager.loadExtensions();
|
||||
await expect(
|
||||
|
||||
@@ -310,11 +310,11 @@ describe('git extension helpers', () => {
|
||||
|
||||
describe('findReleaseAsset', () => {
|
||||
const assets = [
|
||||
{ name: 'darwin.arm64.extension.tar.gz', url: 'url1' },
|
||||
{ name: 'darwin.x64.extension.tar.gz', url: 'url2' },
|
||||
{ name: 'linux.x64.extension.tar.gz', url: 'url3' },
|
||||
{ name: 'win32.x64.extension.tar.gz', url: 'url4' },
|
||||
{ name: 'extension-generic.tar.gz', url: 'url5' },
|
||||
{ name: 'darwin.arm64.extension.tar.gz', browser_download_url: 'url1' },
|
||||
{ name: 'darwin.x64.extension.tar.gz', browser_download_url: 'url2' },
|
||||
{ name: 'linux.x64.extension.tar.gz', browser_download_url: 'url3' },
|
||||
{ name: 'win32.x64.extension.tar.gz', browser_download_url: 'url4' },
|
||||
{ name: 'extension-generic.tar.gz', browser_download_url: 'url5' },
|
||||
];
|
||||
|
||||
it.each([
|
||||
@@ -334,7 +334,9 @@ describe('git extension helpers', () => {
|
||||
);
|
||||
|
||||
it('should find generic asset if it is the only one', () => {
|
||||
const singleAsset = [{ name: 'extension.tar.gz', url: 'aurl5' }];
|
||||
const singleAsset = [
|
||||
{ name: 'extension.tar.gz', browser_download_url: 'url' },
|
||||
];
|
||||
|
||||
mockPlatform.mockReturnValue('darwin');
|
||||
mockArch.mockReturnValue('arm64');
|
||||
@@ -344,8 +346,8 @@ describe('git extension helpers', () => {
|
||||
|
||||
it('should return undefined if multiple generic assets exist', () => {
|
||||
const multipleGenericAssets = [
|
||||
{ name: 'extension-1.tar.gz', url: 'aurl1' },
|
||||
{ name: 'extension-2.tar.gz', url: 'aurl2' },
|
||||
{ name: 'extension-1.tar.gz', browser_download_url: 'url1' },
|
||||
{ name: 'extension-2.tar.gz', browser_download_url: 'url2' },
|
||||
];
|
||||
|
||||
mockPlatform.mockReturnValue('darwin');
|
||||
|
||||
@@ -306,11 +306,8 @@ export async function downloadFromGitHubRelease(
|
||||
let archiveUrl: string | undefined;
|
||||
let isTar = false;
|
||||
let isZip = false;
|
||||
let fileName: string | undefined;
|
||||
|
||||
if (asset) {
|
||||
archiveUrl = asset.url;
|
||||
fileName = asset.name;
|
||||
archiveUrl = asset.browser_download_url;
|
||||
} else {
|
||||
if (releaseData.tarball_url) {
|
||||
archiveUrl = releaseData.tarball_url;
|
||||
@@ -329,10 +326,10 @@ export async function downloadFromGitHubRelease(
|
||||
errorMessage: `No assets found for release with tag ${releaseData.tag_name}`,
|
||||
};
|
||||
}
|
||||
if (!fileName) {
|
||||
fileName = path.basename(new URL(archiveUrl).pathname);
|
||||
}
|
||||
let downloadedAssetPath = path.join(destination, fileName);
|
||||
let downloadedAssetPath = path.join(
|
||||
destination,
|
||||
path.basename(new URL(archiveUrl).pathname),
|
||||
);
|
||||
if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) {
|
||||
downloadedAssetPath += '.tar.gz';
|
||||
} else if (isZip && !downloadedAssetPath.endsWith('.zip')) {
|
||||
@@ -417,7 +414,7 @@ interface GithubReleaseData {
|
||||
|
||||
interface Asset {
|
||||
name: string;
|
||||
url: string;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
export function findReleaseAsset(assets: Asset[]): Asset | undefined {
|
||||
@@ -458,13 +455,8 @@ export function findReleaseAsset(assets: Asset[]): Asset | undefined {
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const headers: {
|
||||
'User-agent': string;
|
||||
Accept: string;
|
||||
Authorization?: string;
|
||||
} = {
|
||||
const headers: { 'User-agent': string; Authorization?: string } = {
|
||||
'User-agent': 'gemini-cli',
|
||||
Accept: 'application/octet-stream',
|
||||
};
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const SettingPaths = {
|
||||
General: {
|
||||
PreferredEditor: 'general.preferredEditor',
|
||||
},
|
||||
} as const;
|
||||
@@ -33,7 +33,6 @@ import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
import type { ExtensionManager } from './extension-manager.js';
|
||||
import { SettingPaths } from './settingPaths.js';
|
||||
|
||||
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||
let current: SettingDefinition | undefined = undefined;
|
||||
@@ -109,7 +108,7 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
memoryImportFormat: 'context.importFormat',
|
||||
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
|
||||
model: 'model.name',
|
||||
preferredEditor: SettingPaths.General.PreferredEditor,
|
||||
preferredEditor: 'general.preferredEditor',
|
||||
retryFetchErrors: 'general.retryFetchErrors',
|
||||
sandbox: 'tools.sandbox',
|
||||
selectedAuthType: 'security.auth.selectedType',
|
||||
|
||||
@@ -488,7 +488,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Use Full Width',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
default: false,
|
||||
description: 'Use the entire width of the terminal for output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
@@ -1080,8 +1080,8 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Use Write Todos',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Enable the write_todos tool.',
|
||||
default: false,
|
||||
description: 'Enable the write_todos_list tool.',
|
||||
showInDialog: false,
|
||||
},
|
||||
security: {
|
||||
@@ -1102,15 +1102,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Disable YOLO mode, even if enabled by a flag.',
|
||||
showInDialog: true,
|
||||
},
|
||||
blockGitExtensions: {
|
||||
type: 'boolean',
|
||||
label: 'Blocks extensions from Git',
|
||||
category: 'Security',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Blocks installing and loading extensions from Git.',
|
||||
showInDialog: true,
|
||||
},
|
||||
folderTrust: {
|
||||
type: 'object',
|
||||
label: 'Folder Trust',
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from './gemini.js';
|
||||
import { type LoadedSettings } from './config/settings.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { type Config, type ResumedSessionData } from '@google/gemini-cli-core';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { type InitializationResult } from './core/initializer.js';
|
||||
|
||||
@@ -189,8 +189,6 @@ describe('gemini.tsx main function', () => {
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
@@ -341,8 +339,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
@@ -395,9 +391,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
screenReader: undefined,
|
||||
useSmartEdit: undefined,
|
||||
useWriteTodos: undefined,
|
||||
resume: undefined,
|
||||
listSessions: undefined,
|
||||
deleteSession: undefined,
|
||||
outputFormat: undefined,
|
||||
fakeResponses: undefined,
|
||||
recordResponses: undefined,
|
||||
@@ -495,7 +488,6 @@ describe('startInteractiveUI', () => {
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: string[],
|
||||
workspaceRoot: string,
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
await act(async () => {
|
||||
@@ -504,7 +496,6 @@ describe('startInteractiveUI', () => {
|
||||
settings,
|
||||
startupWarnings,
|
||||
workspaceRoot,
|
||||
resumedSessionData,
|
||||
initializationResult,
|
||||
);
|
||||
});
|
||||
@@ -519,7 +510,6 @@ describe('startInteractiveUI', () => {
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
undefined,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
@@ -548,7 +538,6 @@ describe('startInteractiveUI', () => {
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
undefined,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
@@ -574,7 +563,6 @@ describe('startInteractiveUI', () => {
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
undefined,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
@@ -591,7 +579,6 @@ describe('startInteractiveUI', () => {
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
undefined,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
@@ -623,7 +610,6 @@ describe('startInteractiveUI', () => {
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
undefined,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
runExitCleanup,
|
||||
} from './utils/cleanup.js';
|
||||
import { getCliVersion } from './utils/version.js';
|
||||
import type { Config, ResumedSessionData } from '@google/gemini-cli-core';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
sessionId,
|
||||
logUserPrompt,
|
||||
@@ -55,7 +55,6 @@ import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.j
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { SessionSelector } from './utils/sessionUtils.js';
|
||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { MouseProvider } from './ui/contexts/MouseContext.js';
|
||||
@@ -69,13 +68,11 @@ import {
|
||||
relaunchOnExitCode,
|
||||
} from './utils/relaunch.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { deleteSession, listSessions } from './utils/sessions.js';
|
||||
import { ExtensionManager } from './config/extension-manager.js';
|
||||
import { createPolicyUpdater } from './config/policy.js';
|
||||
import { requestConsentNonInteractive } from './config/extensions/consent.js';
|
||||
import { disableMouseEvents, enableMouseEvents } from './ui/utils/mouse.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
|
||||
@@ -154,7 +151,6 @@ export async function startInteractiveUI(
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: string[],
|
||||
workspaceRoot: string = process.cwd(),
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
// When not in screen reader mode, disable line wrapping.
|
||||
@@ -188,10 +184,11 @@ export async function startInteractiveUI(
|
||||
|
||||
// Create wrapper component to use hooks inside render
|
||||
const AppWrapper = () => {
|
||||
useKittyKeyboardProtocol();
|
||||
const kittyProtocolStatus = useKittyKeyboardProtocol();
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={kittyProtocolStatus.enabled}
|
||||
config={config}
|
||||
debugKeystrokeLogging={settings.merged.general?.debugKeystrokeLogging}
|
||||
>
|
||||
@@ -206,9 +203,9 @@ export async function startInteractiveUI(
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
settings={settings}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
@@ -418,31 +415,12 @@ export async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle --list-sessions flag
|
||||
if (config.getListSessions()) {
|
||||
await listSessions(config);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle --delete-session flag
|
||||
const sessionToDelete = config.getDeleteSession();
|
||||
if (sessionToDelete) {
|
||||
await deleteSession(config, sessionToDelete);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
|
||||
// Set this as early as possible to avoid spurious characters from
|
||||
// input showing up in the output.
|
||||
process.stdin.setRawMode(true);
|
||||
|
||||
if (settings.merged.ui?.useAlternateBuffer) {
|
||||
process.stdout.write(ansiEscapes.enterAlternativeScreen);
|
||||
|
||||
// Ink will cleanup so there is no need for us to manually cleanup.
|
||||
}
|
||||
|
||||
// This cleanup isn't strictly needed but may help in certain situations.
|
||||
process.on('SIGTERM', () => {
|
||||
process.stdin.setRawMode(wasRaw);
|
||||
@@ -477,26 +455,6 @@ export async function main() {
|
||||
...(await getUserStartupWarnings()),
|
||||
];
|
||||
|
||||
// Handle --resume flag
|
||||
let resumedSessionData: ResumedSessionData | undefined = undefined;
|
||||
if (argv.resume) {
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
try {
|
||||
const result = await sessionSelector.resolveSession(argv.resume);
|
||||
resumedSessionData = {
|
||||
conversation: result.sessionData,
|
||||
filePath: result.sessionPath,
|
||||
};
|
||||
// Use the existing session ID to continue recording to the same session
|
||||
config.setSessionId(resumedSessionData.conversation.sessionId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||
if (config.isInteractive()) {
|
||||
await startInteractiveUI(
|
||||
@@ -504,7 +462,6 @@ export async function main() {
|
||||
settings,
|
||||
startupWarnings,
|
||||
process.cwd(),
|
||||
resumedSessionData,
|
||||
initializationResult,
|
||||
);
|
||||
return;
|
||||
@@ -558,7 +515,6 @@ export async function main() {
|
||||
input,
|
||||
prompt_id,
|
||||
hasDeprecatedPromptArg,
|
||||
resumedSessionData,
|
||||
});
|
||||
// Call cleanup before process.exit, which causes cleanup to not run
|
||||
await runExitCleanup();
|
||||
|
||||
@@ -93,7 +93,6 @@ describe('runNonInteractive', () => {
|
||||
let processStderrSpy: MockInstance;
|
||||
let mockGeminiClient: {
|
||||
sendMessageStream: Mock;
|
||||
resumeChat: Mock;
|
||||
getChatRecordingService: Mock;
|
||||
};
|
||||
const MOCK_SESSION_METRICS: SessionMetrics = {
|
||||
@@ -143,7 +142,6 @@ describe('runNonInteractive', () => {
|
||||
|
||||
mockGeminiClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
resumeChat: vi.fn().mockResolvedValue(undefined),
|
||||
getChatRecordingService: vi.fn(() => ({
|
||||
initialize: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import type {
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
ResumedSessionData,
|
||||
CompletedToolCall,
|
||||
UserFeedbackPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -33,7 +32,6 @@ import {
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js';
|
||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||
@@ -51,7 +49,6 @@ interface RunNonInteractiveParams {
|
||||
input: string;
|
||||
prompt_id: string;
|
||||
hasDeprecatedPromptArg?: boolean;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
export async function runNonInteractive({
|
||||
@@ -60,7 +57,6 @@ export async function runNonInteractive({
|
||||
input,
|
||||
prompt_id,
|
||||
hasDeprecatedPromptArg,
|
||||
resumedSessionData,
|
||||
}: RunNonInteractiveParams): Promise<void> {
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
@@ -189,16 +185,6 @@ export async function runNonInteractive({
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
// Initialize chat. Resume if resume data is passed.
|
||||
if (resumedSessionData) {
|
||||
await geminiClient.resumeChat(
|
||||
convertSessionToHistoryFormats(
|
||||
resumedSessionData.conversation.messages,
|
||||
).clientHistory,
|
||||
resumedSessionData,
|
||||
);
|
||||
}
|
||||
|
||||
// Emit init event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
|
||||
@@ -842,52 +842,6 @@ describe('FileCommandLoader', () => {
|
||||
assert.fail('Incorrect action type');
|
||||
}
|
||||
});
|
||||
|
||||
it('correctly loads extensionId for extension commands', async () => {
|
||||
const extensionId = 'my-test-ext-id-123';
|
||||
const extensionDir = path.join(
|
||||
process.cwd(),
|
||||
GEMINI_DIR,
|
||||
'extensions',
|
||||
'my-test-ext',
|
||||
);
|
||||
|
||||
mock({
|
||||
[extensionDir]: {
|
||||
'gemini-extension.json': JSON.stringify({
|
||||
name: 'my-test-ext',
|
||||
id: extensionId,
|
||||
version: '1.0.0',
|
||||
}),
|
||||
commands: {
|
||||
'my-cmd.toml': 'prompt = "My test command"',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => [
|
||||
{
|
||||
name: 'my-test-ext',
|
||||
id: extensionId,
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: extensionDir,
|
||||
},
|
||||
]),
|
||||
getFolderTrust: vi.fn(() => false),
|
||||
isTrustedFolder: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command.name).toBe('my-cmd');
|
||||
expect(command.extensionName).toBe('my-test-ext');
|
||||
expect(command.extensionId).toBe(extensionId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Argument Handling Integration (via ShellProcessor)', () => {
|
||||
|
||||
@@ -37,7 +37,6 @@ import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
|
||||
interface CommandDirectory {
|
||||
path: string;
|
||||
extensionName?: string;
|
||||
extensionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +110,6 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
path.join(dirInfo.path, file),
|
||||
dirInfo.path,
|
||||
dirInfo.extensionName,
|
||||
dirInfo.extensionId,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -160,7 +158,6 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
const extensionCommandDirs = activeExtensions.map((ext) => ({
|
||||
path: path.join(ext.path, 'commands'),
|
||||
extensionName: ext.name,
|
||||
extensionId: ext.id,
|
||||
}));
|
||||
|
||||
dirs.push(...extensionCommandDirs);
|
||||
@@ -180,7 +177,6 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
filePath: string,
|
||||
baseDir: string,
|
||||
extensionName?: string,
|
||||
extensionId?: string,
|
||||
): Promise<SlashCommand | null> {
|
||||
let fileContent: string;
|
||||
try {
|
||||
@@ -269,7 +265,6 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
description,
|
||||
kind: CommandKind.FILE,
|
||||
extensionName,
|
||||
extensionId,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
_args: string,
|
||||
|
||||
@@ -120,17 +120,17 @@ export const renderWithProviders = (
|
||||
settings = mockSettings,
|
||||
uiState: providedUiState,
|
||||
width,
|
||||
kittyProtocolEnabled = true,
|
||||
mouseEventsEnabled = false,
|
||||
config = configProxy as unknown as Config,
|
||||
useAlternateBuffer,
|
||||
}: {
|
||||
shellFocus?: boolean;
|
||||
settings?: LoadedSettings;
|
||||
uiState?: Partial<UIState>;
|
||||
width?: number;
|
||||
kittyProtocolEnabled?: boolean;
|
||||
mouseEventsEnabled?: boolean;
|
||||
config?: Config;
|
||||
useAlternateBuffer?: boolean;
|
||||
} = {},
|
||||
): ReturnType<typeof render> => {
|
||||
const baseState: UIState = new Proxy(
|
||||
@@ -152,18 +152,7 @@ export const renderWithProviders = (
|
||||
) as UIState;
|
||||
|
||||
const terminalWidth = width ?? baseState.terminalWidth;
|
||||
let finalSettings = settings;
|
||||
if (useAlternateBuffer !== undefined) {
|
||||
finalSettings = createMockSettings({
|
||||
...settings.merged,
|
||||
ui: {
|
||||
...settings.merged.ui,
|
||||
useAlternateBuffer,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, finalSettings);
|
||||
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings);
|
||||
|
||||
const finalUiState = {
|
||||
...baseState,
|
||||
@@ -173,11 +162,11 @@ export const renderWithProviders = (
|
||||
|
||||
return render(
|
||||
<ConfigContext.Provider value={config}>
|
||||
<SettingsContext.Provider value={finalSettings}>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider settings={finalSettings}>
|
||||
<VimModeProvider settings={settings}>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
|
||||
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
|
||||
@@ -12,7 +12,6 @@ import { App } from './App.js';
|
||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||
import { StreamingState } from './types.js';
|
||||
import { ConfigContext } from './contexts/ConfigContext.js';
|
||||
import { AppContext, type AppState } from './contexts/AppContext.js';
|
||||
import { SettingsContext } from './contexts/SettingsContext.js';
|
||||
import {
|
||||
type SettingScope,
|
||||
@@ -48,10 +47,6 @@ vi.mock('./components/QuittingDisplay.js', () => ({
|
||||
QuittingDisplay: () => <Text>Quitting...</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./components/HistoryItemDisplay.js', () => ({
|
||||
HistoryItemDisplay: () => <Text>HistoryItemDisplay</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./components/Footer.js', () => ({
|
||||
Footer: () => <Text>Footer</Text>,
|
||||
}));
|
||||
@@ -70,8 +65,6 @@ describe('App', () => {
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
},
|
||||
history: [],
|
||||
pendingHistoryItems: [],
|
||||
};
|
||||
|
||||
const mockConfig = makeFakeConfig();
|
||||
@@ -91,22 +84,13 @@ describe('App', () => {
|
||||
new Set<SettingScope>(),
|
||||
);
|
||||
|
||||
const mockAppState: AppState = {
|
||||
version: '1.0.0',
|
||||
startupWarnings: [],
|
||||
};
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement, state: UIState) =>
|
||||
render(
|
||||
<AppContext.Provider value={mockAppState}>
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
<SettingsContext.Provider value={mockLoadedSettings}>
|
||||
<UIStateContext.Provider value={state}>
|
||||
{ui}
|
||||
</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</AppContext.Provider>,
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
<SettingsContext.Provider value={mockLoadedSettings}>
|
||||
<UIStateContext.Provider value={state}>{ui}</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
|
||||
it('should render main content and composer when not quitting', () => {
|
||||
@@ -128,25 +112,6 @@ describe('App', () => {
|
||||
expect(lastFrame()).toContain('Quitting...');
|
||||
});
|
||||
|
||||
it('should render full history in alternate buffer mode when quittingMessages is set', () => {
|
||||
const quittingUIState = {
|
||||
...mockUIState,
|
||||
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
|
||||
history: [{ id: 1, type: 'user', text: 'history item' }],
|
||||
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
|
||||
} as UIState;
|
||||
|
||||
mockLoadedSettings.merged.ui = { useAlternateBuffer: true };
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, quittingUIState);
|
||||
|
||||
expect(lastFrame()).toContain('HistoryItemDisplay');
|
||||
expect(lastFrame()).toContain('Quitting...');
|
||||
|
||||
// Reset settings
|
||||
mockLoadedSettings.merged.ui = { useAlternateBuffer: false };
|
||||
});
|
||||
|
||||
it('should render dialog manager when dialogs are visible', () => {
|
||||
const dialogUIState = {
|
||||
...mockUIState,
|
||||
|
||||
@@ -10,24 +10,13 @@ import { StreamingContext } from './contexts/StreamingContext.js';
|
||||
import { QuittingDisplay } from './components/QuittingDisplay.js';
|
||||
import { ScreenReaderAppLayout } from './layouts/ScreenReaderAppLayout.js';
|
||||
import { DefaultAppLayout } from './layouts/DefaultAppLayout.js';
|
||||
import { AlternateBufferQuittingDisplay } from './components/AlternateBufferQuittingDisplay.js';
|
||||
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
||||
|
||||
export const App = () => {
|
||||
const uiState = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
|
||||
if (uiState.quittingMessages) {
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<StreamingContext.Provider value={uiState.streamingState}>
|
||||
<AlternateBufferQuittingDisplay />
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
} else {
|
||||
return <QuittingDisplay />;
|
||||
}
|
||||
return <QuittingDisplay />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,18 +42,16 @@ import {
|
||||
getAllGeminiMdFilenames,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
type ResumedSessionData,
|
||||
recordExitFail,
|
||||
ShellExecutionService,
|
||||
saveApiKey,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
refreshServerHierarchicalMemory,
|
||||
type ModelChangedPayload,
|
||||
type MemoryChangedPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
||||
import process from 'node:process';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
|
||||
@@ -78,7 +76,11 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||
import {
|
||||
type LoadableSettingScope,
|
||||
type LoadedSettings,
|
||||
SettingScope,
|
||||
} from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
import { useFocus } from './hooks/useFocus.js';
|
||||
import { useBracketedPaste } from './hooks/useBracketedPaste.js';
|
||||
@@ -102,12 +104,9 @@ import {
|
||||
useExtensionUpdates,
|
||||
} from './hooks/useExtensionUpdates.js';
|
||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { useSessionResume } from './hooks/useSessionResume.js';
|
||||
import { type ExtensionManager } from '../config/extension-manager.js';
|
||||
import { requestConsentInteractive } from '../config/extensions/consent.js';
|
||||
import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js';
|
||||
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
||||
import { useSettings } from './contexts/SettingsContext.js';
|
||||
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
|
||||
@@ -125,10 +124,10 @@ function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
|
||||
interface AppContainerProps {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
startupWarnings?: string[];
|
||||
version: string;
|
||||
initializationResult: InitializationResult;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,11 +143,9 @@ const SHELL_WIDTH_FRACTION = 0.89;
|
||||
const SHELL_HEIGHT_PADDING = 10;
|
||||
|
||||
export const AppContainer = (props: AppContainerProps) => {
|
||||
const { config, initializationResult, resumedSessionData } = props;
|
||||
const { settings, config, initializationResult } = props;
|
||||
const historyManager = useHistory();
|
||||
useMemoryMonitor(historyManager);
|
||||
const settings = useSettings();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
const [quittingMessages, setQuittingMessages] = useState<
|
||||
@@ -163,6 +160,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const [showDebugProfiler, setShowDebugProfiler] = useState(false);
|
||||
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
||||
|
||||
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(
|
||||
initializationResult.geminiMdFileCount,
|
||||
);
|
||||
const [shellModeActive, setShellModeActive] = useState(false);
|
||||
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
|
||||
useState<boolean>(false);
|
||||
@@ -358,11 +358,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
}, [historyManager.history, logger]);
|
||||
|
||||
const refreshStatic = useCallback(() => {
|
||||
if (!isAlternateBuffer) {
|
||||
if (settings.merged.ui?.useAlternateBuffer === false) {
|
||||
stdout.write(ansiEscapes.clearTerminal);
|
||||
}
|
||||
setHistoryRemountKey((prev) => prev + 1);
|
||||
}, [setHistoryRemountKey, stdout, isAlternateBuffer]);
|
||||
}, [setHistoryRemountKey, stdout, settings]);
|
||||
|
||||
const {
|
||||
isThemeDialogOpen,
|
||||
@@ -397,19 +397,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const isAuthDialogOpen = authState === AuthState.Updating;
|
||||
const isAuthenticating = authState === AuthState.Unauthenticated;
|
||||
|
||||
// Session browser and resume functionality
|
||||
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
|
||||
|
||||
useSessionResume({
|
||||
config,
|
||||
historyManager,
|
||||
refreshStatic,
|
||||
isGeminiClientInitialized,
|
||||
setQuittingMessages,
|
||||
resumedSessionData,
|
||||
isAuthenticating,
|
||||
});
|
||||
|
||||
// Create handleAuthSelect wrapper for backward compatibility
|
||||
const handleAuthSelect = useCallback(
|
||||
async (authType: AuthType | undefined, scope: LoadableSettingScope) => {
|
||||
@@ -582,6 +569,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
refreshStatic,
|
||||
toggleVimEnabled,
|
||||
setIsProcessing,
|
||||
setGeminiMdFileCount,
|
||||
slashCommandActions,
|
||||
extensionsUpdateStateInternal,
|
||||
isConfigInitialized,
|
||||
@@ -596,8 +584,26 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
Date.now(),
|
||||
);
|
||||
try {
|
||||
const { memoryContent, fileCount } =
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
const { memoryContent, fileCount, filePaths } =
|
||||
await loadHierarchicalGeminiMemory(
|
||||
process.cwd(),
|
||||
settings.merged.context?.loadMemoryFromIncludeDirectories
|
||||
? config.getWorkspaceContext().getDirectories()
|
||||
: [],
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
settings.merged,
|
||||
config.getExtensionLoader(),
|
||||
config.isTrustedFolder(),
|
||||
settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
|
||||
config.getFileFilteringOptions(),
|
||||
);
|
||||
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
config.setGeminiMdFilePaths(filePaths);
|
||||
|
||||
setGeminiMdFileCount(fileCount);
|
||||
|
||||
historyManager.addItem(
|
||||
{
|
||||
@@ -629,7 +635,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
);
|
||||
debugLogger.warn('Error refreshing memory:', error);
|
||||
}
|
||||
}, [config, historyManager]);
|
||||
}, [config, historyManager, settings.merged]);
|
||||
|
||||
const cancelHandlerRef = useRef<() => void>(() => {});
|
||||
|
||||
@@ -1035,7 +1041,10 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
|
||||
}
|
||||
|
||||
if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) {
|
||||
if (
|
||||
settings.merged.ui?.useAlternateBuffer &&
|
||||
keyMatchers[Command.TOGGLE_COPY_MODE](key)
|
||||
) {
|
||||
setCopyModeEnabled(true);
|
||||
disableMouseEvents();
|
||||
return;
|
||||
@@ -1107,7 +1116,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
refreshStatic,
|
||||
setCopyModeEnabled,
|
||||
copyModeEnabled,
|
||||
isAlternateBuffer,
|
||||
settings.merged.ui?.useAlternateBuffer,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1239,19 +1248,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
|
||||
);
|
||||
|
||||
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(
|
||||
config.getGeminiMdFileCount(),
|
||||
);
|
||||
useEffect(() => {
|
||||
const handleMemoryChanged = (result: MemoryChangedPayload) => {
|
||||
setGeminiMdFileCount(result.fileCount);
|
||||
};
|
||||
coreEvents.on(CoreEvent.MemoryChanged, handleMemoryChanged);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.MemoryChanged, handleMemoryChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const uiState: UIState = useMemo(
|
||||
() => ({
|
||||
history: historyManager.history,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CommandKind } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
|
||||
import { loadServerHierarchicalMemory } from '@google/gemini-cli-core';
|
||||
|
||||
export function expandHomeDir(p: string): string {
|
||||
if (!p) {
|
||||
@@ -94,7 +94,25 @@ export const directoryCommand: SlashCommand = {
|
||||
|
||||
try {
|
||||
if (config.shouldLoadMemoryFromIncludeDirectories()) {
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
const { memoryContent, fileCount } =
|
||||
await loadServerHierarchicalMemory(
|
||||
config.getWorkingDir(),
|
||||
[
|
||||
...config.getWorkspaceContext().getDirectories(),
|
||||
...pathsToAdd,
|
||||
],
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
config.getExtensionLoader(),
|
||||
config.getFolderTrust(),
|
||||
context.services.settings.merged.context?.importFormat ||
|
||||
'tree', // Use setting or default to 'tree'
|
||||
config.getFileFilteringOptions(),
|
||||
context.services.settings.merged.context?.discoveryMaxDirs,
|
||||
);
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
context.ui.setGeminiMdFileCount(fileCount);
|
||||
}
|
||||
addItem(
|
||||
{
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExtensionLoader,
|
||||
GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import {
|
||||
@@ -16,21 +13,12 @@ import {
|
||||
extensionsCommand,
|
||||
} from './extensionsCommand.js';
|
||||
import { type CommandContext, type SlashCommand } from './types.js';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockedFunction,
|
||||
} from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { type ExtensionUpdateAction } from '../state/extensions.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
import open from 'open';
|
||||
|
||||
vi.mock('open', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
@@ -584,178 +572,4 @@ describe('extensionsCommand', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restart', () => {
|
||||
let restartAction: SlashCommand['action'];
|
||||
let mockRestartExtension: MockedFunction<
|
||||
typeof ExtensionLoader.prototype.restartExtension
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
restartAction = extensionsCommand().subCommands?.find(
|
||||
(c) => c.name === 'restart',
|
||||
)?.action;
|
||||
expect(restartAction).not.toBeNull();
|
||||
|
||||
mockRestartExtension = vi.fn();
|
||||
mockContext.services.config!.getExtensionLoader = vi
|
||||
.fn()
|
||||
.mockImplementation(() => ({
|
||||
getExtensions: mockGetExtensions,
|
||||
restartExtension: mockRestartExtension,
|
||||
}));
|
||||
mockContext.invocation!.name = 'restart';
|
||||
});
|
||||
|
||||
it('restarts all active extensions when --all is provided', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: true },
|
||||
{ name: 'ext2', isActive: true },
|
||||
{ name: 'ext3', isActive: false },
|
||||
] as GeminiCLIExtension[];
|
||||
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||
|
||||
await restartAction!(mockContext, '--all');
|
||||
|
||||
expect(mockRestartExtension).toHaveBeenCalledTimes(2);
|
||||
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
|
||||
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[1]);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Restarting 2 extensions...',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: '2 extensions restarted successfully.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
|
||||
type: 'RESTARTED',
|
||||
payload: { name: 'ext1' },
|
||||
});
|
||||
expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
|
||||
type: 'RESTARTED',
|
||||
payload: { name: 'ext2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('restarts only specified active extensions', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: false },
|
||||
{ name: 'ext2', isActive: true },
|
||||
{ name: 'ext3', isActive: true },
|
||||
] as GeminiCLIExtension[];
|
||||
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||
|
||||
await restartAction!(mockContext, 'ext1 ext3');
|
||||
|
||||
expect(mockRestartExtension).toHaveBeenCalledTimes(1);
|
||||
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[2]);
|
||||
expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({
|
||||
type: 'RESTARTED',
|
||||
payload: { name: 'ext3' },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error if no extension loader is available', async () => {
|
||||
mockContext.services.config!.getExtensionLoader = vi.fn();
|
||||
|
||||
await restartAction!(mockContext, '--all');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: "Extensions are not yet loaded, can't restart yet",
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockRestartExtension).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows usage error for no arguments', async () => {
|
||||
await restartAction!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions restart <extension-names>|--all',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockRestartExtension).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles errors during extension restart', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: true },
|
||||
] as GeminiCLIExtension[];
|
||||
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||
mockRestartExtension.mockRejectedValue(new Error('Failed to restart'));
|
||||
|
||||
await restartAction!(mockContext, '--all');
|
||||
|
||||
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to restart some extensions:\n ext1: Failed to restart',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a warning if an extension is not found', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: true },
|
||||
] as GeminiCLIExtension[];
|
||||
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||
|
||||
await restartAction!(mockContext, 'ext1 ext2');
|
||||
|
||||
expect(mockRestartExtension).toHaveBeenCalledTimes(1);
|
||||
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.WARNING,
|
||||
text: 'Extension(s) not found or not active: ext2',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not restart any extensions if none are found', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: true },
|
||||
] as GeminiCLIExtension[];
|
||||
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||
|
||||
await restartAction!(mockContext, 'ext2 ext3');
|
||||
|
||||
expect(mockRestartExtension).not.toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.WARNING,
|
||||
text: 'Extension(s) not found or not active: ext2, ext3',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should suggest only enabled extension names for the restart command', async () => {
|
||||
mockContext.invocation!.name = 'restart';
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: true },
|
||||
{ name: 'ext2', isActive: false },
|
||||
] as GeminiCLIExtension[];
|
||||
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||
|
||||
const suggestions = completeExtensions(mockContext, 'ext');
|
||||
expect(suggestions).toEqual(['ext1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,12 +7,7 @@
|
||||
import { debugLogger, listExtensions } from '@google/gemini-cli-core';
|
||||
import type { ExtensionUpdateInfo } from '../../config/extension.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
emptyIcon,
|
||||
MessageType,
|
||||
type HistoryItemExtensionsList,
|
||||
type HistoryItemInfo,
|
||||
} from '../types.js';
|
||||
import { MessageType, type HistoryItemExtensionsList } from '../types.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
@@ -22,7 +17,6 @@ import open from 'open';
|
||||
import process from 'node:process';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
async function listAction(context: CommandContext) {
|
||||
const historyItem: HistoryItemExtensionsList = {
|
||||
@@ -122,118 +116,6 @@ function updateAction(context: CommandContext, args: string): Promise<void> {
|
||||
return updateComplete.then((_) => {});
|
||||
}
|
||||
|
||||
async function restartAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void> {
|
||||
const extensionLoader = context.services.config?.getExtensionLoader();
|
||||
if (!extensionLoader) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: "Extensions are not yet loaded, can't restart yet",
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const restartArgs = args.split(' ').filter((value) => value.length > 0);
|
||||
const all = restartArgs.length === 1 && restartArgs[0] === '--all';
|
||||
const names = all ? null : restartArgs;
|
||||
if (!all && names?.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions restart <extension-names>|--all',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
let extensionsToRestart = extensionLoader
|
||||
.getExtensions()
|
||||
.filter((extension) => extension.isActive);
|
||||
if (names) {
|
||||
extensionsToRestart = extensionsToRestart.filter((extension) =>
|
||||
names.includes(extension.name),
|
||||
);
|
||||
if (names.length !== extensionsToRestart.length) {
|
||||
const notFound = names.filter(
|
||||
(name) =>
|
||||
!extensionsToRestart.some((extension) => extension.name === name),
|
||||
);
|
||||
if (notFound.length > 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.WARNING,
|
||||
text: `Extension(s) not found or not active: ${notFound.join(
|
||||
', ',
|
||||
)}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extensionsToRestart.length === 0) {
|
||||
// We will have logged a different message above already.
|
||||
return;
|
||||
}
|
||||
|
||||
const s = extensionsToRestart.length > 1 ? 's' : '';
|
||||
|
||||
const restartingMessage = {
|
||||
type: MessageType.INFO,
|
||||
text: `Restarting ${extensionsToRestart.length} extension${s}...`,
|
||||
color: theme.text.primary,
|
||||
};
|
||||
context.ui.addItem(restartingMessage, Date.now());
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
extensionsToRestart.map(async (extension) => {
|
||||
if (extension.isActive) {
|
||||
await extensionLoader.restartExtension(extension);
|
||||
context.ui.dispatchExtensionStateUpdate({
|
||||
type: 'RESTARTED',
|
||||
payload: {
|
||||
name: extension.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const failures = results.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
|
||||
if (failures.length > 0) {
|
||||
const errorMessages = failures
|
||||
.map((failure, index) => {
|
||||
const extensionName = extensionsToRestart[index].name;
|
||||
return `${extensionName}: ${getErrorMessage(failure.reason)}`;
|
||||
})
|
||||
.join('\n ');
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to restart some extensions:\n ${errorMessages}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} else {
|
||||
const infoItem: HistoryItemInfo = {
|
||||
type: MessageType.INFO,
|
||||
text: `${extensionsToRestart.length} extension${s} restarted successfully.`,
|
||||
icon: emptyIcon,
|
||||
color: theme.text.primary,
|
||||
};
|
||||
context.ui.addItem(infoItem, Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
async function exploreAction(context: CommandContext) {
|
||||
const extensionsUrl = 'https://geminicli.com/extensions/';
|
||||
|
||||
@@ -402,14 +284,10 @@ export function completeExtensions(
|
||||
partialArg: string,
|
||||
) {
|
||||
let extensions = context.services.config?.getExtensions() ?? [];
|
||||
|
||||
if (context.invocation?.name === 'enable') {
|
||||
extensions = extensions.filter((ext) => !ext.isActive);
|
||||
}
|
||||
if (
|
||||
context.invocation?.name === 'disable' ||
|
||||
context.invocation?.name === 'restart'
|
||||
) {
|
||||
if (context.invocation?.name === 'disable') {
|
||||
extensions = extensions.filter((ext) => ext.isActive);
|
||||
}
|
||||
const extensionNames = extensions.map((ext) => ext.name);
|
||||
@@ -473,14 +351,6 @@ const exploreExtensionsCommand: SlashCommand = {
|
||||
action: exploreAction,
|
||||
};
|
||||
|
||||
const restartCommand: SlashCommand = {
|
||||
name: 'restart',
|
||||
description: 'Restart all extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: restartAction,
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
export function extensionsCommand(
|
||||
enableExtensionReloading?: boolean,
|
||||
): SlashCommand {
|
||||
@@ -495,7 +365,6 @@ export function extensionsCommand(
|
||||
listExtensionsCommand,
|
||||
updateExtensionsCommand,
|
||||
exploreExtensionsCommand,
|
||||
restartCommand,
|
||||
...conditionalCommands,
|
||||
],
|
||||
action: (context, args) =>
|
||||
|
||||
@@ -13,11 +13,11 @@ import { MessageType } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import {
|
||||
getErrorMessage,
|
||||
refreshServerHierarchicalMemory,
|
||||
SimpleExtensionLoader,
|
||||
type FileDiscoveryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadServerHierarchicalMemoryResponse } from '@google/gemini-cli-core/index.js';
|
||||
import { loadHierarchicalGeminiMemory } from '../../config/config.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
@@ -28,12 +28,19 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}),
|
||||
refreshServerHierarchicalMemory: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockRefreshServerHierarchicalMemory =
|
||||
refreshServerHierarchicalMemory as Mock;
|
||||
vi.mock('../../config/config.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('../../config/config.js')>();
|
||||
return {
|
||||
...original,
|
||||
loadHierarchicalGeminiMemory: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockLoadHierarchicalGeminiMemory = loadHierarchicalGeminiMemory as Mock;
|
||||
|
||||
describe('memoryCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
@@ -196,8 +203,11 @@ describe('memoryCommand', () => {
|
||||
},
|
||||
} as unknown as LoadedSettings,
|
||||
},
|
||||
ui: {
|
||||
setGeminiMdFileCount: vi.fn(),
|
||||
},
|
||||
});
|
||||
mockRefreshServerHierarchicalMemory.mockClear();
|
||||
mockLoadHierarchicalGeminiMemory.mockClear();
|
||||
});
|
||||
|
||||
it('should display success message when memory is refreshed with content', async () => {
|
||||
@@ -208,7 +218,7 @@ describe('memoryCommand', () => {
|
||||
fileCount: 2,
|
||||
filePaths: ['/path/one/GEMINI.md', '/path/two/GEMINI.md'],
|
||||
};
|
||||
mockRefreshServerHierarchicalMemory.mockResolvedValue(refreshResult);
|
||||
mockLoadHierarchicalGeminiMemory.mockResolvedValue(refreshResult);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
|
||||
@@ -220,7 +230,19 @@ describe('memoryCommand', () => {
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce();
|
||||
expect(mockLoadHierarchicalGeminiMemory).toHaveBeenCalledOnce();
|
||||
expect(mockSetUserMemory).toHaveBeenCalledWith(
|
||||
refreshResult.memoryContent,
|
||||
);
|
||||
expect(mockSetGeminiMdFileCount).toHaveBeenCalledWith(
|
||||
refreshResult.fileCount,
|
||||
);
|
||||
expect(mockSetGeminiMdFilePaths).toHaveBeenCalledWith(
|
||||
refreshResult.filePaths,
|
||||
);
|
||||
expect(mockContext.ui.setGeminiMdFileCount).toHaveBeenCalledWith(
|
||||
refreshResult.fileCount,
|
||||
);
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -235,11 +257,14 @@ describe('memoryCommand', () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const refreshResult = { memoryContent: '', fileCount: 0, filePaths: [] };
|
||||
mockRefreshServerHierarchicalMemory.mockResolvedValue(refreshResult);
|
||||
mockLoadHierarchicalGeminiMemory.mockResolvedValue(refreshResult);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
|
||||
expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce();
|
||||
expect(mockLoadHierarchicalGeminiMemory).toHaveBeenCalledOnce();
|
||||
expect(mockSetUserMemory).toHaveBeenCalledWith('');
|
||||
expect(mockSetGeminiMdFileCount).toHaveBeenCalledWith(0);
|
||||
expect(mockSetGeminiMdFilePaths).toHaveBeenCalledWith([]);
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -254,11 +279,11 @@ describe('memoryCommand', () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const error = new Error('Failed to read memory files.');
|
||||
mockRefreshServerHierarchicalMemory.mockRejectedValue(error);
|
||||
mockLoadHierarchicalGeminiMemory.mockRejectedValue(error);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
|
||||
expect(mockRefreshServerHierarchicalMemory).toHaveBeenCalledOnce();
|
||||
expect(mockLoadHierarchicalGeminiMemory).toHaveBeenCalledOnce();
|
||||
expect(mockSetUserMemory).not.toHaveBeenCalled();
|
||||
expect(mockSetGeminiMdFileCount).not.toHaveBeenCalled();
|
||||
expect(mockSetGeminiMdFilePaths).not.toHaveBeenCalled();
|
||||
@@ -293,7 +318,7 @@ describe('memoryCommand', () => {
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
expect(mockLoadHierarchicalGeminiMemory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
getErrorMessage,
|
||||
refreshServerHierarchicalMemory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { MessageType } from '../types.js';
|
||||
import { loadHierarchicalGeminiMemory } from '../../config/config.js';
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
|
||||
@@ -82,9 +80,26 @@ export const memoryCommand: SlashCommand = {
|
||||
|
||||
try {
|
||||
const config = await context.services.config;
|
||||
const settings = context.services.settings;
|
||||
if (config) {
|
||||
const { memoryContent, fileCount } =
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
const { memoryContent, fileCount, filePaths } =
|
||||
await loadHierarchicalGeminiMemory(
|
||||
config.getWorkingDir(),
|
||||
config.shouldLoadMemoryFromIncludeDirectories()
|
||||
? config.getWorkspaceContext().getDirectories()
|
||||
: [],
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
settings.merged,
|
||||
config.getExtensionLoader(),
|
||||
config.isTrustedFolder(),
|
||||
settings.merged.context?.importFormat || 'tree',
|
||||
config.getFileFilteringOptions(),
|
||||
);
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
config.setGeminiMdFilePaths(filePaths);
|
||||
context.ui.setGeminiMdFileCount(fileCount);
|
||||
|
||||
const successMessage =
|
||||
memoryContent.length > 0
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType, type HistoryItemToolsList } from '../types.js';
|
||||
import { READ_MANY_FILES_TOOL_NAME } from '@google/gemini-cli-core';
|
||||
|
||||
export const toolsCommand: SlashCommand = {
|
||||
name: 'tools',
|
||||
@@ -45,10 +44,7 @@ export const toolsCommand: SlashCommand = {
|
||||
type: MessageType.TOOLS_LIST,
|
||||
tools: geminiTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
displayName:
|
||||
tool.name === READ_MANY_FILES_TOOL_NAME
|
||||
? `${tool.displayName} (Deprecated)`
|
||||
: tool.displayName,
|
||||
displayName: tool.displayName,
|
||||
description: tool.description,
|
||||
})),
|
||||
showDescriptions: useShowDescriptions,
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface CommandContext {
|
||||
toggleCorgiMode: () => void;
|
||||
toggleDebugProfiler: () => void;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
setGeminiMdFileCount: (count: number) => void;
|
||||
reloadCommands: () => void;
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AlternateBufferQuittingDisplay } from './AlternateBufferQuittingDisplay.js';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
|
||||
import { Text } from 'ink';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import type { ToolMessageProps } from './messages/ToolMessage.js';
|
||||
|
||||
vi.mock('../contexts/AppContext.js', () => ({
|
||||
useAppContext: () => ({
|
||||
version: '0.10.0',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getMCPServerStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../GeminiRespondingSpinner.js', () => ({
|
||||
GeminiRespondingSpinner: () => <Text>Spinner</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./messages/ToolMessage.js', () => ({
|
||||
ToolMessage: (props: ToolMessageProps) => (
|
||||
<Text>
|
||||
ToolMessage: {props.name} - {props.status}
|
||||
</Text>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockHistory: HistoryItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'tool_group',
|
||||
tools: [
|
||||
{
|
||||
callId: 'call1',
|
||||
name: 'tool1',
|
||||
description: 'Description for tool 1',
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'tool_group',
|
||||
tools: [
|
||||
{
|
||||
callId: 'call2',
|
||||
name: 'tool2',
|
||||
description: 'Description for tool 2',
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockPendingHistoryItems: HistoryItemWithoutId[] = [
|
||||
{
|
||||
type: 'tool_group',
|
||||
tools: [
|
||||
{
|
||||
callId: 'call3',
|
||||
name: 'tool3',
|
||||
description: 'Description for tool 3',
|
||||
status: ToolCallStatus.Pending,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockConfig = {
|
||||
getScreenReader: () => false,
|
||||
getEnableInteractiveShell: () => false,
|
||||
getModel: () => 'gemini-pro',
|
||||
getTargetDir: () => '/tmp',
|
||||
getDebugMode: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
} as unknown as Config;
|
||||
|
||||
describe('AlternateBufferQuittingDisplay', () => {
|
||||
it('renders with active and pending tool messages', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
uiState: {
|
||||
history: mockHistory,
|
||||
pendingHistoryItems: mockPendingHistoryItems,
|
||||
terminalWidth: 80,
|
||||
mainAreaWidth: 80,
|
||||
slashCommands: [],
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
renderMarkdown: false,
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { QuittingDisplay } from './QuittingDisplay.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
|
||||
export const AlternateBufferQuittingDisplay = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
|
||||
// We render the entire chat history and header here to ensure that the
|
||||
// conversation history is visible to the user after the app quits and the
|
||||
// user exits alternate buffer mode.
|
||||
// Our version of Ink is clever and will render a final frame outside of
|
||||
// the alternate buffer on app exit.
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
width={uiState.terminalWidth}
|
||||
>
|
||||
<AppHeader key="app-header" version={version} />
|
||||
{uiState.history.map((h) => (
|
||||
<HistoryItemDisplay
|
||||
terminalWidth={uiState.mainAreaWidth}
|
||||
availableTerminalHeight={undefined}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
/>
|
||||
))}
|
||||
{uiState.pendingHistoryItems.map((item, i) => (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={undefined}
|
||||
terminalWidth={uiState.mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={false}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
/>
|
||||
))}
|
||||
<QuittingDisplay />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -36,42 +36,3 @@ export const tinyAsciiLogo = `
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
`;
|
||||
|
||||
export const shortAsciiLogoIde = `
|
||||
░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░
|
||||
░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░
|
||||
░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░
|
||||
█████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░
|
||||
███░░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░
|
||||
███░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░
|
||||
███░░░░████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ██████ ███
|
||||
███ ███ ███ ███ ███ ███ ███ █████ ███
|
||||
█████████ ██████████ ███ ███ █████ ███ █████ █████
|
||||
`;
|
||||
|
||||
export const longAsciiLogoIde = `
|
||||
░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░
|
||||
░░░ ░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░
|
||||
░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░
|
||||
███ ░░░ █████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░
|
||||
███ ░░░ ███░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░
|
||||
███ ███░░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░
|
||||
░░░ ███ ███ ░░░█████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ███ ██████ ███
|
||||
███ ███ ███ ███ ███ ███ ███ ███ █████ ███
|
||||
███ █████████ ██████████ ███ ███ █████ ███ █████ █████
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogoIde = `
|
||||
░░░ ░░░░░░░░░
|
||||
░░░ ░░░ ░░░
|
||||
░░░ ░░░
|
||||
███ ░░░ █████████░░░
|
||||
███ ░░░ ███░░ ███░░
|
||||
███ ███░░ ░░░
|
||||
░░░ ███ ███░░░░████░
|
||||
███ ███ ███
|
||||
███ ███ ███
|
||||
███ █████████
|
||||
`;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||
@@ -24,7 +23,6 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
@@ -40,21 +38,11 @@ export const Composer = () => {
|
||||
const terminalWidth = process.stdout.columns;
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { contextFileNames, showAutoAcceptIndicator } = uiState;
|
||||
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
||||
const hideContextSummary =
|
||||
suggestionsVisible && suggestionsPosition === 'above';
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={uiState.mainAreaWidth}
|
||||
flexGrow={0}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Box flexDirection="column" width={uiState.mainAreaWidth} flexShrink={0}>
|
||||
{!uiState.embeddedShellFocused && (
|
||||
<LoadingIndicator
|
||||
thought={
|
||||
@@ -108,8 +96,7 @@ export const Composer = () => {
|
||||
) : uiState.queueErrorMessage ? (
|
||||
<Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>
|
||||
) : (
|
||||
!settings.merged.ui?.hideContextSummary &&
|
||||
!hideContextSummary && (
|
||||
!settings.merged.ui?.hideContextSummary && (
|
||||
<ContextSummaryDisplay
|
||||
ideContext={uiState.ideContextState}
|
||||
geminiMdFileCount={uiState.geminiMdFileCount}
|
||||
@@ -141,7 +128,7 @@ export const Composer = () => {
|
||||
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
|
||||
}
|
||||
width={uiState.mainAreaWidth}
|
||||
hasFocus={uiState.showErrorDetails}
|
||||
hasFocus={true}
|
||||
/>
|
||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||
</Box>
|
||||
@@ -174,8 +161,6 @@ export const Composer = () => {
|
||||
}
|
||||
setQueueErrorMessage={uiActions.setQueueErrorMessage}
|
||||
streamingState={uiState.streamingState}
|
||||
suggestionsPosition={suggestionsPosition}
|
||||
onSuggestionsVisibilityChange={setSuggestionsVisible}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const CopyModeWarning: React.FC = () => {
|
||||
const { copyModeEnabled } = useUIState();
|
||||
|
||||
if (!copyModeEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.warning}>
|
||||
In Copy Mode. Press any key to exit.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -26,7 +26,7 @@ export const DetailedMessagesDisplay: React.FC<
|
||||
> = ({ messages, maxHeight, width, hasFocus }) => {
|
||||
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
|
||||
|
||||
const borderAndPadding = 3;
|
||||
const borderAndPadding = 4;
|
||||
|
||||
const estimatedItemHeight = useCallback(
|
||||
(index: number) => {
|
||||
|
||||
@@ -5,152 +5,50 @@
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Header } from './Header.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import { longAsciiLogo, longAsciiLogoIde } from './AsciiArt.js';
|
||||
import * as semanticColors from '../semantic-colors.js';
|
||||
import * as terminalSetup from '../utils/terminalSetup.js';
|
||||
import { Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { longAsciiLogo } from './AsciiArt.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
getTerminalProgram: vi.fn(),
|
||||
}));
|
||||
vi.mock('ink-gradient', () => {
|
||||
const MockGradient = ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
);
|
||||
return {
|
||||
default: vi.fn(MockGradient),
|
||||
};
|
||||
});
|
||||
vi.mock('../semantic-colors.js');
|
||||
vi.mock('ink', async () => {
|
||||
const originalInk = await vi.importActual<typeof import('ink')>('ink');
|
||||
return {
|
||||
...originalInk,
|
||||
Text: vi.fn(originalInk.Text),
|
||||
};
|
||||
});
|
||||
|
||||
describe('<Header />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue(null);
|
||||
});
|
||||
beforeEach(() => {});
|
||||
|
||||
it('renders the long logo on a wide terminal', () => {
|
||||
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
|
||||
columns: 120,
|
||||
rows: 20,
|
||||
});
|
||||
render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(Text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
children: longAsciiLogo,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the IDE logo when running in an IDE', () => {
|
||||
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
|
||||
columns: 120,
|
||||
rows: 20,
|
||||
});
|
||||
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode');
|
||||
|
||||
render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(Text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
children: longAsciiLogoIde,
|
||||
}),
|
||||
undefined,
|
||||
const { lastFrame, unmount } = render(
|
||||
<Header version="1.0.0" nightly={false} />,
|
||||
);
|
||||
expect(lastFrame()).toContain(longAsciiLogo);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders custom ASCII art when provided', () => {
|
||||
const customArt = 'CUSTOM ART';
|
||||
render(
|
||||
const { lastFrame, unmount } = render(
|
||||
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
|
||||
);
|
||||
expect(Text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
children: customArt,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders custom ASCII art as is when running in an IDE', () => {
|
||||
const customArt = 'CUSTOM ART';
|
||||
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode');
|
||||
render(
|
||||
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
|
||||
);
|
||||
expect(Text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
children: customArt,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(lastFrame()).toContain(customArt);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('displays the version number when nightly is true', () => {
|
||||
render(<Header version="1.0.0" nightly={true} />);
|
||||
const textCalls = (Text as Mock).mock.calls;
|
||||
expect(textCalls[1][0].children.join('')).toBe('v1.0.0');
|
||||
const { lastFrame, unmount } = render(
|
||||
<Header version="1.0.0" nightly={true} />,
|
||||
);
|
||||
expect(lastFrame()).toContain('v1.0.0');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not display the version number when nightly is false', () => {
|
||||
render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(Text).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
children: 'v1.0.0',
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders with no gradient when theme.ui.gradient is undefined', async () => {
|
||||
vi.spyOn(semanticColors, 'theme', 'get').mockReturnValue({
|
||||
ui: { gradient: undefined },
|
||||
} as typeof semanticColors.theme);
|
||||
const Gradient = await import('ink-gradient');
|
||||
render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(Gradient.default).not.toHaveBeenCalled();
|
||||
const textCalls = (Text as Mock).mock.calls;
|
||||
expect(textCalls[0][0]).not.toHaveProperty('color');
|
||||
});
|
||||
|
||||
it('renders with a single color when theme.ui.gradient has one color', async () => {
|
||||
const singleColor = '#FF0000';
|
||||
vi.spyOn(semanticColors, 'theme', 'get').mockReturnValue({
|
||||
ui: { gradient: [singleColor] },
|
||||
} as typeof semanticColors.theme);
|
||||
const Gradient = await import('ink-gradient');
|
||||
render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(Gradient.default).not.toHaveBeenCalled();
|
||||
const textCalls = (Text as Mock).mock.calls;
|
||||
console.log(JSON.stringify(textCalls, null, 2));
|
||||
expect(textCalls.length).toBe(1);
|
||||
expect(textCalls[0][0]).toHaveProperty('color', singleColor);
|
||||
});
|
||||
|
||||
it('renders with a gradient when theme.ui.gradient has two or more colors', async () => {
|
||||
const gradientColors = ['#FF0000', '#00FF00'];
|
||||
vi.spyOn(semanticColors, 'theme', 'get').mockReturnValue({
|
||||
ui: { gradient: gradientColors },
|
||||
} as typeof semanticColors.theme);
|
||||
const Gradient = await import('ink-gradient');
|
||||
render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(Gradient.default).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
colors: gradientColors,
|
||||
}),
|
||||
undefined,
|
||||
const { lastFrame, unmount } = render(
|
||||
<Header version="1.0.0" nightly={false} />,
|
||||
);
|
||||
expect(lastFrame()).not.toContain('v1.0.0');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,17 +8,9 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
shortAsciiLogo,
|
||||
longAsciiLogo,
|
||||
tinyAsciiLogo,
|
||||
shortAsciiLogoIde,
|
||||
longAsciiLogoIde,
|
||||
tinyAsciiLogoIde,
|
||||
} from './AsciiArt.js';
|
||||
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { getTerminalProgram } from '../utils/terminalSetup.js';
|
||||
|
||||
interface HeaderProps {
|
||||
customAsciiArt?: string; // For user-defined ASCII art
|
||||
@@ -26,33 +18,12 @@ interface HeaderProps {
|
||||
nightly: boolean;
|
||||
}
|
||||
|
||||
const ThemedGradient: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const gradient = theme.ui.gradient;
|
||||
|
||||
if (gradient && gradient.length >= 2) {
|
||||
return (
|
||||
<Gradient colors={gradient}>
|
||||
<Text>{children}</Text>
|
||||
</Gradient>
|
||||
);
|
||||
}
|
||||
|
||||
if (gradient && gradient.length === 1) {
|
||||
return <Text color={gradient[0]}>{children}</Text>;
|
||||
}
|
||||
|
||||
return <Text>{children}</Text>;
|
||||
};
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
customAsciiArt,
|
||||
version,
|
||||
nightly,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isIde = getTerminalProgram();
|
||||
let displayTitle;
|
||||
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
||||
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
|
||||
@@ -60,11 +31,11 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
if (customAsciiArt) {
|
||||
displayTitle = customAsciiArt;
|
||||
} else if (terminalWidth >= widthOfLongLogo) {
|
||||
displayTitle = isIde ? longAsciiLogoIde : longAsciiLogo;
|
||||
displayTitle = longAsciiLogo;
|
||||
} else if (terminalWidth >= widthOfShortLogo) {
|
||||
displayTitle = isIde ? shortAsciiLogoIde : shortAsciiLogo;
|
||||
displayTitle = shortAsciiLogo;
|
||||
} else {
|
||||
displayTitle = isIde ? tinyAsciiLogoIde : tinyAsciiLogo;
|
||||
displayTitle = tinyAsciiLogo;
|
||||
}
|
||||
|
||||
const artWidth = getAsciiArtWidth(displayTitle);
|
||||
@@ -76,10 +47,22 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
<ThemedGradient>{displayTitle}</ThemedGradient>
|
||||
{theme.ui.gradient ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>{displayTitle}</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text>{displayTitle}</Text>
|
||||
)}
|
||||
{nightly && (
|
||||
<Box width="100%" flexDirection="row" justifyContent="flex-end">
|
||||
<ThemedGradient>v{version}</ThemedGradient>
|
||||
{theme.ui.gradient ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>v{version}</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text>v{version}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -55,21 +55,17 @@ describe('<HistoryItemDisplay />', () => {
|
||||
expect(lastFrame()).toContain('/theme');
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'renders InfoMessage for "info" type with multi-line text (alternateBuffer=%s)',
|
||||
(useAlternateBuffer) => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
type: MessageType.INFO,
|
||||
text: '⚡ Line 1\n⚡ Line 2\n⚡ Line 3',
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay {...baseItem} item={item} />,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
it('renders InfoMessage for "info" type with multi-line text', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
type: MessageType.INFO,
|
||||
text: '⚡ Line 1\n⚡ Line 2\n⚡ Line 3',
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay {...baseItem} item={item} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders StatsDisplay for "stats" type', () => {
|
||||
const item: HistoryItem = {
|
||||
@@ -207,92 +203,83 @@ describe('<HistoryItemDisplay />', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe.each([true, false])(
|
||||
'gemini items (alternateBuffer=%s)',
|
||||
(useAlternateBuffer) => {
|
||||
const longCode =
|
||||
'# Example code block:\n' +
|
||||
'```python\n' +
|
||||
Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`).join('\n') +
|
||||
'\n```';
|
||||
const longCode =
|
||||
'# Example code block:\n' +
|
||||
'```python\n' +
|
||||
Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`).join('\n') +
|
||||
'\n```';
|
||||
|
||||
it('should render a truncated gemini item', () => {
|
||||
const item: HistoryItem = {
|
||||
id: 1,
|
||||
type: 'gemini',
|
||||
text: longCode,
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay
|
||||
item={item}
|
||||
isPending={false}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={10}
|
||||
/>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
it('should render a truncated gemini item', () => {
|
||||
const item: HistoryItem = {
|
||||
id: 1,
|
||||
type: 'gemini',
|
||||
text: longCode,
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay
|
||||
item={item}
|
||||
isPending={false}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={10}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render a full gemini item when using availableTerminalHeightGemini', () => {
|
||||
const item: HistoryItem = {
|
||||
id: 1,
|
||||
type: 'gemini',
|
||||
text: longCode,
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay
|
||||
item={item}
|
||||
isPending={false}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={10}
|
||||
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
|
||||
/>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
it('should render a full gemini item when using availableTerminalHeightGemini', () => {
|
||||
const item: HistoryItem = {
|
||||
id: 1,
|
||||
type: 'gemini',
|
||||
text: longCode,
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay
|
||||
item={item}
|
||||
isPending={false}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={10}
|
||||
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render a truncated gemini_content item', () => {
|
||||
const item: HistoryItem = {
|
||||
id: 1,
|
||||
type: 'gemini_content',
|
||||
text: longCode,
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay
|
||||
item={item}
|
||||
isPending={false}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={10}
|
||||
/>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
it('should render a truncated gemini_content item', () => {
|
||||
const item: HistoryItem = {
|
||||
id: 1,
|
||||
type: 'gemini_content',
|
||||
text: longCode,
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay
|
||||
item={item}
|
||||
isPending={false}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={10}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render a full gemini_content item when using availableTerminalHeightGemini', () => {
|
||||
const item: HistoryItem = {
|
||||
id: 1,
|
||||
type: 'gemini_content',
|
||||
text: longCode,
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay
|
||||
item={item}
|
||||
isPending={false}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={10}
|
||||
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
|
||||
/>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
it('should render a full gemini_content item when using availableTerminalHeightGemini', () => {
|
||||
const item: HistoryItem = {
|
||||
id: 1,
|
||||
type: 'gemini_content',
|
||||
text: longCode,
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay
|
||||
item={item}
|
||||
isPending={false}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={10}
|
||||
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,10 +57,10 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={itemForDisplay.id} width={terminalWidth}>
|
||||
<Box flexDirection="column" key={itemForDisplay.id}>
|
||||
{/* Render standard message types */}
|
||||
{itemForDisplay.type === 'user' && (
|
||||
<UserMessage text={itemForDisplay.text} width={terminalWidth} />
|
||||
<UserMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'user_shell' && (
|
||||
<UserShellMessage text={itemForDisplay.text} />
|
||||
@@ -86,11 +86,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'info' && (
|
||||
<InfoMessage
|
||||
text={itemForDisplay.text}
|
||||
icon={itemForDisplay.icon}
|
||||
color={itemForDisplay.color}
|
||||
/>
|
||||
<InfoMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'warning' && (
|
||||
<WarningMessage text={itemForDisplay.text} />
|
||||
|
||||
@@ -1297,6 +1297,7 @@ describe('InputPrompt', () => {
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: true },
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -1351,9 +1352,6 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
describe('enhanced input UX - double ESC clear functionality', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('should clear buffer on second ESC press', async () => {
|
||||
const onEscapePromptChange = vi.fn();
|
||||
props.onEscapePromptChange = onEscapePromptChange;
|
||||
@@ -1361,40 +1359,22 @@ describe('InputPrompt', () => {
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: false },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1B');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(onEscapePromptChange).toHaveBeenCalledWith(false);
|
||||
await waitFor(() => {
|
||||
expect(onEscapePromptChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1B');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should clear buffer on double ESC', async () => {
|
||||
const onEscapePromptChange = vi.fn();
|
||||
props.onEscapePromptChange = onEscapePromptChange;
|
||||
props.buffer.setText('text to clear');
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1B\x1B');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
@@ -1406,6 +1386,7 @@ describe('InputPrompt', () => {
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: false },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
@@ -1429,13 +1410,14 @@ describe('InputPrompt', () => {
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: false },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1B');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(props.setShellModeActive).toHaveBeenCalledWith(false);
|
||||
await waitFor(() =>
|
||||
expect(props.setShellModeActive).toHaveBeenCalledWith(false),
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
@@ -1449,23 +1431,26 @@ describe('InputPrompt', () => {
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: false },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1B');
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not call onEscapePromptChange when not provided', async () => {
|
||||
vi.useFakeTimers();
|
||||
props.onEscapePromptChange = undefined;
|
||||
props.buffer.setText('some text');
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: false },
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -1478,12 +1463,14 @@ describe('InputPrompt', () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not interfere with existing keyboard shortcuts', async () => {
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled: false },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
@@ -1548,13 +1535,18 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'standard', escapeSequence: '\x1B' },
|
||||
{ name: 'kitty', escapeSequence: '\u001b[27u' },
|
||||
{ name: 'standard', kittyProtocolEnabled: false, escapeSequence: '\x1B' },
|
||||
{
|
||||
name: 'kitty',
|
||||
kittyProtocolEnabled: true,
|
||||
escapeSequence: '\u001b[27u',
|
||||
},
|
||||
])(
|
||||
'resets reverse search state on Escape ($name)',
|
||||
async ({ escapeSequence }) => {
|
||||
async ({ kittyProtocolEnabled, escapeSequence }) => {
|
||||
const { stdin, stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -72,13 +72,11 @@ export interface InputPromptProps {
|
||||
setShellModeActive: (value: boolean) => void;
|
||||
approvalMode: ApprovalMode;
|
||||
onEscapePromptChange?: (showPrompt: boolean) => void;
|
||||
onSuggestionsVisibilityChange?: (visible: boolean) => void;
|
||||
vimHandleInput?: (key: Key) => boolean;
|
||||
isEmbeddedShellFocused?: boolean;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
streamingState: StreamingState;
|
||||
popAllMessages?: (onPop: (messages: string | undefined) => void) => void;
|
||||
suggestionsPosition?: 'above' | 'below';
|
||||
}
|
||||
|
||||
// The input content, input container, and input suggestions list may have different widths
|
||||
@@ -113,13 +111,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setShellModeActive,
|
||||
approvalMode,
|
||||
onEscapePromptChange,
|
||||
onSuggestionsVisibilityChange,
|
||||
vimHandleInput,
|
||||
isEmbeddedShellFocused,
|
||||
setQueueErrorMessage,
|
||||
streamingState,
|
||||
popAllMessages,
|
||||
suggestionsPosition = 'below',
|
||||
}) => {
|
||||
const kittyProtocol = useKittyKeyboardProtocol();
|
||||
const isShellFocused = useShellFocusState();
|
||||
@@ -947,12 +943,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const activeCompletion = getActiveCompletion();
|
||||
const shouldShowSuggestions = activeCompletion.showSuggestions;
|
||||
|
||||
useEffect(() => {
|
||||
if (onSuggestionsVisibilityChange) {
|
||||
onSuggestionsVisibilityChange(shouldShowSuggestions);
|
||||
}
|
||||
}, [shouldShowSuggestions, onSuggestionsVisibilityChange]);
|
||||
|
||||
const showAutoAcceptStyling =
|
||||
!shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT;
|
||||
const showYoloStyling =
|
||||
@@ -971,30 +961,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
statusText = 'Accepting edits';
|
||||
}
|
||||
|
||||
const suggestionsNode = shouldShowSuggestions ? (
|
||||
<Box paddingRight={2}>
|
||||
<SuggestionsDisplay
|
||||
suggestions={activeCompletion.suggestions}
|
||||
activeIndex={activeCompletion.activeSuggestionIndex}
|
||||
isLoading={activeCompletion.isLoadingSuggestions}
|
||||
width={suggestionsWidth}
|
||||
scrollOffset={activeCompletion.visibleStartIndex}
|
||||
userInput={buffer.text}
|
||||
mode={
|
||||
buffer.text.startsWith('/') &&
|
||||
!reverseSearchActive &&
|
||||
!commandSearchActive
|
||||
? 'slash'
|
||||
: 'reverse'
|
||||
}
|
||||
expandedIndex={expandedSuggestionIndex}
|
||||
/>
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{suggestionsPosition === 'above' && suggestionsNode}
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
@@ -1173,7 +1141,26 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{suggestionsPosition === 'below' && suggestionsNode}
|
||||
{shouldShowSuggestions && (
|
||||
<Box paddingRight={2}>
|
||||
<SuggestionsDisplay
|
||||
suggestions={activeCompletion.suggestions}
|
||||
activeIndex={activeCompletion.activeSuggestionIndex}
|
||||
isLoading={activeCompletion.isLoadingSuggestions}
|
||||
width={suggestionsWidth}
|
||||
scrollOffset={activeCompletion.visibleStartIndex}
|
||||
userInput={buffer.text}
|
||||
mode={
|
||||
buffer.text.startsWith('/') &&
|
||||
!reverseSearchActive &&
|
||||
!commandSearchActive
|
||||
? 'slash'
|
||||
: 'reverse'
|
||||
}
|
||||
expandedIndex={expandedSuggestionIndex}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,23 +11,19 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js';
|
||||
import { ScrollableList } from './shared/ScrollableList.js';
|
||||
import { useMemo, memo, useCallback } from 'react';
|
||||
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
const MemoizedAppHeader = memo(AppHeader);
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
// Limit Gemini messages to a very high number of lines to mitigate performance
|
||||
// issues in the worst case if we somehow get an enormous response from Gemini.
|
||||
// This threshold is arbitrary but should be high enough to never impact normal
|
||||
// usage.
|
||||
const MAX_GEMINI_MESSAGE_LINES = 65536;
|
||||
|
||||
export const MainContent = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const settings = useSettings();
|
||||
const useAlternateBuffer = settings.merged.ui?.useAlternateBuffer ?? false;
|
||||
|
||||
const {
|
||||
pendingHistoryItems,
|
||||
@@ -36,116 +32,65 @@ export const MainContent = () => {
|
||||
availableTerminalHeight,
|
||||
} = uiState;
|
||||
|
||||
const historyItems = uiState.history.map((h) => (
|
||||
<HistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
/>
|
||||
));
|
||||
|
||||
const pendingItems = useMemo(
|
||||
() => (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
{pendingHistoryItems.map((item, i) => (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
/>
|
||||
))}
|
||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
),
|
||||
[
|
||||
pendingHistoryItems,
|
||||
uiState.constrainHeight,
|
||||
availableTerminalHeight,
|
||||
mainAreaWidth,
|
||||
uiState.isEditorDialogOpen,
|
||||
uiState.activePtyId,
|
||||
uiState.embeddedShellFocused,
|
||||
],
|
||||
);
|
||||
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
{ type: 'header' as const },
|
||||
...uiState.history.map((item) => ({ type: 'history' as const, item })),
|
||||
{ type: 'pending' as const },
|
||||
],
|
||||
[uiState.history],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: (typeof virtualizedData)[number] }) => {
|
||||
if (item.type === 'header') {
|
||||
return <MemoizedAppHeader key="app-header" version={version} />;
|
||||
} else if (item.type === 'history') {
|
||||
return (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={item.item.id}
|
||||
item={item.item}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return pendingItems;
|
||||
}
|
||||
},
|
||||
[
|
||||
version,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
uiState.slashCommands,
|
||||
pendingItems,
|
||||
],
|
||||
);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<ScrollableList
|
||||
hasFocus={!uiState.isEditorDialogOpen}
|
||||
data={virtualizedData}
|
||||
renderItem={renderItem}
|
||||
estimatedItemHeight={() => 100}
|
||||
keyExtractor={(item, _index) => {
|
||||
if (item.type === 'header') return 'header';
|
||||
if (item.type === 'history') return item.item.id.toString();
|
||||
return 'pending';
|
||||
}}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
|
||||
const historyItems = [
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...uiState.history.map((h) => (
|
||||
<HistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
/>
|
||||
)),
|
||||
];
|
||||
|
||||
const pendingItems = (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column" width={mainAreaWidth}>
|
||||
{pendingHistoryItems.map((item, i) => (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
/>
|
||||
))}
|
||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
);
|
||||
|
||||
if (useAlternateBuffer) {
|
||||
// Placeholder alternate buffer implementation using a scrollable box that
|
||||
// is always scrolled to the bottom. In follow up PRs we will switch this
|
||||
// to a proper alternate buffer implementation.
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
overflowY="scroll"
|
||||
scrollTop={Number.MAX_SAFE_INTEGER}
|
||||
maxHeight={availableTerminalHeight}
|
||||
>
|
||||
<Box flexDirection="column" flexShrink={0}>
|
||||
{historyItems}
|
||||
{pendingItems}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Static
|
||||
key={uiState.historyRemountKey}
|
||||
items={[
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...historyItems,
|
||||
]}
|
||||
>
|
||||
<Static key={uiState.historyRemountKey} items={historyItems}>
|
||||
{(item) => item}
|
||||
</Static>
|
||||
{pendingItems}
|
||||
|
||||
@@ -52,7 +52,6 @@ const renderComponent = (
|
||||
getUseSmartEdit: vi.fn(() => false),
|
||||
getUseModelRouter: vi.fn(() => false),
|
||||
getProxy: vi.fn(() => undefined),
|
||||
isInteractive: vi.fn(() => false),
|
||||
|
||||
// --- Spread test-specific overrides ---
|
||||
...contextValue,
|
||||
@@ -189,7 +188,6 @@ describe('<ModelDialog />', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: false,
|
||||
sequence: '',
|
||||
});
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
@@ -200,7 +198,6 @@ describe('<ModelDialog />', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence: '',
|
||||
});
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -234,7 +234,7 @@ const renderDialog = (
|
||||
},
|
||||
) =>
|
||||
render(
|
||||
<KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
onSelect={onSelect}
|
||||
@@ -679,7 +679,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<VimModeProvider settings={settings}>
|
||||
<KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>
|
||||
</VimModeProvider>,
|
||||
@@ -1062,7 +1062,7 @@ describe('SettingsDialog', () => {
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount, rerender } = render(
|
||||
<KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
@@ -1087,7 +1087,7 @@ describe('SettingsDialog', () => {
|
||||
{},
|
||||
);
|
||||
rerender(
|
||||
<KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Text } from 'ink';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StickyHeader } from './StickyHeader.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
|
||||
describe('StickyHeader', () => {
|
||||
it('renders children', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<StickyHeader width={80}>
|
||||
<Text>Hello Sticky</Text>
|
||||
</StickyHeader>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Hello Sticky');
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export interface StickyHeaderProps {
|
||||
children: React.ReactNode;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const StickyHeader: React.FC<StickyHeaderProps> = ({
|
||||
children,
|
||||
width,
|
||||
}) => (
|
||||
<Box
|
||||
sticky
|
||||
minHeight={1}
|
||||
flexShrink={0}
|
||||
width={width}
|
||||
stickyChildren={
|
||||
<Box
|
||||
borderStyle="single"
|
||||
width={width}
|
||||
opaque
|
||||
borderColor={theme.ui.dark}
|
||||
borderTop={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
paddingX={1}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box paddingX={1} width={width}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -78,7 +78,7 @@ describe('ThemeDialog Snapshots', () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame } = render(
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ThemeDialog {...baseProps} settings={settings} />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
@@ -91,7 +91,7 @@ describe('ThemeDialog Snapshots', () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin } = render(
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ThemeDialog {...baseProps} settings={settings} />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
@@ -113,7 +113,7 @@ describe('ThemeDialog Snapshots', () => {
|
||||
const settings = createMockSettings();
|
||||
const { stdin } = render(
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ThemeDialog
|
||||
{...baseProps}
|
||||
onCancel={mockOnCancel}
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||
|
||||
interface ThemeDialogProps {
|
||||
@@ -45,7 +44,6 @@ export function ThemeDialog({
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}: ThemeDialogProps): React.JSX.Element {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
@@ -245,19 +243,17 @@ export function ThemeDialog({
|
||||
paddingRight={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{colorizeCode({
|
||||
code: `# function
|
||||
{colorizeCode(
|
||||
`# function
|
||||
def fibonacci(n):
|
||||
a, b = 0, 1
|
||||
for _ in range(n):
|
||||
a, b = b, a + b
|
||||
return a`,
|
||||
language: 'python',
|
||||
availableHeight:
|
||||
isAlternateBuffer === false ? codeBlockHeight : undefined,
|
||||
maxWidth: colorizeCodeWidth,
|
||||
settings,
|
||||
})}
|
||||
'python',
|
||||
codeBlockHeight,
|
||||
colorizeCodeWidth,
|
||||
)}
|
||||
<Box marginTop={1} />
|
||||
<DiffRenderer
|
||||
diffContent={`--- a/util.py
|
||||
@@ -266,9 +262,7 @@ def fibonacci(n):
|
||||
- print("Hello, " + name)
|
||||
+ print(f"Hello, {name}!")
|
||||
`}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer === false ? diffHeight : undefined
|
||||
}
|
||||
availableTerminalHeight={diffHeight}
|
||||
terminalWidth={colorizeCodeWidth}
|
||||
theme={previewTheme}
|
||||
/>
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ToolMessage: tool1 - Success │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ToolMessage: tool2 - Success │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ToolMessage: tool3 - Pending │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -1,11 +1,11 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro (100%)"`;
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro (100%)"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro (100% context left)"`;
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...ectories/to/make/it/long no sandbox (see /docs) gemini-pro (100% context left)"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...directories/to/make/it/long no sandbox (see /docs)"`;
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...ectories/to/make/it/long no sandbox (see /docs)"`;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
|
||||
exports[`<HistoryItemDisplay /> > renders InfoMessage for "info" type with multi-line text 1`] = `
|
||||
"
|
||||
ℹ ⚡ Line 1
|
||||
⚡ Line 2
|
||||
⚡ Line 3"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
|
||||
"✦ Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
@@ -54,7 +61,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
|
||||
exports[`<HistoryItemDisplay /> > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
|
||||
" Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
@@ -108,7 +115,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = `
|
||||
exports[`<HistoryItemDisplay /> > should render a truncated gemini item 1`] = `
|
||||
"✦ Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
@@ -122,7 +129,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = `
|
||||
exports[`<HistoryItemDisplay /> > should render a truncated gemini_content item 1`] = `
|
||||
" Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
@@ -135,233 +142,3 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
|
||||
"✦ Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
|
||||
" Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a truncated gemini item 1`] = `
|
||||
"✦ Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a truncated gemini_content item 1`] = `
|
||||
" Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > renders InfoMessage for "info" type with multi-line text (alternateBuffer=false) 1`] = `
|
||||
"
|
||||
ℹ ⚡ Line 1
|
||||
⚡ Line 2
|
||||
⚡ Line 3"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > renders InfoMessage for "info" type with multi-line text (alternateBuffer=true) 1`] = `
|
||||
"
|
||||
ℹ ⚡ Line 1
|
||||
⚡ Line 2
|
||||
⚡ Line 3"
|
||||
`;
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) Type your message or @path/to/file │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
|
||||
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
|
||||
..."
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-expanded-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) Type your message or @path/to/file │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
|
||||
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
|
||||
llllllllllllllllllllllllllllllllllllllllllllllllll"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ! Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ! Type your message or @path/to/file │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ * Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ * Type your message or @path/to/file │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import * as CodeColorizer from '../../utils/CodeColorizer.js';
|
||||
import { vi } from 'vitest';
|
||||
@@ -20,11 +20,8 @@ describe('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
|
||||
const sanitizeOutput = (output: string | undefined, terminalWidth: number) =>
|
||||
output?.replace(/GAP_INDICATOR/g, '═'.repeat(terminalWidth));
|
||||
|
||||
describe.each([true, false])(
|
||||
'with useAlternateBuffer = %s',
|
||||
(useAlternateBuffer) => {
|
||||
it('should call colorizeCode with correct language for new file with known extension', () => {
|
||||
const newFileDiffContent = `
|
||||
it('should call colorizeCode with correct language for new file with known extension', () => {
|
||||
const newFileDiffContent = `
|
||||
diff --git a/test.py b/test.py
|
||||
new file mode 100644
|
||||
index 0000000..e69de29
|
||||
@@ -33,28 +30,26 @@ index 0000000..e69de29
|
||||
@@ -0,0 +1 @@
|
||||
+print("hello world")
|
||||
`;
|
||||
renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.py"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith({
|
||||
code: 'print("hello world")',
|
||||
language: 'python',
|
||||
availableHeight: undefined,
|
||||
maxWidth: 80,
|
||||
theme: undefined,
|
||||
settings: expect.anything(),
|
||||
});
|
||||
});
|
||||
render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.py"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||
'print("hello world")',
|
||||
'python',
|
||||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call colorizeCode with null language for new file with unknown extension', () => {
|
||||
const newFileDiffContent = `
|
||||
it('should call colorizeCode with null language for new file with unknown extension', () => {
|
||||
const newFileDiffContent = `
|
||||
diff --git a/test.unknown b/test.unknown
|
||||
new file mode 100644
|
||||
index 0000000..e69de29
|
||||
@@ -63,28 +58,26 @@ index 0000000..e69de29
|
||||
@@ -0,0 +1 @@
|
||||
+some content
|
||||
`;
|
||||
renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.unknown"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith({
|
||||
code: 'some content',
|
||||
language: null,
|
||||
availableHeight: undefined,
|
||||
maxWidth: 80,
|
||||
theme: undefined,
|
||||
settings: expect.anything(),
|
||||
});
|
||||
});
|
||||
render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.unknown"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||
'some content',
|
||||
null,
|
||||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call colorizeCode with null language for new file if no filename is provided', () => {
|
||||
const newFileDiffContent = `
|
||||
it('should call colorizeCode with null language for new file if no filename is provided', () => {
|
||||
const newFileDiffContent = `
|
||||
diff --git a/test.txt b/test.txt
|
||||
new file mode 100644
|
||||
index 0000000..e69de29
|
||||
@@ -93,25 +86,22 @@ index 0000000..e69de29
|
||||
@@ -0,0 +1 @@
|
||||
+some text content
|
||||
`;
|
||||
renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith({
|
||||
code: 'some text content',
|
||||
language: null,
|
||||
availableHeight: undefined,
|
||||
maxWidth: 80,
|
||||
theme: undefined,
|
||||
settings: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', () => {
|
||||
const existingFileDiffContent = `
|
||||
render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||
'some text content',
|
||||
null,
|
||||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', () => {
|
||||
const existingFileDiffContent = `
|
||||
diff --git a/test.txt b/test.txt
|
||||
index 0000001..0000002 100644
|
||||
--- a/test.txt
|
||||
@@ -120,64 +110,61 @@ index 0000001..0000002 100644
|
||||
-old line
|
||||
+new line
|
||||
`;
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={existingFileDiffContent}
|
||||
filename="test.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
// colorizeCode is used internally by the line-by-line rendering, not for the whole block
|
||||
expect(mockColorizeCode).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('old line'),
|
||||
}),
|
||||
);
|
||||
expect(mockColorizeCode).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('new line'),
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={existingFileDiffContent}
|
||||
filename="test.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
// colorizeCode is used internally by the line-by-line rendering, not for the whole block
|
||||
expect(mockColorizeCode).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('old line'),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockColorizeCode).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('new line'),
|
||||
expect.anything(),
|
||||
);
|
||||
const output = lastFrame();
|
||||
const lines = output!.split('\n');
|
||||
expect(lines[0]).toBe('1 - old line');
|
||||
expect(lines[1]).toBe('1 + new line');
|
||||
});
|
||||
|
||||
it('should handle diff with only header and no changes', () => {
|
||||
const noChangeDiff = `diff --git a/file.txt b/file.txt
|
||||
it('should handle diff with only header and no changes', () => {
|
||||
const noChangeDiff = `diff --git a/file.txt b/file.txt
|
||||
index 1234567..1234567 100644
|
||||
--- a/file.txt
|
||||
+++ b/file.txt
|
||||
`;
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={noChangeDiff}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(mockColorizeCode).not.toHaveBeenCalled();
|
||||
});
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={noChangeDiff}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain('No changes detected');
|
||||
expect(mockColorizeCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty diff content', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent="" terminalWidth={80} />
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(mockColorizeCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render a gap indicator for skipped lines', () => {
|
||||
const diffWithGap = `
|
||||
it('should handle empty diff content', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent="" terminalWidth={80} />
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain('No diff content');
|
||||
expect(mockColorizeCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render a gap indicator for skipped lines', () => {
|
||||
const diffWithGap = `
|
||||
diff --git a/file.txt b/file.txt
|
||||
index 123..456 100644
|
||||
--- a/file.txt
|
||||
@@ -190,22 +177,26 @@ index 123..456 100644
|
||||
context line 10
|
||||
context line 11
|
||||
`;
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithGap}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithGap}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('═'); // Check for the border character used in the gap
|
||||
|
||||
it('should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP)', () => {
|
||||
const diffWithSmallGap = `
|
||||
// Verify that lines before and after the gap are rendered
|
||||
expect(output).toContain('context line 1');
|
||||
expect(output).toContain('added line');
|
||||
expect(output).toContain('context line 10');
|
||||
});
|
||||
|
||||
it('should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP)', () => {
|
||||
const diffWithSmallGap = `
|
||||
diff --git a/file.txt b/file.txt
|
||||
index abc..def 100644
|
||||
--- a/file.txt
|
||||
@@ -223,22 +214,25 @@ index abc..def 100644
|
||||
context line 14
|
||||
context line 15
|
||||
`;
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithSmallGap}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithSmallGap}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('═'); // Ensure no separator is rendered
|
||||
|
||||
describe('should correctly render a diff with multiple hunks and a gap indicator', () => {
|
||||
const diffWithMultipleHunks = `
|
||||
// Verify that lines before and after the gap are rendered
|
||||
expect(output).toContain('context line 5');
|
||||
expect(output).toContain('context line 11');
|
||||
});
|
||||
|
||||
describe('should correctly render a diff with multiple hunks and a gap indicator', () => {
|
||||
const diffWithMultipleHunks = `
|
||||
diff --git a/multi.js b/multi.js
|
||||
index 123..789 100644
|
||||
--- a/multi.js
|
||||
@@ -255,42 +249,61 @@ index 123..789 100644
|
||||
console.log('end of second hunk');
|
||||
`;
|
||||
|
||||
it.each([
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: undefined,
|
||||
},
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: 6,
|
||||
},
|
||||
{
|
||||
terminalWidth: 30,
|
||||
height: 6,
|
||||
},
|
||||
])(
|
||||
'with terminalWidth $terminalWidth and height $height',
|
||||
({ terminalWidth, height }) => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithMultipleHunks}
|
||||
filename="multi.js"
|
||||
terminalWidth={terminalWidth}
|
||||
availableTerminalHeight={height}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(sanitizeOutput(output, terminalWidth)).toMatchSnapshot();
|
||||
},
|
||||
it.each([
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: undefined,
|
||||
expected: ` 1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first hunk');
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');`,
|
||||
},
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: 6,
|
||||
expected: `... first 4 lines hidden ...
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');`,
|
||||
},
|
||||
{
|
||||
terminalWidth: 30,
|
||||
height: 6,
|
||||
expected: `... first 10 lines hidden ...
|
||||
;
|
||||
21 + const anotherNew = 'test'
|
||||
;
|
||||
22 console.log('end of
|
||||
second hunk');`,
|
||||
},
|
||||
])(
|
||||
'with terminalWidth $terminalWidth and height $height',
|
||||
({ terminalWidth, height, expected }) => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithMultipleHunks}
|
||||
filename="multi.js"
|
||||
terminalWidth={terminalWidth}
|
||||
availableTerminalHeight={height}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly render a diff with a SVN diff format', () => {
|
||||
const newFileDiff = `
|
||||
const output = lastFrame();
|
||||
expect(sanitizeOutput(output, terminalWidth)).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly render a diff with a SVN diff format', () => {
|
||||
const newFileDiff = `
|
||||
fileDiff Index: file.txt
|
||||
===================================================================
|
||||
--- a/file.txt Current
|
||||
@@ -305,22 +318,26 @@ fileDiff Index: file.txt
|
||||
+const anotherNew = 'test';
|
||||
\\ No newline at end of file
|
||||
`;
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="TEST"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="TEST"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
it('should correctly render a new file with no file extension correctly', () => {
|
||||
const newFileDiff = `
|
||||
expect(output).toEqual(` 1 - const oldVar = 1;
|
||||
1 + const newVar = 1;
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 - const anotherOld = 'test';
|
||||
20 + const anotherNew = 'test';`);
|
||||
});
|
||||
|
||||
it('should correctly render a new file with no file extension correctly', () => {
|
||||
const newFileDiff = `
|
||||
fileDiff Index: Dockerfile
|
||||
===================================================================
|
||||
--- Dockerfile Current
|
||||
@@ -331,18 +348,18 @@ fileDiff Index: Dockerfile
|
||||
+RUN npm run build
|
||||
\\ No newline at end of file
|
||||
`;
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="Dockerfile"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
},
|
||||
);
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="Dockerfile"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toEqual(`1 FROM node:14
|
||||
2 RUN npm install
|
||||
3 RUN npm run build`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,15 +5,12 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import crypto from 'node:crypto';
|
||||
import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { theme as semanticTheme } from '../../semantic-colors.js';
|
||||
import type { Theme } from '../../themes/theme.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface DiffLine {
|
||||
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
||||
@@ -101,100 +98,75 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
terminalWidth,
|
||||
theme,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const screenReaderEnabled = useIsScreenReaderEnabled();
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
return <Text color={semanticTheme.status.warning}>No diff content.</Text>;
|
||||
}
|
||||
|
||||
const parsedLines = useMemo(() => {
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
return [];
|
||||
}
|
||||
return parseDiffWithLineNumbers(diffContent);
|
||||
}, [diffContent]);
|
||||
const parsedLines = parseDiffWithLineNumbers(diffContent);
|
||||
|
||||
const isNewFile = useMemo(() => {
|
||||
if (parsedLines.length === 0) return false;
|
||||
return parsedLines.every(
|
||||
(line) =>
|
||||
line.type === 'add' ||
|
||||
line.type === 'hunk' ||
|
||||
line.type === 'other' ||
|
||||
line.content.startsWith('diff --git') ||
|
||||
line.content.startsWith('new file mode'),
|
||||
if (parsedLines.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={semanticTheme.border.default}
|
||||
padding={1}
|
||||
>
|
||||
<Text dimColor>No changes detected.</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [parsedLines]);
|
||||
}
|
||||
if (screenReaderEnabled) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{parsedLines.map((line, index) => (
|
||||
<Text key={index}>
|
||||
{line.type}: {line.content}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const renderedOutput = useMemo(() => {
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
return <Text color={semanticTheme.status.warning}>No diff content.</Text>;
|
||||
}
|
||||
// Check if the diff represents a new file (only additions and header lines)
|
||||
const isNewFile = parsedLines.every(
|
||||
(line) =>
|
||||
line.type === 'add' ||
|
||||
line.type === 'hunk' ||
|
||||
line.type === 'other' ||
|
||||
line.content.startsWith('diff --git') ||
|
||||
line.content.startsWith('new file mode'),
|
||||
);
|
||||
|
||||
if (parsedLines.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={semanticTheme.border.default}
|
||||
padding={1}
|
||||
>
|
||||
<Text dimColor>No changes detected.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (screenReaderEnabled) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{parsedLines.map((line, index) => (
|
||||
<Text key={index}>
|
||||
{line.type}: {line.content}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
let renderedOutput;
|
||||
|
||||
if (isNewFile) {
|
||||
// Extract only the added lines' content
|
||||
const addedContent = parsedLines
|
||||
.filter((line) => line.type === 'add')
|
||||
.map((line) => line.content)
|
||||
.join('\n');
|
||||
// Attempt to infer language from filename, default to plain text if no filename
|
||||
const fileExtension = filename?.split('.').pop() || null;
|
||||
const language = fileExtension
|
||||
? getLanguageFromExtension(fileExtension)
|
||||
: null;
|
||||
return colorizeCode({
|
||||
code: addedContent,
|
||||
language,
|
||||
availableHeight: availableTerminalHeight,
|
||||
maxWidth: terminalWidth,
|
||||
theme,
|
||||
settings,
|
||||
});
|
||||
} else {
|
||||
return renderDiffContent(
|
||||
parsedLines,
|
||||
filename,
|
||||
tabWidth,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
!isAlternateBuffer,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
diffContent,
|
||||
parsedLines,
|
||||
screenReaderEnabled,
|
||||
isNewFile,
|
||||
filename,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
theme,
|
||||
settings,
|
||||
isAlternateBuffer,
|
||||
tabWidth,
|
||||
]);
|
||||
if (isNewFile) {
|
||||
// Extract only the added lines' content
|
||||
const addedContent = parsedLines
|
||||
.filter((line) => line.type === 'add')
|
||||
.map((line) => line.content)
|
||||
.join('\n');
|
||||
// Attempt to infer language from filename, default to plain text if no filename
|
||||
const fileExtension = filename?.split('.').pop() || null;
|
||||
const language = fileExtension
|
||||
? getLanguageFromExtension(fileExtension)
|
||||
: null;
|
||||
renderedOutput = colorizeCode(
|
||||
addedContent,
|
||||
language,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
theme,
|
||||
);
|
||||
} else {
|
||||
renderedOutput = renderDiffContent(
|
||||
parsedLines,
|
||||
filename,
|
||||
tabWidth,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
);
|
||||
}
|
||||
|
||||
return renderedOutput;
|
||||
};
|
||||
@@ -205,7 +177,6 @@ const renderDiffContent = (
|
||||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
availableTerminalHeight: number | undefined,
|
||||
terminalWidth: number,
|
||||
useMaxSizedBox: boolean,
|
||||
) => {
|
||||
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
||||
const normalizedLines = parsedLines.map((line) => ({
|
||||
@@ -264,151 +235,115 @@ const renderDiffContent = (
|
||||
let lastLineNumber: number | null = null;
|
||||
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
|
||||
|
||||
const content = displayableLines.reduce<React.ReactNode[]>(
|
||||
(acc, line, index) => {
|
||||
// Determine the relevant line number for gap calculation based on type
|
||||
let relevantLineNumberForGapCalc: number | null = null;
|
||||
if (line.type === 'add' || line.type === 'context') {
|
||||
relevantLineNumberForGapCalc = line.newLine ?? null;
|
||||
} else if (line.type === 'del') {
|
||||
// For deletions, the gap is typically in relation to the original file's line numbering
|
||||
relevantLineNumberForGapCalc = line.oldLine ?? null;
|
||||
}
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableTerminalHeight}
|
||||
maxWidth={terminalWidth}
|
||||
key={key}
|
||||
>
|
||||
{displayableLines.reduce<React.ReactNode[]>((acc, line, index) => {
|
||||
// Determine the relevant line number for gap calculation based on type
|
||||
let relevantLineNumberForGapCalc: number | null = null;
|
||||
if (line.type === 'add' || line.type === 'context') {
|
||||
relevantLineNumberForGapCalc = line.newLine ?? null;
|
||||
} else if (line.type === 'del') {
|
||||
// For deletions, the gap is typically in relation to the original file's line numbering
|
||||
relevantLineNumberForGapCalc = line.oldLine ?? null;
|
||||
}
|
||||
|
||||
if (
|
||||
lastLineNumber !== null &&
|
||||
relevantLineNumberForGapCalc !== null &&
|
||||
relevantLineNumberForGapCalc >
|
||||
lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
|
||||
) {
|
||||
acc.push(
|
||||
<Box key={`gap-${index}`}>
|
||||
{useMaxSizedBox ? (
|
||||
if (
|
||||
lastLineNumber !== null &&
|
||||
relevantLineNumberForGapCalc !== null &&
|
||||
relevantLineNumberForGapCalc >
|
||||
lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
|
||||
) {
|
||||
acc.push(
|
||||
<Box key={`gap-${index}`}>
|
||||
<Text wrap="truncate" color={semanticTheme.text.secondary}>
|
||||
{'═'.repeat(terminalWidth)}
|
||||
</Text>
|
||||
) : (
|
||||
// We can use a proper separator when not using max sized box.
|
||||
<Box
|
||||
borderStyle="double"
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderBottom={false}
|
||||
width={terminalWidth}
|
||||
borderColor={semanticTheme.text.secondary}
|
||||
marginRight={1}
|
||||
></Box>
|
||||
)}
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
const lineKey = `diff-line-${index}`;
|
||||
let gutterNumStr = '';
|
||||
let prefixSymbol = ' ';
|
||||
const lineKey = `diff-line-${index}`;
|
||||
let gutterNumStr = '';
|
||||
let prefixSymbol = ' ';
|
||||
|
||||
switch (line.type) {
|
||||
case 'add':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
prefixSymbol = '+';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
case 'del':
|
||||
gutterNumStr = (line.oldLine ?? '').toString();
|
||||
prefixSymbol = '-';
|
||||
// For deletions, update lastLineNumber based on oldLine if it's advancing.
|
||||
// This helps manage gaps correctly if there are multiple consecutive deletions
|
||||
// or if a deletion is followed by a context line far away in the original file.
|
||||
if (line.oldLine !== undefined) {
|
||||
lastLineNumber = line.oldLine;
|
||||
}
|
||||
break;
|
||||
case 'context':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
prefixSymbol = ' ';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
default:
|
||||
return acc;
|
||||
}
|
||||
switch (line.type) {
|
||||
case 'add':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
prefixSymbol = '+';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
case 'del':
|
||||
gutterNumStr = (line.oldLine ?? '').toString();
|
||||
prefixSymbol = '-';
|
||||
// For deletions, update lastLineNumber based on oldLine if it's advancing.
|
||||
// This helps manage gaps correctly if there are multiple consecutive deletions
|
||||
// or if a deletion is followed by a context line far away in the original file.
|
||||
if (line.oldLine !== undefined) {
|
||||
lastLineNumber = line.oldLine;
|
||||
}
|
||||
break;
|
||||
case 'context':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
prefixSymbol = ' ';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
default:
|
||||
return acc;
|
||||
}
|
||||
|
||||
const displayContent = line.content.substring(baseIndentation);
|
||||
const displayContent = line.content.substring(baseIndentation);
|
||||
|
||||
const backgroundColor =
|
||||
line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: line.type === 'del'
|
||||
? semanticTheme.background.diff.removed
|
||||
: undefined;
|
||||
acc.push(
|
||||
<Box key={lineKey} flexDirection="row">
|
||||
{useMaxSizedBox ? (
|
||||
acc.push(
|
||||
<Box key={lineKey} flexDirection="row">
|
||||
<Text
|
||||
color={semanticTheme.text.secondary}
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
{gutterNumStr.padStart(gutterWidth)}{' '}
|
||||
</Text>
|
||||
) : (
|
||||
<Box
|
||||
width={gutterWidth + 1}
|
||||
paddingRight={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={backgroundColor}
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Text color={semanticTheme.text.secondary}>{gutterNumStr}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{line.type === 'context' ? (
|
||||
<>
|
||||
<Text>{prefixSymbol} </Text>
|
||||
<Text wrap="wrap">{colorizeLine(displayContent, language)}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
backgroundColor={
|
||||
line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: semanticTheme.background.diff.removed
|
||||
: line.type === 'del'
|
||||
? semanticTheme.background.diff.removed
|
||||
: undefined
|
||||
}
|
||||
wrap="wrap"
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
line.type === 'add'
|
||||
? semanticTheme.status.success
|
||||
: semanticTheme.status.error
|
||||
}
|
||||
>
|
||||
{prefixSymbol}
|
||||
</Text>{' '}
|
||||
{colorizeLine(displayContent, language)}
|
||||
{gutterNumStr.padStart(gutterWidth)}{' '}
|
||||
</Text>
|
||||
)}
|
||||
</Box>,
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (useMaxSizedBox) {
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableTerminalHeight}
|
||||
maxWidth={terminalWidth}
|
||||
key={key}
|
||||
>
|
||||
{content}
|
||||
</MaxSizedBox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={key} flexDirection="column" width={terminalWidth} flexShrink={0}>
|
||||
{content}
|
||||
</Box>
|
||||
{line.type === 'context' ? (
|
||||
<>
|
||||
<Text>{prefixSymbol} </Text>
|
||||
<Text wrap="wrap">
|
||||
{colorizeLine(displayContent, language)}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
backgroundColor={
|
||||
line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: semanticTheme.background.diff.removed
|
||||
}
|
||||
wrap="wrap"
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
line.type === 'add'
|
||||
? semanticTheme.status.success
|
||||
: semanticTheme.status.error
|
||||
}
|
||||
>
|
||||
{prefixSymbol}
|
||||
</Text>{' '}
|
||||
{colorizeLine(displayContent, language)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>,
|
||||
);
|
||||
return acc;
|
||||
}, [])}
|
||||
</MaxSizedBox>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface GeminiMessageProps {
|
||||
text: string;
|
||||
@@ -29,7 +28,6 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
@@ -41,9 +39,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer ? undefined : availableTerminalHeight
|
||||
}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,6 @@ import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface GeminiMessageContentProps {
|
||||
text: string;
|
||||
@@ -30,7 +29,6 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const originalPrefix = '✦ ';
|
||||
const prefixWidth = originalPrefix.length;
|
||||
|
||||
@@ -39,9 +37,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer ? undefined : availableTerminalHeight
|
||||
}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
|
||||
@@ -11,28 +11,21 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
|
||||
interface InfoMessageProps {
|
||||
text: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const InfoMessage: React.FC<InfoMessageProps> = ({
|
||||
text,
|
||||
icon,
|
||||
color,
|
||||
}) => {
|
||||
color ??= theme.status.warning;
|
||||
const prefix = icon ?? 'ℹ ';
|
||||
export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
|
||||
const prefix = 'ℹ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={color}>{prefix}</Text>
|
||||
<Text color={theme.status.warning}>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
{text.split('\n').map((line, index) => (
|
||||
<Text wrap="wrap" key={index}>
|
||||
<RenderInline text={line} defaultColor={color} />
|
||||
<RenderInline text={line} defaultColor={theme.status.warning} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -20,7 +20,7 @@ const createTodoHistoryItem = (todos: Todo[]): HistoryItem =>
|
||||
id: '1',
|
||||
tools: [
|
||||
{
|
||||
name: 'write_todos',
|
||||
name: 'write_todos_list',
|
||||
callId: 'tool-1',
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
@@ -21,7 +21,6 @@ import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
export interface ToolConfirmationMessageProps {
|
||||
confirmationDetails: ToolCallConfirmationDetails;
|
||||
@@ -43,8 +42,6 @@ export const ToolConfirmationMessage: React.FC<
|
||||
const { onConfirm } = confirmationDetails;
|
||||
const childWidth = terminalWidth - 2; // 2 for padding
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
|
||||
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
|
||||
|
||||
@@ -93,230 +90,42 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
|
||||
|
||||
const { question, bodyContent, options } = useMemo(() => {
|
||||
let bodyContent: React.ReactNode | null = null;
|
||||
let question = '';
|
||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [];
|
||||
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
|
||||
let question: string;
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (!confirmationDetails.isModifying) {
|
||||
question = `Apply this change?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Yes, allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
});
|
||||
}
|
||||
if (!config.getIdeMode() || !isDiffingEnabled) {
|
||||
options.push({
|
||||
label: 'Modify with external editor',
|
||||
value: ToolConfirmationOutcome.ModifyWithEditor,
|
||||
key: 'Modify with external editor',
|
||||
});
|
||||
}
|
||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = new Array<
|
||||
RadioSelectItem<ToolConfirmationOutcome>
|
||||
>();
|
||||
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
}
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const executionProps =
|
||||
confirmationDetails as ToolExecuteConfirmationDetails;
|
||||
// Body content is now the DiffRenderer, passing filename to it
|
||||
// The bordered box is removed from here and handled within DiffRenderer
|
||||
|
||||
question = `Allow execution of: '${executionProps.rootCommand}'?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: `Yes, allow always ...`,
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: `Yes, allow always ...`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
} else if (confirmationDetails.type === 'info') {
|
||||
question = `Do you want to proceed?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Yes, allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
} else {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
||||
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
|
||||
key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
});
|
||||
options.push({
|
||||
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
key: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
function availableBodyContentHeight() {
|
||||
if (options.length === 0) {
|
||||
// This should not happen in practice as options are always added before this is called.
|
||||
throw new Error('Options not provided for confirmation message');
|
||||
}
|
||||
|
||||
function availableBodyContentHeight() {
|
||||
if (options.length === 0) {
|
||||
// Should not happen if we populated options correctly above for all types
|
||||
// except when isModifying is true, but in that case we don't call this because we don't enter the if block for it.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (availableTerminalHeight === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Calculate the vertical space (in lines) consumed by UI elements
|
||||
// surrounding the main body content.
|
||||
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
|
||||
const MARGIN_BODY_BOTTOM = 1; // margin on the body container.
|
||||
const HEIGHT_QUESTION = 1; // The question text is one line.
|
||||
const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container.
|
||||
const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line.
|
||||
|
||||
const surroundingElementsHeight =
|
||||
PADDING_OUTER_Y +
|
||||
MARGIN_BODY_BOTTOM +
|
||||
HEIGHT_QUESTION +
|
||||
MARGIN_QUESTION_BOTTOM +
|
||||
HEIGHT_OPTIONS;
|
||||
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
|
||||
if (availableTerminalHeight === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (!confirmationDetails.isModifying) {
|
||||
bodyContent = (
|
||||
<DiffRenderer
|
||||
diffContent={confirmationDetails.fileDiff}
|
||||
filename={confirmationDetails.fileName}
|
||||
availableTerminalHeight={availableBodyContentHeight()}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const executionProps =
|
||||
confirmationDetails as ToolExecuteConfirmationDetails;
|
||||
let bodyContentHeight = availableBodyContentHeight();
|
||||
if (bodyContentHeight !== undefined) {
|
||||
bodyContentHeight -= 2; // Account for padding;
|
||||
}
|
||||
// Calculate the vertical space (in lines) consumed by UI elements
|
||||
// surrounding the main body content.
|
||||
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
|
||||
const MARGIN_BODY_BOTTOM = 1; // margin on the body container.
|
||||
const HEIGHT_QUESTION = 1; // The question text is one line.
|
||||
const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container.
|
||||
const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line.
|
||||
|
||||
const commandBox = (
|
||||
<Box>
|
||||
<Text color={theme.text.link}>{executionProps.command}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1}>
|
||||
{isAlternateBuffer ? (
|
||||
commandBox
|
||||
) : (
|
||||
<MaxSizedBox
|
||||
maxHeight={bodyContentHeight}
|
||||
maxWidth={Math.max(childWidth, 1)}
|
||||
>
|
||||
{commandBox}
|
||||
</MaxSizedBox>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'info') {
|
||||
const infoProps = confirmationDetails;
|
||||
const displayUrls =
|
||||
infoProps.urls &&
|
||||
!(
|
||||
infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt
|
||||
);
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.link}>
|
||||
<RenderInline
|
||||
text={infoProps.prompt}
|
||||
defaultColor={theme.text.link}
|
||||
/>
|
||||
</Text>
|
||||
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>URLs to fetch:</Text>
|
||||
{infoProps.urls.map((url) => (
|
||||
<Text key={url}>
|
||||
{' '}
|
||||
- <RenderInline text={url} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.link}>MCP Server: {mcpProps.serverName}</Text>
|
||||
<Text color={theme.text.link}>Tool: {mcpProps.toolName}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return { question, bodyContent, options };
|
||||
}, [
|
||||
confirmationDetails,
|
||||
isTrustedFolder,
|
||||
config,
|
||||
isDiffingEnabled,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
isAlternateBuffer,
|
||||
childWidth,
|
||||
]);
|
||||
const surroundingElementsHeight =
|
||||
PADDING_OUTER_Y +
|
||||
MARGIN_BODY_BOTTOM +
|
||||
HEIGHT_QUESTION +
|
||||
MARGIN_QUESTION_BOTTOM +
|
||||
HEIGHT_OPTIONS;
|
||||
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (confirmationDetails.isModifying) {
|
||||
@@ -336,29 +145,177 @@ export const ToolConfirmationMessage: React.FC<
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
question = `Apply this change?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Yes, allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
});
|
||||
}
|
||||
if (!config.getIdeMode() || !isDiffingEnabled) {
|
||||
options.push({
|
||||
label: 'Modify with external editor',
|
||||
value: ToolConfirmationOutcome.ModifyWithEditor,
|
||||
key: 'Modify with external editor',
|
||||
});
|
||||
}
|
||||
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
|
||||
bodyContent = (
|
||||
<DiffRenderer
|
||||
diffContent={confirmationDetails.fileDiff}
|
||||
filename={confirmationDetails.fileName}
|
||||
availableTerminalHeight={availableBodyContentHeight()}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const executionProps =
|
||||
confirmationDetails as ToolExecuteConfirmationDetails;
|
||||
|
||||
question = `Allow execution of: '${executionProps.rootCommand}'?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: `Yes, allow always ...`,
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: `Yes, allow always ...`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
|
||||
let bodyContentHeight = availableBodyContentHeight();
|
||||
if (bodyContentHeight !== undefined) {
|
||||
bodyContentHeight -= 2; // Account for padding;
|
||||
}
|
||||
bodyContent = (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1} marginLeft={1}>
|
||||
<MaxSizedBox
|
||||
maxHeight={bodyContentHeight}
|
||||
maxWidth={Math.max(childWidth - 4, 1)}
|
||||
>
|
||||
<Box>
|
||||
<Text color={theme.text.link}>{executionProps.command}</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'info') {
|
||||
const infoProps = confirmationDetails;
|
||||
const displayUrls =
|
||||
infoProps.urls &&
|
||||
!(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt);
|
||||
|
||||
question = `Do you want to proceed?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Yes, allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1} marginLeft={1}>
|
||||
<RenderInline text={infoProps.prompt} defaultColor={theme.text.link} />
|
||||
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>URLs to fetch:</Text>
|
||||
{infoProps.urls.map((url) => (
|
||||
<Text key={url}>
|
||||
{' '}
|
||||
- <RenderInline text={url} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1} marginLeft={1}>
|
||||
<Text color={theme.text.link}>MCP Server: {mcpProps.serverName}</Text>
|
||||
<Text color={theme.text.link}>Tool: {mcpProps.toolName}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
|
||||
key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
});
|
||||
options.push({
|
||||
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
key: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
|
||||
<Box flexDirection="column" padding={1} width={childWidth}>
|
||||
{/* Body Content (Diff Renderer or Command Info) */}
|
||||
{/* No separate context display here anymore for edits */}
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
overflow="hidden"
|
||||
marginBottom={1}
|
||||
paddingLeft={1}
|
||||
>
|
||||
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
|
||||
{bodyContent}
|
||||
</Box>
|
||||
|
||||
{/* Confirmation Question */}
|
||||
<Box marginBottom={1} flexShrink={0} paddingX={1}>
|
||||
<Text color={theme.text.primary}>{question}</Text>
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text color={theme.text.primary} wrap="truncate">
|
||||
{question}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Select Input for Options */}
|
||||
<Box flexShrink={0} paddingX={1}>
|
||||
<Box flexShrink={0}>
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import type { ToolCallConfirmationDetails } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
ToolCallConfirmationDetails,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { TOOL_STATUS } from '../../constants.js';
|
||||
import { ConfigContext } from '../../contexts/ConfigContext.js';
|
||||
|
||||
// Mock child components to isolate ToolGroupMessage behavior
|
||||
vi.mock('./ToolMessage.js', () => ({
|
||||
@@ -61,6 +66,8 @@ vi.mock('./ToolConfirmationMessage.js', () => ({
|
||||
}));
|
||||
|
||||
describe('<ToolGroupMessage />', () => {
|
||||
const mockConfig: Config = {} as Config;
|
||||
|
||||
const createToolCall = (
|
||||
overrides: Partial<IndividualToolCallDisplay> = {},
|
||||
): IndividualToolCallDisplay => ({
|
||||
@@ -80,6 +87,14 @@ describe('<ToolGroupMessage />', () => {
|
||||
isFocused: true,
|
||||
};
|
||||
|
||||
// Helper to wrap component with required providers
|
||||
const renderWithProviders = (component: React.ReactElement) =>
|
||||
render(
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
{component}
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
|
||||
describe('Golden Snapshots', () => {
|
||||
it('renders single successful tool call', () => {
|
||||
const toolCalls = [createToolCall()];
|
||||
|
||||
@@ -14,7 +14,6 @@ import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
groupId: number;
|
||||
@@ -48,7 +47,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
);
|
||||
|
||||
const config = useConfig();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const isShellCommand = toolCalls.some(
|
||||
(t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME,
|
||||
);
|
||||
@@ -61,8 +59,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
|
||||
// This is a bit of a magic number, but it accounts for the border and
|
||||
// marginLeft in regular mode and just the border in alternate buffer mode.
|
||||
const innerWidth = isAlternateBuffer ? terminalWidth - 3 : terminalWidth - 4;
|
||||
// marginLeft.
|
||||
const innerWidth = terminalWidth - 4;
|
||||
|
||||
// only prompt for tool approval on the first 'confirming' tool in the list
|
||||
// note, after the CTA, this automatically moves over to the next 'confirming' tool
|
||||
@@ -108,23 +106,24 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
{toolCalls.map((tool) => {
|
||||
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
|
||||
return (
|
||||
<Box
|
||||
key={tool.callId}
|
||||
flexDirection="column"
|
||||
minHeight={1}
|
||||
width={innerWidth}
|
||||
>
|
||||
<ToolMessage
|
||||
{...tool}
|
||||
availableTerminalHeight={availableTerminalHeightPerToolMessage}
|
||||
terminalWidth={innerWidth}
|
||||
emphasis={
|
||||
isConfirming ? 'high' : toolAwaitingApproval ? 'low' : 'medium'
|
||||
}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
config={config}
|
||||
/>
|
||||
<Box key={tool.callId} flexDirection="column" minHeight={1}>
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
<ToolMessage
|
||||
{...tool}
|
||||
availableTerminalHeight={availableTerminalHeightPerToolMessage}
|
||||
terminalWidth={innerWidth}
|
||||
emphasis={
|
||||
isConfirming
|
||||
? 'high'
|
||||
: toolAwaitingApproval
|
||||
? 'low'
|
||||
: 'medium'
|
||||
}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
config={config}
|
||||
/>
|
||||
</Box>
|
||||
{tool.status === ToolCallStatus.Confirming &&
|
||||
isConfirming &&
|
||||
tool.confirmationDetails && (
|
||||
|
||||
@@ -14,7 +14,6 @@ import { AnsiOutputText } from '../AnsiOutput.js';
|
||||
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { ShellInputPrompt } from '../ShellInputPrompt.js';
|
||||
import { StickyHeader } from '../StickyHeader.js';
|
||||
import {
|
||||
SHELL_COMMAND_NAME,
|
||||
SHELL_NAME,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { AnsiOutput, Config } from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
const STATIC_HEIGHT = 1;
|
||||
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
|
||||
@@ -60,7 +58,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
config,
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const isThisShellFocused =
|
||||
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
|
||||
status === ToolCallStatus.Executing &&
|
||||
@@ -111,93 +108,23 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
: undefined;
|
||||
|
||||
// Long tool call response in MarkdownDisplay doesn't respect availableTerminalHeight properly,
|
||||
// so if we aren't using alternate buffer mode, we're forcing it to not render as markdown when the response is too long, it will fallback
|
||||
// we're forcing it to not render as markdown when the response is too long, it will fallback
|
||||
// to render as plain text, which is contained within the terminal using MaxSizedBox
|
||||
if (availableHeight && !isAlternateBuffer) {
|
||||
if (availableHeight) {
|
||||
renderOutputAsMarkdown = false;
|
||||
}
|
||||
const childWidth = terminalWidth;
|
||||
|
||||
const truncatedResultDisplay = React.useMemo(() => {
|
||||
if (typeof resultDisplay === 'string') {
|
||||
if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
|
||||
return '...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
|
||||
}
|
||||
const childWidth = terminalWidth - 3; // account for padding.
|
||||
if (typeof resultDisplay === 'string') {
|
||||
if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
|
||||
// Truncate the result display to fit within the available width.
|
||||
resultDisplay =
|
||||
'...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
|
||||
}
|
||||
return resultDisplay;
|
||||
}, [resultDisplay]);
|
||||
|
||||
const renderedResult = React.useMemo(() => {
|
||||
if (!truncatedResultDisplay) return null;
|
||||
|
||||
return (
|
||||
<Box width={terminalWidth} flexDirection="column" paddingLeft={1}>
|
||||
<Box flexDirection="column">
|
||||
{typeof truncatedResultDisplay === 'string' &&
|
||||
renderOutputAsMarkdown ? (
|
||||
<Box flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={truncatedResultDisplay}
|
||||
terminalWidth={childWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
isPending={false}
|
||||
/>
|
||||
</Box>
|
||||
) : typeof truncatedResultDisplay === 'string' &&
|
||||
!renderOutputAsMarkdown ? (
|
||||
isAlternateBuffer ? (
|
||||
<Box flexDirection="column" width={childWidth}>
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{truncatedResultDisplay}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
|
||||
<Box>
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{truncatedResultDisplay}
|
||||
</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
)
|
||||
) : typeof truncatedResultDisplay === 'object' &&
|
||||
'fileDiff' in truncatedResultDisplay ? (
|
||||
<DiffRenderer
|
||||
diffContent={truncatedResultDisplay.fileDiff}
|
||||
filename={truncatedResultDisplay.fileName}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
) : typeof truncatedResultDisplay === 'object' &&
|
||||
'todos' in truncatedResultDisplay ? (
|
||||
// display nothing, as the TodoTray will handle rendering todos
|
||||
<></>
|
||||
) : (
|
||||
<AnsiOutputText
|
||||
data={truncatedResultDisplay as AnsiOutput}
|
||||
availableTerminalHeight={availableHeight}
|
||||
width={childWidth}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, [
|
||||
truncatedResultDisplay,
|
||||
renderOutputAsMarkdown,
|
||||
childWidth,
|
||||
renderMarkdown,
|
||||
isAlternateBuffer,
|
||||
availableHeight,
|
||||
terminalWidth,
|
||||
]);
|
||||
|
||||
}
|
||||
return (
|
||||
// We have the StickyHeader intentionally exceedsthe allowed width for this
|
||||
// component by 1 so tne horizontal line it renders can extend into the 1
|
||||
// pixel of padding of the box drawn by the parent of the ToolMessage.
|
||||
<>
|
||||
<StickyHeader width={terminalWidth + 1}>
|
||||
<Box paddingX={1} paddingY={0} flexDirection="column">
|
||||
<Box minHeight={1}>
|
||||
<ToolStatusIndicator status={status} name={name} />
|
||||
<ToolInfo
|
||||
name={name}
|
||||
@@ -213,8 +140,50 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
{emphasis === 'high' && <TrailingIndicator />}
|
||||
</StickyHeader>
|
||||
{renderedResult}
|
||||
</Box>
|
||||
{resultDisplay && (
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
|
||||
<Box flexDirection="column">
|
||||
{typeof resultDisplay === 'string' && renderOutputAsMarkdown ? (
|
||||
<Box flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={resultDisplay}
|
||||
isPending={false}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
</Box>
|
||||
) : typeof resultDisplay === 'string' && !renderOutputAsMarkdown ? (
|
||||
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
|
||||
<Box>
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{resultDisplay}
|
||||
</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
) : typeof resultDisplay === 'object' &&
|
||||
'fileDiff' in resultDisplay ? (
|
||||
<DiffRenderer
|
||||
diffContent={resultDisplay.fileDiff}
|
||||
filename={resultDisplay.fileName}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
) : typeof resultDisplay === 'object' &&
|
||||
'todos' in resultDisplay ? (
|
||||
// display nothing, as the TodoTray will handle rendering todos
|
||||
<></>
|
||||
) : (
|
||||
<AnsiOutputText
|
||||
data={resultDisplay as AnsiOutput}
|
||||
availableTerminalHeight={availableHeight}
|
||||
width={childWidth}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{isThisShellFocused && config && (
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
|
||||
<ShellInputPrompt
|
||||
@@ -223,7 +192,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -302,7 +271,10 @@ const ToolInfo: React.FC<ToolInfo> = ({
|
||||
}, [emphasis]);
|
||||
return (
|
||||
<Box>
|
||||
<Text strikethrough={status === ToolCallStatus.Canceled}>
|
||||
<Text
|
||||
wrap="truncate-end"
|
||||
strikethrough={status === ToolCallStatus.Canceled}
|
||||
>
|
||||
<Text color={nameColor} bold>
|
||||
{name}
|
||||
</Text>{' '}
|
||||
|
||||
@@ -23,52 +23,20 @@ describe('<ToolMessage /> - Raw Markdown Display Snapshots', () => {
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
renderMarkdown: true,
|
||||
useAlternateBuffer: false,
|
||||
description: '(default, regular buffer)',
|
||||
},
|
||||
{
|
||||
renderMarkdown: true,
|
||||
useAlternateBuffer: true,
|
||||
description: '(default, alternate buffer)',
|
||||
},
|
||||
{ renderMarkdown: true, description: '(default)' },
|
||||
{
|
||||
renderMarkdown: false,
|
||||
useAlternateBuffer: false,
|
||||
description: '(raw markdown, regular buffer)',
|
||||
},
|
||||
{
|
||||
renderMarkdown: false,
|
||||
useAlternateBuffer: true,
|
||||
description: '(raw markdown, alternate buffer)',
|
||||
},
|
||||
// Test cases where height constraint affects rendering in regular buffer but not alternate
|
||||
{
|
||||
renderMarkdown: true,
|
||||
useAlternateBuffer: false,
|
||||
availableTerminalHeight: 10,
|
||||
description: '(constrained height, regular buffer -> forces raw)',
|
||||
},
|
||||
{
|
||||
renderMarkdown: true,
|
||||
useAlternateBuffer: true,
|
||||
availableTerminalHeight: 10,
|
||||
description: '(constrained height, alternate buffer -> keeps markdown)',
|
||||
description: '(raw markdown with syntax highlighting, no line numbers)',
|
||||
},
|
||||
])(
|
||||
'renders with renderMarkdown=$renderMarkdown, useAlternateBuffer=$useAlternateBuffer $description',
|
||||
({ renderMarkdown, useAlternateBuffer, availableTerminalHeight }) => {
|
||||
'renders with renderMarkdown=$renderMarkdown $description',
|
||||
({ renderMarkdown }) => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<StreamingContext.Provider value={StreamingState.Idle}>
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
/>
|
||||
<ToolMessage {...baseProps} />
|
||||
</StreamingContext.Provider>,
|
||||
{
|
||||
uiState: { renderMarkdown, streamingState: StreamingState.Idle },
|
||||
useAlternateBuffer,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
@@ -12,10 +12,9 @@ import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.
|
||||
|
||||
interface UserMessageProps {
|
||||
text: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
|
||||
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
|
||||
const prefix = '> ';
|
||||
const prefixWidth = prefix.length;
|
||||
const isSlashCommand = checkIsSlashCommand(text);
|
||||
@@ -23,14 +22,8 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
|
||||
const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
paddingY={0}
|
||||
marginY={1}
|
||||
alignSelf="flex-start"
|
||||
width={width}
|
||||
>
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Box flexDirection="row" paddingY={0} marginY={1} alignSelf="flex-start">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.accent} aria-label={SCREEN_READER_USER_PREFIX}>
|
||||
{prefix}
|
||||
</Text>
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with a SVN diff format 1`] = `
|
||||
" 1 - const oldVar = 1;
|
||||
1 + const newVar = 1;
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 - const anotherOld = 'test';
|
||||
20 + const anotherNew = 'test';"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = `
|
||||
"... first 10 lines hidden ...
|
||||
;
|
||||
21 + const anotherNew = 'test'
|
||||
;
|
||||
22 console.log('end of
|
||||
second hunk');"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
|
||||
"... first 4 lines hidden ...
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height undefined 1`] = `
|
||||
" 1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first hunk');
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a new file with no file extension correctly 1`] = `
|
||||
"1 FROM node:14
|
||||
2 RUN npm install
|
||||
3 RUN npm run build"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should handle diff with only header and no changes 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ No changes detected. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should handle empty diff content 1`] = `"No diff content."`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP) 1`] = `
|
||||
" 1 context line 1
|
||||
2 context line 2
|
||||
3 context line 3
|
||||
4 context line 4
|
||||
5 context line 5
|
||||
11 context line 11
|
||||
12 context line 12
|
||||
13 context line 13
|
||||
14 context line 14
|
||||
15 context line 15"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should render a gap indicator for skipped lines 1`] = `
|
||||
" 1 context line 1
|
||||
2 - deleted line
|
||||
2 + added line
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
10 context line 10
|
||||
11 context line 11"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should render diff content for existing file (not calling colorizeCode directly for the whole block) 1`] = `
|
||||
"1 - old line
|
||||
1 + new line"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with a SVN diff format 1`] = `
|
||||
" 1 - const oldVar = 1;
|
||||
1 + const newVar = 1;
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
20 - const anotherOld = 'test';
|
||||
20 + const anotherNew = 'test';"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = `
|
||||
" 1 console.log('first hunk');
|
||||
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first
|
||||
hunk');
|
||||
═════════════════════════════
|
||||
20 console.log('second
|
||||
hunk');
|
||||
21 - const anotherOld =
|
||||
'test';
|
||||
21 + const anotherNew =
|
||||
'test';
|
||||
22 console.log('end of second
|
||||
hunk');"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
|
||||
" 1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first hunk');
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height undefined 1`] = `
|
||||
" 1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first hunk');
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a new file with no file extension correctly 1`] = `
|
||||
"1 FROM node:14
|
||||
2 RUN npm install
|
||||
3 RUN npm run build"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should handle diff with only header and no changes 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ No changes detected. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should handle empty diff content 1`] = `"No diff content."`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP) 1`] = `
|
||||
" 1 context line 1
|
||||
2 context line 2
|
||||
3 context line 3
|
||||
4 context line 4
|
||||
5 context line 5
|
||||
11 context line 11
|
||||
12 context line 12
|
||||
13 context line 13
|
||||
14 context line 14
|
||||
15 context line 15"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should render a gap indicator for skipped lines 1`] = `
|
||||
" 1 context line 1
|
||||
2 - deleted line
|
||||
2 + added line
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
10 context line 10
|
||||
11 context line 11"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should render diff content for existing file (not calling colorizeCode directly for the whole block) 1`] = `
|
||||
"1 - old line
|
||||
1 + new line"
|
||||
`;
|
||||
+3
-4
@@ -91,10 +91,9 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
|
||||
"╭──────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ │
|
||||
│very-long-tool-name-that-might-wrap │
|
||||
│- This is a very long description │
|
||||
│that might cause wrapping issues │
|
||||
│(medium) │
|
||||
│very-long-tool-name-that-might-wrap - │
|
||||
│This is a very long description that │
|
||||
│might cause wrapping issues (medium) │
|
||||
╰──────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
|
||||
+5
-23
@@ -1,31 +1,13 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false, useAlternateBuffer=false '(raw markdown, regular buffer)' 1`] = `
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false '(raw markdown with syntax highlightin…' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
Test **bold** and \`code\` markdown"
|
||||
|
||||
Test **bold** and \`code\` markdown"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false, useAlternateBuffer=true '(raw markdown, alternate buffer)' 1`] = `
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true '(default)' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
Test **bold** and \`code\` markdown"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=false '(constrained height, regular buffer -…' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
Test **bold** and \`code\` markdown"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=false '(default, regular buffer)' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
Test bold and code markdown"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=true '(constrained height, alternate buffer…' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
Test bold and code markdown"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=true '(default, alternate buffer)' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
Test bold and code markdown"
|
||||
Test bold and code markdown"
|
||||
`;
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { Scrollable } from './Scrollable.js';
|
||||
import { Text } from 'ink';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as ScrollProviderModule from '../../contexts/ScrollProvider.js';
|
||||
import { act } from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
@@ -21,22 +19,7 @@ vi.mock('ink', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../hooks/useAnimatedScrollbar.js', () => ({
|
||||
useAnimatedScrollbar: (
|
||||
hasFocus: boolean,
|
||||
scrollBy: (delta: number) => void,
|
||||
) => ({
|
||||
scrollbarColor: 'white',
|
||||
flashScrollbar: vi.fn(),
|
||||
scrollByWithAnimation: scrollBy,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('<Scrollable />', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders children', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<Scrollable hasFocus={false} height={5}>
|
||||
@@ -69,52 +52,4 @@ describe('<Scrollable />', () => {
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('updates scroll position correctly when scrollBy is called multiple times in the same tick', () => {
|
||||
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
|
||||
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
|
||||
(entry, isActive) => {
|
||||
if (isActive) {
|
||||
capturedEntry = entry as ScrollProviderModule.ScrollableEntry;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<Scrollable hasFocus={true} height={5}>
|
||||
<Text>Line 1</Text>
|
||||
<Text>Line 2</Text>
|
||||
<Text>Line 3</Text>
|
||||
<Text>Line 4</Text>
|
||||
<Text>Line 5</Text>
|
||||
<Text>Line 6</Text>
|
||||
<Text>Line 7</Text>
|
||||
<Text>Line 8</Text>
|
||||
<Text>Line 9</Text>
|
||||
<Text>Line 10</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
|
||||
expect(capturedEntry).toBeDefined();
|
||||
|
||||
if (!capturedEntry) {
|
||||
throw new Error('capturedEntry is undefined');
|
||||
}
|
||||
|
||||
// Initial state (starts at bottom because of auto-scroll logic)
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(5);
|
||||
|
||||
// Call scrollBy multiple times (upwards) in the same tick
|
||||
act(() => {
|
||||
capturedEntry!.scrollBy(-1);
|
||||
capturedEntry!.scrollBy(-1);
|
||||
});
|
||||
// Should have moved up by 2
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(3);
|
||||
|
||||
act(() => {
|
||||
capturedEntry!.scrollBy(-2);
|
||||
});
|
||||
expect(capturedEntry.getScrollState().scrollTop).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ import { Box, getInnerHeight, getScrollHeight, type DOMElement } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
|
||||
interface ScrollableProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -82,20 +81,17 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
childrenCountRef.current = childCountCurrent;
|
||||
});
|
||||
|
||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||
|
||||
const scrollBy = useCallback(
|
||||
(delta: number) => {
|
||||
const { scrollHeight, innerHeight } = sizeRef.current;
|
||||
const current = getScrollTop();
|
||||
const next = Math.min(
|
||||
Math.max(0, current + delta),
|
||||
Math.max(0, scrollHeight - innerHeight),
|
||||
setScrollTop((prev: number) =>
|
||||
Math.min(
|
||||
Math.max(0, prev + delta),
|
||||
Math.max(0, scrollHeight - innerHeight),
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(next);
|
||||
setScrollTop(next);
|
||||
},
|
||||
[sizeRef, getScrollTop, setPendingScrollTop],
|
||||
[sizeRef],
|
||||
);
|
||||
|
||||
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
||||
@@ -117,11 +113,11 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
|
||||
const getScrollState = useCallback(
|
||||
() => ({
|
||||
scrollTop: getScrollTop(),
|
||||
scrollTop,
|
||||
scrollHeight: size.scrollHeight,
|
||||
innerHeight: size.innerHeight,
|
||||
}),
|
||||
[getScrollTop, size.scrollHeight, size.innerHeight],
|
||||
[scrollTop, size.scrollHeight, size.innerHeight],
|
||||
);
|
||||
|
||||
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
|
||||
|
||||
@@ -12,8 +12,6 @@ import { ScrollProvider } from '../../contexts/ScrollProvider.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import { MouseProvider } from '../../contexts/MouseContext.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
|
||||
// Mock useStdout to provide a fixed size for testing
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
@@ -80,7 +78,7 @@ const TestComponent = ({
|
||||
|
||||
return (
|
||||
<MouseProvider mouseEventsEnabled={false}>
|
||||
<KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ScrollProvider>
|
||||
<Box flexDirection="column" width={80} height={24} padding={1}>
|
||||
<Box flexGrow={1} borderStyle="round" borderColor="cyan">
|
||||
@@ -154,20 +152,28 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
addItem?.();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame!()).toContain('Count: 1001');
|
||||
});
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (lastFrame!()?.includes('Count: 1001')) break;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
}
|
||||
expect(lastFrame!()).toContain('Item 1001');
|
||||
expect(lastFrame!()).toContain('Count: 1001');
|
||||
expect(lastFrame!()).not.toContain('Item 990'); // Should have scrolled past it
|
||||
|
||||
// Add item 1002
|
||||
await act(async () => {
|
||||
addItem?.();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame!()).toContain('Count: 1002');
|
||||
});
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (lastFrame!()?.includes('Count: 1002')) break;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
}
|
||||
expect(lastFrame!()).toContain('Item 1002');
|
||||
expect(lastFrame!()).toContain('Count: 1002');
|
||||
expect(lastFrame!()).not.toContain('Item 991');
|
||||
|
||||
// Scroll up directly via ref
|
||||
@@ -182,103 +188,13 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
await act(async () => {
|
||||
addItem?.();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame!()).toContain('Count: 1003');
|
||||
});
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (lastFrame!()?.includes('Count: 1003')) break;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
}
|
||||
expect(lastFrame!()).not.toContain('Item 1003');
|
||||
});
|
||||
|
||||
it('should display sticky header when scrolled past the item', async () => {
|
||||
let listRef: ScrollableListRef<Item> | null = null;
|
||||
const StickyTestComponent = () => {
|
||||
const items = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: String(i),
|
||||
title: `Item ${i + 1}`,
|
||||
}));
|
||||
|
||||
const ref = useRef<ScrollableListRef<Item>>(null);
|
||||
useEffect(() => {
|
||||
listRef = ref.current;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MouseProvider mouseEventsEnabled={false}>
|
||||
<KeypressProvider>
|
||||
<ScrollProvider>
|
||||
<Box flexDirection="column" width={80} height={10}>
|
||||
<ScrollableList
|
||||
ref={ref}
|
||||
data={items}
|
||||
renderItem={({ item, index }) => (
|
||||
<Box flexDirection="column" height={3}>
|
||||
{index === 0 ? (
|
||||
<Box
|
||||
sticky
|
||||
stickyChildren={<Text>[STICKY] {item.title}</Text>}
|
||||
>
|
||||
<Text>[Normal] {item.title}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text>[Normal] {item.title}</Text>
|
||||
)}
|
||||
<Text>Content for {item.title}</Text>
|
||||
<Text>More content for {item.title}</Text>
|
||||
</Box>
|
||||
)}
|
||||
estimatedItemHeight={() => 3}
|
||||
keyExtractor={(item) => item.id}
|
||||
hasFocus={true}
|
||||
/>
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</KeypressProvider>
|
||||
</MouseProvider>
|
||||
);
|
||||
};
|
||||
|
||||
let lastFrame: () => string | undefined;
|
||||
await act(async () => {
|
||||
const result = render(<StickyTestComponent />);
|
||||
lastFrame = result.lastFrame;
|
||||
});
|
||||
|
||||
// Initially at top, should see Normal Item 1
|
||||
await waitFor(() => {
|
||||
expect(lastFrame!()).toContain('[Normal] Item 1');
|
||||
});
|
||||
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
|
||||
|
||||
// Scroll down slightly. Item 1 (height 3) is now partially off-screen (-2), so it should stick.
|
||||
await act(async () => {
|
||||
listRef?.scrollBy(2);
|
||||
});
|
||||
|
||||
// Now Item 1 should be stuck
|
||||
await waitFor(() => {
|
||||
expect(lastFrame!()).toContain('[STICKY] Item 1');
|
||||
});
|
||||
expect(lastFrame!()).not.toContain('[Normal] Item 1');
|
||||
|
||||
// Scroll further down to unmount Item 1.
|
||||
// Viewport height 10, item height 3. Scroll to 10.
|
||||
// startIndex should be around 2, so Item 1 (index 0) is unmounted.
|
||||
await act(async () => {
|
||||
listRef?.scrollTo(10);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
|
||||
});
|
||||
|
||||
// Scroll back to top
|
||||
await act(async () => {
|
||||
listRef?.scrollTo(0);
|
||||
});
|
||||
|
||||
// Should be normal again
|
||||
await waitFor(() => {
|
||||
expect(lastFrame!()).toContain('[Normal] Item 1');
|
||||
});
|
||||
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
|
||||
expect(lastFrame!()).toContain('Count: 1003');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -280,48 +280,4 @@ describe('<VirtualizedList />', () => {
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
expect(lastFrame()).toContain('Item 9');
|
||||
});
|
||||
|
||||
it('updates scroll position correctly when scrollBy is called multiple times in the same tick', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
const itemHeight = 1;
|
||||
const renderItem1px = ({ item }: { item: string }) => (
|
||||
<Box height={itemHeight}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
render(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => itemHeight}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(0);
|
||||
|
||||
await act(async () => {
|
||||
ref.current?.scrollBy(1);
|
||||
ref.current?.scrollBy(1);
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(2);
|
||||
|
||||
await act(async () => {
|
||||
ref.current?.scrollBy(2);
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
|
||||
import { type DOMElement, measureElement, Box } from 'ink';
|
||||
|
||||
@@ -364,8 +363,6 @@ function VirtualizedList<T>(
|
||||
}
|
||||
}
|
||||
|
||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -373,7 +370,7 @@ function VirtualizedList<T>(
|
||||
if (delta < 0) {
|
||||
setIsStickingToBottom(false);
|
||||
}
|
||||
const currentScrollTop = getScrollTop();
|
||||
const currentScrollTop = scrollTop;
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
@@ -381,7 +378,6 @@ function VirtualizedList<T>(
|
||||
currentScrollTop + delta,
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
},
|
||||
scrollTo: (offset: number) => {
|
||||
@@ -390,7 +386,6 @@ function VirtualizedList<T>(
|
||||
0,
|
||||
Math.min(totalHeight - scrollableContainerHeight, offset),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
},
|
||||
scrollToEnd: () => {
|
||||
@@ -421,7 +416,6 @@ function VirtualizedList<T>(
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
}
|
||||
},
|
||||
@@ -446,14 +440,13 @@ function VirtualizedList<T>(
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
}
|
||||
}
|
||||
},
|
||||
getScrollIndex: () => scrollAnchor.index,
|
||||
getScrollState: () => ({
|
||||
scrollTop: getScrollTop(),
|
||||
scrollTop,
|
||||
scrollHeight: totalHeight,
|
||||
innerHeight: containerHeight,
|
||||
}),
|
||||
@@ -465,8 +458,7 @@ function VirtualizedList<T>(
|
||||
getAnchorForScrollTop,
|
||||
data,
|
||||
scrollableContainerHeight,
|
||||
getScrollTop,
|
||||
setPendingScrollTop,
|
||||
scrollTop,
|
||||
containerHeight,
|
||||
],
|
||||
);
|
||||
@@ -481,7 +473,6 @@ function VirtualizedList<T>(
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={1}
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
|
||||
@@ -977,7 +977,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence: 'h',
|
||||
}),
|
||||
);
|
||||
@@ -988,7 +987,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence: 'i',
|
||||
}),
|
||||
);
|
||||
@@ -1006,7 +1004,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence: '\r',
|
||||
}),
|
||||
);
|
||||
@@ -1024,7 +1021,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: false,
|
||||
sequence: '\t',
|
||||
}),
|
||||
);
|
||||
@@ -1042,7 +1038,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: true,
|
||||
paste: false,
|
||||
insertable: false,
|
||||
sequence: '\u001b[9;2u',
|
||||
}),
|
||||
);
|
||||
@@ -1065,7 +1060,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: false,
|
||||
sequence: '\x7f',
|
||||
}),
|
||||
);
|
||||
@@ -1090,7 +1084,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: false,
|
||||
sequence: '\x7f',
|
||||
});
|
||||
result.current.handleInput({
|
||||
@@ -1099,7 +1092,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: false,
|
||||
sequence: '\x7f',
|
||||
});
|
||||
result.current.handleInput({
|
||||
@@ -1108,7 +1100,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: false,
|
||||
sequence: '\x7f',
|
||||
});
|
||||
});
|
||||
@@ -1168,7 +1159,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: false,
|
||||
sequence: '\x1b[D',
|
||||
}),
|
||||
); // cursor [0,1]
|
||||
@@ -1180,7 +1170,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: false,
|
||||
sequence: '\x1b[C',
|
||||
}),
|
||||
); // cursor [0,2]
|
||||
@@ -1200,7 +1189,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence: textWithAnsi,
|
||||
}),
|
||||
);
|
||||
@@ -1218,7 +1206,6 @@ describe('useTextBuffer', () => {
|
||||
meta: false,
|
||||
shift: true,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence: '\r',
|
||||
}),
|
||||
); // Simulates Shift+Enter in VSCode terminal
|
||||
@@ -1423,7 +1410,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence,
|
||||
});
|
||||
|
||||
@@ -1482,7 +1468,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence: largeTextWithUnsafe,
|
||||
}),
|
||||
);
|
||||
@@ -1517,7 +1502,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence: largeTextWithAnsi,
|
||||
}),
|
||||
);
|
||||
@@ -1542,7 +1526,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence: emojis,
|
||||
}),
|
||||
);
|
||||
@@ -1734,35 +1717,12 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: true,
|
||||
sequence: '\r',
|
||||
}),
|
||||
);
|
||||
expect(getBufferState(result).lines).toEqual(['']);
|
||||
});
|
||||
|
||||
it('should not print anything for function keys when singleLine is true', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
viewport,
|
||||
isValidPath: () => false,
|
||||
singleLine: true,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.handleInput({
|
||||
name: 'f1',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
insertable: false,
|
||||
sequence: '\u001bOP',
|
||||
}),
|
||||
);
|
||||
expect(getBufferState(result).lines).toEqual(['']);
|
||||
});
|
||||
|
||||
it('should strip newlines from pasted text when singleLine is true', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
stripUnsafeCharacters,
|
||||
getCachedStringWidth,
|
||||
} from '../../utils/textUtils.js';
|
||||
import type { Key } from '../../contexts/KeypressContext.js';
|
||||
import type { VimAction } from './vim-buffer-actions.js';
|
||||
import { handleVimAction } from './vim-buffer-actions.js';
|
||||
|
||||
@@ -1908,7 +1907,14 @@ export function useTextBuffer({
|
||||
);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key): void => {
|
||||
(key: {
|
||||
name: string;
|
||||
ctrl: boolean;
|
||||
meta: boolean;
|
||||
shift: boolean;
|
||||
paste: boolean;
|
||||
sequence: string;
|
||||
}): void => {
|
||||
const { sequence: input } = key;
|
||||
|
||||
if (key.paste) {
|
||||
@@ -1958,7 +1964,7 @@ export function useTextBuffer({
|
||||
else if (key.name === 'delete' || (key.ctrl && key.name === 'd')) del();
|
||||
else if (key.ctrl && !key.shift && key.name === 'z') undo();
|
||||
else if (key.ctrl && key.shift && key.name === 'z') redo();
|
||||
else if (key.insertable) {
|
||||
else if (input && !key.ctrl && !key.meta && key.name !== 'tab') {
|
||||
insert(input, { paste: key.paste });
|
||||
}
|
||||
},
|
||||
@@ -2260,7 +2266,14 @@ export interface TextBuffer {
|
||||
/**
|
||||
* High level "handleInput" – receives what Ink gives us.
|
||||
*/
|
||||
handleInput: (key: Key) => void;
|
||||
handleInput: (key: {
|
||||
name: string;
|
||||
ctrl: boolean;
|
||||
meta: boolean;
|
||||
shift: boolean;
|
||||
paste: boolean;
|
||||
sequence: string;
|
||||
}) => void;
|
||||
/**
|
||||
* Opens the current buffer contents in the user's preferred terminal text
|
||||
* editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks
|
||||
|
||||
@@ -18,12 +18,6 @@ export const SHELL_COMMAND_NAME = 'Shell Command';
|
||||
|
||||
export const SHELL_NAME = 'Shell';
|
||||
|
||||
// Limit Gemini messages to a very high number of lines to mitigate performance
|
||||
// issues in the worst case if we somehow get an enormous response from Gemini.
|
||||
// This threshold is arbitrary but should be high enough to never impact normal
|
||||
// usage.
|
||||
export const MAX_GEMINI_MESSAGE_LINES = 65536;
|
||||
|
||||
// Tool status symbols used in ToolMessage component
|
||||
export const TOOL_STATUS = {
|
||||
SUCCESS: '✓',
|
||||
|
||||
@@ -9,12 +9,17 @@ import { act } from 'react';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import type { Mock } from 'vitest';
|
||||
import { vi, afterAll, beforeAll } from 'vitest';
|
||||
import { vi } from 'vitest';
|
||||
import type { Key } from './KeypressContext.js';
|
||||
import {
|
||||
KeypressProvider,
|
||||
useKeypressContext,
|
||||
ESC_TIMEOUT,
|
||||
DRAG_COMPLETION_TIMEOUT_MS,
|
||||
KITTY_SEQUENCE_TIMEOUT_MS,
|
||||
// CSI_END_O,
|
||||
// SS3_END,
|
||||
SINGLE_QUOTE,
|
||||
DOUBLE_QUOTE,
|
||||
} from './KeypressContext.js';
|
||||
import { useStdin } from 'ink';
|
||||
import { EventEmitter } from 'node:events';
|
||||
@@ -48,10 +53,12 @@ class MockStdin extends EventEmitter {
|
||||
}
|
||||
|
||||
// Helper function to setup keypress test with standard configuration
|
||||
const setupKeypressTest = () => {
|
||||
const setupKeypressTest = (kittyProtocolEnabled = true) => {
|
||||
const keyHandler = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider>{children}</KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
@@ -60,17 +67,22 @@ const setupKeypressTest = () => {
|
||||
return { result, keyHandler };
|
||||
};
|
||||
|
||||
describe('KeypressContext', () => {
|
||||
describe('KeypressContext - Kitty Protocol', () => {
|
||||
let stdin: MockStdin;
|
||||
const mockSetRawMode = vi.fn();
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider>{children}</KeypressProvider>
|
||||
const wrapper = ({
|
||||
children,
|
||||
kittyProtocolEnabled = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
kittyProtocolEnabled?: boolean;
|
||||
}) => (
|
||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled ?? false}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
beforeAll(() => vi.useFakeTimers());
|
||||
afterAll(() => vi.useRealTimers());
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stdin = new MockStdin();
|
||||
@@ -91,13 +103,16 @@ describe('KeypressContext', () => {
|
||||
sequence: '\x1b[57414u',
|
||||
},
|
||||
])('should recognize $name in kitty protocol', async ({ sequence }) => {
|
||||
const { keyHandler } = setupKeypressTest();
|
||||
const { keyHandler } = setupKeypressTest(true);
|
||||
|
||||
act(() => stdin.write(sequence));
|
||||
act(() => {
|
||||
stdin.write(sequence);
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'return',
|
||||
kittyProtocol: true,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
@@ -105,21 +120,6 @@ describe('KeypressContext', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle backslash return', async () => {
|
||||
const { keyHandler } = setupKeypressTest();
|
||||
|
||||
act(() => stdin.write('\\\r'));
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'return',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
modifier: 'Shift',
|
||||
@@ -139,23 +139,42 @@ describe('KeypressContext', () => {
|
||||
])(
|
||||
'should handle numpad enter with $modifier modifier',
|
||||
async ({ sequence, expected }) => {
|
||||
const { keyHandler } = setupKeypressTest();
|
||||
const { keyHandler } = setupKeypressTest(true);
|
||||
|
||||
act(() => stdin.write(sequence));
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'return',
|
||||
kittyProtocol: true,
|
||||
...expected,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('should not process kitty sequences when kitty protocol is disabled', async () => {
|
||||
const { keyHandler } = setupKeypressTest(false);
|
||||
|
||||
// Send kitty protocol sequence for numpad enter
|
||||
act(() => {
|
||||
stdin.write(`\x1b[57414u`);
|
||||
});
|
||||
|
||||
// When kitty protocol is disabled, the sequence should be passed through
|
||||
// as individual keypresses, not recognized as a single enter key
|
||||
expect(keyHandler).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'return',
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Escape key handling', () => {
|
||||
it('should recognize escape key (keycode 27) in kitty protocol', async () => {
|
||||
const { keyHandler } = setupKeypressTest();
|
||||
const { keyHandler } = setupKeypressTest(true);
|
||||
|
||||
// Send kitty protocol sequence for escape: ESC[27u
|
||||
act(() => {
|
||||
@@ -165,41 +184,19 @@ describe('KeypressContext', () => {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'escape',
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle double Escape', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider>{children}</KeypressProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x1b');
|
||||
vi.advanceTimersByTime(10);
|
||||
stdin.write('\x1b');
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(ESC_TIMEOUT);
|
||||
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ name: 'escape', meta: true }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ name: 'escape', meta: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle lone Escape key (keycode 27) with timeout when kitty protocol is enabled', async () => {
|
||||
// Use real timers for this test to avoid issues with stream/buffer timing
|
||||
vi.useRealTimers();
|
||||
const keyHandler = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider>{children}</KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={true}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
@@ -207,19 +204,23 @@ describe('KeypressContext', () => {
|
||||
// Send just ESC
|
||||
act(() => {
|
||||
stdin.write('\x1b');
|
||||
|
||||
// Should be buffered initially
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(ESC_TIMEOUT + 10);
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'escape',
|
||||
meta: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Should be buffered initially
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Wait for timeout
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'escape',
|
||||
meta: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,7 +254,7 @@ describe('KeypressContext', () => {
|
||||
])(
|
||||
'should recognize $name in kitty protocol',
|
||||
async ({ sequence, expected }) => {
|
||||
const { keyHandler } = setupKeypressTest();
|
||||
const { keyHandler } = setupKeypressTest(true);
|
||||
|
||||
act(() => {
|
||||
stdin.write(sequence);
|
||||
@@ -262,6 +263,7 @@ describe('KeypressContext', () => {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...expected,
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -339,7 +341,10 @@ describe('KeypressContext', () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider debugKeystrokeLogging={false}>
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={true}
|
||||
debugKeystrokeLogging={false}
|
||||
>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
@@ -363,7 +368,10 @@ describe('KeypressContext', () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider debugKeystrokeLogging={true}>
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={true}
|
||||
debugKeystrokeLogging={true}
|
||||
>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
@@ -376,7 +384,76 @@ describe('KeypressContext', () => {
|
||||
act(() => stdin.write('\x1b[27u'));
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
`[DEBUG] Raw StdIn: ${JSON.stringify('\x1b[27u')}`,
|
||||
'[DEBUG] Input buffer accumulating:',
|
||||
expect.stringContaining('"\\u001b[27u"'),
|
||||
);
|
||||
const parsedCall = consoleLogSpy.mock.calls.find(
|
||||
(args) =>
|
||||
typeof args[0] === 'string' &&
|
||||
args[0].includes('[DEBUG] Sequence parsed successfully'),
|
||||
);
|
||||
expect(parsedCall).toBeTruthy();
|
||||
expect(parsedCall?.[1]).toEqual(expect.stringContaining('\\u001b[27u'));
|
||||
});
|
||||
|
||||
it('should log kitty buffer overflow when debugKeystrokeLogging is true', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={true}
|
||||
debugKeystrokeLogging={true}
|
||||
>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
// Send a long sequence starting with a valid kitty prefix to trigger overflow
|
||||
const longSequence = '\x1b[1;' + '1'.repeat(100);
|
||||
act(() => stdin.write(longSequence));
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'[DEBUG] Input buffer overflow, clearing:',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log kitty buffer clear on Ctrl+C when debugKeystrokeLogging is true', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={true}
|
||||
debugKeystrokeLogging={true}
|
||||
>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE));
|
||||
|
||||
// Send Ctrl+C
|
||||
act(() => stdin.write('\x03'));
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'[DEBUG] Input buffer cleared on Ctrl+C:',
|
||||
INCOMPLETE_KITTY_SEQUENCE,
|
||||
);
|
||||
|
||||
// Verify Ctrl+C was handled
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'c',
|
||||
ctrl: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -384,7 +461,10 @@ describe('KeypressContext', () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider debugKeystrokeLogging={true}>
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={true}
|
||||
debugKeystrokeLogging={true}
|
||||
>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
@@ -398,7 +478,14 @@ describe('KeypressContext', () => {
|
||||
|
||||
// Verify debug logging for accumulation
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
`[DEBUG] Raw StdIn: ${JSON.stringify(INCOMPLETE_KITTY_SEQUENCE)}`,
|
||||
'[DEBUG] Input buffer accumulating:',
|
||||
JSON.stringify(INCOMPLETE_KITTY_SEQUENCE),
|
||||
);
|
||||
|
||||
// Verify warning for char codes
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Input sequence buffer has content:',
|
||||
JSON.stringify(INCOMPLETE_KITTY_SEQUENCE),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -467,7 +554,7 @@ describe('KeypressContext', () => {
|
||||
|
||||
describe('Double-tap and batching', () => {
|
||||
it('should emit two delete events for double-tap CSI[3~', async () => {
|
||||
const { keyHandler } = setupKeypressTest();
|
||||
const { keyHandler } = setupKeypressTest(true);
|
||||
|
||||
act(() => stdin.write(`\x1b[3~`));
|
||||
act(() => stdin.write(`\x1b[3~`));
|
||||
@@ -483,7 +570,7 @@ describe('KeypressContext', () => {
|
||||
});
|
||||
|
||||
it('should parse two concatenated tilde-coded sequences in one chunk', async () => {
|
||||
const { keyHandler } = setupKeypressTest();
|
||||
const { keyHandler } = setupKeypressTest(true);
|
||||
|
||||
act(() => stdin.write(`\x1b[3~\x1b[5~`));
|
||||
|
||||
@@ -494,6 +581,145 @@ describe('KeypressContext', () => {
|
||||
expect.objectContaining({ name: 'pageup' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore incomplete CSI then parse the next complete sequence', async () => {
|
||||
const { keyHandler } = setupKeypressTest(true);
|
||||
|
||||
// Incomplete ESC sequence then a complete Delete
|
||||
act(() => {
|
||||
// Provide an incomplete ESC sequence chunk with a real ESC character
|
||||
stdin.write('\x1b[1;');
|
||||
});
|
||||
act(() => stdin.write(`\x1b[3~`));
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'delete' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drag and Drop Handling', () => {
|
||||
let stdin: MockStdin;
|
||||
const mockSetRawMode = vi.fn();
|
||||
|
||||
const wrapper = ({
|
||||
children,
|
||||
kittyProtocolEnabled = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
kittyProtocolEnabled?: boolean;
|
||||
}) => (
|
||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
stdin = new MockStdin();
|
||||
(useStdin as Mock).mockReturnValue({
|
||||
stdin,
|
||||
setRawMode: mockSetRawMode,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('drag start by quotes', () => {
|
||||
it.each([
|
||||
{ name: 'single quote', quote: SINGLE_QUOTE },
|
||||
{ name: 'double quote', quote: DOUBLE_QUOTE },
|
||||
])(
|
||||
'should start collecting when $name arrives and not broadcast immediately',
|
||||
async ({ quote }) => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => stdin.write(quote));
|
||||
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('drag collection and completion', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'collect single character inputs during drag mode',
|
||||
characters: ['a'],
|
||||
expectedText: 'a',
|
||||
},
|
||||
{
|
||||
name: 'collect multiple characters and complete on timeout',
|
||||
characters: ['p', 'a', 't', 'h'],
|
||||
expectedText: 'path',
|
||||
},
|
||||
])('should $name', async ({ characters, expectedText }) => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => stdin.write(SINGLE_QUOTE));
|
||||
|
||||
characters.forEach((char) => {
|
||||
act(() => stdin.write(char));
|
||||
});
|
||||
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DRAG_COMPLETION_TIMEOUT_MS + 10);
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '',
|
||||
paste: true,
|
||||
sequence: `${SINGLE_QUOTE}${expectedText}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kitty Sequence Parsing', () => {
|
||||
let stdin: MockStdin;
|
||||
const mockSetRawMode = vi.fn();
|
||||
|
||||
const wrapper = ({
|
||||
children,
|
||||
kittyProtocolEnabled = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
kittyProtocolEnabled?: boolean;
|
||||
}) => (
|
||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
stdin = new MockStdin();
|
||||
(useStdin as Mock).mockReturnValue({
|
||||
stdin,
|
||||
setRawMode: mockSetRawMode,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Cross-terminal Alt key handling (simulating macOS)', () => {
|
||||
@@ -539,6 +765,7 @@ describe('KeypressContext', () => {
|
||||
meta: true,
|
||||
shift: false,
|
||||
paste: false,
|
||||
kittyProtocol: true,
|
||||
},
|
||||
};
|
||||
} else if (terminal === 'MacTerminal') {
|
||||
@@ -579,10 +806,20 @@ describe('KeypressContext', () => {
|
||||
),
|
||||
)(
|
||||
'should handle Alt+$key in $terminal',
|
||||
({ chunk, expected }: { chunk: string; expected: Partial<Key> }) => {
|
||||
({
|
||||
chunk,
|
||||
expected,
|
||||
kitty = true,
|
||||
}: {
|
||||
chunk: string;
|
||||
expected: Partial<Key>;
|
||||
kitty?: boolean;
|
||||
}) => {
|
||||
const keyHandler = vi.fn();
|
||||
const testWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider>{children}</KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={kitty}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: testWrapper,
|
||||
@@ -599,8 +836,16 @@ describe('KeypressContext', () => {
|
||||
});
|
||||
|
||||
describe('Backslash key handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should treat backslash as a regular keystroke', () => {
|
||||
const { keyHandler } = setupKeypressTest();
|
||||
const { keyHandler } = setupKeypressTest(true);
|
||||
|
||||
act(() => stdin.write('\\'));
|
||||
|
||||
@@ -630,7 +875,7 @@ describe('KeypressContext', () => {
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Advance time just before timeout
|
||||
act(() => vi.advanceTimersByTime(ESC_TIMEOUT - 5));
|
||||
act(() => vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS - 5));
|
||||
|
||||
// Still shouldn't broadcast
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
@@ -641,7 +886,7 @@ describe('KeypressContext', () => {
|
||||
// Should now broadcast the incomplete sequence as regular input
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'undefined',
|
||||
name: '',
|
||||
sequence: INCOMPLETE_KITTY_SEQUENCE,
|
||||
paste: false,
|
||||
}),
|
||||
@@ -681,6 +926,7 @@ describe('KeypressContext', () => {
|
||||
expect.objectContaining({
|
||||
name: 'a',
|
||||
ctrl: true,
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -701,6 +947,7 @@ describe('KeypressContext', () => {
|
||||
expect.objectContaining({
|
||||
name: 'a',
|
||||
ctrl: true,
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
@@ -708,6 +955,31 @@ describe('KeypressContext', () => {
|
||||
expect.objectContaining({
|
||||
name: 'b',
|
||||
ctrl: true,
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear kitty buffer and timeout on Ctrl+C', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE));
|
||||
|
||||
// Press Ctrl+C
|
||||
act(() => stdin.write('\x03'));
|
||||
|
||||
// Advance past timeout
|
||||
act(() => vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS + 10));
|
||||
|
||||
// Should only have received Ctrl+C, not the incomplete sequence
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'c',
|
||||
ctrl: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -728,6 +1000,7 @@ describe('KeypressContext', () => {
|
||||
1,
|
||||
expect.objectContaining({
|
||||
name: 'return',
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
@@ -738,31 +1011,61 @@ describe('KeypressContext', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([1, ESC_TIMEOUT - 1])(
|
||||
'should handle sequences arriving character by character with %s ms delay',
|
||||
async (delay) => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
it('should not buffer sequences when kitty protocol is disabled', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper: ({ children }) =>
|
||||
wrapper({ children, kittyProtocolEnabled: false }),
|
||||
});
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
// Send kitty sequence character by character
|
||||
for (const char of '\x1b[27u') {
|
||||
act(() => stdin.write(char));
|
||||
// Advance time but not enough to timeout
|
||||
vi.advanceTimersByTime(delay);
|
||||
}
|
||||
// Send what would be a kitty sequence
|
||||
act(() => stdin.write('\x1b[13u'));
|
||||
|
||||
// Should parse once complete
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'escape',
|
||||
}),
|
||||
);
|
||||
// Should pass through without parsing
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sequence: '\x1b[13u',
|
||||
}),
|
||||
);
|
||||
expect(keyHandler).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'return',
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle sequences arriving character by character', async () => {
|
||||
vi.useRealTimers(); // Required for correct buffering timing.
|
||||
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Send kitty sequence character by character
|
||||
const sequence = '\x1b[27u'; // Escape key
|
||||
for (const char of sequence) {
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from(char));
|
||||
});
|
||||
},
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
// Should parse once complete
|
||||
await waitFor(() => {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'escape',
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset timeout when new input arrives', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
@@ -792,10 +1095,108 @@ describe('KeypressContext', () => {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'a',
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should flush incomplete kitty sequence on FOCUS_IN event', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE));
|
||||
|
||||
// Incomplete sequence should be buffered, not broadcast
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Send FOCUS_IN event
|
||||
act(() => stdin.write('\x1b[I'));
|
||||
|
||||
// The buffered sequence should be flushed
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '',
|
||||
sequence: INCOMPLETE_KITTY_SEQUENCE,
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should flush incomplete kitty sequence on FOCUS_OUT event', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE));
|
||||
|
||||
// Incomplete sequence should be buffered, not broadcast
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Send FOCUS_OUT event
|
||||
act(() => stdin.write('\x1b[O'));
|
||||
|
||||
// The buffered sequence should be flushed
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '',
|
||||
sequence: INCOMPLETE_KITTY_SEQUENCE,
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should flush incomplete kitty sequence on paste event', async () => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE));
|
||||
|
||||
// Incomplete sequence should be buffered, not broadcast
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Send paste start sequence
|
||||
act(() => stdin.write(`\x1b[200~`));
|
||||
|
||||
// The buffered sequence should be flushed
|
||||
expect(keyHandler).toHaveBeenCalledTimes(1);
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '',
|
||||
sequence: INCOMPLETE_KITTY_SEQUENCE,
|
||||
paste: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Now send some paste content and end paste to make sure paste still works
|
||||
const pastedText = 'hello';
|
||||
const PASTE_MODE_SUFFIX = `\x1b[201~`;
|
||||
act(() => {
|
||||
stdin.write(pastedText);
|
||||
stdin.write(PASTE_MODE_SUFFIX);
|
||||
});
|
||||
|
||||
act(() => vi.runAllTimers());
|
||||
|
||||
// The paste event should be broadcast
|
||||
expect(keyHandler).toHaveBeenCalledTimes(2);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: pastedText,
|
||||
}),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('SGR Mouse Handling', () => {
|
||||
it('should ignore SGR mouse sequences', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
@@ -847,13 +1248,16 @@ describe('KeypressContext', () => {
|
||||
// Space is 32. 32+0=32 (button 0), 32+33=65 ('A', col 33), 32+34=66 ('B', row 34)
|
||||
const x11Seq = '\x1b[M AB';
|
||||
|
||||
act(() => stdin.write(x11Seq));
|
||||
act(() => {
|
||||
stdin.write(x11Seq);
|
||||
});
|
||||
|
||||
// Should not broadcast as keystrokes
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not flush slow SGR mouse sequences as garbage', async () => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
@@ -863,13 +1267,15 @@ describe('KeypressContext', () => {
|
||||
act(() => stdin.write('\x1b[<'));
|
||||
|
||||
// Advance time past the normal kitty timeout (50ms)
|
||||
act(() => vi.advanceTimersByTime(ESC_TIMEOUT + 10));
|
||||
act(() => vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS + 10));
|
||||
|
||||
// Send the rest
|
||||
act(() => stdin.write('0;37;25M'));
|
||||
|
||||
// Should NOT have flushed the prefix as garbage, and should have consumed the whole thing
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should ignore specific SGR mouse sequence sandwiched between keystrokes', async () => {
|
||||
@@ -897,44 +1303,61 @@ describe('KeypressContext', () => {
|
||||
});
|
||||
|
||||
describe('Ignored Sequences', () => {
|
||||
it.each([
|
||||
{ name: 'Focus In', sequence: '\x1b[I' },
|
||||
{ name: 'Focus Out', sequence: '\x1b[O' },
|
||||
{ name: 'SGR Mouse Release', sequence: '\u001b[<0;44;18m' },
|
||||
{ name: 'something mouse', sequence: '\u001b[<0;53;19M' },
|
||||
{ name: 'another mouse', sequence: '\u001b[<0;29;19m' },
|
||||
])('should ignore $name sequence', async ({ sequence }) => {
|
||||
describe.each([true, false])(
|
||||
'with kittyProtocolEnabled = %s',
|
||||
(kittyEnabled) => {
|
||||
it.each([
|
||||
{ name: 'Focus In', sequence: '\x1b[I' },
|
||||
{ name: 'Focus Out', sequence: '\x1b[O' },
|
||||
{ name: 'SGR Mouse Release', sequence: '\u001b[<0;44;18m' },
|
||||
{ name: 'something mouse', sequence: '\u001b[<0;53;19M' },
|
||||
{ name: 'another mouse', sequence: '\u001b[<0;29;19m' },
|
||||
])('should ignore $name sequence', async ({ sequence }) => {
|
||||
vi.useFakeTimers();
|
||||
const keyHandler = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider kittyProtocolEnabled={kittyEnabled}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper,
|
||||
});
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
for (const char of sequence) {
|
||||
act(() => {
|
||||
stdin.write(char);
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
}
|
||||
|
||||
act(() => {
|
||||
stdin.write('HI');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledTimes(2);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ name: 'h', sequence: 'H', shift: true }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ name: 'i', sequence: 'I', shift: true }),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle F12 when kittyProtocolEnabled is false', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider>{children}</KeypressProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useKeypressContext(), {
|
||||
wrapper,
|
||||
});
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
for (const char of sequence) {
|
||||
act(() => stdin.write(char));
|
||||
act(() => vi.advanceTimersByTime(0));
|
||||
}
|
||||
|
||||
act(() => stdin.write('HI'));
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledTimes(2);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ name: 'h', sequence: 'H', shift: true }),
|
||||
);
|
||||
expect(keyHandler).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ name: 'i', sequence: 'I', shift: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle F12', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider>{children}</KeypressProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
{children}
|
||||
</KeypressProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
@@ -948,27 +1371,4 @@ describe('KeypressContext', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Individual Character Input', () => {
|
||||
it.each([
|
||||
'abc', // ASCII character
|
||||
'你好', // Chinese characters
|
||||
'こんにちは', // Japanese characters
|
||||
'안녕하세요', // Korean characters
|
||||
'A你B好C', // Mixed characters
|
||||
])('should correctly handle string "%s"', async (inputString) => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => stdin.write(inputString));
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledTimes(inputString.length);
|
||||
for (const char of inputString) {
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sequence: char }),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user