Compare commits

..

16 Commits

Author SHA1 Message Date
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
47 changed files with 1634 additions and 301 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
+8 -1
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):
+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-preview.4",
"version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.15.0-preview.4",
"version": "0.15.0",
"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-preview.4",
"version": "0.15.0",
"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-preview.4",
"version": "0.15.0",
"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-preview.4",
"version": "0.15.0",
"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-preview.4",
"version": "0.15.0",
"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-preview.4",
"version": "0.15.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.15.0-preview.4",
"version": "0.15.0",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.0-preview.4"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.0"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -61,7 +61,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.2",
"ink": "npm:@jrichman/ink@6.4.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-preview.4",
"version": "0.15.0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.15.0-preview.4",
"version": "0.15.0",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.0-preview.4"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.15.0"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -42,7 +42,7 @@
"fzf": "^0.5.2",
"glob": "^10.4.5",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.2",
"ink": "npm:@jrichman/ink@6.4.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',
+11 -1
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',
+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>
+9 -2
View File
@@ -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);
@@ -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,
@@ -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 │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -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);
@@ -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);
});
});
@@ -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,82 @@ describe('ScrollProvider', () => {
vi.useRealTimers();
});
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 +312,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();
});
});
+155 -2
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;
@@ -146,7 +157,91 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
}
};
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;
}
}
const candidates = findScrollableCandidates(
mouseEvent,
scrollablesRef.current,
@@ -158,6 +253,60 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
}
};
const handleMove = (mouseEvent: MouseEvent) => {
const state = dragStateRef.current;
if (!state.active || !state.id) return;
const entry = scrollablesRef.current.get(state.id);
if (!entry || !entry.ref.current) {
state.active = false;
return;
}
const boundingBox = getBoundingBox(entry.ref.current);
if (!boundingBox) return;
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;
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);
}
};
const handleLeftRelease = () => {
if (dragStateRef.current.active) {
dragStateRef.current = {
active: false,
id: null,
offset: 0,
};
}
};
useMouse(
(event: MouseEvent) => {
if (event.name === 'scroll-up') {
@@ -165,7 +314,11 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
} else if (event.name === 'scroll-down') {
handleScroll('down', event);
} else if (event.name === 'left-press') {
handleClick(event);
handleLeftPress(event);
} else if (event.name === 'move') {
handleMove(event);
} else if (event.name === 'left-release') {
handleLeftRelease();
}
},
{ isActive: true },
@@ -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,
+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}>
+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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.15.0-preview.4",
"version": "0.15.0",
"description": "Gemini CLI Core",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.15.0-preview.4",
"version": "0.15.0",
"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-preview.4",
"version": "0.15.0",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+9 -2
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": {