Scrollable support (#12544)

This commit is contained in:
Jacob Richman
2025-11-04 16:21:00 -08:00
committed by GitHub
parent da3da19844
commit 3937461272
17 changed files with 2018 additions and 63 deletions

View File

@@ -0,0 +1,165 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef } from 'react';
import { render, Box, Text, useInput, useStdout } from 'ink';
import {
ScrollableList,
type ScrollableListRef,
} from '../src/ui/components/shared/ScrollableList.js';
import { ScrollProvider } from '../src/ui/contexts/ScrollProvider.js';
import { MouseProvider } from '../src/ui/contexts/MouseContext.js';
import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js';
import {
enableMouseEvents,
disableMouseEvents,
} from '../src/ui/utils/mouse.js';
interface Item {
id: string;
title: string;
}
const getLorem = (index: number) =>
Array(10)
.fill(null)
.map(() => 'lorem ipsum '.repeat((index % 3) + 1).trim())
.join('\n');
const Demo = () => {
const { stdout } = useStdout();
const [size, setSize] = useState({
columns: stdout.columns,
rows: stdout.rows,
});
useEffect(() => {
const onResize = () => {
setSize({
columns: stdout.columns,
rows: stdout.rows,
});
};
stdout.on('resize', onResize);
return () => {
stdout.off('resize', onResize);
};
}, [stdout]);
const [items, setItems] = useState<Item[]>(() =>
Array.from({ length: 1000 }, (_, i) => ({
id: String(i),
title: `Item ${i + 1}`,
})),
);
const listRef = useRef<ScrollableListRef<Item>>(null);
useInput((input, key) => {
if (input === 'a' || input === 'A') {
setItems((prev) => [
...prev,
{ id: String(prev.length), title: `Item ${prev.length + 1}` },
]);
}
if ((input === 'e' || input === 'E') && !key.ctrl) {
setItems((prev) => {
if (prev.length === 0) return prev;
const lastIndex = prev.length - 1;
const lastItem = prev[lastIndex]!;
const newItem = { ...lastItem, title: lastItem.title + 'e' };
return [...prev.slice(0, lastIndex), newItem];
});
}
if (key.ctrl && input === 'e') {
listRef.current?.scrollToEnd();
}
// Let Ink handle Ctrl+C via exitOnCtrlC (default true) or handle explicitly if needed.
// For alternate buffer, explicit handling is often safer for cleanup.
if (key.escape || (key.ctrl && input === 'c')) {
process.exit(0);
}
});
return (
<MouseProvider mouseEventsEnabled={true}>
<KeypressProvider>
<ScrollProvider>
<Box
flexDirection="column"
width={size.columns}
height={size.rows - 1}
padding={1}
>
<Text>
Press &apos;A&apos; to add an item. Press &apos;E&apos; to edit
last item. Press &apos;Ctrl+E&apos; to scroll to end. Press
&apos;Esc&apos; to exit. Mouse wheel or Shift+Up/Down to scroll.
</Text>
<Box flexGrow={1} borderStyle="round" borderColor="cyan">
<ScrollableList
ref={listRef}
data={items}
renderItem={({ item, index }) => (
<Box flexDirection="column" paddingBottom={2}>
<Box
sticky
flexDirection="column"
width={size.columns - 2}
opaque
stickyChildren={
<Box
flexDirection="column"
width={size.columns - 2}
opaque
>
<Text>{item.title}</Text>
<Box
borderStyle="single"
borderTop={true}
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor="gray"
/>
</Box>
}
>
<Text>{item.title}</Text>
</Box>
<Text color="gray">{getLorem(index)}</Text>
</Box>
)}
estimatedItemHeight={() => 14}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
initialScrollOffsetInIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
<Text>Count: {items.length}</Text>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
// Enable mouse reporting before rendering
enableMouseEvents();
// Ensure cleanup happens on exit
process.on('exit', () => {
disableMouseEvents();
});
// Handle SIGINT explicitly to ensure cleanup runs if Ink doesn't catch it in time
process.on('SIGINT', () => {
process.exit(0);
});
render(<Demo />, { alternateBuffer: true });

View File

@@ -72,6 +72,7 @@ import { ExtensionManager } from './config/extension-manager.js';
import { createPolicyUpdater } from './config/policy.js';
import { requestConsentNonInteractive } from './config/extensions/consent.js';
import { disableMouseEvents, enableMouseEvents } from './ui/utils/mouse.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
const SLOW_RENDER_MS = 200;
@@ -197,17 +198,19 @@ export async function startInteractiveUI(
settings.merged.general?.debugKeystrokeLogging
}
>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
<ScrollProvider>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
</VimModeProvider>
</SessionStatsProvider>
</ScrollProvider>
</MouseProvider>
</KeypressProvider>
</SettingsContext.Provider>

View File

@@ -18,6 +18,7 @@ import { ConfigContext } from '../ui/contexts/ConfigContext.js';
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 { type Config } from '@google/gemini-cli-core';
@@ -167,14 +168,16 @@ export const renderWithProviders = (
<ShellFocusContext.Provider value={shellFocus}>
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</MouseProvider>
</KeypressProvider>
</ShellFocusContext.Provider>

View File

@@ -12,6 +12,12 @@ import { App } from './App.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import { StreamingState } from './types.js';
import { ConfigContext } from './contexts/ConfigContext.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import {
type SettingScope,
LoadedSettings,
type SettingsFile,
} from '../config/settings.js';
vi.mock('ink', async (importOriginal) => {
const original = await importOriginal<typeof import('ink')>();
@@ -63,10 +69,27 @@ describe('App', () => {
const mockConfig = makeFakeConfig();
const mockSettingsFile: SettingsFile = {
settings: {},
originalSettings: {},
path: '/mock/path',
};
const mockLoadedSettings = new LoadedSettings(
mockSettingsFile,
mockSettingsFile,
mockSettingsFile,
mockSettingsFile,
true,
new Set<SettingScope>(),
);
const renderWithProviders = (ui: React.ReactElement, state: UIState) =>
render(
<ConfigContext.Provider value={mockConfig}>
<UIStateContext.Provider value={state}>{ui}</UIStateContext.Provider>
<SettingsContext.Provider value={mockLoadedSettings}>
<UIStateContext.Provider value={state}>{ui}</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
);

View File

@@ -128,6 +128,7 @@ export const Composer = () => {
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
}
width={uiState.mainAreaWidth}
hasFocus={true}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>

View File

@@ -4,79 +4,114 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useRef, useCallback } from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { ConsoleMessageItem } from '../types.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
interface DetailedMessagesDisplayProps {
messages: ConsoleMessageItem[];
maxHeight: number | undefined;
width: number;
// debugMode is not needed here if App.tsx filters debug messages before passing them.
// If DetailedMessagesDisplay should handle filtering, add debugMode prop.
hasFocus: boolean;
}
export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps
> = ({ messages, maxHeight, width }) => {
if (messages.length === 0) {
return null; // Don't render anything if there are no messages
}
> = ({ messages, maxHeight, width, hasFocus }) => {
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
const borderAndPadding = 4;
const estimatedItemHeight = useCallback(
(index: number) => {
const msg = messages[index];
if (!msg) {
return 1;
}
const iconAndSpace = 2;
const textWidth = width - borderAndPadding - iconAndSpace;
if (textWidth <= 0) {
return 1;
}
const lines = Math.ceil((msg.content?.length || 1) / textWidth);
return Math.max(1, lines);
},
[width, messages],
);
if (messages.length === 0) {
return null;
}
return (
<Box
flexDirection="column"
marginTop={1}
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
paddingLeft={1}
width={width}
height={maxHeight}
flexShrink={0}
flexGrow={0}
overflow="hidden"
>
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
</Text>
</Box>
<MaxSizedBox maxHeight={maxHeight} maxWidth={width - borderAndPadding}>
{messages.map((msg, index) => {
let textColor = theme.text.primary;
let icon = '\u2139'; // Information source ()
<Box height={maxHeight} width={width - borderAndPadding}>
<ScrollableList
ref={scrollableListRef}
data={messages}
renderItem={({ item: msg }: { item: ConsoleMessageItem }) => {
let textColor = theme.text.primary;
let icon = ''; // Information source ()
switch (msg.type) {
case 'warn':
textColor = theme.status.warning;
icon = '\u26A0'; // Warning sign (⚠)
break;
case 'error':
textColor = theme.status.error;
icon = '\u2716'; // Heavy multiplication x (✖)
break;
case 'debug':
textColor = theme.text.secondary; // Or theme.text.secondary
icon = '\u{1F50D}'; // Left-pointing magnifying glass (🔍)
break;
case 'log':
default:
// Default textColor and icon are already set
break;
}
switch (msg.type) {
case 'warn':
textColor = theme.status.warning;
icon = ''; // Warning sign (⚠)
break;
case 'error':
textColor = theme.status.error;
icon = ''; // Heavy multiplication x (✖)
break;
case 'debug':
textColor = theme.text.secondary; // Or theme.text.secondary
icon = '🔍'; // Left-pointing magnifying glass (🔍)
break;
case 'log':
default:
// Default textColor and icon are already set
break;
}
return (
<Box key={index} flexDirection="row">
<Text color={textColor}>{icon} </Text>
<Text color={textColor} wrap="wrap">
{msg.content}
{msg.count && msg.count > 1 && (
<Text color={theme.text.secondary}> (x{msg.count})</Text>
)}
</Text>
</Box>
);
})}
</MaxSizedBox>
return (
<Box flexDirection="row">
<Text color={textColor}>{icon} </Text>
<Text color={textColor} wrap="wrap">
{msg.content}
{msg.count && msg.count > 1 && (
<Text color={theme.text.secondary}> (x{msg.count})</Text>
)}
</Text>
</Box>
);
}}
keyExtractor={(item, index) => `${item.content}-${index}`}
estimatedItemHeight={estimatedItemHeight}
hasFocus={hasFocus}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { Scrollable } from './Scrollable.js';
import { Text } from 'ink';
import { describe, it, expect, vi } from 'vitest';
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
getInnerHeight: vi.fn(() => 5),
getScrollHeight: vi.fn(() => 10),
getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 5 })),
};
});
describe('<Scrollable />', () => {
it('renders children', () => {
const { lastFrame } = renderWithProviders(
<Scrollable hasFocus={false} height={5}>
<Text>Hello World</Text>
</Scrollable>,
);
expect(lastFrame()).toContain('Hello World');
});
it('renders multiple children', () => {
const { lastFrame } = renderWithProviders(
<Scrollable hasFocus={false} height={5}>
<Text>Line 1</Text>
<Text>Line 2</Text>
<Text>Line 3</Text>
</Scrollable>,
);
expect(lastFrame()).toContain('Line 1');
expect(lastFrame()).toContain('Line 2');
expect(lastFrame()).toContain('Line 3');
});
it('matches snapshot', () => {
const { lastFrame } = renderWithProviders(
<Scrollable hasFocus={false} height={5}>
<Text>Line 1</Text>
<Text>Line 2</Text>
<Text>Line 3</Text>
</Scrollable>,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,161 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, {
useState,
useEffect,
useRef,
useLayoutEffect,
useCallback,
useMemo,
} from 'react';
import { Box, getInnerHeight, getScrollHeight, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
interface ScrollableProps {
children?: React.ReactNode;
width?: number;
height?: number | string;
maxWidth?: number;
maxHeight?: number;
hasFocus: boolean;
scrollToBottom?: boolean;
flexGrow?: number;
}
export const Scrollable: React.FC<ScrollableProps> = ({
children,
width,
height,
maxWidth,
maxHeight,
hasFocus,
scrollToBottom,
flexGrow,
}) => {
const [scrollTop, setScrollTop] = useState(0);
const ref = useRef<DOMElement>(null);
const [size, setSize] = useState({
innerHeight: 0,
scrollHeight: 0,
});
const sizeRef = useRef(size);
useEffect(() => {
sizeRef.current = size;
}, [size]);
const childrenCountRef = useRef(0);
// This effect needs to run on every render to correctly measure the container
// and scroll to the bottom if new children are added. The if conditions
// prevent infinite loops.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
if (!ref.current) {
return;
}
const innerHeight = Math.round(getInnerHeight(ref.current));
const scrollHeight = Math.round(getScrollHeight(ref.current));
const isAtBottom = scrollTop >= size.scrollHeight - size.innerHeight - 1;
if (
size.innerHeight !== innerHeight ||
size.scrollHeight !== scrollHeight
) {
setSize({ innerHeight, scrollHeight });
if (isAtBottom) {
setScrollTop(Math.max(0, scrollHeight - innerHeight));
}
}
const childCountCurrent = React.Children.count(children);
if (scrollToBottom && childrenCountRef.current !== childCountCurrent) {
setScrollTop(Math.max(0, scrollHeight - innerHeight));
}
childrenCountRef.current = childCountCurrent;
});
const scrollBy = useCallback(
(delta: number) => {
const { scrollHeight, innerHeight } = sizeRef.current;
setScrollTop((prev: number) =>
Math.min(
Math.max(0, prev + delta),
Math.max(0, scrollHeight - innerHeight),
),
);
},
[sizeRef],
);
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
useAnimatedScrollbar(hasFocus, scrollBy);
useKeypress(
(key: Key) => {
if (key.shift) {
if (key.name === 'up') {
scrollByWithAnimation(-1);
}
if (key.name === 'down') {
scrollByWithAnimation(1);
}
}
},
{ isActive: hasFocus },
);
const getScrollState = useCallback(
() => ({
scrollTop,
scrollHeight: size.scrollHeight,
innerHeight: size.innerHeight,
}),
[scrollTop, size.scrollHeight, size.innerHeight],
);
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
const scrollableEntry = useMemo(
() => ({
ref: ref as React.RefObject<DOMElement>,
getScrollState,
scrollBy: scrollByWithAnimation,
hasFocus: hasFocusCallback,
flashScrollbar,
}),
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
);
useScrollable(scrollableEntry, hasFocus && ref.current !== null);
return (
<Box
ref={ref}
maxHeight={maxHeight}
width={width ?? maxWidth}
height={height}
flexDirection="column"
overflowY="scroll"
overflowX="hidden"
scrollTop={scrollTop}
flexGrow={flexGrow}
scrollbarThumbColor={scrollbarColor}
>
{/*
This inner box is necessary to prevent the parent from shrinking
based on the children's content. It also adds a right padding to
make room for the scrollbar.
*/}
<Box flexShrink={0} paddingRight={1} flexDirection="column">
{children}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,200 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef, act } from 'react';
import { render } from 'ink-testing-library';
import { Box, Text } from 'ink';
import { ScrollableList, type ScrollableListRef } from './ScrollableList.js';
import { ScrollProvider } from '../../contexts/ScrollProvider.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { MouseProvider } from '../../contexts/MouseContext.js';
import { describe, it, expect, vi } from 'vitest';
// Mock useStdout to provide a fixed size for testing
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
useStdout: () => ({
stdout: {
columns: 80,
rows: 24,
on: vi.fn(),
off: vi.fn(),
write: vi.fn(),
},
}),
};
});
interface Item {
id: string;
title: string;
}
const getLorem = (index: number) =>
Array(10)
.fill(null)
.map(() => 'lorem ipsum '.repeat((index % 3) + 1).trim())
.join('\n');
const TestComponent = ({
initialItems = 1000,
onAddItem,
onRef,
}: {
initialItems?: number;
onAddItem?: (addItem: () => void) => void;
onRef?: (ref: ScrollableListRef<Item> | null) => void;
}) => {
const [items, setItems] = useState<Item[]>(() =>
Array.from({ length: initialItems }, (_, i) => ({
id: String(i),
title: `Item ${i + 1}`,
})),
);
const listRef = useRef<ScrollableListRef<Item>>(null);
useEffect(() => {
onAddItem?.(() => {
setItems((prev) => [
...prev,
{
id: String(prev.length),
title: `Item ${prev.length + 1}`,
},
]);
});
}, [onAddItem]);
useEffect(() => {
if (onRef) {
onRef(listRef.current);
}
}, [onRef]);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider kittyProtocolEnabled={false}>
<ScrollProvider>
<Box flexDirection="column" width={80} height={24} padding={1}>
<Box flexGrow={1} borderStyle="round" borderColor="cyan">
<ScrollableList
ref={listRef}
data={items}
renderItem={({ item, index }) => (
<Box flexDirection="column" paddingBottom={2}>
<Box
sticky
flexDirection="column"
width={78}
opaque
stickyChildren={
<Box flexDirection="column" width={78} opaque>
<Text>{item.title}</Text>
<Box
borderStyle="single"
borderTop={true}
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor="gray"
/>
</Box>
}
>
<Text>{item.title}</Text>
</Box>
<Text color="gray">{getLorem(index)}</Text>
</Box>
)}
estimatedItemHeight={() => 14}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
<Text>Count: {items.length}</Text>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
describe('ScrollableList Demo Behavior', () => {
it('should scroll to bottom when new items are added and stop when scrolled up', async () => {
let addItem: (() => void) | undefined;
let listRef: ScrollableListRef<Item> | null = null;
let lastFrame: () => string | undefined;
await act(async () => {
const result = render(
<TestComponent
onAddItem={(add) => {
addItem = add;
}}
onRef={(ref) => {
listRef = ref;
}}
/>,
);
lastFrame = result.lastFrame;
});
// Initial render should show Item 1000
expect(lastFrame!()).toContain('Item 1000');
expect(lastFrame!()).toContain('Count: 1000');
// Add item 1001
await act(async () => {
addItem?.();
});
for (let i = 0; i < 20; i++) {
if (lastFrame!()?.includes('Count: 1001')) break;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
}
expect(lastFrame!()).toContain('Item 1001');
expect(lastFrame!()).toContain('Count: 1001');
expect(lastFrame!()).not.toContain('Item 990'); // Should have scrolled past it
// Add item 1002
await act(async () => {
addItem?.();
});
for (let i = 0; i < 20; i++) {
if (lastFrame!()?.includes('Count: 1002')) break;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
}
expect(lastFrame!()).toContain('Item 1002');
expect(lastFrame!()).toContain('Count: 1002');
expect(lastFrame!()).not.toContain('Item 991');
// Scroll up directly via ref
await act(async () => {
listRef?.scrollBy(-5);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
// Add item 1003 - should NOT be visible because we scrolled up
await act(async () => {
addItem?.();
});
for (let i = 0; i < 20; i++) {
if (lastFrame!()?.includes('Count: 1003')) break;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
}
expect(lastFrame!()).not.toContain('Item 1003');
expect(lastFrame!()).toContain('Count: 1003');
});
});

View File

@@ -0,0 +1,131 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useRef,
forwardRef,
useImperativeHandle,
useCallback,
useMemo,
} from 'react';
import type React from 'react';
import { VirtualizedList, type VirtualizedListRef } 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';
type VirtualizedListProps<T> = {
data: T[];
renderItem: (info: { item: T; index: number }) => React.ReactElement;
estimatedItemHeight: (index: number) => number;
keyExtractor: (item: T, index: number) => string;
initialScrollIndex?: number;
initialScrollOffsetInIndex?: number;
};
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
hasFocus: boolean;
}
export type ScrollableListRef<T> = VirtualizedListRef<T>;
function ScrollableList<T>(
props: ScrollableListProps<T>,
ref: React.Ref<ScrollableListRef<T>>,
) {
const { hasFocus } = props;
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
useImperativeHandle(
ref,
() => ({
scrollBy: (delta) => virtualizedListRef.current?.scrollBy(delta),
scrollTo: (offset) => virtualizedListRef.current?.scrollTo(offset),
scrollToEnd: () => virtualizedListRef.current?.scrollToEnd(),
scrollToIndex: (params) =>
virtualizedListRef.current?.scrollToIndex(params),
scrollToItem: (params) =>
virtualizedListRef.current?.scrollToItem(params),
getScrollIndex: () => virtualizedListRef.current?.getScrollIndex() ?? 0,
getScrollState: () =>
virtualizedListRef.current?.getScrollState() ?? {
scrollTop: 0,
scrollHeight: 0,
innerHeight: 0,
},
}),
[],
);
const getScrollState = useCallback(
() =>
virtualizedListRef.current?.getScrollState() ?? {
scrollTop: 0,
scrollHeight: 0,
innerHeight: 0,
},
[],
);
const scrollBy = useCallback((delta: number) => {
virtualizedListRef.current?.scrollBy(delta);
}, []);
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
useAnimatedScrollbar(hasFocus, scrollBy);
useKeypress(
(key: Key) => {
if (key.shift) {
if (key.name === 'up') {
scrollByWithAnimation(-1);
}
if (key.name === 'down') {
scrollByWithAnimation(1);
}
}
},
{ isActive: hasFocus },
);
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
const scrollableEntry = useMemo(
() => ({
ref: containerRef as React.RefObject<DOMElement>,
getScrollState,
scrollBy: scrollByWithAnimation,
hasFocus: hasFocusCallback,
flashScrollbar,
}),
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
);
useScrollable(scrollableEntry, hasFocus);
return (
<Box
ref={containerRef}
flexGrow={1}
flexDirection="column"
overflow="hidden"
>
<VirtualizedList
ref={virtualizedListRef}
{...props}
scrollbarThumbColor={scrollbarColor}
/>
</Box>
);
}
const ScrollableListWithForwardRef = forwardRef(ScrollableList) as <T>(
props: ScrollableListProps<T> & { ref?: React.Ref<ScrollableListRef<T>> },
) => React.ReactElement;
export { ScrollableListWithForwardRef as ScrollableList };

View File

@@ -0,0 +1,283 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
import { Text, Box } from 'ink';
import {
createRef,
act,
useEffect,
createContext,
useContext,
useState,
} from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe('<VirtualizedList />', () => {
const keyExtractor = (item: string) => item;
beforeEach(() => {
vi.clearAllMocks();
});
describe('with 10px height and 100 items', () => {
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
// We use 1px for items. Container is 10px.
// Viewport shows 10 items. Overscan adds 10 items.
const itemHeight = 1;
const renderItem1px = ({ item }: { item: string }) => (
<Box height={itemHeight}>
<Text>{item}</Text>
</Box>
);
it.each([
{
name: 'top',
initialScrollIndex: undefined,
visible: ['Item 0', 'Item 7'],
notVisible: ['Item 8', 'Item 15', 'Item 50', 'Item 99'],
},
{
name: 'scrolled to bottom',
initialScrollIndex: 99,
visible: ['Item 99', 'Item 92'],
notVisible: ['Item 91', 'Item 85', 'Item 50', 'Item 0'],
},
])(
'renders only visible items ($name)',
async ({ initialScrollIndex, visible, notVisible }) => {
const { lastFrame } = render(
<Box height={10} width={100} borderStyle="round">
<VirtualizedList
data={longData}
renderItem={renderItem1px}
keyExtractor={keyExtractor}
estimatedItemHeight={() => itemHeight}
initialScrollIndex={initialScrollIndex}
/>
</Box>,
);
await act(async () => {
await delay(0);
});
const frame = lastFrame();
visible.forEach((item) => {
expect(frame).toContain(item);
});
notVisible.forEach((item) => {
expect(frame).not.toContain(item);
});
expect(frame).toMatchSnapshot();
},
);
it('sticks to bottom when new items added', async () => {
const { lastFrame, rerender } = render(
<Box height={10} width={100} borderStyle="round">
<VirtualizedList
data={longData}
renderItem={renderItem1px}
keyExtractor={keyExtractor}
estimatedItemHeight={() => itemHeight}
initialScrollIndex={99}
/>
</Box>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toContain('Item 99');
// Add items
const newData = [...longData, 'Item 100', 'Item 101'];
rerender(
<Box height={10} width={100} borderStyle="round">
<VirtualizedList
data={newData}
renderItem={renderItem1px}
keyExtractor={keyExtractor}
estimatedItemHeight={() => itemHeight}
// We don't need to pass initialScrollIndex again for it to stick,
// but passing it doesn't hurt. The component should auto-stick because it was at bottom.
/>
</Box>,
);
await act(async () => {
await delay(0);
});
const frame = lastFrame();
expect(frame).toContain('Item 101');
expect(frame).not.toContain('Item 0');
});
it('scrolls down to show new items when requested via ref', async () => {
const ref = createRef<VirtualizedListRef<string>>();
const { lastFrame } = render(
<Box height={10} width={100} borderStyle="round">
<VirtualizedList
ref={ref}
data={longData}
renderItem={renderItem1px}
keyExtractor={keyExtractor}
estimatedItemHeight={() => itemHeight}
/>
</Box>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toContain('Item 0');
// Scroll to bottom via ref
await act(async () => {
ref.current?.scrollToEnd();
await delay(0);
});
const frame = lastFrame();
expect(frame).toContain('Item 99');
});
it.each([
{ initialScrollIndex: 0, expectedMountedCount: 5 },
{ initialScrollIndex: 500, expectedMountedCount: 6 },
{ initialScrollIndex: 999, expectedMountedCount: 5 },
])(
'mounts only visible items with 1000 items and 10px height (scroll: $initialScrollIndex)',
async ({ initialScrollIndex, expectedMountedCount }) => {
let mountedCount = 0;
const tallItemHeight = 5;
const ItemWithEffect = ({ item }: { item: string }) => {
useEffect(() => {
mountedCount++;
return () => {
mountedCount--;
};
}, []);
return (
<Box height={tallItemHeight}>
<Text>{item}</Text>
</Box>
);
};
const veryLongData = Array.from(
{ length: 1000 },
(_, i) => `Item ${i}`,
);
const { lastFrame } = render(
<Box height={20} width={100} borderStyle="round">
<VirtualizedList
data={veryLongData}
renderItem={({ item }) => (
<ItemWithEffect key={item} item={item} />
)}
keyExtractor={keyExtractor}
estimatedItemHeight={() => tallItemHeight}
initialScrollIndex={initialScrollIndex}
/>
</Box>,
);
await act(async () => {
await delay(0);
});
const frame = lastFrame();
expect(mountedCount).toBe(expectedMountedCount);
expect(frame).toMatchSnapshot();
},
);
});
it('renders more items when a visible item shrinks via context update', async () => {
const SizeContext = createContext<{
firstItemHeight: number;
setFirstItemHeight: (h: number) => void;
}>({
firstItemHeight: 10,
setFirstItemHeight: () => {},
});
const items = Array.from({ length: 20 }, (_, i) => ({
id: `Item ${i}`,
}));
const ItemWithContext = ({
item,
index,
}: {
item: { id: string };
index: number;
}) => {
const { firstItemHeight } = useContext(SizeContext);
const height = index === 0 ? firstItemHeight : 1;
return (
<Box height={height}>
<Text>{item.id}</Text>
</Box>
);
};
const TestComponent = () => {
const [firstItemHeight, setFirstItemHeight] = useState(10);
return (
<SizeContext.Provider value={{ firstItemHeight, setFirstItemHeight }}>
<Box height={10} width={100}>
<VirtualizedList
data={items}
renderItem={({ item, index }) => (
<ItemWithContext item={item} index={index} />
)}
keyExtractor={(item) => item.id}
estimatedItemHeight={() => 1}
/>
</Box>
{/* Expose setter for testing */}
<TestControl setFirstItemHeight={setFirstItemHeight} />
</SizeContext.Provider>
);
};
let setHeightFn: (h: number) => void = () => {};
const TestControl = ({
setFirstItemHeight,
}: {
setFirstItemHeight: (h: number) => void;
}) => {
setHeightFn = setFirstItemHeight;
return null;
};
const { lastFrame } = render(<TestComponent />);
await act(async () => {
await delay(0);
});
// Initially, only Item 0 (height 10) fills the 10px viewport
expect(lastFrame()).toContain('Item 0');
expect(lastFrame()).not.toContain('Item 1');
// Shrink Item 0 to 1px via context
await act(async () => {
setHeightFn(1);
await delay(0);
});
// Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px
expect(lastFrame()).toContain('Item 0');
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 9');
});
});

View File

@@ -0,0 +1,492 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useState,
useRef,
useLayoutEffect,
forwardRef,
useImperativeHandle,
useEffect,
useMemo,
useCallback,
} from 'react';
import type React from 'react';
import { theme } from '../../semantic-colors.js';
import { type DOMElement, measureElement, Box } from 'ink';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
type VirtualizedListProps<T> = {
data: T[];
renderItem: (info: { item: T; index: number }) => React.ReactElement;
estimatedItemHeight: (index: number) => number;
keyExtractor: (item: T, index: number) => string;
initialScrollIndex?: number;
initialScrollOffsetInIndex?: number;
scrollbarThumbColor?: string;
};
export type VirtualizedListRef<T> = {
scrollBy: (delta: number) => void;
scrollTo: (offset: number) => void;
scrollToEnd: () => void;
scrollToIndex: (params: {
index: number;
viewOffset?: number;
viewPosition?: number;
}) => void;
scrollToItem: (params: {
item: T;
viewOffset?: number;
viewPosition?: number;
}) => void;
getScrollIndex: () => number;
getScrollState: () => {
scrollTop: number;
scrollHeight: number;
innerHeight: number;
};
};
function findLastIndex<T>(
array: T[],
predicate: (value: T, index: number, obj: T[]) => unknown,
): number {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i]!, i, array)) {
return i;
}
}
return -1;
}
function VirtualizedList<T>(
props: VirtualizedListProps<T>,
ref: React.Ref<VirtualizedListRef<T>>,
) {
const {
data,
renderItem,
estimatedItemHeight,
keyExtractor,
initialScrollIndex,
initialScrollOffsetInIndex,
} = props;
const dataRef = useRef(data);
useEffect(() => {
dataRef.current = data;
}, [data]);
const [scrollAnchor, setScrollAnchor] = useState(() => {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
(typeof initialScrollIndex === 'number' &&
initialScrollIndex >= data.length - 1 &&
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
if (scrollToEnd) {
return {
index: data.length > 0 ? data.length - 1 : 0,
offset: SCROLL_TO_ITEM_END,
};
}
if (typeof initialScrollIndex === 'number') {
return {
index: Math.max(0, Math.min(data.length - 1, initialScrollIndex)),
offset: initialScrollOffsetInIndex ?? 0,
};
}
return { index: 0, offset: 0 };
});
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
(typeof initialScrollIndex === 'number' &&
initialScrollIndex >= data.length - 1 &&
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
return scrollToEnd;
});
const containerRef = useRef<DOMElement>(null);
const [containerHeight, setContainerHeight] = useState(0);
const itemRefs = useRef<Array<DOMElement | null>>([]);
const [heights, setHeights] = useState<number[]>([]);
const isInitialScrollSet = useRef(false);
const { totalHeight, offsets } = useMemo(() => {
const offsets: number[] = [0];
let totalHeight = 0;
for (let i = 0; i < data.length; i++) {
const height = heights[i] ?? estimatedItemHeight(i);
totalHeight += height;
offsets.push(totalHeight);
}
return { totalHeight, offsets };
}, [heights, data, estimatedItemHeight]);
useEffect(() => {
setHeights((prevHeights) => {
if (data.length === prevHeights.length) {
return prevHeights;
}
const newHeights = [...prevHeights];
if (data.length < prevHeights.length) {
newHeights.length = data.length;
} else {
for (let i = prevHeights.length; i < data.length; i++) {
newHeights[i] = estimatedItemHeight(i);
}
}
return newHeights;
});
}, [data, estimatedItemHeight]);
// This layout effect needs to run on every render to correctly measure the
// container and ensure we recompute the layout if it has changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
if (containerRef.current) {
const height = Math.round(measureElement(containerRef.current).height);
if (containerHeight !== height) {
setContainerHeight(height);
}
}
let newHeights: number[] | null = null;
for (let i = startIndex; i <= endIndex; i++) {
const itemRef = itemRefs.current[i];
if (itemRef) {
const height = Math.round(measureElement(itemRef).height);
if (height !== heights[i]) {
if (!newHeights) {
newHeights = [...heights];
}
newHeights[i] = height;
}
}
}
if (newHeights) {
setHeights(newHeights);
}
});
const scrollableContainerHeight = containerRef.current
? Math.round(measureElement(containerRef.current).height)
: containerHeight;
const getAnchorForScrollTop = useCallback(
(
scrollTop: number,
offsets: number[],
): { index: number; offset: number } => {
const index = findLastIndex(offsets, (offset) => offset <= scrollTop);
if (index === -1) {
return { index: 0, offset: 0 };
}
return { index, offset: scrollTop - offsets[index]! };
},
[],
);
const scrollTop = useMemo(() => {
const offset = offsets[scrollAnchor.index];
if (typeof offset !== 'number') {
return 0;
}
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
const itemHeight = heights[scrollAnchor.index] ?? 0;
return offset + itemHeight - scrollableContainerHeight;
}
return offset + scrollAnchor.offset;
}, [scrollAnchor, offsets, heights, scrollableContainerHeight]);
const prevDataLength = useRef(data.length);
const prevTotalHeight = useRef(totalHeight);
const prevScrollTop = useRef(scrollTop);
const prevContainerHeight = useRef(scrollableContainerHeight);
useLayoutEffect(() => {
const contentPreviouslyFit =
prevTotalHeight.current <= prevContainerHeight.current;
const wasScrolledToBottomPixels =
prevScrollTop.current >=
prevTotalHeight.current - prevContainerHeight.current - 1;
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
// If the user was at the bottom, they are now sticking. This handles
// manually scrolling back to the bottom.
if (wasAtBottom && scrollTop >= prevScrollTop.current) {
setIsStickingToBottom(true);
}
const listGrew = data.length > prevDataLength.current;
const containerChanged =
prevContainerHeight.current !== scrollableContainerHeight;
// We scroll to the end if:
// 1. The list grew AND we were already at the bottom (or sticking).
// 2. We are sticking to the bottom AND the container size changed.
if (
(listGrew && (isStickingToBottom || wasAtBottom)) ||
(isStickingToBottom && containerChanged)
) {
setScrollAnchor({
index: data.length > 0 ? data.length - 1 : 0,
offset: SCROLL_TO_ITEM_END,
});
// If we are scrolling to the bottom, we are by definition sticking.
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
}
// Scenario 2: The list has changed (shrunk) in a way that our
// current scroll position or anchor is invalid. We should adjust to the bottom.
else if (
(scrollAnchor.index >= data.length ||
scrollTop > totalHeight - scrollableContainerHeight) &&
data.length > 0
) {
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
} else if (data.length === 0) {
// List is now empty, reset scroll to top.
setScrollAnchor({ index: 0, offset: 0 });
}
// Update refs for the next render cycle.
prevDataLength.current = data.length;
prevTotalHeight.current = totalHeight;
prevScrollTop.current = scrollTop;
prevContainerHeight.current = scrollableContainerHeight;
}, [
data.length,
totalHeight,
scrollTop,
scrollableContainerHeight,
scrollAnchor.index,
getAnchorForScrollTop,
offsets,
isStickingToBottom,
]);
useLayoutEffect(() => {
if (
isInitialScrollSet.current ||
offsets.length <= 1 ||
totalHeight <= 0 ||
containerHeight <= 0
) {
return;
}
if (typeof initialScrollIndex === 'number') {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
(initialScrollIndex >= data.length - 1 &&
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
if (scrollToEnd) {
setScrollAnchor({
index: data.length - 1,
offset: SCROLL_TO_ITEM_END,
});
setIsStickingToBottom(true);
isInitialScrollSet.current = true;
return;
}
const index = Math.max(0, Math.min(data.length - 1, initialScrollIndex));
const offset = initialScrollOffsetInIndex ?? 0;
const newScrollTop = (offsets[index] ?? 0) + offset;
const clampedScrollTop = Math.max(
0,
Math.min(totalHeight - scrollableContainerHeight, newScrollTop),
);
setScrollAnchor(getAnchorForScrollTop(clampedScrollTop, offsets));
isInitialScrollSet.current = true;
}
}, [
initialScrollIndex,
initialScrollOffsetInIndex,
offsets,
totalHeight,
containerHeight,
getAnchorForScrollTop,
data.length,
heights,
scrollableContainerHeight,
]);
const startIndex = Math.max(
0,
findLastIndex(offsets, (offset) => offset <= scrollTop) - 1,
);
const endIndexOffset = offsets.findIndex(
(offset) => offset > scrollTop + scrollableContainerHeight,
);
const endIndex =
endIndexOffset === -1
? data.length - 1
: Math.min(data.length - 1, endIndexOffset);
const topSpacerHeight = offsets[startIndex] ?? 0;
const bottomSpacerHeight =
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
const renderedItems = [];
for (let i = startIndex; i <= endIndex; i++) {
const item = data[i];
if (item) {
renderedItems.push(
<Box
key={keyExtractor(item, i)}
width="100%"
ref={(el) => {
itemRefs.current[i] = el;
}}
>
{renderItem({ item, index: i })}
</Box>,
);
}
}
useImperativeHandle(
ref,
() => ({
scrollBy: (delta: number) => {
if (delta < 0) {
setIsStickingToBottom(false);
}
const currentScrollTop = scrollTop;
const newScrollTop = Math.max(
0,
Math.min(
totalHeight - scrollableContainerHeight,
currentScrollTop + delta,
),
);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
},
scrollTo: (offset: number) => {
setIsStickingToBottom(false);
const newScrollTop = Math.max(
0,
Math.min(totalHeight - scrollableContainerHeight, offset),
);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
},
scrollToEnd: () => {
setIsStickingToBottom(true);
if (data.length > 0) {
setScrollAnchor({
index: data.length - 1,
offset: SCROLL_TO_ITEM_END,
});
}
},
scrollToIndex: ({
index,
viewOffset = 0,
viewPosition = 0,
}: {
index: number;
viewOffset?: number;
viewPosition?: number;
}) => {
setIsStickingToBottom(false);
const offset = offsets[index];
if (offset !== undefined) {
const newScrollTop = Math.max(
0,
Math.min(
totalHeight - scrollableContainerHeight,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
}
},
scrollToItem: ({
item,
viewOffset = 0,
viewPosition = 0,
}: {
item: T;
viewOffset?: number;
viewPosition?: number;
}) => {
setIsStickingToBottom(false);
const index = data.indexOf(item);
if (index !== -1) {
const offset = offsets[index];
if (offset !== undefined) {
const newScrollTop = Math.max(
0,
Math.min(
totalHeight - scrollableContainerHeight,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
}
}
},
getScrollIndex: () => scrollAnchor.index,
getScrollState: () => ({
scrollTop,
scrollHeight: totalHeight,
innerHeight: containerHeight,
}),
}),
[
offsets,
scrollAnchor,
totalHeight,
getAnchorForScrollTop,
data,
scrollableContainerHeight,
scrollTop,
containerHeight,
],
);
return (
<Box
ref={containerRef}
overflowY="scroll"
overflowX="hidden"
scrollTop={scrollTop}
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
width="100%"
height="100%"
flexDirection="column"
>
<Box flexShrink={0} width="100%" flexDirection="column">
<Box height={topSpacerHeight} flexShrink={0} />
{renderedItems}
<Box height={bottomSpacerHeight} flexShrink={0} />
</Box>
</Box>
);
}
const VirtualizedListWithForwardRef = forwardRef(VirtualizedList) as <T>(
props: VirtualizedListProps<T> & { ref?: React.Ref<VirtualizedListRef<T>> },
) => React.ReactElement;
export { VirtualizedListWithForwardRef as VirtualizedList };
VirtualizedList.displayName = 'VirtualizedList';

View File

@@ -0,0 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Scrollable /> > matches snapshot 1`] = `
"Line 1
Line 2
Line 3
"
`;

View File

@@ -0,0 +1,96 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: +0) 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│Item 0 █│
│ │
│ │
│ │
│ │
│Item 1 │
│ │
│ │
│ │
│ │
│Item 2 │
│ │
│ │
│ │
│ │
│Item 3 │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 500) 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│Item 500 │
│ │
│ │
│ │
│ │
│Item 501 │
│ │
│ │
│ ▄│
│ ▀│
│Item 502 │
│ │
│ │
│ │
│ │
│Item 503 │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 999) 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│Item 997 │
│ │
│ │
│ │
│ │
│Item 998 │
│ │
│ │
│ │
│ │
│Item 999 │
│ │
│ │
│ │
│ █│
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<VirtualizedList /> > with 10px height and 100 items > renders only visible items ('scrolled to bottom') 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│Item 92 │
│Item 93 │
│Item 94 │
│Item 95 │
│Item 96 │
│Item 97 │
│Item 98 │
│Item 99 █│
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<VirtualizedList /> > with 10px height and 100 items > renders only visible items ('top') 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│Item 0 █│
│Item 1 │
│Item 2 │
│Item 3 │
│Item 4 │
│Item 5 │
│Item 6 │
│Item 7 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -0,0 +1,184 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { getBoundingBox, type DOMElement } from 'ink';
import { useMouse, type MouseEvent } from '../hooks/useMouse.js';
export interface ScrollState {
scrollTop: number;
scrollHeight: number;
innerHeight: number;
}
export interface ScrollableEntry {
id: string;
ref: React.RefObject<DOMElement>;
getScrollState: () => ScrollState;
scrollBy: (delta: number) => void;
hasFocus: () => boolean;
flashScrollbar: () => void;
}
interface ScrollContextType {
register: (entry: ScrollableEntry) => void;
unregister: (id: string) => void;
}
const ScrollContext = createContext<ScrollContextType | null>(null);
const findScrollableCandidates = (
mouseEvent: MouseEvent,
scrollables: Map<string, ScrollableEntry>,
) => {
const candidates: Array<ScrollableEntry & { area: number }> = [];
for (const entry of scrollables.values()) {
if (!entry.ref.current || !entry.hasFocus()) {
continue;
}
const boundingBox = getBoundingBox(entry.ref.current);
if (!boundingBox) continue;
const { x, y, width, height } = boundingBox;
const isInside =
mouseEvent.col >= x &&
mouseEvent.col < x + width + 1 && // Intentionally add one to width to include scrollbar.
mouseEvent.row >= y &&
mouseEvent.row < y + height;
if (isInside) {
candidates.push({ ...entry, area: width * height });
}
}
// Sort by smallest area first
candidates.sort((a, b) => a.area - b.area);
return candidates;
};
export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [scrollables, setScrollables] = useState(
new Map<string, ScrollableEntry>(),
);
const register = useCallback((entry: ScrollableEntry) => {
setScrollables((prev) => new Map(prev).set(entry.id, entry));
}, []);
const unregister = useCallback((id: string) => {
setScrollables((prev) => {
const next = new Map(prev);
next.delete(id);
return next;
});
}, []);
const scrollablesRef = useRef(scrollables);
useEffect(() => {
scrollablesRef.current = scrollables;
}, [scrollables]);
const handleScroll = (direction: 'up' | 'down', mouseEvent: MouseEvent) => {
const delta = direction === 'up' ? -1 : 1;
const candidates = findScrollableCandidates(
mouseEvent,
scrollablesRef.current,
);
for (const candidate of candidates) {
const { scrollTop, scrollHeight, innerHeight } =
candidate.getScrollState();
// Epsilon to handle floating point inaccuracies.
const canScrollUp = scrollTop > 0.001;
const canScrollDown = scrollTop < scrollHeight - innerHeight - 0.001;
if (direction === 'up' && canScrollUp) {
candidate.scrollBy(delta);
return;
}
if (direction === 'down' && canScrollDown) {
candidate.scrollBy(delta);
return;
}
}
};
const handleClick = (mouseEvent: MouseEvent) => {
const candidates = findScrollableCandidates(
mouseEvent,
scrollablesRef.current,
);
if (candidates.length > 0) {
// The first candidate is the innermost one.
candidates[0].flashScrollbar();
}
};
useMouse(
(event: MouseEvent) => {
if (event.name === 'scroll-up') {
handleScroll('up', event);
} else if (event.name === 'scroll-down') {
handleScroll('down', event);
} else if (event.name === 'left-press') {
handleClick(event);
}
},
{ isActive: true },
);
const contextValue = useMemo(
() => ({ register, unregister }),
[register, unregister],
);
return (
<ScrollContext.Provider value={contextValue}>
{children}
</ScrollContext.Provider>
);
};
let nextId = 0;
export const useScrollable = (
entry: Omit<ScrollableEntry, 'id'>,
isActive: boolean,
) => {
const context = useContext(ScrollContext);
if (!context) {
throw new Error('useScrollable must be used within a ScrollProvider');
}
const [id] = useState(() => `scrollable-${nextId++}`);
useEffect(() => {
if (isActive) {
context.register({ ...entry, id });
return () => {
context.unregister(id);
};
}
return;
}, [context, entry, id, isActive]);
};

View File

@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { theme } from '../semantic-colors.js';
import { interpolateColor } from '../themes/color-utils.js';
export function useAnimatedScrollbar(
isFocused: boolean,
scrollBy: (delta: number) => void,
) {
const [scrollbarColor, setScrollbarColor] = useState(theme.ui.dark);
const colorRef = useRef(scrollbarColor);
colorRef.current = scrollbarColor;
const animationFrame = useRef<NodeJS.Timeout | null>(null);
const timeout = useRef<NodeJS.Timeout | null>(null);
const cleanup = useCallback(() => {
if (animationFrame.current) {
clearInterval(animationFrame.current);
animationFrame.current = null;
}
if (timeout.current) {
clearTimeout(timeout.current);
timeout.current = null;
}
}, []);
const flashScrollbar = useCallback(() => {
cleanup();
const fadeInDuration = 200;
const visibleDuration = 1000;
const fadeOutDuration = 300;
const focusedColor = theme.text.secondary;
const unfocusedColor = theme.ui.dark;
const startColor = colorRef.current;
// Phase 1: Fade In
let start = Date.now();
const animateFadeIn = () => {
const elapsed = Date.now() - start;
const progress = Math.min(elapsed / fadeInDuration, 1);
setScrollbarColor(interpolateColor(startColor, focusedColor, progress));
if (progress === 1) {
if (animationFrame.current) {
clearInterval(animationFrame.current);
animationFrame.current = null;
}
// Phase 2: Wait
timeout.current = setTimeout(() => {
// Phase 3: Fade Out
start = Date.now();
const animateFadeOut = () => {
const elapsed = Date.now() - start;
const progress = Math.min(elapsed / fadeOutDuration, 1);
setScrollbarColor(
interpolateColor(focusedColor, unfocusedColor, progress),
);
if (progress === 1) {
if (animationFrame.current) {
clearInterval(animationFrame.current);
animationFrame.current = null;
}
}
};
animationFrame.current = setInterval(animateFadeOut, 33);
}, visibleDuration);
}
};
animationFrame.current = setInterval(animateFadeIn, 33);
}, [cleanup]);
const wasFocused = useRef(isFocused);
useEffect(() => {
if (isFocused && !wasFocused.current) {
flashScrollbar();
} else if (!isFocused && wasFocused.current) {
cleanup();
setScrollbarColor(theme.ui.dark);
}
wasFocused.current = isFocused;
return cleanup;
}, [isFocused, flashScrollbar, cleanup]);
const scrollByWithAnimation = useCallback(
(delta: number) => {
scrollBy(delta);
flashScrollbar();
},
[scrollBy, flashScrollbar],
);
return { scrollbarColor, flashScrollbar, scrollByWithAnimation };
}

View File

@@ -13,10 +13,12 @@ import { Composer } from '../components/Composer.js';
import { ExitWarning } from '../components/ExitWarning.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
import { useSettings } from '../contexts/SettingsContext.js';
export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
const { rootUiRef, terminalHeight } = uiState;
const settings = useSettings();
useFlickerDetector(rootUiRef, terminalHeight);
return (
@@ -24,6 +26,12 @@ export const DefaultAppLayout: React.FC = () => {
flexDirection="column"
width={uiState.mainAreaWidth}
ref={uiState.rootUiRef}
height={
settings.merged.ui?.useAlternateBuffer ? terminalHeight - 1 : undefined
}
flexShrink={0}
flexGrow={0}
overflow="hidden"
>
<MainContent />