Compare commits

...

27 Commits

Author SHA1 Message Date
gemini-cli-robot 2c6d3eb512 chore(release): v0.15.1 2025-11-14 21:29:14 +00:00
gemini-cli-robot 79d8673793 fix(patch): cherry-pick ba15eeb to release/v0.15.0-pr-13083 to patch version v0.15.0 and create version 0.15.1 (#13094)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-11-14 13:11:09 -08:00
gemini-cli-robot 90adfb9a5a chore(release): v0.15.0 2025-11-14 00:01:57 +00:00
gemini-cli-robot 37af6f4f8a chore(release): v0.15.0-preview.7 2025-11-13 23:25:44 +00:00
jacob314 9893da300f Fix snapshot. 2025-11-13 15:10:07 -08:00
Jacob Richman 3032a82425 Polish sticky headers (#13024) 2025-11-13 14:59:51 -08:00
jacob314 9ac47ebf8b Fix merge conflicts. 2025-11-13 14:55:25 -08:00
Jacob Richman f581ae81db jacob314/drag scrollbar (#12998) 2025-11-13 14:48:57 -08:00
Jacob Richman 3955871052 Switch back to truncating headers. Unfortunately we have too many headers that are 10+ lines for no good reason so we need to add back this truncation until we fix them. (#13018) 2025-11-13 14:48:38 -08:00
Jacob Richman e192efa1f9 feat(ui) support animated page up/down, fn-up/down and end+home (#13012) 2025-11-13 14:47:38 -08:00
Jacob Richman 1cab681854 Support incremental update experiment flag. (#12926) 2025-11-13 14:45:22 -08:00
Jacob Richman b37c674f2b feat(ui) Make useAlternateBuffer the default (#12976) 2025-11-13 14:44:27 -08:00
Jacob Richman 046b3011c2 Sticky headers where the top rounded border is sticky. (#12971) 2025-11-13 14:44:10 -08:00
Jacob Richman 0c4d3b2666 Turns out the node console.clear() clears the buffer. (#12959) 2025-11-13 14:42:17 -08:00
gemini-cli-robot d13152b05f chore(release): v0.15.0-preview.6 2025-11-13 22:02:24 +00:00
gemini-cli-robot 24b5eec883 fix(patch): cherry-pick fb99b95 to release/v0.15.0-preview.5-pr-13018 [CONFLICTS] (#13022)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-11-13 13:34:00 -08:00
gemini-cli-robot 16f40a2847 chore(release): v0.15.0-preview.5 2025-11-13 18:08:49 +00:00
gemini-cli-robot 77751a0739 fix(patch): cherry-pick 13d8d94 to release/v0.15.0-preview.4-pr-12981 to patch version v0.15.0-preview.4 and create version 0.15.0-preview.5 (#13011)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-11-13 12:48:15 -05:00
gemini-cli-robot 4ae2d4b184 chore(release): v0.15.0-preview.4 2025-11-13 17:26:10 +00:00
gemini-cli-robot 2639d74814 fix(patch): cherry-pick 102905b to release/v0.15.0-preview.3-pr-12991 to patch version v0.15.0-preview.3 and create version 0.15.0-preview.4 (#13008)
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
2025-11-13 16:57:49 +00:00
gemini-cli-robot fcd9b2a5fe chore(release): v0.15.0-preview.3 2025-11-13 08:35:56 +00:00
gemini-cli-robot 605d9167dd fix(patch): cherry-pick fe1bfc6 to release/v0.15.0-preview.2-pr-12989 to patch version v0.15.0-preview.2 and create version 0.15.0-preview.3 (#12990)
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
2025-11-13 00:16:38 -08:00
gemini-cli-robot 128c22ece6 chore(release): v0.15.0-preview.2 2025-11-12 20:47:51 +00:00
gemini-cli-robot e27197096a fix(patch): cherry-pick 7ec7845 to release/v0.15.0-preview.1-pr-12905 [CONFLICTS] (#12962)
Co-authored-by: anj-s <32556631+anj-s@users.noreply.github.com>
Co-authored-by: Anjali Sridhar <anjsridhar@gmail.com>
2025-11-12 12:31:21 -08:00
gemini-cli-robot 48fa48ca38 chore(release): v0.15.0-preview.1 2025-11-12 19:22:34 +00:00
gemini-cli-robot dfe7fc9a53 fix(patch): cherry-pick 540f606 to release/v0.15.0-preview.0-pr-12949 to patch version v0.15.0-preview.0 and create version 0.15.0-preview.1 (#12956)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-11-12 18:41:49 +00:00
gemini-cli-robot af5a1ebec5 chore(release): v0.15.0-preview.0 2025-11-12 00:54:17 +00:00
69 changed files with 2095 additions and 373 deletions
+13
View File
@@ -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
+10 -3
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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.
+5 -2
View File
@@ -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`,
);
+1 -1
View File
@@ -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');
+7 -2
View File
@@ -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),
+12 -12
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
+3 -3
View File
@@ -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",
+16
View File
@@ -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 }],
+11
View File
@@ -0,0 +1,11 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const SettingPaths = {
General: {
PreferredEditor: 'general.preferredEditor',
},
} as const;
+2 -1
View File
@@ -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',
+13 -3
View File
@@ -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: {
+4
View File
@@ -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
View File
@@ -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.
+20 -14
View File
@@ -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>
+35 -5
View File
@@ -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.
+36 -1
View File
@@ -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;
+13 -4
View File
@@ -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();
});
});
+12
View File
@@ -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>
@@ -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([
@@ -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)
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -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([
{
+17 -1
View File
@@ -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();
});
});
+170 -6
View File
@@ -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,
+1 -1
View File
@@ -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
+38
View File
@@ -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/);
});
});
+5 -7
View File
@@ -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}>
+1 -1
View File
@@ -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,
+10 -2
View File
@@ -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') {
+2 -1
View File
@@ -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;
+2
View File
@@ -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 -1
View File
@@ -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",
+2 -2
View File
@@ -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.`,
);
}
}
+34
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.15.0-nightly.20251111.51f952e7",
"version": "0.15.1",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+12 -5
View File
@@ -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": {