feat(ui) support animated page up/down, fn-up/down and end+home (#13012)

This commit is contained in:
Jacob Richman
2025-11-13 11:16:23 -08:00
committed by jacob314
parent 1cab681854
commit e192efa1f9
6 changed files with 426 additions and 21 deletions

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

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 }],
@@ -199,3 +215,155 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
};
<<<<<<< HEAD
=======
interface CommandCategory {
readonly title: string;
readonly commands: readonly Command[];
}
/**
* Presentation metadata for grouping commands in documentation or UI.
*/
export const commandCategories: readonly CommandCategory[] = [
{
title: 'Basic Controls',
commands: [Command.RETURN, Command.ESCAPE],
},
{
title: 'Cursor Movement',
commands: [Command.HOME, Command.END],
},
{
title: 'Editing',
commands: [
Command.KILL_LINE_RIGHT,
Command.KILL_LINE_LEFT,
Command.CLEAR_INPUT,
Command.DELETE_WORD_BACKWARD,
],
},
{
title: 'Screen Control',
commands: [Command.CLEAR_SCREEN],
},
{
title: 'Scrolling',
commands: [
Command.SCROLL_UP,
Command.SCROLL_DOWN,
Command.SCROLL_HOME,
Command.SCROLL_END,
Command.PAGE_UP,
Command.PAGE_DOWN,
],
},
{
title: 'History & Search',
commands: [
Command.HISTORY_UP,
Command.HISTORY_DOWN,
Command.REVERSE_SEARCH,
Command.SUBMIT_REVERSE_SEARCH,
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
],
},
{
title: 'Navigation',
commands: [
Command.NAVIGATION_UP,
Command.NAVIGATION_DOWN,
Command.DIALOG_NAVIGATION_UP,
Command.DIALOG_NAVIGATION_DOWN,
],
},
{
title: 'Suggestions & Completions',
commands: [
Command.ACCEPT_SUGGESTION,
Command.COMPLETION_UP,
Command.COMPLETION_DOWN,
Command.EXPAND_SUGGESTION,
Command.COLLAPSE_SUGGESTION,
],
},
{
title: 'Text Input',
commands: [Command.SUBMIT, Command.NEWLINE],
},
{
title: 'External Tools',
commands: [Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD_IMAGE],
},
{
title: 'App Controls',
commands: [
Command.SHOW_ERROR_DETAILS,
Command.SHOW_FULL_TODOS,
Command.TOGGLE_IDE_CONTEXT_DETAIL,
Command.TOGGLE_MARKDOWN,
Command.TOGGLE_COPY_MODE,
Command.SHOW_MORE_LINES,
Command.TOGGLE_SHELL_INPUT_FOCUS,
],
},
{
title: 'Session Control',
commands: [Command.QUIT, Command.EXIT],
},
];
/**
* Human-readable descriptions for each command, used in docs/tooling.
*/
export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.RETURN]: 'Confirm the current selection or choice.',
[Command.ESCAPE]: 'Dismiss dialogs or cancel the current focus.',
[Command.HOME]: 'Move the cursor to the start of the line.',
[Command.END]: 'Move the cursor to the end of the line.',
[Command.KILL_LINE_RIGHT]: 'Delete from the cursor to the end of the line.',
[Command.KILL_LINE_LEFT]: 'Delete from the cursor to the start of the line.',
[Command.CLEAR_INPUT]: 'Clear all text in the input field.',
[Command.DELETE_WORD_BACKWARD]: 'Delete the previous word.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.SCROLL_UP]: 'Scroll content up.',
[Command.SCROLL_DOWN]: 'Scroll content down.',
[Command.SCROLL_HOME]: 'Scroll to the top.',
[Command.SCROLL_END]: 'Scroll to the bottom.',
[Command.PAGE_UP]: 'Scroll up by one page.',
[Command.PAGE_DOWN]: 'Scroll down by one page.',
[Command.HISTORY_UP]: 'Show the previous entry in history.',
[Command.HISTORY_DOWN]: 'Show the next entry in history.',
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
[Command.NAVIGATION_DOWN]: 'Move selection down in lists.',
[Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.',
[Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.',
[Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.',
[Command.COMPLETION_UP]: 'Move to the previous completion option.',
[Command.COMPLETION_DOWN]: 'Move to the next completion option.',
[Command.SUBMIT]: 'Submit the current prompt.',
[Command.NEWLINE]: 'Insert a newline without submitting.',
[Command.OPEN_EXTERNAL_EDITOR]:
'Open the current prompt in an external editor.',
[Command.PASTE_CLIPBOARD_IMAGE]: 'Paste an image from the clipboard.',
[Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.',
[Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.',
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: 'Toggle IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]:
'Toggle copy mode when the terminal is using the alternate buffer.',
[Command.QUIT]: 'Cancel the current request or quit the CLI.',
[Command.EXIT]: 'Exit the CLI when the input buffer is empty.',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines.',
[Command.REVERSE_SEARCH]: 'Start reverse search through history.',
[Command.SUBMIT_REVERSE_SEARCH]: 'Insert the selected reverse-search match.',
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
'Accept a suggestion while reverse searching.',
[Command.TOGGLE_SHELL_INPUT_FOCUS]:
'Toggle focus between the shell and Gemini input.',
[Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.',
[Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.',
};
>>>>>>> 60fe5acd (feat(ui) support animated page up/down, fn-up/down and end+home (#13012))

View File

@@ -251,32 +251,20 @@ describe('<ToolGroupMessage />', () => {
unmount();
});
<<<<<<< HEAD
it('renders header when scrolled', () => {
=======
it('renders sticky header when scrolled', () => {
>>>>>>> ee7065f6 (Sticky headers where the top rounded border is sticky. (#12971))
const toolCalls = [
createToolCall({
callId: '1',
name: 'tool-1',
<<<<<<< HEAD
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',
=======
description: 'Description 1\n'.repeat(5),
>>>>>>> ee7065f6 (Sticky headers where the top rounded border is sticky. (#12971))
}),
createToolCall({
callId: '2',
name: 'tool-2',
<<<<<<< HEAD
description: 'Description 2',
resultDisplay: 'line1\nline2',
=======
description: 'Description 2\n'.repeat(5),
>>>>>>> ee7065f6 (Sticky headers where the top rounded border is sticky. (#12971))
}),
];
const { lastFrame, unmount } = renderWithProviders(

View File

@@ -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);
});
});
});
});

View File

@@ -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,112 @@ 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),
);
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 },
@@ -103,7 +208,7 @@ function ScrollableList<T>(
hasFocus: hasFocusCallback,
flashScrollbar,
}),
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
[getScrollState, hasFocusCallback, flashScrollbar, scrollByWithAnimation],
);
useScrollable(scrollableEntry, hasFocus);

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,