mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
feat(ui) support animated page up/down, fn-up/down and end+home (#13012)
This commit is contained in:
@@ -35,6 +35,17 @@ available combinations.
|
||||
| -------------------------------------------- | ---------- |
|
||||
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
|
||||
|
||||
#### 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
|
||||
|
||||
| Action | Keys |
|
||||
|
||||
@@ -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 }],
|
||||
@@ -230,6 +246,17 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
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: [
|
||||
@@ -298,6 +325,12 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[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.',
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user