mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c6d3eb512 | |||
| 79d8673793 | |||
| 90adfb9a5a | |||
| 37af6f4f8a | |||
| 9893da300f | |||
| 3032a82425 | |||
| 9ac47ebf8b | |||
| f581ae81db | |||
| 3955871052 | |||
| e192efa1f9 | |||
| 1cab681854 | |||
| b37c674f2b | |||
| 046b3011c2 | |||
| 0c4d3b2666 | |||
| d13152b05f | |||
| 24b5eec883 | |||
| 16f40a2847 | |||
| 77751a0739 | |||
| 4ae2d4b184 | |||
| 2639d74814 | |||
| fcd9b2a5fe | |||
| 605d9167dd | |||
| 128c22ece6 | |||
| e27197096a | |||
| 48fa48ca38 | |||
| dfe7fc9a53 | |||
| af5a1ebec5 |
@@ -74,6 +74,19 @@ This document lists the available keyboard shortcuts within Gemini CLI.
|
||||
| -------- | --------------------------------- |
|
||||
| `Ctrl+G` | See context CLI received from IDE |
|
||||
|
||||
#### Scrolling
|
||||
|
||||
| Action | Keys |
|
||||
| ------------------------ | -------------------- |
|
||||
| Scroll content up. | `Shift + Up Arrow` |
|
||||
| Scroll content down. | `Shift + Down Arrow` |
|
||||
| Scroll to the top. | `Home` |
|
||||
| Scroll to the bottom. | `End` |
|
||||
| Scroll up by one page. | `Page Up` |
|
||||
| Scroll down by one page. | `Page Down` |
|
||||
|
||||
#### History & Search
|
||||
|
||||
## Meta+key combos on mac
|
||||
|
||||
On Mac, all Meta+char combos should work normally except for these three which
|
||||
|
||||
@@ -232,7 +232,14 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **`ui.useAlternateBuffer`** (boolean):
|
||||
- **Description:** Use an alternate screen buffer for the UI, preserving shell
|
||||
history.
|
||||
- **Default:** `false`
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.incrementalRendering`** (boolean):
|
||||
- **Description:** Enable incremental rendering for the UI. This option will
|
||||
reduce flickering but may cause rendering artifacts. Only supported when
|
||||
useAlternateBuffer is enabled.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.customWittyPhrases`** (array):
|
||||
@@ -480,8 +487,8 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
#### `useWriteTodos`
|
||||
|
||||
- **`useWriteTodos`** (boolean):
|
||||
- **Description:** Enable the write_todos_list tool.
|
||||
- **Default:** `false`
|
||||
- **Description:** Enable the write_todos tool.
|
||||
- **Default:** `true`
|
||||
|
||||
#### `security`
|
||||
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ Gemini CLI's built-in tools can be broadly categorized as follows:
|
||||
- **[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.14.0) A specialized tool for reading content from
|
||||
will be removed in v0.16.0) A specialized tool for reading content from
|
||||
multiple files or directories.
|
||||
- **[Memory Tool](./memory.md) (`save_memory`):** For saving and recalling
|
||||
information across sessions.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Multi File Read Tool (`read_many_files`)
|
||||
|
||||
> **Deprecated:** This tool is deprecated and will be removed in v0.14.0. Please
|
||||
> **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`.
|
||||
|
||||
|
||||
+4
-3
@@ -6,7 +6,8 @@ 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.
|
||||
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.
|
||||
|
||||
### Arguments
|
||||
|
||||
@@ -49,8 +50,8 @@ write_todos({
|
||||
|
||||
## Important notes
|
||||
|
||||
- **Enabling:** This tool is disabled by default. To use it, you must enable it
|
||||
in your `settings.json` file by setting `"useWriteTodos": true`.
|
||||
- **Enabling:** This tool is enabled by default. You can disable it in your
|
||||
`settings.json` file by setting `"useWriteTodos": false`.
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -84,8 +84,11 @@ describe('extension reloading', () => {
|
||||
await run.expectText('- hello');
|
||||
|
||||
// Update the extension, expect the list to update, and mcp servers as well.
|
||||
await run.sendText('/extensions update test-extension');
|
||||
await run.type('\r');
|
||||
await run.sendKeys('/extensions update test-extension');
|
||||
await run.expectText('/extensions update test-extension');
|
||||
await run.sendKeys('\r');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await run.sendKeys('\r');
|
||||
await run.expectText(
|
||||
` * test-server (remote): http://localhost:${portB}/mcp`,
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
describe('read_many_files', () => {
|
||||
it('should be able to read multiple files', async () => {
|
||||
it.skip('should be able to read multiple files', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should be able to read multiple files');
|
||||
rig.createFile('file1.txt', 'file 1 content');
|
||||
|
||||
@@ -192,7 +192,12 @@ export class InteractiveRun {
|
||||
timeout,
|
||||
200,
|
||||
);
|
||||
expect(found, `Did not find expected text: "${text}"`).toBe(true);
|
||||
expect(
|
||||
found,
|
||||
`Did not find expected text: "${text}". Output was:\n${stripAnsi(
|
||||
this.output,
|
||||
)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// This types slowly to make sure command is correct, but only work for short
|
||||
@@ -1004,7 +1009,7 @@ export class TestRig {
|
||||
const options: pty.IPtyForkOptions = {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
rows: 80,
|
||||
cwd: this.testDir!,
|
||||
env: Object.fromEntries(
|
||||
Object.entries(env).filter(([, v]) => v !== undefined),
|
||||
|
||||
Generated
+12
-12
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.2",
|
||||
"ink": "npm:@jrichman/ink@6.4.3",
|
||||
"latest-version": "^9.0.0",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
@@ -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.3",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.3.tgz",
|
||||
"integrity": "sha512-2qm05tjtdia+d1gD7LQjPJyCPJluKDuR5B+FI3ZZXshFoU1igZBFvXs2++x9OT6d9755q+gkRPOdtH8jzx5MiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
@@ -16960,7 +16960,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.2",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17250,7 +17250,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -17266,7 +17266,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.3",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
@@ -17350,7 +17350,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@google-cloud/logging": "^11.2.1",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
|
||||
@@ -17494,7 +17494,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
@@ -17505,7 +17505,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"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-nightly.20251111.51f952e7"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.1"
|
||||
},
|
||||
"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.3",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -118,7 +118,7 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.2",
|
||||
"ink": "npm:@jrichman/ink@6.4.3",
|
||||
"latest-version": "^9.0.0",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"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-nightly.20251111.51f952e7"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.1"
|
||||
},
|
||||
"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.3",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -25,6 +25,14 @@ export enum Command {
|
||||
// Screen control
|
||||
CLEAR_SCREEN = 'clearScreen',
|
||||
|
||||
// Scrolling
|
||||
SCROLL_UP = 'scrollUp',
|
||||
SCROLL_DOWN = 'scrollDown',
|
||||
SCROLL_HOME = 'scrollHome',
|
||||
SCROLL_END = 'scrollEnd',
|
||||
PAGE_UP = 'pageUp',
|
||||
PAGE_DOWN = 'pageDown',
|
||||
|
||||
// History navigation
|
||||
HISTORY_UP = 'historyUp',
|
||||
HISTORY_DOWN = 'historyDown',
|
||||
@@ -120,6 +128,14 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
// Screen control
|
||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||
|
||||
// Scrolling
|
||||
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
|
||||
[Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],
|
||||
[Command.SCROLL_HOME]: [{ key: 'home' }],
|
||||
[Command.SCROLL_END]: [{ key: 'end' }],
|
||||
[Command.PAGE_UP]: [{ key: 'pageup' }],
|
||||
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
|
||||
|
||||
// History navigation
|
||||
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
|
||||
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const SettingPaths = {
|
||||
General: {
|
||||
PreferredEditor: 'general.preferredEditor',
|
||||
},
|
||||
} as const;
|
||||
@@ -33,6 +33,7 @@ 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;
|
||||
@@ -108,7 +109,7 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
memoryImportFormat: 'context.importFormat',
|
||||
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
|
||||
model: 'model.name',
|
||||
preferredEditor: 'general.preferredEditor',
|
||||
preferredEditor: SettingPaths.General.PreferredEditor,
|
||||
retryFetchErrors: 'general.retryFetchErrors',
|
||||
sandbox: 'tools.sandbox',
|
||||
selectedAuthType: 'security.auth.selectedType',
|
||||
|
||||
@@ -497,11 +497,21 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Use Alternate Screen Buffer',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description:
|
||||
'Use an alternate screen buffer for the UI, preserving shell history.',
|
||||
showInDialog: true,
|
||||
},
|
||||
incrementalRendering: {
|
||||
type: 'boolean',
|
||||
label: 'Incremental Rendering',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
customWittyPhrases: {
|
||||
type: 'array',
|
||||
label: 'Custom Witty Phrases',
|
||||
@@ -1080,8 +1090,8 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Use Write Todos',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable the write_todos_list tool.',
|
||||
default: true,
|
||||
description: 'Enable the write_todos tool.',
|
||||
showInDialog: false,
|
||||
},
|
||||
security: {
|
||||
|
||||
@@ -474,6 +474,8 @@ describe('startInteractiveUI', () => {
|
||||
|
||||
vi.mock('./ui/utils/kittyProtocolDetector.js', () => ({
|
||||
detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve(true)),
|
||||
isKittyProtocolSupported: vi.fn(() => true),
|
||||
isKittyProtocolEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('./ui/utils/updateCheck.js', () => ({
|
||||
@@ -529,7 +531,9 @@ describe('startInteractiveUI', () => {
|
||||
|
||||
// Verify render options
|
||||
expect(options).toEqual({
|
||||
alternateBuffer: true,
|
||||
exitOnCtrlC: false,
|
||||
incrementalRendering: true,
|
||||
isScreenReaderEnabled: false,
|
||||
onRender: expect.any(Function),
|
||||
});
|
||||
|
||||
+16
-24
@@ -76,6 +76,7 @@ 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';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
|
||||
@@ -157,31 +158,19 @@ export async function startInteractiveUI(
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
// When not in screen reader mode, disable line wrapping.
|
||||
// We rely on Ink to manage all line wrapping by forcing all content to be
|
||||
// narrower than the terminal width so there is no need for the terminal to
|
||||
// also attempt line wrapping.
|
||||
// Disabling line wrapping reduces Ink rendering artifacts particularly when
|
||||
// the terminal is resized on terminals that full respect this escape code
|
||||
// such as Ghostty. Some terminals such as Iterm2 only respect line wrapping
|
||||
// when using the alternate buffer, which Gemini CLI does not use because we
|
||||
// do not yet have support for scrolling in that mode.
|
||||
if (!config.getScreenReader()) {
|
||||
process.stdout.write('\x1b[?7l');
|
||||
}
|
||||
|
||||
const mouseEventsEnabled = settings.merged.ui?.useAlternateBuffer === true;
|
||||
// Never enter Ink alternate buffer mode when screen reader mode is enabled
|
||||
// as there is no benefit of alternate buffer mode when using a screen reader
|
||||
// and the Ink alternate buffer mode requires line wrapping harmful to
|
||||
// screen readers.
|
||||
const useAlternateBuffer =
|
||||
isAlternateBufferEnabled(settings) && !config.getScreenReader();
|
||||
const mouseEventsEnabled = useAlternateBuffer;
|
||||
if (mouseEventsEnabled) {
|
||||
enableMouseEvents();
|
||||
}
|
||||
|
||||
registerCleanup(() => {
|
||||
// Re-enable line wrapping on exit.
|
||||
process.stdout.write('\x1b[?7h');
|
||||
if (mouseEventsEnabled) {
|
||||
registerCleanup(() => {
|
||||
disableMouseEvents();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const version = await getCliVersion();
|
||||
setWindowTitle(basename(workspaceRoot), settings);
|
||||
@@ -236,7 +225,10 @@ export async function startInteractiveUI(
|
||||
recordSlowRender(config, renderTime);
|
||||
}
|
||||
},
|
||||
alternateBuffer: settings.merged.ui?.useAlternateBuffer,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
incrementalRendering:
|
||||
settings.merged.ui?.incrementalRendering !== false &&
|
||||
useAlternateBuffer,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -437,7 +429,7 @@ export async function main() {
|
||||
// input showing up in the output.
|
||||
process.stdin.setRawMode(true);
|
||||
|
||||
if (settings.merged.ui?.useAlternateBuffer) {
|
||||
if (isAlternateBufferEnabled(settings)) {
|
||||
process.stdout.write(ansiEscapes.enterAlternativeScreen);
|
||||
|
||||
// Ink will cleanup so there is no need for us to manually cleanup.
|
||||
|
||||
@@ -19,6 +19,7 @@ import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js';
|
||||
import { VimModeProvider } from '../ui/contexts/VimModeContext.js';
|
||||
import { MouseProvider } from '../ui/contexts/MouseContext.js';
|
||||
import { ScrollProvider } from '../ui/contexts/ScrollProvider.js';
|
||||
import { StreamingContext } from '../ui/contexts/StreamingContext.js';
|
||||
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -69,6 +70,9 @@ const mockConfig = {
|
||||
getTargetDir: () =>
|
||||
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
|
||||
getDebugMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getEnableInteractiveShell: () => true,
|
||||
};
|
||||
|
||||
const configProxy = new Proxy(mockConfig, {
|
||||
@@ -177,20 +181,22 @@ export const renderWithProviders = (
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider settings={finalSettings}>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<KeypressProvider>
|
||||
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
<StreamingContext.Provider value={finalUiState.streamingState}>
|
||||
<KeypressProvider>
|
||||
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</StreamingContext.Provider>
|
||||
</ShellFocusContext.Provider>
|
||||
</VimModeProvider>
|
||||
</UIStateContext.Provider>
|
||||
|
||||
@@ -109,7 +109,7 @@ 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 WARNING_PROMPT_DURATION_MS = 1000;
|
||||
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
|
||||
|
||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
@@ -734,9 +734,16 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
const handleClearScreen = useCallback(() => {
|
||||
historyManager.clearItems();
|
||||
clearConsoleMessagesState();
|
||||
console.clear();
|
||||
if (!isAlternateBuffer) {
|
||||
console.clear();
|
||||
}
|
||||
refreshStatic();
|
||||
}, [historyManager, clearConsoleMessagesState, refreshStatic]);
|
||||
}, [
|
||||
historyManager,
|
||||
clearConsoleMessagesState,
|
||||
refreshStatic,
|
||||
isAlternateBuffer,
|
||||
]);
|
||||
|
||||
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
||||
|
||||
@@ -885,6 +892,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
>();
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
|
||||
const [selectionWarning, setSelectionWarning] = useState(false);
|
||||
|
||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
|
||||
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
|
||||
@@ -894,6 +902,26 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
} = useIdeTrustListener();
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
const handleSelectionWarning = () => {
|
||||
setSelectionWarning(true);
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
timeoutId = setTimeout(() => {
|
||||
setSelectionWarning(false);
|
||||
}, WARNING_PROMPT_DURATION_MS);
|
||||
};
|
||||
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (ideNeedsRestart) {
|
||||
// IDE trust changed, force a restart.
|
||||
@@ -969,7 +997,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
ctrlCTimerRef.current = setTimeout(() => {
|
||||
setCtrlCPressCount(0);
|
||||
ctrlCTimerRef.current = null;
|
||||
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||
}, WARNING_PROMPT_DURATION_MS);
|
||||
}
|
||||
}, [ctrlCPressCount, config, setCtrlCPressCount, handleSlashCommand]);
|
||||
|
||||
@@ -987,7 +1015,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
ctrlDTimerRef.current = setTimeout(() => {
|
||||
setCtrlDPressCount(0);
|
||||
ctrlDTimerRef.current = null;
|
||||
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||
}, WARNING_PROMPT_DURATION_MS);
|
||||
}
|
||||
}, [ctrlDPressCount, config, setCtrlDPressCount, handleSlashCommand]);
|
||||
|
||||
@@ -1338,6 +1366,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
copyModeEnabled,
|
||||
selectionWarning,
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
@@ -1423,6 +1452,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
||||
apiKeyDefaultValue,
|
||||
authState,
|
||||
copyModeEnabled,
|
||||
selectionWarning,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ 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: () => ({
|
||||
@@ -32,14 +31,6 @@ 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,
|
||||
|
||||
@@ -36,3 +36,42 @@ export const tinyAsciiLogo = `
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
`;
|
||||
|
||||
export const shortAsciiLogoIde = `
|
||||
░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░
|
||||
░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░
|
||||
░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░
|
||||
█████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░
|
||||
███░░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░
|
||||
███░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░
|
||||
███░░░░████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ██████ ███
|
||||
███ ███ ███ ███ ███ ███ ███ █████ ███
|
||||
█████████ ██████████ ███ ███ █████ ███ █████ █████
|
||||
`;
|
||||
|
||||
export const longAsciiLogoIde = `
|
||||
░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░
|
||||
░░░ ░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░
|
||||
░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░
|
||||
███ ░░░ █████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░
|
||||
███ ░░░ ███░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░
|
||||
███ ███░░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░
|
||||
░░░ ███ ███ ░░░█████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ███ ██████ ███
|
||||
███ ███ ███ ███ ███ ███ ███ ███ █████ ███
|
||||
███ █████████ ██████████ ███ ███ █████ ███ █████ █████
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogoIde = `
|
||||
░░░ ░░░░░░░░░
|
||||
░░░ ░░░ ░░░
|
||||
░░░ ░░░
|
||||
███ ░░░ █████████░░░
|
||||
███ ░░░ ███░░ ███░░
|
||||
███ ███░░ ░░░
|
||||
░░░ ███ ███░░░░████░
|
||||
███ ███ ███
|
||||
███ ███ ███
|
||||
███ █████████
|
||||
`;
|
||||
|
||||
@@ -99,6 +99,10 @@ export const Composer = () => {
|
||||
<Text color={theme.status.warning}>
|
||||
Press Ctrl+C again to exit.
|
||||
</Text>
|
||||
) : uiState.selectionWarning ? (
|
||||
<Text color={theme.status.warning}>
|
||||
Press Ctrl-S to enter selection mode to copy text.
|
||||
</Text>
|
||||
) : uiState.ctrlDPressedOnce ? (
|
||||
<Text color={theme.status.warning}>
|
||||
Press Ctrl+D again to exit.
|
||||
|
||||
@@ -8,12 +8,16 @@ import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { Header } from './Header.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import { longAsciiLogo } from './AsciiArt.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';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
getTerminalProgram: vi.fn(),
|
||||
}));
|
||||
vi.mock('ink-gradient', () => {
|
||||
const MockGradient = ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
@@ -34,6 +38,7 @@ vi.mock('ink', async () => {
|
||||
describe('<Header />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('renders the long logo on a wide terminal', () => {
|
||||
@@ -50,6 +55,22 @@ describe('<Header />', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders custom ASCII art when provided', () => {
|
||||
const customArt = 'CUSTOM ART';
|
||||
render(
|
||||
@@ -63,6 +84,20 @@ describe('<Header />', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
it('displays the version number when nightly is true', () => {
|
||||
render(<Header version="1.0.0" nightly={true} />);
|
||||
const textCalls = (Text as Mock).mock.calls;
|
||||
|
||||
@@ -8,9 +8,17 @@ 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 } from './AsciiArt.js';
|
||||
import {
|
||||
shortAsciiLogo,
|
||||
longAsciiLogo,
|
||||
tinyAsciiLogo,
|
||||
shortAsciiLogoIde,
|
||||
longAsciiLogoIde,
|
||||
tinyAsciiLogoIde,
|
||||
} 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
|
||||
@@ -44,6 +52,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
nightly,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isIde = getTerminalProgram();
|
||||
let displayTitle;
|
||||
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
||||
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
|
||||
@@ -51,11 +60,11 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
if (customAsciiArt) {
|
||||
displayTitle = customAsciiArt;
|
||||
} else if (terminalWidth >= widthOfLongLogo) {
|
||||
displayTitle = longAsciiLogo;
|
||||
displayTitle = isIde ? longAsciiLogoIde : longAsciiLogo;
|
||||
} else if (terminalWidth >= widthOfShortLogo) {
|
||||
displayTitle = shortAsciiLogo;
|
||||
displayTitle = isIde ? shortAsciiLogoIde : shortAsciiLogo;
|
||||
} else {
|
||||
displayTitle = tinyAsciiLogo;
|
||||
displayTitle = isIde ? tinyAsciiLogoIde : tinyAsciiLogo;
|
||||
}
|
||||
|
||||
const artWidth = getAsciiArtWidth(displayTitle);
|
||||
|
||||
@@ -60,4 +60,15 @@ describe('Help Component', () => {
|
||||
expect(output).not.toContain('hidden-child');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render keyboard shortcuts', () => {
|
||||
const { lastFrame, unmount } = render(<Help commands={mockCommands} />);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Keyboard Shortcuts:');
|
||||
expect(output).toContain('Ctrl+C');
|
||||
expect(output).toContain('Ctrl+S');
|
||||
expect(output).toContain('Page Up/Down');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +136,12 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
</Text>{' '}
|
||||
- Clear the screen
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Ctrl+S
|
||||
</Text>{' '}
|
||||
- Enter selection mode to copy text
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'}
|
||||
@@ -160,6 +166,12 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
</Text>{' '}
|
||||
- Cancel operation / Clear input (double press)
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Page Up/Down
|
||||
</Text>{' '}
|
||||
- Scroll page up/down
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Shift+Tab
|
||||
|
||||
@@ -379,8 +379,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const relY = mouseY - y;
|
||||
const visualRow = buffer.visualScrollRow + relY;
|
||||
buffer.moveToVisualPosition(visualRow, relX);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[buffer],
|
||||
);
|
||||
|
||||
@@ -10,9 +10,14 @@ import { StickyHeader } from './StickyHeader.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
|
||||
describe('StickyHeader', () => {
|
||||
it('renders children', () => {
|
||||
it.each([true, false])('renders children with isFirst=%s', (isFirst) => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<StickyHeader width={80}>
|
||||
<StickyHeader
|
||||
isFirst={isFirst}
|
||||
width={80}
|
||||
borderColor="green"
|
||||
borderDimColor={false}
|
||||
>
|
||||
<Text>Hello Sticky</Text>
|
||||
</StickyHeader>,
|
||||
);
|
||||
|
||||
@@ -11,11 +11,17 @@ import { theme } from '../semantic-colors.js';
|
||||
export interface StickyHeaderProps {
|
||||
children: React.ReactNode;
|
||||
width: number;
|
||||
isFirst: boolean;
|
||||
borderColor: string;
|
||||
borderDimColor: boolean;
|
||||
}
|
||||
|
||||
export const StickyHeader: React.FC<StickyHeaderProps> = ({
|
||||
children,
|
||||
width,
|
||||
isFirst,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
}) => (
|
||||
<Box
|
||||
sticky
|
||||
@@ -24,20 +30,43 @@ export const StickyHeader: React.FC<StickyHeaderProps> = ({
|
||||
width={width}
|
||||
stickyChildren={
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderStyle="round"
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
opaque
|
||||
borderColor={theme.ui.dark}
|
||||
borderTop={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
paddingX={1}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderBottom={false}
|
||||
borderTop={isFirst}
|
||||
paddingTop={isFirst ? 0 : 1}
|
||||
>
|
||||
{children}
|
||||
<Box paddingX={1}>{children}</Box>
|
||||
{/* Dark border to separate header from content. */}
|
||||
<Box
|
||||
width={width - 2}
|
||||
borderColor={theme.ui.dark}
|
||||
borderStyle="single"
|
||||
borderTop={false}
|
||||
borderBottom={true}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
></Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box paddingX={1} width={width}>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
width={width}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderBottom={false}
|
||||
borderTop={isFirst}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
paddingX={1}
|
||||
paddingBottom={1}
|
||||
paddingTop={isFirst ? 0 : 1}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
+12
-9
@@ -16,13 +16,16 @@ Tips for getting started:
|
||||
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 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
╭─────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─────────────────────────────────────────────────────────────────────────────╮
|
||||
│ o tool3 Description for tool 3 │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -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`] = `" ...irectories/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`] = `" ...irectories/to/make/it/long no sandbox (see /docs)"`;
|
||||
|
||||
@@ -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 │
|
||||
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -20,7 +20,7 @@ const createTodoHistoryItem = (todos: Todo[]): HistoryItem =>
|
||||
id: '1',
|
||||
tools: [
|
||||
{
|
||||
name: 'write_todos_list',
|
||||
name: 'write_todos',
|
||||
callId: 'tool-1',
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: {
|
||||
|
||||
@@ -41,7 +41,6 @@ export const ToolConfirmationMessage: React.FC<
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { onConfirm } = confirmationDetails;
|
||||
const childWidth = terminalWidth - 2; // 2 for padding
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
@@ -249,21 +248,15 @@ export const ToolConfirmationMessage: React.FC<
|
||||
</Box>
|
||||
);
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1}>
|
||||
{isAlternateBuffer ? (
|
||||
commandBox
|
||||
) : (
|
||||
<MaxSizedBox
|
||||
maxHeight={bodyContentHeight}
|
||||
maxWidth={Math.max(childWidth, 1)}
|
||||
>
|
||||
{commandBox}
|
||||
</MaxSizedBox>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
bodyContent = isAlternateBuffer ? (
|
||||
commandBox
|
||||
) : (
|
||||
<MaxSizedBox
|
||||
maxHeight={bodyContentHeight}
|
||||
maxWidth={Math.max(terminalWidth, 1)}
|
||||
>
|
||||
{commandBox}
|
||||
</MaxSizedBox>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'info') {
|
||||
const infoProps = confirmationDetails;
|
||||
@@ -274,7 +267,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
);
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.link}>
|
||||
<RenderInline
|
||||
text={infoProps.prompt}
|
||||
@@ -299,7 +292,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.link}>MCP Server: {mcpProps.serverName}</Text>
|
||||
<Text color={theme.text.link}>Tool: {mcpProps.toolName}</Text>
|
||||
</Box>
|
||||
@@ -315,7 +308,6 @@ export const ToolConfirmationMessage: React.FC<
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
isAlternateBuffer,
|
||||
childWidth,
|
||||
]);
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
@@ -326,7 +318,8 @@ export const ToolConfirmationMessage: React.FC<
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
justifyContent="space-around"
|
||||
padding={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Text color={theme.text.primary}>Modify in progress: </Text>
|
||||
@@ -342,23 +335,17 @@ export const ToolConfirmationMessage: React.FC<
|
||||
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
|
||||
{/* 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}>
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{question}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Select Input for Options */}
|
||||
<Box flexShrink={0} paddingX={1}>
|
||||
<Box flexShrink={0}>
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
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 { TOOL_STATUS } from '../../constants.js';
|
||||
import { Scrollable } from '../shared/Scrollable.js';
|
||||
import { Text } from 'ink';
|
||||
|
||||
// Mock child components to isolate ToolGroupMessage behavior
|
||||
vi.mock('./ToolMessage.js', () => ({
|
||||
@@ -250,6 +251,76 @@ describe('<ToolGroupMessage />', () => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders header when scrolled', () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
callId: '1',
|
||||
name: 'tool-1',
|
||||
description:
|
||||
'Description 1. This is a long description that will need to be truncated if the terminal width is small.',
|
||||
resultDisplay: 'line1\nline2\nline3\nline4\nline5',
|
||||
}),
|
||||
createToolCall({
|
||||
callId: '2',
|
||||
name: 'tool-2',
|
||||
description: 'Description 2',
|
||||
resultDisplay: 'line1\nline2',
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<Scrollable height={10} hasFocus={true} scrollToBottom={true}>
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />
|
||||
</Scrollable>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders tool call with outputFile', () => {
|
||||
const toolCalls = [
|
||||
createToolCall({
|
||||
callId: 'tool-output-file',
|
||||
name: 'tool-with-file',
|
||||
description: 'Tool that saved output to file',
|
||||
status: ToolCallStatus.Success,
|
||||
outputFile: '/path/to/output.txt',
|
||||
}),
|
||||
];
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders two tool groups where only the last line of the previous group is visible', () => {
|
||||
const toolCalls1 = [
|
||||
createToolCall({
|
||||
callId: '1',
|
||||
name: 'tool-1',
|
||||
description: 'Description 1',
|
||||
resultDisplay: 'line1\nline2\nline3\nline4\nline5',
|
||||
}),
|
||||
];
|
||||
const toolCalls2 = [
|
||||
createToolCall({
|
||||
callId: '2',
|
||||
name: 'tool-2',
|
||||
description: 'Description 2',
|
||||
resultDisplay: 'line1',
|
||||
}),
|
||||
];
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<Scrollable height={6} hasFocus={true} scrollToBottom={true}>
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls1} />
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls2} />
|
||||
</Scrollable>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Border Color Logic', () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -59,10 +57,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
? theme.status.warning
|
||||
: theme.border.default;
|
||||
|
||||
const borderDimColor =
|
||||
hasPending && (!isShellCommand || !isEmbeddedShellFocused);
|
||||
|
||||
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;
|
||||
|
||||
// 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
|
||||
@@ -89,9 +87,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
// This box doesn't have a border even though it conceptually does because
|
||||
// we need to allow the sticky headers to render the borders themselves so
|
||||
// that the top border can be sticky.
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
/*
|
||||
This width constraint is highly important and protects us from an Ink rendering bug.
|
||||
Since the ToolGroup can typically change rendering states frequently, it can cause
|
||||
@@ -99,55 +99,86 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
cause tearing.
|
||||
*/
|
||||
width={terminalWidth}
|
||||
borderDimColor={
|
||||
hasPending && (!isShellCommand || !isEmbeddedShellFocused)
|
||||
}
|
||||
borderColor={borderColor}
|
||||
gap={1}
|
||||
>
|
||||
{toolCalls.map((tool) => {
|
||||
{toolCalls.map((tool, index) => {
|
||||
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
|
||||
const isFirst = index === 0;
|
||||
return (
|
||||
<Box
|
||||
key={tool.callId}
|
||||
flexDirection="column"
|
||||
minHeight={1}
|
||||
width={innerWidth}
|
||||
width={terminalWidth}
|
||||
>
|
||||
<ToolMessage
|
||||
{...tool}
|
||||
availableTerminalHeight={availableTerminalHeightPerToolMessage}
|
||||
terminalWidth={innerWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
emphasis={
|
||||
isConfirming ? 'high' : toolAwaitingApproval ? 'low' : 'medium'
|
||||
}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
config={config}
|
||||
isFirst={isFirst}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
/>
|
||||
{tool.status === ToolCallStatus.Confirming &&
|
||||
isConfirming &&
|
||||
tool.confirmationDetails && (
|
||||
<ToolConfirmationMessage
|
||||
confirmationDetails={tool.confirmationDetails}
|
||||
config={config}
|
||||
isFocused={isFocused}
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightPerToolMessage
|
||||
}
|
||||
terminalWidth={innerWidth}
|
||||
/>
|
||||
<Box
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
{tool.status === ToolCallStatus.Confirming &&
|
||||
isConfirming &&
|
||||
tool.confirmationDetails && (
|
||||
<ToolConfirmationMessage
|
||||
confirmationDetails={tool.confirmationDetails}
|
||||
config={config}
|
||||
isFocused={isFocused}
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightPerToolMessage
|
||||
}
|
||||
terminalWidth={terminalWidth - 4}
|
||||
/>
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
Output too long and was saved to: {tool.outputFile}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
<Box marginX={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
Output too long and was saved to: {tool.outputFile}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{
|
||||
/*
|
||||
We have to keep the bottom border separate so it doesn't get
|
||||
drawn over by the sticky header directly inside it.
|
||||
*/
|
||||
toolCalls.length > 0 && (
|
||||
<Box
|
||||
height={0}
|
||||
width={terminalWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -89,6 +89,9 @@ describe('<ToolMessage />', () => {
|
||||
terminalWidth: 80,
|
||||
confirmationDetails: undefined,
|
||||
emphasis: 'medium',
|
||||
isFirst: true,
|
||||
borderColor: 'green',
|
||||
borderDimColor: false,
|
||||
};
|
||||
|
||||
it('renders basic tool information', () => {
|
||||
|
||||
@@ -42,6 +42,9 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||
renderOutputAsMarkdown?: boolean;
|
||||
activeShellPtyId?: number | null;
|
||||
embeddedShellFocused?: boolean;
|
||||
isFirst: boolean;
|
||||
borderColor: string;
|
||||
borderDimColor: boolean;
|
||||
config?: Config;
|
||||
}
|
||||
|
||||
@@ -58,6 +61,9 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
embeddedShellFocused,
|
||||
ptyId,
|
||||
config,
|
||||
isFirst,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
@@ -116,7 +122,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
if (availableHeight && !isAlternateBuffer) {
|
||||
renderOutputAsMarkdown = false;
|
||||
}
|
||||
const childWidth = terminalWidth;
|
||||
const combinedPaddingAndBorderWidth = 4;
|
||||
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
|
||||
|
||||
const truncatedResultDisplay = React.useMemo(() => {
|
||||
if (typeof resultDisplay === 'string') {
|
||||
@@ -131,7 +138,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
if (!truncatedResultDisplay) return null;
|
||||
|
||||
return (
|
||||
<Box width={terminalWidth} flexDirection="column" paddingLeft={1}>
|
||||
<Box width={childWidth} flexDirection="column">
|
||||
<Box flexDirection="column">
|
||||
{typeof truncatedResultDisplay === 'string' &&
|
||||
renderOutputAsMarkdown ? (
|
||||
@@ -189,15 +196,16 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
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}>
|
||||
<StickyHeader
|
||||
width={terminalWidth}
|
||||
isFirst={isFirst}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
>
|
||||
<ToolStatusIndicator status={status} name={name} />
|
||||
<ToolInfo
|
||||
name={name}
|
||||
@@ -214,15 +222,28 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
)}
|
||||
{emphasis === 'high' && <TrailingIndicator />}
|
||||
</StickyHeader>
|
||||
{renderedResult}
|
||||
{isThisShellFocused && config && (
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
|
||||
<ShellInputPrompt
|
||||
activeShellPtyId={activeShellPtyId ?? null}
|
||||
focus={embeddedShellFocused}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{renderedResult}
|
||||
{isThisShellFocused && config && (
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
|
||||
<ShellInputPrompt
|
||||
activeShellPtyId={activeShellPtyId ?? null}
|
||||
focus={embeddedShellFocused}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -301,8 +322,8 @@ const ToolInfo: React.FC<ToolInfo> = ({
|
||||
}
|
||||
}, [emphasis]);
|
||||
return (
|
||||
<Box>
|
||||
<Text strikethrough={status === ToolCallStatus.Canceled}>
|
||||
<Box overflow="hidden" height={1} flexGrow={1} flexShrink={1}>
|
||||
<Text strikethrough={status === ToolCallStatus.Canceled} wrap="truncate">
|
||||
<Text color={nameColor} bold>
|
||||
{name}
|
||||
</Text>{' '}
|
||||
|
||||
@@ -20,6 +20,9 @@ describe('<ToolMessage /> - Raw Markdown Display Snapshots', () => {
|
||||
terminalWidth: 80,
|
||||
confirmationDetails: undefined,
|
||||
emphasis: 'medium',
|
||||
isFirst: true,
|
||||
borderColor: 'green',
|
||||
borderDimColor: false,
|
||||
};
|
||||
|
||||
it.each([
|
||||
|
||||
+57
-54
@@ -1,109 +1,112 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: ✓ another-tool - A tool for testing (medium) │
|
||||
"MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)
|
||||
MockTool[tool-2]: ✓ another-tool - A tool for testing (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shell commands even when successful 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ run_shell_command - A tool for testing (medium) │
|
||||
"MockTool[tool-123]: ✓ run_shell_command - A tool for testing (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when tools are pending 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: o test-tool - A tool for testing (medium) │
|
||||
"MockTool[tool-123]: o test-tool - A tool for testing (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ? first-confirm - A tool for testing (high) │
|
||||
│MockConfirmation: Confirm first tool │
|
||||
│ │
|
||||
│MockTool[tool-2]: ? second-confirm - A tool for testing (low) │
|
||||
"MockTool[tool-1]: ? first-confirm - A tool for testing (high)
|
||||
│ MockConfirmation: Confirm first tool │
|
||||
MockTool[tool-2]: ? second-confirm - A tool for testing (low)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
|
||||
"MockTool[1]: ✓ tool-1 - Description 1. This is a long description that will need
|
||||
to be truncated if the terminal width is small. (medium)
|
||||
MockTool[2]: ✓ tool-2 - Description 2 (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ✓ read_file - Read a file (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: ⊷ run_shell_command - Run command (medium) │
|
||||
│ │
|
||||
│MockTool[tool-3]: o write_file - Write to file (medium) │
|
||||
"MockTool[tool-1]: ✓ read_file - Read a file (medium)
|
||||
MockTool[tool-2]: ⊷ run_shell_command - Run command (medium)
|
||||
MockTool[tool-3]: o write_file - Write to file (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: o pending-tool - This tool is pending (medium) │
|
||||
│ │
|
||||
│MockTool[tool-3]: x error-tool - This tool failed (medium) │
|
||||
"MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium)
|
||||
MockTool[tool-2]: o pending-tool - This tool is pending (medium)
|
||||
MockTool[tool-3]: x error-tool - This tool failed (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders shell command with yellow border 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[shell-1]: ✓ run_shell_command - Execute shell command (medium) │
|
||||
"MockTool[shell-1]: ✓ run_shell_command - Execute shell command (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
|
||||
"MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting confirmation 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-confirm]: ? confirmation-tool - This tool needs confirmation │
|
||||
│(high) │
|
||||
│MockConfirmation: Are you sure you want to proceed? │
|
||||
"MockTool[tool-confirm]: ? confirmation-tool - This tool needs confirmation
|
||||
(high)
|
||||
│ MockConfirmation: Are you sure you want to proceed? │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with outputFile 1`] = `
|
||||
"MockTool[tool-output-file]: ✓ tool-with-file - Tool that saved output to file
|
||||
(medium)
|
||||
│ Output too long and was saved to: /path/to/output.txt │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = `
|
||||
"MockTool[1]: ✓ tool-1 - Description 1 (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
MockTool[2]: ✓ tool-2 - Description 2 (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders when not focused 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
|
||||
"MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: ✓ another-tool - Another tool (medium) │
|
||||
"MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium)
|
||||
MockTool[tool-2]: ✓ another-tool - Another tool (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
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) │
|
||||
"MockTool[tool-123]: ✓
|
||||
very-long-tool-name-that-might-wrap -
|
||||
This is a very long description that
|
||||
might cause wrapping issues (medium)
|
||||
╰──────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Height Calculation > calculates available height correctly with multiple tools with results 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ✓ test-tool - A tool for testing (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: ✓ test-tool - A tool for testing (medium) │
|
||||
│ │
|
||||
│MockTool[tool-3]: ✓ test-tool - A tool for testing (medium) │
|
||||
"MockTool[tool-1]: ✓ test-tool - A tool for testing (medium)
|
||||
MockTool[tool-2]: ✓ test-tool - A tool for testing (medium)
|
||||
MockTool[tool-3]: ✓ test-tool - A tool for testing (medium)
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
+24
-12
@@ -1,31 +1,43 @@
|
||||
// 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`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
Test **bold** and \`code\` markdown"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ test-tool A tool for testing │
|
||||
│ │
|
||||
│ Test **bold** and \`code\` markdown │"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false, useAlternateBuffer=true '(raw markdown, alternate buffer)' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
Test **bold** and \`code\` markdown"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ 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"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ 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"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ 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"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ 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-tool A tool for testing │
|
||||
│ │
|
||||
│ Test bold and code markdown │"
|
||||
`;
|
||||
|
||||
@@ -281,4 +281,97 @@ describe('ScrollableList Demo Behavior', () => {
|
||||
});
|
||||
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
|
||||
});
|
||||
|
||||
describe('Keyboard Navigation', () => {
|
||||
it('should handle scroll keys correctly', async () => {
|
||||
let listRef: ScrollableListRef<Item> | null = null;
|
||||
let lastFrame: () => string | undefined;
|
||||
let stdin: { write: (data: string) => void };
|
||||
|
||||
const items = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: String(i),
|
||||
title: `Item ${i}`,
|
||||
}));
|
||||
|
||||
await act(async () => {
|
||||
const result = render(
|
||||
<MouseProvider mouseEventsEnabled={false}>
|
||||
<KeypressProvider>
|
||||
<ScrollProvider>
|
||||
<Box flexDirection="column" width={80} height={10}>
|
||||
<ScrollableList
|
||||
ref={(ref) => {
|
||||
listRef = ref;
|
||||
}}
|
||||
data={items}
|
||||
renderItem={({ item }) => <Text>{item.title}</Text>}
|
||||
estimatedItemHeight={() => 1}
|
||||
keyExtractor={(item) => item.id}
|
||||
hasFocus={true}
|
||||
/>
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</KeypressProvider>
|
||||
</MouseProvider>,
|
||||
);
|
||||
lastFrame = result.lastFrame;
|
||||
stdin = result.stdin;
|
||||
});
|
||||
|
||||
// Initial state
|
||||
expect(lastFrame!()).toContain('Item 0');
|
||||
expect(listRef).toBeDefined();
|
||||
expect(listRef!.getScrollState()?.scrollTop).toBe(0);
|
||||
|
||||
// Scroll Down (Shift+Down) -> \x1b[b
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[b');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Scroll Up (Shift+Up) -> \x1b[a
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[a');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
// Page Down -> \x1b[6~
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[6~');
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Height is 10, so should scroll ~10 units
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThanOrEqual(9);
|
||||
});
|
||||
|
||||
// Page Up -> \x1b[5~
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[5~');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBeLessThan(2);
|
||||
});
|
||||
|
||||
// End -> \x1b[F
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[F');
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Total 50 items, height 10. Max scroll ~40.
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(30);
|
||||
});
|
||||
|
||||
// Home -> \x1b[H
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[H');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,13 +10,21 @@ import {
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
|
||||
import {
|
||||
VirtualizedList,
|
||||
type VirtualizedListRef,
|
||||
SCROLL_TO_ITEM_END,
|
||||
} from './VirtualizedList.js';
|
||||
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
import { Box, type DOMElement } from 'ink';
|
||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
type VirtualizedListProps<T> = {
|
||||
data: T[];
|
||||
@@ -79,15 +87,122 @@ function ScrollableList<T>(
|
||||
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
||||
useAnimatedScrollbar(hasFocus, scrollBy);
|
||||
|
||||
const smoothScrollState = useRef<{
|
||||
active: boolean;
|
||||
start: number;
|
||||
from: number;
|
||||
to: number;
|
||||
duration: number;
|
||||
timer: NodeJS.Timeout | null;
|
||||
}>({ active: false, start: 0, from: 0, to: 0, duration: 0, timer: null });
|
||||
|
||||
const stopSmoothScroll = useCallback(() => {
|
||||
if (smoothScrollState.current.timer) {
|
||||
clearInterval(smoothScrollState.current.timer);
|
||||
smoothScrollState.current.timer = null;
|
||||
}
|
||||
smoothScrollState.current.active = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
|
||||
|
||||
const smoothScrollTo = useCallback(
|
||||
(targetScrollTop: number, duration: number = 200) => {
|
||||
stopSmoothScroll();
|
||||
|
||||
const scrollState = virtualizedListRef.current?.getScrollState() ?? {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
innerHeight: 0,
|
||||
};
|
||||
const {
|
||||
scrollTop: startScrollTop,
|
||||
scrollHeight,
|
||||
innerHeight,
|
||||
} = scrollState;
|
||||
|
||||
const maxScrollTop = Math.max(0, scrollHeight - innerHeight);
|
||||
|
||||
let effectiveTarget = targetScrollTop;
|
||||
if (targetScrollTop === SCROLL_TO_ITEM_END) {
|
||||
effectiveTarget = maxScrollTop;
|
||||
}
|
||||
|
||||
const clampedTarget = Math.max(
|
||||
0,
|
||||
Math.min(maxScrollTop, effectiveTarget),
|
||||
);
|
||||
|
||||
if (duration === 0) {
|
||||
if (targetScrollTop === SCROLL_TO_ITEM_END) {
|
||||
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
|
||||
} else {
|
||||
virtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
|
||||
}
|
||||
flashScrollbar();
|
||||
return;
|
||||
}
|
||||
|
||||
smoothScrollState.current = {
|
||||
active: true,
|
||||
start: Date.now(),
|
||||
from: startScrollTop,
|
||||
to: clampedTarget,
|
||||
duration,
|
||||
timer: setInterval(() => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - smoothScrollState.current.start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease-in-out
|
||||
const t = progress;
|
||||
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||
|
||||
const current =
|
||||
smoothScrollState.current.from +
|
||||
(smoothScrollState.current.to - smoothScrollState.current.from) *
|
||||
ease;
|
||||
|
||||
if (progress >= 1) {
|
||||
if (targetScrollTop === SCROLL_TO_ITEM_END) {
|
||||
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
|
||||
} else {
|
||||
virtualizedListRef.current?.scrollTo(Math.round(current));
|
||||
}
|
||||
stopSmoothScroll();
|
||||
flashScrollbar();
|
||||
} else {
|
||||
virtualizedListRef.current?.scrollTo(Math.round(current));
|
||||
}
|
||||
}, ANIMATION_FRAME_DURATION_MS),
|
||||
};
|
||||
},
|
||||
[stopSmoothScroll, flashScrollbar],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (key.shift) {
|
||||
if (key.name === 'up') {
|
||||
scrollByWithAnimation(-1);
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
scrollByWithAnimation(1);
|
||||
}
|
||||
if (keyMatchers[Command.SCROLL_UP](key)) {
|
||||
stopSmoothScroll();
|
||||
scrollByWithAnimation(-1);
|
||||
} else if (keyMatchers[Command.SCROLL_DOWN](key)) {
|
||||
stopSmoothScroll();
|
||||
scrollByWithAnimation(1);
|
||||
} else if (
|
||||
keyMatchers[Command.PAGE_UP](key) ||
|
||||
keyMatchers[Command.PAGE_DOWN](key)
|
||||
) {
|
||||
const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1;
|
||||
const scrollState = getScrollState();
|
||||
const current = smoothScrollState.current.active
|
||||
? smoothScrollState.current.to
|
||||
: scrollState.scrollTop;
|
||||
const innerHeight = scrollState.innerHeight;
|
||||
smoothScrollTo(current + direction * innerHeight);
|
||||
} else if (keyMatchers[Command.SCROLL_HOME](key)) {
|
||||
smoothScrollTo(0);
|
||||
} else if (keyMatchers[Command.SCROLL_END](key)) {
|
||||
smoothScrollTo(SCROLL_TO_ITEM_END);
|
||||
}
|
||||
},
|
||||
{ isActive: hasFocus },
|
||||
@@ -100,10 +215,17 @@ function ScrollableList<T>(
|
||||
ref: containerRef as React.RefObject<DOMElement>,
|
||||
getScrollState,
|
||||
scrollBy: scrollByWithAnimation,
|
||||
scrollTo: smoothScrollTo,
|
||||
hasFocus: hasFocusCallback,
|
||||
flashScrollbar,
|
||||
}),
|
||||
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
|
||||
[
|
||||
getScrollState,
|
||||
hasFocusCallback,
|
||||
flashScrollbar,
|
||||
scrollByWithAnimation,
|
||||
smoothScrollTo,
|
||||
],
|
||||
);
|
||||
|
||||
useScrollable(scrollableEntry, hasFocus);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { vi, type Mock } from 'vitest';
|
||||
import type React from 'react';
|
||||
import { useStdin } from 'ink';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
|
||||
// Mock the 'ink' module to control stdin
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
@@ -21,6 +22,18 @@ vi.mock('ink', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock appEvents
|
||||
vi.mock('../../utils/events.js', () => ({
|
||||
appEvents: {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
AppEvent: {
|
||||
SelectionWarning: 'selection-warning',
|
||||
},
|
||||
}));
|
||||
|
||||
class MockStdin extends EventEmitter {
|
||||
isTTY = true;
|
||||
setRawMode = vi.fn();
|
||||
@@ -47,6 +60,7 @@ describe('MouseContext', () => {
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MouseProvider mouseEventsEnabled={true}>{children}</MouseProvider>
|
||||
);
|
||||
vi.mocked(appEvents.emit).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -91,6 +105,34 @@ describe('MouseContext', () => {
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit SelectionWarning when move event is unhandled and has coordinates', () => {
|
||||
renderHook(() => useMouseContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
// Move event (32) at 10, 20
|
||||
stdin.write('\x1b[<32;10;20M');
|
||||
});
|
||||
|
||||
expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.SelectionWarning);
|
||||
});
|
||||
|
||||
it('should not emit SelectionWarning when move event is handled', () => {
|
||||
const handler = vi.fn().mockReturnValue(true);
|
||||
const { result } = renderHook(() => useMouseContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(handler);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// Move event (32) at 10, 20
|
||||
stdin.write('\x1b[<32;10;20M');
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(appEvents.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('SGR Mouse Events', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'react';
|
||||
import { ESC } from '../utils/input.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import {
|
||||
isIncompleteMouseSequence,
|
||||
parseMouseEvent,
|
||||
@@ -89,8 +90,23 @@ export function MouseProvider({
|
||||
let mouseBuffer = '';
|
||||
|
||||
const broadcast = (event: MouseEvent) => {
|
||||
let handled = false;
|
||||
for (const handler of subscribers) {
|
||||
handler(event);
|
||||
if (handler(event) === true) {
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!handled &&
|
||||
event.name === 'move' &&
|
||||
event.col >= 0 &&
|
||||
event.row >= 0
|
||||
) {
|
||||
// Terminal apps only receive mouse move events when the mouse is down
|
||||
// so this always indicates a mouse drag that the user was expecting
|
||||
// would trigger text selection but does not as we are handling mouse
|
||||
// events not the terminal.
|
||||
appEvents.emit(AppEvent.SelectionWarning);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import {
|
||||
ScrollProvider,
|
||||
useScrollable,
|
||||
type ScrollState,
|
||||
} from './ScrollProvider.js';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useRef, useImperativeHandle, forwardRef, type RefObject } from 'react';
|
||||
import { Box, type DOMElement } from 'ink';
|
||||
import type { MouseEvent } from '../hooks/useMouse.js';
|
||||
|
||||
// Mock useMouse hook
|
||||
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void>();
|
||||
vi.mock('../hooks/useMouse.js', async () => {
|
||||
// We need to import React dynamically because this factory runs before top-level imports
|
||||
const React = await import('react');
|
||||
return {
|
||||
useMouse: (callback: (event: MouseEvent) => void) => {
|
||||
React.useEffect(() => {
|
||||
mockUseMouseCallbacks.add(callback);
|
||||
return () => {
|
||||
mockUseMouseCallbacks.delete(callback);
|
||||
};
|
||||
}, [callback]);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ink's getBoundingBox
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...actual,
|
||||
getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 10 })),
|
||||
};
|
||||
});
|
||||
|
||||
const TestScrollable = forwardRef(
|
||||
(
|
||||
props: {
|
||||
id: string;
|
||||
scrollBy: (delta: number) => void;
|
||||
getScrollState: () => ScrollState;
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const elementRef = useRef<DOMElement>(null);
|
||||
useImperativeHandle(ref, () => elementRef.current);
|
||||
|
||||
useScrollable(
|
||||
{
|
||||
ref: elementRef as RefObject<DOMElement>,
|
||||
getScrollState: props.getScrollState,
|
||||
scrollBy: props.scrollBy,
|
||||
hasFocus: () => true,
|
||||
flashScrollbar: () => {},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
return <Box ref={elementRef} />;
|
||||
},
|
||||
);
|
||||
TestScrollable.displayName = 'TestScrollable';
|
||||
|
||||
describe('ScrollProvider Drag', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockUseMouseCallbacks.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('drags the scrollbar thumb', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
// Scrollbar at x + width = 10.
|
||||
// Height 10.
|
||||
// scrollHeight 100, innerHeight 10.
|
||||
// thumbHeight = 1.
|
||||
// maxScrollTop = 90. maxThumbY = 9. Ratio = 10.
|
||||
// Thumb at 0.
|
||||
|
||||
// 1. Click on thumb (row 0)
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 0,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Move mouse to row 1
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'move',
|
||||
col: 10, // col doesn't matter for move if dragging
|
||||
row: 1,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Delta row = 1. Delta scroll = 10.
|
||||
// scrollBy called with 10.
|
||||
expect(scrollBy).toHaveBeenCalledWith(10);
|
||||
|
||||
// 3. Move mouse to row 2
|
||||
scrollBy.mockClear();
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'move',
|
||||
col: 10,
|
||||
row: 2,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Delta row from start (0) is 2. Delta scroll = 20.
|
||||
// startScrollTop was 0. target 20.
|
||||
// scrollBy called with (20 - scrollTop). scrollTop is still 0 in mock.
|
||||
expect(scrollBy).toHaveBeenCalledWith(20);
|
||||
|
||||
// 4. Release
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-release',
|
||||
col: 10,
|
||||
row: 2,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Move again - should not scroll
|
||||
scrollBy.mockClear();
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'move',
|
||||
col: 10,
|
||||
row: 3,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
expect(scrollBy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('jumps to position and starts drag when clicking track below thumb', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
// Thumb at 0. Click at 5.
|
||||
// thumbHeight 1.
|
||||
// targetThumbY = 5.
|
||||
// targetScrollTop = 50.
|
||||
|
||||
// 1. Click on track below thumb
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Should jump to 50 (delta 50)
|
||||
expect(scrollBy).toHaveBeenCalledWith(50);
|
||||
scrollBy.mockClear();
|
||||
|
||||
// 2. Move mouse to 6 - should drag
|
||||
// Start drag captured at row 5, startScrollTop 50.
|
||||
// Move to 6. Delta row 1. Delta scroll 10.
|
||||
// Target = 60.
|
||||
// scrollBy called with 60 - 0 (current state still 0).
|
||||
// Note: In real app, state would update, but here getScrollState is static mock 0.
|
||||
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'move',
|
||||
col: 10,
|
||||
row: 6,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
expect(scrollBy).toHaveBeenCalledWith(60);
|
||||
});
|
||||
|
||||
it('jumps to position when clicking track above thumb', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
// Start scrolled down
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 50,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
// Thumb at 5. Click at 2.
|
||||
// targetThumbY = 2.
|
||||
// targetScrollTop = 20.
|
||||
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 2,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Jump to 20 (delta = 20 - 50 = -30)
|
||||
expect(scrollBy).toHaveBeenCalledWith(-30);
|
||||
});
|
||||
|
||||
it('jumps to top when clicking very top of track', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 50,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
// Thumb at 5. Click at 0.
|
||||
// targetThumbY = 0.
|
||||
// targetScrollTop = 0.
|
||||
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 0,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Scroll to top (delta = 0 - 50 = -50)
|
||||
expect(scrollBy).toHaveBeenCalledWith(-50);
|
||||
});
|
||||
|
||||
it('jumps to bottom when clicking very bottom of track', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
// Thumb at 0. Click at 9.
|
||||
// targetThumbY = 9.
|
||||
// targetScrollTop = 90.
|
||||
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 9,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Scroll to bottom (delta = 90 - 0 = 90)
|
||||
expect(scrollBy).toHaveBeenCalledWith(90);
|
||||
});
|
||||
|
||||
it('uses scrollTo with 0 duration if provided', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const scrollTo = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
// Custom component that provides scrollTo
|
||||
const TestScrollableWithScrollTo = forwardRef(
|
||||
(
|
||||
props: {
|
||||
id: string;
|
||||
scrollBy: (delta: number) => void;
|
||||
scrollTo: (scrollTop: number, duration?: number) => void;
|
||||
getScrollState: () => ScrollState;
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const elementRef = useRef<DOMElement>(null);
|
||||
useImperativeHandle(ref, () => elementRef.current);
|
||||
useScrollable(
|
||||
{
|
||||
ref: elementRef as RefObject<DOMElement>,
|
||||
getScrollState: props.getScrollState,
|
||||
scrollBy: props.scrollBy,
|
||||
scrollTo: props.scrollTo,
|
||||
hasFocus: () => true,
|
||||
flashScrollbar: () => {},
|
||||
},
|
||||
true,
|
||||
);
|
||||
return <Box ref={elementRef} />;
|
||||
},
|
||||
);
|
||||
TestScrollableWithScrollTo.displayName = 'TestScrollableWithScrollTo';
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollableWithScrollTo
|
||||
id="test-scrollable-scrollto"
|
||||
scrollBy={scrollBy}
|
||||
scrollTo={scrollTo}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
// Click on track (jump)
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Expect scrollTo to be called with target (and undefined/default duration)
|
||||
expect(scrollTo).toHaveBeenCalledWith(50);
|
||||
|
||||
scrollTo.mockClear();
|
||||
|
||||
// Move mouse (drag)
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'move',
|
||||
col: 10,
|
||||
row: 6,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
// Expect scrollTo to be called with target and duration 0
|
||||
expect(scrollTo).toHaveBeenCalledWith(60, 0);
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,12 @@ import { Box, type DOMElement } from 'ink';
|
||||
import type { MouseEvent } from '../hooks/useMouse.js';
|
||||
|
||||
// Mock useMouse hook
|
||||
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void>();
|
||||
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void | boolean>();
|
||||
vi.mock('../hooks/useMouse.js', async () => {
|
||||
// We need to import React dynamically because this factory runs before top-level imports
|
||||
const React = await import('react');
|
||||
return {
|
||||
useMouse: (callback: (event: MouseEvent) => void) => {
|
||||
useMouse: (callback: (event: MouseEvent) => void | boolean) => {
|
||||
React.useEffect(() => {
|
||||
mockUseMouseCallbacks.add(callback);
|
||||
return () => {
|
||||
@@ -46,6 +46,7 @@ const TestScrollable = forwardRef(
|
||||
props: {
|
||||
id: string;
|
||||
scrollBy: (delta: number) => void;
|
||||
scrollTo?: (scrollTop: number) => void;
|
||||
getScrollState: () => ScrollState;
|
||||
},
|
||||
ref,
|
||||
@@ -58,6 +59,7 @@ const TestScrollable = forwardRef(
|
||||
ref: elementRef as RefObject<DOMElement>,
|
||||
getScrollState: props.getScrollState,
|
||||
scrollBy: props.scrollBy,
|
||||
scrollTo: props.scrollTo,
|
||||
hasFocus: () => true,
|
||||
flashScrollbar: () => {},
|
||||
},
|
||||
@@ -79,6 +81,157 @@ describe('ScrollProvider', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Event Handling Status', () => {
|
||||
it('returns true when scroll event is handled', () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
let handled = false;
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
if (
|
||||
callback({
|
||||
name: 'scroll-down',
|
||||
col: 5,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
}) === true
|
||||
) {
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when scroll event is ignored (cannot scroll further)', () => {
|
||||
const scrollBy = vi.fn();
|
||||
// Already at bottom
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 90,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
let handled = false;
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
if (
|
||||
callback({
|
||||
name: 'scroll-down',
|
||||
col: 5,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
}) === true
|
||||
) {
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls scrollTo when clicking scrollbar track if available', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const scrollTo = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
scrollTo={scrollTo}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
// Scrollbar is at x + width = 0 + 10 = 10.
|
||||
// Height is 10. y is 0.
|
||||
// Click at col 10, row 5.
|
||||
// Thumb height = 10/100 * 10 = 1.
|
||||
// Max thumb Y = 10 - 1 = 9.
|
||||
// Current thumb Y = 0.
|
||||
// Click at row 5 (relative Y = 5). This is outside the thumb (0).
|
||||
// It's a track click.
|
||||
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
expect(scrollBy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls scrollBy when clicking scrollbar track if scrollTo is not available', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
expect(scrollBy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('batches multiple scroll events into a single update', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
@@ -234,4 +387,120 @@ describe('ScrollProvider', () => {
|
||||
expect(scrollBy).toHaveBeenCalledTimes(1);
|
||||
expect(scrollBy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('calls scrollTo when dragging scrollbar thumb if available', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const scrollTo = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
scrollTo={scrollTo}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
// Start drag on thumb
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 0,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Move mouse down
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'move',
|
||||
col: 10,
|
||||
row: 5, // Move down 5 units
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Release
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-release',
|
||||
col: 10,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
expect(scrollBy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls scrollBy when dragging scrollbar thumb if scrollTo is not available', async () => {
|
||||
const scrollBy = vi.fn();
|
||||
const getScrollState = vi.fn(() => ({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 100,
|
||||
innerHeight: 10,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<TestScrollable
|
||||
id="test-scrollable"
|
||||
scrollBy={scrollBy}
|
||||
getScrollState={getScrollState}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
|
||||
// Start drag on thumb
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 0,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Move mouse down
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'move',
|
||||
col: 10,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const callback of mockUseMouseCallbacks) {
|
||||
callback({
|
||||
name: 'left-release',
|
||||
col: 10,
|
||||
row: 5,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
});
|
||||
}
|
||||
|
||||
expect(scrollBy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface ScrollableEntry {
|
||||
ref: React.RefObject<DOMElement>;
|
||||
getScrollState: () => ScrollState;
|
||||
scrollBy: (delta: number) => void;
|
||||
scrollTo?: (scrollTop: number, duration?: number) => void;
|
||||
hasFocus: () => boolean;
|
||||
flashScrollbar: () => void;
|
||||
}
|
||||
@@ -98,6 +99,16 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
const pendingScrollsRef = useRef(new Map<string, number>());
|
||||
const flushScheduledRef = useRef(false);
|
||||
|
||||
const dragStateRef = useRef<{
|
||||
active: boolean;
|
||||
id: string | null;
|
||||
offset: number;
|
||||
}>({
|
||||
active: false,
|
||||
id: null,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const scheduleFlush = useCallback(() => {
|
||||
if (!flushScheduledRef.current) {
|
||||
flushScheduledRef.current = true;
|
||||
@@ -135,18 +146,103 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
if (direction === 'up' && canScrollUp) {
|
||||
pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
|
||||
scheduleFlush();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (direction === 'down' && canScrollDown) {
|
||||
pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
|
||||
scheduleFlush();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleClick = (mouseEvent: MouseEvent) => {
|
||||
const handleLeftPress = (mouseEvent: MouseEvent) => {
|
||||
// Check for scrollbar interaction first
|
||||
for (const entry of scrollablesRef.current.values()) {
|
||||
if (!entry.ref.current || !entry.hasFocus()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const boundingBox = getBoundingBox(entry.ref.current);
|
||||
if (!boundingBox) continue;
|
||||
|
||||
const { x, y, width, height } = boundingBox;
|
||||
|
||||
// Check if click is on the scrollbar column (x + width)
|
||||
// The findScrollableCandidates logic implies scrollbar is at x + width.
|
||||
if (
|
||||
mouseEvent.col === x + width &&
|
||||
mouseEvent.row >= y &&
|
||||
mouseEvent.row < y + height
|
||||
) {
|
||||
const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState();
|
||||
|
||||
if (scrollHeight <= innerHeight) continue;
|
||||
|
||||
const thumbHeight = Math.max(
|
||||
1,
|
||||
Math.floor((innerHeight / scrollHeight) * innerHeight),
|
||||
);
|
||||
const maxScrollTop = scrollHeight - innerHeight;
|
||||
const maxThumbY = innerHeight - thumbHeight;
|
||||
|
||||
if (maxThumbY <= 0) continue;
|
||||
|
||||
const currentThumbY = Math.round(
|
||||
(scrollTop / maxScrollTop) * maxThumbY,
|
||||
);
|
||||
|
||||
const absoluteThumbTop = y + currentThumbY;
|
||||
const absoluteThumbBottom = absoluteThumbTop + thumbHeight;
|
||||
|
||||
const isTop = mouseEvent.row === y;
|
||||
const isBottom = mouseEvent.row === y + height - 1;
|
||||
|
||||
const hitTop = isTop ? absoluteThumbTop : absoluteThumbTop - 1;
|
||||
const hitBottom = isBottom
|
||||
? absoluteThumbBottom
|
||||
: absoluteThumbBottom + 1;
|
||||
|
||||
const isThumbClick =
|
||||
mouseEvent.row >= hitTop && mouseEvent.row < hitBottom;
|
||||
|
||||
let offset = 0;
|
||||
const relativeMouseY = mouseEvent.row - y;
|
||||
|
||||
if (isThumbClick) {
|
||||
offset = relativeMouseY - currentThumbY;
|
||||
} else {
|
||||
// Track click - Jump to position
|
||||
// Center the thumb on the mouse click
|
||||
const targetThumbY = Math.max(
|
||||
0,
|
||||
Math.min(maxThumbY, relativeMouseY - Math.floor(thumbHeight / 2)),
|
||||
);
|
||||
|
||||
const newScrollTop = Math.round(
|
||||
(targetThumbY / maxThumbY) * maxScrollTop,
|
||||
);
|
||||
if (entry.scrollTo) {
|
||||
entry.scrollTo(newScrollTop);
|
||||
} else {
|
||||
entry.scrollBy(newScrollTop - scrollTop);
|
||||
}
|
||||
|
||||
offset = relativeMouseY - targetThumbY;
|
||||
}
|
||||
|
||||
// Start drag (for both thumb and track clicks)
|
||||
dragStateRef.current = {
|
||||
active: true,
|
||||
id: entry.id,
|
||||
offset,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = findScrollableCandidates(
|
||||
mouseEvent,
|
||||
scrollablesRef.current,
|
||||
@@ -155,18 +251,86 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
if (candidates.length > 0) {
|
||||
// The first candidate is the innermost one.
|
||||
candidates[0].flashScrollbar();
|
||||
// We don't consider just flashing the scrollbar as handling the event
|
||||
// in a way that should prevent other handlers (like drag warning)
|
||||
// from checking it, although for left-press it doesn't matter much.
|
||||
// But returning false is safer.
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleMove = (mouseEvent: MouseEvent) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state.active || !state.id) return false;
|
||||
|
||||
const entry = scrollablesRef.current.get(state.id);
|
||||
if (!entry || !entry.ref.current) {
|
||||
state.active = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const boundingBox = getBoundingBox(entry.ref.current);
|
||||
if (!boundingBox) return false;
|
||||
|
||||
const { y } = boundingBox;
|
||||
const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState();
|
||||
|
||||
const thumbHeight = Math.max(
|
||||
1,
|
||||
Math.floor((innerHeight / scrollHeight) * innerHeight),
|
||||
);
|
||||
const maxScrollTop = scrollHeight - innerHeight;
|
||||
const maxThumbY = innerHeight - thumbHeight;
|
||||
|
||||
if (maxThumbY <= 0) return false;
|
||||
|
||||
const relativeMouseY = mouseEvent.row - y;
|
||||
// Calculate the target thumb position based on the mouse position and the offset.
|
||||
// We clamp it to the valid range [0, maxThumbY].
|
||||
const targetThumbY = Math.max(
|
||||
0,
|
||||
Math.min(maxThumbY, relativeMouseY - state.offset),
|
||||
);
|
||||
|
||||
const targetScrollTop = Math.round(
|
||||
(targetThumbY / maxThumbY) * maxScrollTop,
|
||||
);
|
||||
|
||||
if (entry.scrollTo) {
|
||||
entry.scrollTo(targetScrollTop, 0);
|
||||
} else {
|
||||
entry.scrollBy(targetScrollTop - scrollTop);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleLeftRelease = () => {
|
||||
if (dragStateRef.current.active) {
|
||||
dragStateRef.current = {
|
||||
active: false,
|
||||
id: null,
|
||||
offset: 0,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useMouse(
|
||||
(event: MouseEvent) => {
|
||||
if (event.name === 'scroll-up') {
|
||||
handleScroll('up', event);
|
||||
return handleScroll('up', event);
|
||||
} else if (event.name === 'scroll-down') {
|
||||
handleScroll('down', event);
|
||||
return handleScroll('down', event);
|
||||
} else if (event.name === 'left-press') {
|
||||
handleClick(event);
|
||||
return handleLeftPress(event);
|
||||
} else if (event.name === 'move') {
|
||||
return handleMove(event);
|
||||
} else if (event.name === 'left-release') {
|
||||
return handleLeftRelease();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -124,6 +124,7 @@ export interface UIState {
|
||||
showDebugProfiler: boolean;
|
||||
showFullTodos: boolean;
|
||||
copyModeEnabled: boolean;
|
||||
selectionWarning: boolean;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
@@ -28,8 +28,27 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { appEvents } from '../../utils/events.js';
|
||||
|
||||
const { logSlashCommand } = vi.hoisted(() => ({
|
||||
const {
|
||||
logSlashCommand,
|
||||
mockBuiltinLoadCommands,
|
||||
mockFileLoadCommands,
|
||||
mockMcpLoadCommands,
|
||||
mockIdeClientGetInstance,
|
||||
mockUseAlternateBuffer,
|
||||
} = vi.hoisted(() => ({
|
||||
logSlashCommand: vi.fn(),
|
||||
mockBuiltinLoadCommands: vi.fn().mockResolvedValue([]),
|
||||
mockFileLoadCommands: vi.fn().mockResolvedValue([]),
|
||||
mockMcpLoadCommands: vi.fn().mockResolvedValue([]),
|
||||
mockIdeClientGetInstance: vi.fn().mockResolvedValue({
|
||||
addStatusChangeListener: vi.fn(),
|
||||
removeStatusChangeListener: vi.fn(),
|
||||
}),
|
||||
mockUseAlternateBuffer: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock('./useAlternateBuffer.js', () => ({
|
||||
useAlternateBuffer: mockUseAlternateBuffer,
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
@@ -41,10 +60,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
logSlashCommand,
|
||||
getIdeInstaller: vi.fn().mockReturnValue(null),
|
||||
IdeClient: {
|
||||
getInstance: vi.fn().mockResolvedValue({
|
||||
addStatusChangeListener: vi.fn(),
|
||||
removeStatusChangeListener: vi.fn(),
|
||||
}),
|
||||
getInstance: mockIdeClientGetInstance,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -65,23 +81,20 @@ vi.mock('node:process', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const mockBuiltinLoadCommands = vi.fn();
|
||||
vi.mock('../../services/BuiltinCommandLoader.js', () => ({
|
||||
BuiltinCommandLoader: vi.fn().mockImplementation(() => ({
|
||||
BuiltinCommandLoader: vi.fn(() => ({
|
||||
loadCommands: mockBuiltinLoadCommands,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockFileLoadCommands = vi.fn();
|
||||
vi.mock('../../services/FileCommandLoader.js', () => ({
|
||||
FileCommandLoader: vi.fn().mockImplementation(() => ({
|
||||
FileCommandLoader: vi.fn(() => ({
|
||||
loadCommands: mockFileLoadCommands,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockMcpLoadCommands = vi.fn();
|
||||
vi.mock('../../services/McpPromptLoader.js', () => ({
|
||||
McpPromptLoader: vi.fn().mockImplementation(() => ({
|
||||
McpPromptLoader: vi.fn(() => ({
|
||||
loadCommands: mockMcpLoadCommands,
|
||||
})),
|
||||
}));
|
||||
@@ -130,6 +143,12 @@ describe('useSlashCommandProcessor', () => {
|
||||
mockBuiltinLoadCommands.mockResolvedValue([]);
|
||||
mockFileLoadCommands.mockResolvedValue([]);
|
||||
mockMcpLoadCommands.mockResolvedValue([]);
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
mockIdeClientGetInstance.mockResolvedValue({
|
||||
addStatusChangeListener: vi.fn(),
|
||||
removeStatusChangeListener: vi.fn(),
|
||||
});
|
||||
vi.spyOn(console, 'clear').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -137,6 +156,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
await unmountHook();
|
||||
unmountHook = undefined;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const setupProcessorHook = async (
|
||||
@@ -205,6 +225,44 @@ describe('useSlashCommandProcessor', () => {
|
||||
};
|
||||
};
|
||||
|
||||
describe('Console Clear Safety', () => {
|
||||
it('should not call console.clear if alternate buffer is active', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
const clearCommand = createTestCommand({
|
||||
name: 'clear',
|
||||
action: async (context) => {
|
||||
context.ui.clear();
|
||||
},
|
||||
});
|
||||
const result = await setupProcessorHook([clearCommand]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/clear');
|
||||
});
|
||||
|
||||
expect(mockClearItems).toHaveBeenCalled();
|
||||
expect(console.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call console.clear if alternate buffer is not active', async () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
const clearCommand = createTestCommand({
|
||||
name: 'clear',
|
||||
action: async (context) => {
|
||||
context.ui.clear();
|
||||
},
|
||||
});
|
||||
const result = await setupProcessorHook([clearCommand]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/clear');
|
||||
});
|
||||
|
||||
expect(mockClearItems).toHaveBeenCalled();
|
||||
expect(console.clear).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Initialization and Command Loading', () => {
|
||||
it('should initialize CommandService with all required loaders', async () => {
|
||||
await setupProcessorHook();
|
||||
@@ -980,36 +1038,37 @@ describe('useSlashCommandProcessor', () => {
|
||||
|
||||
describe('Slash Command Logging', () => {
|
||||
const mockCommandAction = vi.fn().mockResolvedValue({ type: 'handled' });
|
||||
const loggingTestCommands: SlashCommand[] = [
|
||||
createTestCommand({
|
||||
name: 'logtest',
|
||||
action: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'message', content: 'hello world' }),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'logwithsub',
|
||||
subCommands: [
|
||||
createTestCommand({
|
||||
name: 'sub',
|
||||
action: mockCommandAction,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'fail',
|
||||
action: vi.fn().mockRejectedValue(new Error('oh no!')),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'logalias',
|
||||
altNames: ['la'],
|
||||
action: mockCommandAction,
|
||||
}),
|
||||
];
|
||||
let loggingTestCommands: SlashCommand[];
|
||||
|
||||
beforeEach(() => {
|
||||
mockCommandAction.mockClear();
|
||||
vi.mocked(logSlashCommand).mockClear();
|
||||
loggingTestCommands = [
|
||||
createTestCommand({
|
||||
name: 'logtest',
|
||||
action: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'message', content: 'hello world' }),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'logwithsub',
|
||||
subCommands: [
|
||||
createTestCommand({
|
||||
name: 'sub',
|
||||
action: mockCommandAction,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'fail',
|
||||
action: vi.fn().mockRejectedValue(new Error('oh no!')),
|
||||
}),
|
||||
createTestCommand({
|
||||
name: 'logalias',
|
||||
altNames: ['la'],
|
||||
action: mockCommandAction,
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type ExtensionUpdateStatus,
|
||||
} from '../state/extensions.js';
|
||||
import { appEvents } from '../../utils/events.js';
|
||||
import { useAlternateBuffer } from './useAlternateBuffer.js';
|
||||
|
||||
interface SlashCommandProcessorActions {
|
||||
openAuthDialog: () => void;
|
||||
@@ -81,6 +82,7 @@ export const useSlashCommandProcessor = (
|
||||
const [commands, setCommands] = useState<readonly SlashCommand[] | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const alternateBuffer = useAlternateBuffer();
|
||||
const [reloadTrigger, setReloadTrigger] = useState(0);
|
||||
|
||||
const reloadCommands = useCallback(() => {
|
||||
@@ -196,7 +198,9 @@ export const useSlashCommandProcessor = (
|
||||
addItem,
|
||||
clear: () => {
|
||||
clearItems();
|
||||
console.clear();
|
||||
if (!alternateBuffer) {
|
||||
console.clear();
|
||||
}
|
||||
refreshStatic();
|
||||
},
|
||||
loadHistory,
|
||||
@@ -218,6 +222,7 @@ export const useSlashCommandProcessor = (
|
||||
},
|
||||
}),
|
||||
[
|
||||
alternateBuffer,
|
||||
config,
|
||||
settings,
|
||||
gitService,
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
*/
|
||||
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
export const isAlternateBufferEnabled = (settings: LoadedSettings): boolean =>
|
||||
settings.merged.ui?.useAlternateBuffer !== false;
|
||||
|
||||
export const useAlternateBuffer = (): boolean => {
|
||||
const settings = useSettings();
|
||||
return settings.merged.ui?.useAlternateBuffer ?? false;
|
||||
return isAlternateBufferEnabled(settings);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
allowEditorTypeInSandbox,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { SettingPaths } from '../../config/settingPaths.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
return {
|
||||
@@ -114,7 +116,7 @@ describe('useEditorSettings', () => {
|
||||
|
||||
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
|
||||
scope,
|
||||
'preferredEditor',
|
||||
SettingPaths.General.PreferredEditor,
|
||||
editorType,
|
||||
);
|
||||
|
||||
@@ -142,7 +144,7 @@ describe('useEditorSettings', () => {
|
||||
|
||||
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
|
||||
scope,
|
||||
'preferredEditor',
|
||||
SettingPaths.General.PreferredEditor,
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -171,7 +173,7 @@ describe('useEditorSettings', () => {
|
||||
|
||||
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
|
||||
scope,
|
||||
'preferredEditor',
|
||||
SettingPaths.General.PreferredEditor,
|
||||
editorType,
|
||||
);
|
||||
|
||||
@@ -201,7 +203,7 @@ describe('useEditorSettings', () => {
|
||||
|
||||
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
|
||||
scope,
|
||||
'preferredEditor',
|
||||
SettingPaths.General.PreferredEditor,
|
||||
editorType,
|
||||
);
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
checkHasEditorType,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { SettingPaths } from '../../config/settingPaths.js';
|
||||
|
||||
interface UseEditorSettingsReturn {
|
||||
isEditorDialogOpen: boolean;
|
||||
openEditorDialog: () => void;
|
||||
@@ -48,7 +50,11 @@ export const useEditorSettings = (
|
||||
}
|
||||
|
||||
try {
|
||||
loadedSettings.setValue(scope, 'preferredEditor', editorType);
|
||||
loadedSettings.setValue(
|
||||
scope,
|
||||
SettingPaths.General.PreferredEditor,
|
||||
editorType,
|
||||
);
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
|
||||
@@ -200,7 +200,7 @@ export const INFORMATIVE_TIPS = [
|
||||
'Set the number of lines to keep when truncating outputs (/settings)...',
|
||||
'Enable policy-based tool confirmation via message bus (/settings)...',
|
||||
'Enable smart-edit tool for more precise editing (/settings)...',
|
||||
'Enable write_todos_list tool to generate task lists (/settings)...',
|
||||
'Enable write_todos tool to generate task lists (/settings)...',
|
||||
'Enable model routing based on complexity (/settings)...',
|
||||
'Enable experimental subagents for task delegation (/settings)...',
|
||||
//Settings tips end here
|
||||
|
||||
@@ -33,6 +33,12 @@ describe('keyMatchers', () => {
|
||||
[Command.DELETE_WORD_BACKWARD]: (key: Key) =>
|
||||
(key.ctrl || key.meta) && key.name === 'backspace',
|
||||
[Command.CLEAR_SCREEN]: (key: Key) => key.ctrl && key.name === 'l',
|
||||
[Command.SCROLL_UP]: (key: Key) => key.name === 'up' && !!key.shift,
|
||||
[Command.SCROLL_DOWN]: (key: Key) => key.name === 'down' && !!key.shift,
|
||||
[Command.SCROLL_HOME]: (key: Key) => key.name === 'home',
|
||||
[Command.SCROLL_END]: (key: Key) => key.name === 'end',
|
||||
[Command.PAGE_UP]: (key: Key) => key.name === 'pageup',
|
||||
[Command.PAGE_DOWN]: (key: Key) => key.name === 'pagedown',
|
||||
[Command.HISTORY_UP]: (key: Key) => key.ctrl && key.name === 'p',
|
||||
[Command.HISTORY_DOWN]: (key: Key) => key.ctrl && key.name === 'n',
|
||||
[Command.NAVIGATION_UP]: (key: Key) => key.name === 'up',
|
||||
@@ -141,6 +147,38 @@ describe('keyMatchers', () => {
|
||||
negative: [createKey('l'), createKey('k', { ctrl: true })],
|
||||
},
|
||||
|
||||
// Scrolling
|
||||
{
|
||||
command: Command.SCROLL_UP,
|
||||
positive: [createKey('up', { shift: true })],
|
||||
negative: [createKey('up'), createKey('up', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.SCROLL_DOWN,
|
||||
positive: [createKey('down', { shift: true })],
|
||||
negative: [createKey('down'), createKey('down', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.SCROLL_HOME,
|
||||
positive: [createKey('home')],
|
||||
negative: [createKey('end')],
|
||||
},
|
||||
{
|
||||
command: Command.SCROLL_END,
|
||||
positive: [createKey('end')],
|
||||
negative: [createKey('home')],
|
||||
},
|
||||
{
|
||||
command: Command.PAGE_UP,
|
||||
positive: [createKey('pageup'), createKey('pageup', { shift: true })],
|
||||
negative: [createKey('pagedown'), createKey('up')],
|
||||
},
|
||||
{
|
||||
command: Command.PAGE_DOWN,
|
||||
positive: [createKey('pagedown'), createKey('pagedown', { ctrl: true })],
|
||||
negative: [createKey('pageup'), createKey('down')],
|
||||
},
|
||||
|
||||
// History navigation
|
||||
{
|
||||
command: Command.HISTORY_UP,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { colorizeCode } from './CodeColorizer.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
describe('colorizeCode', () => {
|
||||
it('renders empty lines correctly when useAlternateBuffer is true', () => {
|
||||
const code = 'line 1\n\nline 3';
|
||||
const settings = new LoadedSettings(
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{
|
||||
path: '',
|
||||
settings: { ui: { useAlternateBuffer: true, showLineNumbers: false } },
|
||||
originalSettings: {
|
||||
ui: { useAlternateBuffer: true, showLineNumbers: false },
|
||||
},
|
||||
},
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const result = colorizeCode({
|
||||
code,
|
||||
language: 'javascript',
|
||||
maxWidth: 80,
|
||||
settings,
|
||||
hideLineNumbers: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderWithProviders(<>{result}</>);
|
||||
// We expect the output to preserve the empty line.
|
||||
// If the bug exists, it might look like "line 1\nline 3"
|
||||
// If fixed, it should look like "line 1\n \nline 3" (if we use space) or just have the newline.
|
||||
|
||||
// We can check if the output matches the code (ignoring color codes if any, but lastFrame returns plain text usually unless configured otherwise)
|
||||
// Actually lastFrame() returns string with ANSI codes stripped by default in some setups, or not.
|
||||
// But ink-testing-library usually returns the visual representation.
|
||||
|
||||
expect(lastFrame()).toMatch(/line 1\s*\n\s*\n\s*line 3/);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../components/shared/MaxSizedBox.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
// Configure theming and parsing utilities.
|
||||
const lowlight = createLowlight(common);
|
||||
@@ -150,7 +151,7 @@ export function colorizeCode({
|
||||
? false
|
||||
: (settings?.merged.ui?.showLineNumbers ?? true);
|
||||
|
||||
const useMaxSizedBox = settings?.merged.ui?.useAlternateBuffer !== true;
|
||||
const useMaxSizedBox = !isAlternateBufferEnabled(settings);
|
||||
try {
|
||||
// Render the HAST tree using the adapted theme
|
||||
// Apply the theme's default foreground color to the top-level Text element
|
||||
@@ -160,10 +161,7 @@ export function colorizeCode({
|
||||
let hiddenLinesCount = 0;
|
||||
|
||||
// Optimization to avoid highlighting lines that cannot possibly be displayed.
|
||||
if (
|
||||
availableHeight !== undefined &&
|
||||
settings?.merged.ui?.useAlternateBuffer === false
|
||||
) {
|
||||
if (availableHeight !== undefined && useMaxSizedBox) {
|
||||
availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT);
|
||||
if (lines.length > availableHeight) {
|
||||
const sliceIndex = lines.length - availableHeight;
|
||||
@@ -180,7 +178,7 @@ export function colorizeCode({
|
||||
);
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
<Box key={index} minHeight={useMaxSizedBox ? undefined : 1}>
|
||||
{/* We have to render line numbers differently depending on whether we are using MaxSizeBox or not */}
|
||||
{showLineNumbers && useMaxSizedBox && (
|
||||
<Text color={activeTheme.colors.Gray}>
|
||||
@@ -238,7 +236,7 @@ export function colorizeCode({
|
||||
const lines = codeToHighlight.split('\n');
|
||||
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
|
||||
const fallbackLines = lines.map((line, index) => (
|
||||
<Box key={index}>
|
||||
<Box key={index} minHeight={useMaxSizedBox ? undefined : 1}>
|
||||
{/* We have to render line numbers differently depending on whether we are using MaxSizeBox or not */}
|
||||
{showLineNumbers && useMaxSizedBox && (
|
||||
<Text color={activeTheme.defaultColor}>
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface MouseEvent {
|
||||
ctrl: boolean;
|
||||
}
|
||||
|
||||
export type MouseHandler = (event: MouseEvent) => void;
|
||||
export type MouseHandler = (event: MouseEvent) => void | boolean;
|
||||
|
||||
export function getMouseEventName(
|
||||
buttonCode: number,
|
||||
|
||||
@@ -53,8 +53,7 @@ export interface TerminalSetupResult {
|
||||
|
||||
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf';
|
||||
|
||||
// Terminal detection
|
||||
async function detectTerminal(): Promise<SupportedTerminal | null> {
|
||||
export function getTerminalProgram(): SupportedTerminal | null {
|
||||
const termProgram = process.env['TERM_PROGRAM'];
|
||||
|
||||
// Check VS Code and its forks - check forks first to avoid false positives
|
||||
@@ -75,6 +74,15 @@ async function detectTerminal(): Promise<SupportedTerminal | null> {
|
||||
if (termProgram === 'vscode' || process.env['VSCODE_GIT_IPC_HANDLE']) {
|
||||
return 'vscode';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Terminal detection
|
||||
async function detectTerminal(): Promise<SupportedTerminal | null> {
|
||||
const envTerminal = getTerminalProgram();
|
||||
if (envTerminal) {
|
||||
return envTerminal;
|
||||
}
|
||||
|
||||
// Check parent process name
|
||||
if (os.platform() !== 'win32') {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { lerp } from '../../utils/math.js';
|
||||
import { type LoadedSettings } from '../../config/settings.js';
|
||||
import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
const getMainAreaWidthInternal = (terminalWidth: number): number => {
|
||||
if (terminalWidth <= 80) {
|
||||
@@ -27,7 +28,7 @@ export const calculateMainAreaWidth = (
|
||||
settings: LoadedSettings,
|
||||
): number => {
|
||||
if (settings.merged.ui?.useFullWidth !== false) {
|
||||
if (settings.merged.ui?.useAlternateBuffer) {
|
||||
if (isAlternateBufferEnabled(settings)) {
|
||||
return terminalWidth - 1;
|
||||
}
|
||||
return terminalWidth;
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum AppEvent {
|
||||
OauthDisplayMessage = 'oauth-display-message',
|
||||
Flicker = 'flicker',
|
||||
McpClientUpdate = 'mcp-client-update',
|
||||
SelectionWarning = 'selection-warning',
|
||||
}
|
||||
|
||||
export interface AppEvents extends ExtensionEvents {
|
||||
@@ -21,6 +22,7 @@ export interface AppEvents extends ExtensionEvents {
|
||||
[AppEvent.OauthDisplayMessage]: string[];
|
||||
[AppEvent.Flicker]: never[];
|
||||
[AppEvent.McpClientUpdate]: Array<Map<string, McpClient> | never>;
|
||||
[AppEvent.SelectionWarning]: never[];
|
||||
}
|
||||
|
||||
export const appEvents = new EventEmitter<AppEvents>();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"description": "Gemini CLI Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -514,7 +514,7 @@ export class Config {
|
||||
params.truncateToolOutputLines ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES;
|
||||
this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true;
|
||||
this.useSmartEdit = params.useSmartEdit ?? true;
|
||||
this.useWriteTodos = params.useWriteTodos ?? false;
|
||||
this.useWriteTodos = params.useWriteTodos ?? true;
|
||||
this.initialUseModelRouter = params.useModelRouter ?? false;
|
||||
this.useModelRouter = this.initialUseModelRouter;
|
||||
this.disableModelRouterForAuth = params.disableModelRouterForAuth ?? [];
|
||||
@@ -650,7 +650,7 @@ export class Config {
|
||||
if (list.includes(tool.name) || list.includes(tool.alternateName)) {
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
`The tool '${tool.name}' (or '${tool.alternateName}') specified in '${listName}' is deprecated and will be removed in v0.14.0.`,
|
||||
`The tool '${tool.name}' (or '${tool.alternateName}') specified in '${listName}' is deprecated and will be removed in v0.16.0.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,14 +173,48 @@ export class WriteTodosTool extends BaseDeclarativeTool<
|
||||
},
|
||||
},
|
||||
required: ['description', 'status'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['todos'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
override get schema() {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
parametersJsonSchema: this.parameterSchema,
|
||||
responseJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
todos: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: {
|
||||
type: 'string',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: TODO_STATUSES,
|
||||
},
|
||||
},
|
||||
required: ['description', 'status'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['todos'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected override validateToolParamValues(
|
||||
params: WriteTodosToolParams,
|
||||
): string | null {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.15.0-nightly.20251111.51f952e7",
|
||||
"version": "0.15.1",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -278,8 +278,15 @@
|
||||
"useAlternateBuffer": {
|
||||
"title": "Use Alternate Screen Buffer",
|
||||
"description": "Use an alternate screen buffer for the UI, preserving shell history.",
|
||||
"markdownDescription": "Use an alternate screen buffer for the UI, preserving shell history.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `false`",
|
||||
"default": false,
|
||||
"markdownDescription": "Use an alternate screen buffer for the UI, preserving shell history.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `true`",
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"incrementalRendering": {
|
||||
"title": "Incremental Rendering",
|
||||
"description": "Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.",
|
||||
"markdownDescription": "Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `true`",
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"customWittyPhrases": {
|
||||
@@ -978,9 +985,9 @@
|
||||
},
|
||||
"useWriteTodos": {
|
||||
"title": "Use Write Todos",
|
||||
"description": "Enable the write_todos_list tool.",
|
||||
"markdownDescription": "Enable the write_todos_list tool.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"description": "Enable the write_todos tool.",
|
||||
"markdownDescription": "Enable the write_todos tool.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `true`",
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"security": {
|
||||
|
||||
Reference in New Issue
Block a user