feat(cli) Scrollbar for input prompt (#21992)

This commit is contained in:
Jacob Richman
2026-04-03 15:10:04 -07:00
committed by jacob314
parent 7872d6d7fe
commit 5a49437cea
7 changed files with 89 additions and 23 deletions
+2
View File
@@ -515,6 +515,8 @@ const baseMockUiState = {
activePtyId: undefined,
backgroundTasks: new Map(),
backgroundTaskHeight: 0,
copyModeEnabled: false,
mouseMode: true,
quota: {
userTier: undefined,
stats: undefined,
+7 -2
View File
@@ -2014,6 +2014,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
const isSelectionMode = isAlternateBuffer && !mouseMode;
useKeypress(
(key: Key) => {
if (
@@ -2028,13 +2030,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
setCopyModeEnabled(false);
if (mouseMode) {
if (isSelectionMode) {
setMouseMode(true);
} else if (mouseMode) {
enableMouseEvents();
}
return true;
},
{
isActive: copyModeEnabled,
isActive: copyModeEnabled || isSelectionMode,
// We need to receive keypresses first so they do not bubble to other
// handlers.
priority: KeypressPriority.Critical,
@@ -8,18 +8,56 @@ import { CopyModeWarning } from './CopyModeWarning.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { useInputState } from '../contexts/InputContext.js';
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core';
vi.mock('../contexts/InputContext.js');
vi.mock('../contexts/UIStateContext.js');
vi.mock('../contexts/ConfigContext.js');
describe('CopyModeWarning', () => {
const mockUseUIState = vi.mocked(useUIState);
const mockUseConfig = vi.mocked(useConfig);
const mockUseInputState = vi.mocked(useInputState);
beforeEach(() => {
vi.clearAllMocks();
});
it('renders nothing when copy mode is disabled', async () => {
vi.mocked(useInputState).mockReturnValue({
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
} as unknown as Config);
mockUseInputState.mockReturnValue({
copyModeEnabled: false,
} as unknown as ReturnType<typeof useInputState>);
mockUseUIState.mockReturnValue({
mouseMode: true,
} as unknown as UIState);
});
it('renders nothing when copy mode is disabled and not in alternate buffer', async () => {
mockUseInputState.mockReturnValue({
copyModeEnabled: false,
} as unknown as ReturnType<typeof useInputState>);
mockUseUIState.mockReturnValue({
mouseMode: true,
} as unknown as UIState);
const { lastFrame, unmount } = await renderWithProviders(
<CopyModeWarning />,
);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders nothing when copy mode is disabled and mouse mode is disabled but not in alternate buffer', async () => {
mockUseInputState.mockReturnValue({
copyModeEnabled: false,
} as unknown as ReturnType<typeof useInputState>);
mockUseUIState.mockReturnValue({
mouseMode: false,
} as unknown as UIState);
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
} as unknown as Config);
const { lastFrame, unmount } = await renderWithProviders(
<CopyModeWarning />,
);
@@ -28,9 +66,31 @@ describe('CopyModeWarning', () => {
});
it('renders warning when copy mode is enabled', async () => {
vi.mocked(useInputState).mockReturnValue({
mockUseInputState.mockReturnValue({
copyModeEnabled: true,
} as unknown as ReturnType<typeof useInputState>);
mockUseUIState.mockReturnValue({
mouseMode: true,
} as unknown as UIState);
const { lastFrame, unmount } = await renderWithProviders(
<CopyModeWarning />,
);
expect(lastFrame()).toContain('In Copy Mode');
expect(lastFrame()).toContain('Use Page Up/Down to scroll');
expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit');
unmount();
});
it('renders warning when in alternate buffer and mouse mode is disabled', async () => {
mockUseInputState.mockReturnValue({
copyModeEnabled: false,
} as unknown as ReturnType<typeof useInputState>);
mockUseUIState.mockReturnValue({
mouseMode: false,
} as unknown as UIState);
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
} as unknown as Config);
const { lastFrame, unmount } = await renderWithProviders(
<CopyModeWarning />,
);
@@ -7,14 +7,22 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { useInputState } from '../contexts/InputContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { theme } from '../semantic-colors.js';
export const CopyModeWarning: React.FC = () => {
const { copyModeEnabled } = useInputState();
const { mouseMode } = useUIState();
const config = useConfig();
const isTrueAlternateBuffer = config.getUseAlternateBuffer();
const isSelectionMode = isTrueAlternateBuffer && !mouseMode;
const showWarning = copyModeEnabled || isSelectionMode;
return (
<Box height={1}>
{copyModeEnabled && (
{showWarning && (
<Text color={theme.status.warning}>
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other
key to exit.
@@ -33,7 +33,6 @@ interface ScrollableListProps<T> extends VirtualizedListProps<T> {
width?: string | number;
scrollbar?: boolean;
stableScrollback?: boolean;
copyModeEnabled?: boolean;
isStatic?: boolean;
fixedItemHeight?: boolean;
targetScrollIndex?: number;
@@ -316,9 +316,8 @@ describe('<VirtualizedList />', () => {
unmount();
});
it('renders correctly in copyModeEnabled when scrolled', async () => {
it('renders correctly with scrollbar={false} when scrolled', async () => {
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
// Use copy mode
const { lastFrame, unmount } = await render(
<Box height={10} width={100}>
<VirtualizedList
@@ -331,7 +330,7 @@ describe('<VirtualizedList />', () => {
keyExtractor={(item) => item}
estimatedItemHeight={() => 1}
initialScrollIndex={50}
copyModeEnabled={true}
scrollbar={false}
/>
</Box>,
);
@@ -39,7 +39,6 @@ export type VirtualizedListProps<T> = {
overflowToBackbuffer?: boolean;
scrollbar?: boolean;
stableScrollback?: boolean;
copyModeEnabled?: boolean;
fixedItemHeight?: boolean;
containerHeight?: number;
};
@@ -144,7 +143,6 @@ function VirtualizedList<T>(
overflowToBackbuffer,
scrollbar = true,
stableScrollback,
copyModeEnabled = false,
fixedItemHeight = false,
} = props;
const dataRef = useRef(data);
@@ -727,25 +725,20 @@ function VirtualizedList<T>(
return (
<Box
ref={containerRefCallback}
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
overflowY="scroll"
overflowX="hidden"
scrollTop={copyModeEnabled ? 0 : scrollTop}
scrollTop={scrollTop}
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
backgroundColor={props.backgroundColor}
width="100%"
height="100%"
flexDirection="column"
paddingRight={copyModeEnabled ? 0 : 1}
paddingRight={1}
overflowToBackbuffer={overflowToBackbuffer}
scrollbar={scrollbar}
stableScrollback={stableScrollback}
>
<Box
flexShrink={0}
width="100%"
flexDirection="column"
marginTop={copyModeEnabled ? -actualScrollTop : 0}
>
<Box flexShrink={0} width="100%" flexDirection="column">
<Box height={topSpacerHeight} flexShrink={0} />
{renderedItems}
<Box height={bottomSpacerHeight} flexShrink={0} />