Optimize VirtualizedList

Checkpoint optimizing virtualized list

Fixes for fallback rendering where terminalBuffer=false
Change terminalBuffer false back to the default while we fix performance
with very large chats.

Checkpoint changes to virtualized list.

Fix virtualized list

NO commit

Update ink version.

Fix UI snapshot mismatch in MainContent tests and VirtualizedList computation

Checkpoint.
This commit is contained in:
jacob314
2026-04-06 21:30:30 -07:00
parent 1d72a120fb
commit 4ee4bfcb02
24 changed files with 1453 additions and 259 deletions
+1
View File
@@ -80,6 +80,7 @@ they appear in the UI.
| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Max Scrollback Length | `ui.maxScrollbackLength` | Maximum number of lines to keep in the terminal scrollback buffer. | `1000` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, all, or off. | `"off"` |
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
+6
View File
@@ -376,6 +376,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`ui.maxScrollbackLength`** (number):
- **Description:** Maximum number of lines to keep in the terminal scrollback
buffer.
- **Default:** `1000`
- **Requires restart:** Yes
- **`ui.showSpinner`** (boolean):
- **Description:** Show the spinner during operations.
- **Default:** `true`
+5 -5
View File
@@ -11,7 +11,7 @@
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.6.9",
"ink": "npm:@jrichman/ink@7.0.0-beta.2",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
@@ -10020,9 +10020,9 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "6.6.9",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
"version": "7.0.0-beta.2",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-7.0.0-beta.2.tgz",
"integrity": "sha512-cc222452y0FK1gl7/p+veunoABGL1LAfF57RfDYCGYcTxxogN3IaM/KbkaY0pKQLngLBj8mz7GyOabq+O4DY2A==",
"license": "MIT",
"dependencies": {
"ansi-escapes": "^7.0.0",
@@ -18228,7 +18228,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.6.9",
"ink": "npm:@jrichman/ink@7.0.0-beta.2",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+2 -2
View File
@@ -73,7 +73,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.6.9",
"ink": "npm:@jrichman/ink@7.0.0-beta.2",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -143,7 +143,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.6.9",
"ink": "npm:@jrichman/ink@7.0.0-beta.2",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
+1 -1
View File
@@ -49,7 +49,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.6.9",
"ink": "npm:@jrichman/ink@7.0.0-beta.2",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+10
View File
@@ -816,6 +816,16 @@ const SETTINGS_SCHEMA = {
'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.',
showInDialog: true,
},
maxScrollbackLength: {
type: 'number',
label: 'Max Scrollback Length',
category: 'UI',
requiresRestart: true,
default: 1000,
description:
'Maximum number of lines to keep in the terminal scrollback buffer.',
showInDialog: true,
},
showSpinner: {
type: 'boolean',
label: 'Show Spinner',
+4 -2
View File
@@ -155,8 +155,7 @@ export async function startInteractiveUI(
}
profiler.reportFrameRendered();
},
standardReactLayoutTiming:
useAlternateBuffer || config.getUseTerminalBuffer(),
standardReactLayoutTiming: false,
patchConsole: false,
alternateBuffer: useAlternateBuffer,
terminalBuffer: config.getUseTerminalBuffer(),
@@ -167,6 +166,9 @@ export async function startInteractiveUI(
useAlternateBuffer &&
!isShpool,
debugRainbow: settings.merged.ui.debugRainbow === true,
// @ts-expect-error Custom option in our fork of ink
maxScrollbackLength: settings.merged.ui.maxScrollbackLength,
},
);
+5 -2
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { useIsScreenReaderEnabled } from 'ink';
import { useUIState } from './contexts/UIStateContext.js';
import { StreamingContext } from './contexts/StreamingContext.js';
@@ -13,7 +14,7 @@ import { DefaultAppLayout } from './layouts/DefaultAppLayout.js';
import { AlternateBufferQuittingDisplay } from './components/AlternateBufferQuittingDisplay.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
export const App = () => {
export const App = React.memo(() => {
const uiState = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
@@ -35,4 +36,6 @@ export const App = () => {
{isScreenReaderEnabled ? <ScreenReaderAppLayout /> : <DefaultAppLayout />}
</StreamingContext.Provider>
);
};
});
App.displayName = 'App';
+9 -4
View File
@@ -1556,6 +1556,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
terminalHeight - stableControlsHeight - backgroundTaskHeight - 1,
);
// In terminalBuffer mode, we return terminalHeight - 1 to prevent frequent
// invalidation of UIState. This value is correct for the few cases where a
// fixed terminal height must be respected.
const uiStateAvailableTerminalHeight = config.getUseTerminalBuffer()
? terminalHeight - 1
: availableTerminalHeight;
config.setShellExecutionConfig({
terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
terminalHeight: Math.max(
@@ -2506,7 +2513,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
ctrlDPressedOnce: ctrlDPressCount >= 1,
shortcutsHelpVisible,
cleanUiDetailsVisible,
isFocused,
elapsedTime,
currentLoadingPhrase,
currentTip,
@@ -2520,7 +2526,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
currentModel,
contextFileNames,
errorCount,
availableTerminalHeight,
availableTerminalHeight: uiStateAvailableTerminalHeight,
stableControlsHeight,
mainAreaWidth,
staticAreaMaxItemHeight,
@@ -2619,7 +2625,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
ctrlDPressCount,
shortcutsHelpVisible,
cleanUiDetailsVisible,
isFocused,
elapsedTime,
currentLoadingPhrase,
currentTip,
@@ -2632,7 +2637,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
allowPlanMode,
contextFileNames,
errorCount,
availableTerminalHeight,
uiStateAvailableTerminalHeight,
stableControlsHeight,
mainAreaWidth,
staticAreaMaxItemHeight,
@@ -61,6 +61,15 @@ Tips for getting started:
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
Composer
"
`;
@@ -143,6 +143,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{uiState.isInputActive && (
<InputPrompt
maxAvailableWidth={terminalWidth}
onSubmit={uiActions.handleFinalSubmit}
setBannerVisible={uiActions.setBannerVisible}
onClearScreen={uiActions.handleClearScreen}
@@ -432,6 +432,7 @@ describe('InputPrompt', () => {
vi.mocked(clipboardy.read).mockResolvedValue('');
props = {
maxAvailableWidth: 80,
onQueueMessage: vi.fn(),
buffer: mockBuffer,
+11 -10
View File
@@ -20,9 +20,9 @@ import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
FixedScrollableList,
type FixedScrollableListRef,
} from './shared/FixedScrollableList.js';
import { ListeningIndicator } from './ListeningIndicator.js';
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
import {
@@ -112,6 +112,7 @@ export type ScrollableItem =
| { type: 'ghostLine'; ghostLine: string; index: number };
export interface InputPromptProps {
maxAvailableWidth: number;
onSubmit: (value: string) => void;
onClearScreen: () => void;
config: Config;
@@ -200,6 +201,7 @@ export function tryTogglePasteExpansion(buffer: TextBuffer): boolean {
}
export const InputPrompt: React.FC<InputPromptProps> = ({
maxAvailableWidth,
onSubmit,
onClearScreen,
config,
@@ -283,7 +285,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const innerBoxRef = useRef<DOMElement>(null);
const hasUserNavigatedSuggestions = useRef(false);
const listRef = useRef<ScrollableListRef<ScrollableItem>>(null);
const listRef = useRef<FixedScrollableListRef<ScrollableItem>>(null);
const { isRecording, handleVoiceInput, resetTurnBaseline } = useVoiceMode({
buffer,
@@ -1855,22 +1857,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
height={Math.min(buffer.viewportHeight, scrollableData.length)}
width="100%"
>
{config.getUseTerminalBuffer() ? (
<ScrollableList
{isAlternateBuffer ? (
<FixedScrollableList
ref={listRef}
hasFocus={focus}
data={scrollableData}
renderItem={renderItem}
estimatedItemHeight={() => 1}
fixedItemHeight={true}
itemHeight={1}
keyExtractor={(item) =>
item.type === 'visualLine'
? `line-${item.absoluteVisualIdx}`
: `ghost-${item.index}`
}
width={inputWidth}
width={maxAvailableWidth}
backgroundColor={listBackgroundColor}
containerHeight={Math.min(
maxHeight={Math.min(
buffer.viewportHeight,
scrollableData.length,
)}
@@ -358,6 +358,7 @@ describe('MainContent', () => {
bannerVisible: false,
copyModeEnabled: false,
terminalWidth: 100,
mouseMode: true,
};
beforeEach(() => {
@@ -803,7 +804,6 @@ describe('MainContent', () => {
expect(output).toContain('Planning execution');
expect(output).toContain('Refining approach');
expect(output).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
@@ -22,6 +22,7 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { appEvents, AppEvent } from '../../utils/events.js';
import { useInputState } from '../contexts/InputContext.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
@@ -37,6 +38,7 @@ export const MainContent = () => {
const config = useConfig();
const useTerminalBuffer = config.getUseTerminalBuffer();
const isAlternateBuffer = config.getUseAlternateBuffer();
const { copyModeEnabled } = useInputState();
const confirmingTool = useConfirmingTool();
const showConfirmationQueue = confirmingTool !== null;
@@ -234,7 +236,7 @@ export const MainContent = () => {
[showHeaderDetails, version, pendingItems],
);
const estimatedItemHeight = useCallback(() => 100, []);
const estimatedItemHeight = useCallback(() => 10, []);
const keyExtractor = useCallback(
(item: (typeof virtualizedData)[number], _index: number) => {
@@ -271,7 +273,7 @@ export const MainContent = () => {
renderStatic={useTerminalBuffer}
isStaticItem={useTerminalBuffer ? isStaticItem : undefined}
overflowToBackbuffer={useTerminalBuffer && !isAlternateBuffer}
scrollbar={mouseMode}
scrollbar={mouseMode && !copyModeEnabled}
/>
// TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()}
// as that will reduce the # of cases where we will have to clear the
@@ -295,6 +297,7 @@ export const MainContent = () => {
isStaticItem,
mouseMode,
isAlternateBuffer,
copyModeEnabled,
]);
if (!uiState.isConfigInitialized) {
@@ -213,24 +213,3 @@ AppHeader(full)
│ refine the solution.
"
`;
exports[`MainContent > renders multiple thinking messages sequentially correctly 2`] = `
"ScrollableList
AppHeader(full)
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> Plan a solution
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
Thinking...
│ Initial analysis
│ This is a multiple line paragraph for the first thinking message of how the
│ model analyzes the problem.
│ Planning execution
│ This a second multiple line paragraph for the second thinking message
│ explaining the plan in detail so that it wraps around the terminal display.
│ Refining approach
│ And finally a third multiple line paragraph for the third thinking message to
│ refine the solution."
`;
@@ -22,7 +22,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { Scrollable } from '../shared/Scrollable.js';
import { ScrollableList } from '../shared/ScrollableList.js';
import { FixedScrollableList } from '../shared/FixedScrollableList.js';
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
@@ -213,12 +213,12 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const data = resultDisplay as AnsiOutput;
// Calculate list height: if not constrained, use full data length.
// If constrained (e.g. alternate buffer), limit to available height
// to ensure virtualization works and fits within the viewport.
const listHeight = !constrainHeight
? data.length
: Math.min(data.length, limit);
// In alternate buffer, always constrain to limit to ensure virtualization works and fits viewport.
const listHeight = isAlternateBuffer
? Math.min(data.length, limit)
: !constrainHeight
? data.length
: Math.min(data.length, limit);
if (isAlternateBuffer) {
const initialScrollIndex =
@@ -226,13 +226,12 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
return (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
<ScrollableList
<FixedScrollableList
width={childWidth}
containerHeight={listHeight}
maxHeight={listHeight}
data={data}
renderItem={renderVirtualizedAnsiLine}
estimatedItemHeight={() => 1}
fixedItemHeight={true}
itemHeight={1}
keyExtractor={keyExtractor}
initialScrollIndex={initialScrollIndex}
hasFocus={hasFocus}
@@ -0,0 +1,299 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useRef,
forwardRef,
useImperativeHandle,
useCallback,
useMemo,
useEffect,
} from 'react';
import type React from 'react';
import {
FixedVirtualizedList,
type FixedVirtualizedListRef,
type FixedVirtualizedListProps,
SCROLL_TO_ITEM_END,
} from './FixedVirtualizedList.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 { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { useSettings } from '../../contexts/SettingsContext.js';
const ANIMATION_FRAME_DURATION_MS = 33;
interface FixedScrollableListProps<T> extends FixedVirtualizedListProps<T> {
hasFocus: boolean;
width: number;
scrollbar?: boolean;
stableScrollback?: boolean;
isStatic?: boolean;
fixedItemHeight?: boolean;
targetScrollIndex?: number;
scrollbarThumbColor?: string;
}
export type FixedScrollableListRef<T> = FixedVirtualizedListRef<T>;
function FixedScrollableList<T>(
props: FixedScrollableListProps<T>,
ref: React.Ref<FixedScrollableListRef<T>>,
) {
const keyMatchers = useKeyMatchers();
const settings = useSettings();
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
const {
hasFocus,
width,
maxHeight,
scrollbar = true,
stableScrollback,
} = props;
const fixedVirtualizedListRef = useRef<FixedVirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
useImperativeHandle(
ref,
() => ({
scrollBy: (delta) => fixedVirtualizedListRef.current?.scrollBy(delta),
scrollTo: (offset) => fixedVirtualizedListRef.current?.scrollTo(offset),
scrollToEnd: () => fixedVirtualizedListRef.current?.scrollToEnd(),
scrollToIndex: (params) =>
fixedVirtualizedListRef.current?.scrollToIndex(params),
scrollToItem: (params) =>
fixedVirtualizedListRef.current?.scrollToItem(params),
getScrollIndex: () =>
fixedVirtualizedListRef.current?.getScrollIndex() ?? 0,
getScrollState: () =>
fixedVirtualizedListRef.current?.getScrollState() ?? {
scrollTop: 0,
scrollHeight: 0,
innerHeight: 0,
},
}),
[],
);
const getScrollState = useCallback(
() =>
fixedVirtualizedListRef.current?.getScrollState() ?? {
scrollTop: 0,
scrollHeight: 0,
innerHeight: 0,
},
[],
);
const scrollBy = useCallback((delta: number) => {
fixedVirtualizedListRef.current?.scrollBy(delta);
}, []);
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 = process.env['NODE_ENV'] === 'test' ? 0 : 200,
) => {
stopSmoothScroll();
const scrollState = fixedVirtualizedListRef.current?.getScrollState() ?? {
scrollTop: 0,
scrollHeight: 0,
innerHeight: 0,
};
const {
scrollTop: rawStartScrollTop,
scrollHeight,
innerHeight,
} = scrollState;
const maxScrollTop = Math.max(0, scrollHeight - innerHeight);
const startScrollTop = Math.min(rawStartScrollTop, maxScrollTop);
let effectiveTarget = targetScrollTop;
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
effectiveTarget = maxScrollTop;
}
const clampedTarget = Math.max(
0,
Math.min(maxScrollTop, effectiveTarget),
);
if (duration === 0) {
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
fixedVirtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
} else {
fixedVirtualizedListRef.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 ||
targetScrollTop >= maxScrollTop
) {
fixedVirtualizedListRef.current?.scrollTo(
Number.MAX_SAFE_INTEGER,
);
} else {
fixedVirtualizedListRef.current?.scrollTo(Math.round(current));
}
stopSmoothScroll();
flashScrollbar();
} else {
fixedVirtualizedListRef.current?.scrollTo(Math.round(current));
}
}, ANIMATION_FRAME_DURATION_MS),
};
},
[stopSmoothScroll, flashScrollbar],
);
useKeypress(
(key: Key) => {
if (keyMatchers[Command.SCROLL_UP](key)) {
stopSmoothScroll();
scrollByWithAnimation(-1);
return true;
} else if (keyMatchers[Command.SCROLL_DOWN](key)) {
stopSmoothScroll();
scrollByWithAnimation(1);
return true;
} 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 maxScroll = Math.max(
0,
scrollState.scrollHeight - scrollState.innerHeight,
);
const current = smoothScrollState.current.active
? smoothScrollState.current.to
: Math.min(scrollState.scrollTop, maxScroll);
const innerHeight = scrollState.innerHeight;
smoothScrollTo(current + direction * innerHeight);
return true;
} else if (keyMatchers[Command.SCROLL_HOME](key)) {
smoothScrollTo(0);
return true;
} else if (keyMatchers[Command.SCROLL_END](key)) {
smoothScrollTo(SCROLL_TO_ITEM_END);
return true;
}
return false;
},
{ isActive: hasFocus },
);
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
const scrollableEntry = useMemo(
() => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ref: containerRef as React.RefObject<DOMElement>,
getScrollState,
scrollBy: scrollByWithAnimation,
scrollTo: smoothScrollTo,
hasFocus: hasFocusCallback,
flashScrollbar,
}),
[
getScrollState,
hasFocusCallback,
flashScrollbar,
scrollByWithAnimation,
smoothScrollTo,
],
);
useScrollable(scrollableEntry, true);
return (
<Box
ref={containerRef}
flexGrow={1}
flexDirection="column"
width={width}
maxHeight={maxHeight}
>
<FixedVirtualizedList
ref={fixedVirtualizedListRef}
{...props}
scrollbar={scrollbar}
scrollbarThumbColor={scrollbarColor}
stableScrollback={stableScrollback}
maxScrollbackLength={maxScrollbackLength}
/>
</Box>
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const FixedScrollableListWithForwardRef = forwardRef(FixedScrollableList) as <
T,
>(
props: FixedScrollableListProps<T> & {
ref?: React.Ref<FixedScrollableListRef<T>>;
},
) => React.ReactElement;
export { FixedScrollableListWithForwardRef as FixedScrollableList };
@@ -0,0 +1,588 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useState,
useRef,
forwardRef,
useImperativeHandle,
useMemo,
useCallback,
memo,
} from 'react';
import type React from 'react';
import { theme } from '../../semantic-colors.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { Box, StaticRender } from 'ink';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
export type FixedVirtualizedListProps<T> = {
data: T[];
renderItem: (info: { item: T; index: number }) => React.ReactElement;
itemHeight: number;
keyExtractor: (item: T, index: number) => string;
initialScrollIndex?: number;
initialScrollOffsetInIndex?: number;
targetScrollIndex?: number;
backgroundColor?: string;
scrollbarThumbColor?: string;
renderStatic?: boolean;
isStaticItem?: (item: T, index: number) => boolean;
width: number;
overflowToBackbuffer?: boolean;
scrollbar?: boolean;
stableScrollback?: boolean;
maxHeight: number;
maxScrollbackLength?: number;
};
export type FixedVirtualizedListRef<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;
};
};
const FixedVirtualizedListItem = memo(
({
content,
shouldBeStatic,
width,
itemKey,
}: {
content: React.ReactElement;
shouldBeStatic: boolean;
width: number;
itemKey: string;
}) => (
<Box width="100%" flexDirection="column" flexShrink={0}>
{shouldBeStatic ? (
<StaticRender width={width} key={itemKey + '-static-' + width}>
{() => content}
</StaticRender>
) : (
content
)}
</Box>
),
);
FixedVirtualizedListItem.displayName = 'FixedVirtualizedListItem';
function FixedVirtualizedList<T>(
props: FixedVirtualizedListProps<T>,
ref: React.Ref<FixedVirtualizedListRef<T>>,
) {
const {
data,
renderItem,
itemHeight,
keyExtractor,
initialScrollIndex,
initialScrollOffsetInIndex,
renderStatic,
isStaticItem,
width,
overflowToBackbuffer,
scrollbar = true,
stableScrollback,
maxScrollbackLength,
maxHeight,
} = props;
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,
};
}
if (typeof props.targetScrollIndex === 'number') {
return {
index: props.targetScrollIndex,
offset: 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 totalHeight = data.length * itemHeight;
const scrollableContainerHeight = maxHeight;
const isInitialScrollSet = useRef(false);
const getAnchorForScrollTop = useCallback(
(scrollTop: number): { index: number; offset: number } => {
const index = Math.max(
0,
Math.min(data.length - 1, Math.floor(scrollTop / itemHeight)),
);
if (data.length === 0) {
return { index: 0, offset: 0 };
}
return { index, offset: scrollTop - index * itemHeight };
},
[data.length, itemHeight],
);
const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState(
props.targetScrollIndex,
);
const prevDataLength = useRef(data.length);
if (
(props.targetScrollIndex !== undefined &&
props.targetScrollIndex !== prevTargetScrollIndex &&
data.length > 0) ||
(props.targetScrollIndex !== undefined &&
prevDataLength.current === 0 &&
data.length > 0)
) {
if (props.targetScrollIndex !== prevTargetScrollIndex) {
setPrevTargetScrollIndex(props.targetScrollIndex);
}
prevDataLength.current = data.length;
setIsStickingToBottom(false);
setScrollAnchor({ index: props.targetScrollIndex, offset: 0 });
} else {
prevDataLength.current = data.length;
}
const rawStateActualScrollTop = (() => {
const offset = scrollAnchor.index * itemHeight;
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
return offset + itemHeight - scrollableContainerHeight;
}
return offset + scrollAnchor.offset;
})();
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
const stateActualScrollTop = Math.max(
0,
Math.min(maxScroll, rawStateActualScrollTop),
);
const prevTotalHeight = useRef(totalHeight);
const prevScrollTop = useRef(rawStateActualScrollTop);
const prevContainerHeight = useRef(scrollableContainerHeight);
// Render-time state derivation to avoid useEffect for static rendering
let currentScrollAnchor = scrollAnchor;
let currentIsStickingToBottom = isStickingToBottom;
const contentPreviouslyFit =
prevTotalHeight.current <= prevContainerHeight.current;
const wasScrolledToBottomPixels =
prevScrollTop.current >=
prevTotalHeight.current - prevContainerHeight.current - 1;
// Crucial fix: we were previously only evaluating wasAtBottom against rawStateActualScrollTop *if* it was at bottom *last* frame.
// But if the content just exceeded the container height, wasScrolledToBottomPixels is false, but contentPreviouslyFit is true.
// If it previously fit, it implicitly means we should stick to the bottom if the new height exceeds the container.
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
if (
wasAtBottom &&
(rawStateActualScrollTop >= prevScrollTop.current || contentPreviouslyFit)
) {
if (!currentIsStickingToBottom) {
currentIsStickingToBottom = true;
if (scrollAnchor === currentScrollAnchor) {
// Avoid infinite loop if we already updated state
setIsStickingToBottom(true);
}
}
}
const listGrew = data.length > prevDataLength.current;
const containerChanged =
prevContainerHeight.current !== scrollableContainerHeight;
const shouldAutoScroll = props.targetScrollIndex === undefined;
if (
shouldAutoScroll &&
((listGrew && (currentIsStickingToBottom || wasAtBottom)) ||
(currentIsStickingToBottom && containerChanged))
) {
const newIndex = data.length > 0 ? data.length - 1 : 0;
if (
currentScrollAnchor.index !== newIndex ||
currentScrollAnchor.offset !== SCROLL_TO_ITEM_END
) {
currentScrollAnchor = {
index: newIndex,
offset: SCROLL_TO_ITEM_END,
};
setScrollAnchor(currentScrollAnchor);
}
if (!currentIsStickingToBottom) {
currentIsStickingToBottom = true;
setIsStickingToBottom(true);
}
} else if (
(currentScrollAnchor.index >= data.length ||
stateActualScrollTop > totalHeight - scrollableContainerHeight) &&
data.length > 0
) {
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
const newAnchor = getAnchorForScrollTop(newScrollTop);
if (
currentScrollAnchor.index !== newAnchor.index ||
currentScrollAnchor.offset !== newAnchor.offset
) {
currentScrollAnchor = newAnchor;
setScrollAnchor(newAnchor);
}
} else if (data.length === 0) {
if (currentScrollAnchor.index !== 0 || currentScrollAnchor.offset !== 0) {
currentScrollAnchor = { index: 0, offset: 0 };
setScrollAnchor(currentScrollAnchor);
}
}
// Initial scroll setup during render
if (
!isInitialScrollSet.current &&
data.length > 0 &&
totalHeight > 0 &&
scrollableContainerHeight > 0
) {
if (props.targetScrollIndex !== undefined) {
isInitialScrollSet.current = true;
} else if (typeof initialScrollIndex === 'number') {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
(initialScrollIndex >= data.length - 1 &&
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
if (scrollToEnd) {
currentScrollAnchor = {
index: data.length - 1,
offset: SCROLL_TO_ITEM_END,
};
setScrollAnchor(currentScrollAnchor);
currentIsStickingToBottom = true;
setIsStickingToBottom(true);
isInitialScrollSet.current = true;
} else {
const index = Math.max(
0,
Math.min(data.length - 1, initialScrollIndex),
);
const offset = initialScrollOffsetInIndex ?? 0;
const newScrollTop = index * itemHeight + offset;
const clampedScrollTop = Math.max(
0,
Math.min(totalHeight - scrollableContainerHeight, newScrollTop),
);
currentScrollAnchor = getAnchorForScrollTop(clampedScrollTop);
setScrollAnchor(currentScrollAnchor);
isInitialScrollSet.current = true;
}
}
}
// After all derived state updates, update refs for the next render
prevDataLength.current = data.length;
prevTotalHeight.current = totalHeight;
const rawDerivedActualScrollTop = (() => {
const offset = currentScrollAnchor.index * itemHeight;
if (currentScrollAnchor.offset === SCROLL_TO_ITEM_END) {
return offset + itemHeight - scrollableContainerHeight;
}
return offset + currentScrollAnchor.offset;
})();
const derivedActualScrollTop = Math.max(
0,
Math.min(maxScroll, rawDerivedActualScrollTop),
);
prevScrollTop.current = rawDerivedActualScrollTop;
prevContainerHeight.current = scrollableContainerHeight;
const scrollTop = currentIsStickingToBottom
? Number.MAX_SAFE_INTEGER
: derivedActualScrollTop;
const startIndex = Math.max(
0,
Math.floor(derivedActualScrollTop / itemHeight) - 1,
);
const viewHeightForEndIndex =
scrollableContainerHeight > 0 ? scrollableContainerHeight : 50;
const maxEndIndex = data.length - 1;
const endIndex = Math.min(
maxEndIndex,
Math.ceil((derivedActualScrollTop + viewHeightForEndIndex) / itemHeight),
);
const renderRangeStart = (() => {
if (renderStatic) return 0;
if (overflowToBackbuffer) {
if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) {
const targetOffset = Math.max(
0,
derivedActualScrollTop - maxScrollbackLength,
);
const index = Math.floor(targetOffset / itemHeight);
return Math.max(0, index - 1);
}
return 0;
}
return startIndex;
})();
const renderRangeEnd = renderStatic ? maxEndIndex : endIndex;
const topSpacerHeight = renderRangeStart * itemHeight;
const bottomSpacerHeight = renderStatic
? 0
: totalHeight - (renderRangeEnd + 1) * itemHeight;
const renderedItems = useMemo(() => {
const items = [];
for (let i = renderRangeStart; i <= renderRangeEnd; i++) {
const item = data[i];
if (item) {
const isOutsideViewport = i < startIndex || i > endIndex;
const shouldBeStatic =
(renderStatic === true && isOutsideViewport) ||
isStaticItem?.(item, i) === true;
const content = renderItem({ item, index: i });
const key = keyExtractor(item, i);
items.push(
<FixedVirtualizedListItem
key={key}
itemKey={key}
content={content}
shouldBeStatic={shouldBeStatic}
width={width}
/>,
);
}
}
return items;
}, [
renderRangeStart,
renderRangeEnd,
data,
startIndex,
endIndex,
renderStatic,
isStaticItem,
renderItem,
keyExtractor,
width,
]);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
useImperativeHandle(
ref,
() => ({
scrollBy: (delta: number) => {
if (delta < 0) {
setIsStickingToBottom(false);
}
const currentScrollTop = getScrollTop();
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
const actualCurrent = Math.min(currentScrollTop, maxScroll);
let newScrollTop = Math.max(0, actualCurrent + delta);
if (newScrollTop >= maxScroll) {
setIsStickingToBottom(true);
newScrollTop = Number.MAX_SAFE_INTEGER;
}
setPendingScrollTop(newScrollTop);
setScrollAnchor(
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll)),
);
},
scrollTo: (offset: number) => {
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) {
setIsStickingToBottom(true);
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
if (data.length > 0) {
setScrollAnchor({
index: data.length - 1,
offset: SCROLL_TO_ITEM_END,
});
}
} else {
setIsStickingToBottom(false);
const newScrollTop = Math.max(0, offset);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
}
},
scrollToEnd: () => {
setIsStickingToBottom(true);
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
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 = index * itemHeight;
if (index >= 0 && index < data.length) {
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
maxScroll,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
}
},
scrollToItem: ({
item,
viewOffset = 0,
viewPosition = 0,
}: {
item: T;
viewOffset?: number;
viewPosition?: number;
}) => {
setIsStickingToBottom(false);
const index = data.indexOf(item);
if (index !== -1) {
const offset = index * itemHeight;
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
maxScroll,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
}
},
getScrollIndex: () => scrollAnchor.index,
getScrollState: () => {
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
scrollHeight: totalHeight,
innerHeight: scrollableContainerHeight,
};
},
}),
[
scrollAnchor,
totalHeight,
getAnchorForScrollTop,
data,
scrollableContainerHeight,
getScrollTop,
setPendingScrollTop,
itemHeight,
],
);
return (
<Box
overflowY="scroll"
overflowX="hidden"
scrollTop={scrollTop}
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
backgroundColor={props.backgroundColor}
width="100%"
height="100%"
flexDirection="column"
paddingRight={1}
overflowToBackbuffer={overflowToBackbuffer}
scrollbar={scrollbar}
stableScrollback={stableScrollback}
>
<Box flexShrink={0} width="100%" flexDirection="column">
<Box height={topSpacerHeight} flexShrink={0} />
{renderedItems}
<Box height={Math.max(0, bottomSpacerHeight)} flexShrink={0} />
</Box>
</Box>
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const FixedVirtualizedListWithForwardRef = forwardRef(FixedVirtualizedList) as <
T,
>(
props: FixedVirtualizedListProps<T> & {
ref?: React.Ref<FixedVirtualizedListRef<T>>;
},
) => React.ReactElement;
export { FixedVirtualizedListWithForwardRef as FixedVirtualizedList };
FixedVirtualizedList.displayName = 'FixedVirtualizedList';
@@ -25,6 +25,7 @@ import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { useSettings } from '../../contexts/SettingsContext.js';
const ANIMATION_FRAME_DURATION_MS = 33;
@@ -33,9 +34,7 @@ interface ScrollableListProps<T> extends VirtualizedListProps<T> {
width?: string | number;
scrollbar?: boolean;
stableScrollback?: boolean;
copyModeEnabled?: boolean;
isStatic?: boolean;
fixedItemHeight?: boolean;
targetScrollIndex?: number;
containerHeight?: number;
scrollbarThumbColor?: string;
@@ -48,6 +47,8 @@ function ScrollableList<T>(
ref: React.Ref<ScrollableListRef<T>>,
) {
const keyMatchers = useKeyMatchers();
const settings = useSettings();
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
@@ -265,6 +266,7 @@ function ScrollableList<T>(
scrollbar={scrollbar}
scrollbarThumbColor={scrollbarColor}
stableScrollback={stableScrollback}
maxScrollbackLength={maxScrollbackLength}
/>
</Box>
);
@@ -170,7 +170,7 @@ describe('<VirtualizedList />', () => {
(_, i) => `Item ${i}`,
);
const { lastFrame, unmount } = await render(
const { lastFrame, unmount, waitUntilReady } = await render(
<Box height={20} width={100} borderStyle="round">
<VirtualizedList
data={veryLongData}
@@ -184,6 +184,11 @@ describe('<VirtualizedList />', () => {
</Box>,
);
await waitUntilReady();
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
const frame = lastFrame();
expect(mountedCount).toBe(expectedMountedCount);
expect(frame).toMatchSnapshot();
@@ -316,32 +321,161 @@ describe('<VirtualizedList />', () => {
unmount();
});
it('renders correctly in copyModeEnabled when scrolled', async () => {
it('culls items that exceed maxScrollbackLength when overflowToBackbuffer is true', async () => {
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
// Use copy mode
const { lastFrame, unmount } = await render(
<Box height={10} width={100}>
const renderedIndices = new Set<number>();
const renderItem1px = ({
item,
index,
}: {
item: string;
index: number;
}) => {
renderedIndices.add(index);
return (
<Box height={1}>
<Text>{item}</Text>
</Box>
);
};
const { unmount } = await render(
<Box height={10} width={100} borderStyle="round">
<VirtualizedList
data={longData}
renderItem={renderItem1px}
keyExtractor={(item) => item}
estimatedItemHeight={() => 1}
initialScrollIndex={99}
overflowToBackbuffer={true}
maxScrollbackLength={10}
/>
</Box>,
);
// Viewport height is 10, total items = 100.
// actualScrollTop = 92 (due to top/bottom borders taking 2 lines out of 10, inner height 8).
// wait, if height is 10 with round border, inner height is 8.
// actualScrollTop = 100 - 8 = 92.
// maxScrollbackLength = 10.
// targetOffset = 92 - 10 = 82.
// So renderRangeStart should be 81 (or 82).
// Items 0 to 80 should not be rendered!
// Check viewport items are rendered
expect(renderedIndices.has(95)).toBe(true);
expect(renderedIndices.has(99)).toBe(true);
// Check items in maxScrollbackLength are rendered
expect(renderedIndices.has(85)).toBe(true);
// Check items beyond maxScrollbackLength are NOT rendered
expect(renderedIndices.has(0)).toBe(false);
expect(renderedIndices.has(50)).toBe(false);
expect(renderedIndices.has(75)).toBe(false);
unmount();
});
it('does not forget item heights when items are prepended', async () => {
const ref = createRef<VirtualizedListRef<string>>();
const data = ['Item 1', 'Item 2'];
const { rerender, waitUntilReady, unmount } = await render(
<Box height={10} width={100}>
<VirtualizedList
ref={ref}
data={data}
renderItem={({ item }) => (
<Box height={1}>
<Text>{item}</Text>
</Box>
)}
keyExtractor={(item) => item}
estimatedItemHeight={() => 1}
initialScrollIndex={50}
copyModeEnabled={true}
estimatedItemHeight={() => 1000}
/>
</Box>,
);
// Item 50 should be visible
expect(lastFrame()).toContain('Item 50');
// And surrounding items
expect(lastFrame()).toContain('Item 59');
// But far away items should not be (ensures we are actually scrolled)
expect(lastFrame()).not.toContain('Item 0');
await waitUntilReady();
await new Promise((r) => setTimeout(r, 50));
// Item 1 and 2 measured. totalHeight = 2.
expect(ref.current?.getScrollState().scrollHeight).toBe(2);
// Prepend Item 0
const newData = ['Item 0', 'Item 1', 'Item 2'];
await act(async () => {
rerender(
<Box height={10} width={100}>
<VirtualizedList
ref={ref}
data={newData}
renderItem={({ item }) => (
<Box height={1}>
<Text>{item}</Text>
</Box>
)}
keyExtractor={(item) => item}
estimatedItemHeight={() => 1000}
/>
</Box>,
);
});
// With the Map-based cache, Item 1 and 2 heights (1 each) should be preserved
// even though their indices changed.
// Item 0 is new and uses estimate 1000.
// So totalHeight should be 1002 (before Item 0 is measured).
// Note: It might already be 3 if Item 0 was measured immediately, but it
// definitely shouldn't be 3000 (which it would be if Item 1 and 2 were forgotten).
const scrollHeight = ref.current?.getScrollState().scrollHeight;
expect(scrollHeight).toBeGreaterThan(0);
expect(scrollHeight).toBeLessThan(3000);
await waitFor(() => {
expect(ref.current?.getScrollState().scrollHeight).toBe(3);
});
unmount();
});
it('updates totalHeight correctly when estimated height differs from real height and scrolled up', async () => {
const ref = createRef<VirtualizedListRef<string>>();
const longData = Array.from({ length: 10 }, (_, i) => `Item ${i}`);
const itemHeight = 1;
const renderItem1px = ({ item }: { item: string }) => (
<Box height={itemHeight}>
<Text>{item}</Text>
</Box>
);
const keyExtractor = (item: string) => item;
const { unmount } = await render(
<Box height={5} width={100}>
<VirtualizedList
ref={ref}
data={longData}
renderItem={renderItem1px}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1000}
/>
</Box>,
);
for (let i = 1; i <= 10; i++) {
await act(async () => {
ref.current?.scrollTo(i * 1000);
});
await new Promise((r) => setTimeout(r, 10)); // allow React/Ink to process the scroll
}
await act(async () => {
ref.current?.scrollTo(0);
});
// Wait for the final scroll top to settle and height to be correct
await waitFor(() => {
expect(ref.current?.getScrollState().scrollTop).toBe(0);
expect(ref.current?.getScrollState().scrollHeight).toBe(10);
});
unmount();
});
});
@@ -13,6 +13,7 @@ import {
useMemo,
useCallback,
memo,
useEffect,
} from 'react';
import type React from 'react';
import { theme } from '../../semantic-colors.js';
@@ -39,9 +40,9 @@ export type VirtualizedListProps<T> = {
overflowToBackbuffer?: boolean;
scrollbar?: boolean;
stableScrollback?: boolean;
copyModeEnabled?: boolean;
fixedItemHeight?: boolean;
containerHeight?: number;
maxScrollbackLength?: number;
};
export type VirtualizedListRef<T> = {
@@ -78,47 +79,46 @@ function findLastIndex<T>(
return -1;
}
const MemoizedStaticItem = memo(
({
content,
width,
itemKey,
}: {
content: React.ReactElement;
width: number;
itemKey: string;
}) => (
<StaticRender width={width} key={itemKey}>
{() => content}
</StaticRender>
),
);
MemoizedStaticItem.displayName = 'MemoizedStaticItem';
const VirtualizedListItem = memo(
({
content,
shouldBeStatic,
width,
containerWidth,
itemKey,
index,
onSetRef,
}: {
content: React.ReactElement;
shouldBeStatic: boolean;
width: number | string | undefined;
containerWidth: number;
itemKey: string;
index: number;
onSetRef: (index: number, el: DOMElement | null) => void;
onSetRef: (index: number, itemKey: string, el: DOMElement | null) => void;
}) => {
const itemRef = useCallback(
(el: DOMElement | null) => {
onSetRef(index, el);
onSetRef(index, itemKey, el);
},
[index, onSetRef],
[index, itemKey, onSetRef],
);
return (
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
{shouldBeStatic ? (
<StaticRender
width={typeof width === 'number' ? width : containerWidth}
key={
itemKey +
'-static-' +
(typeof width === 'number' ? width : containerWidth)
}
>
{content}
</StaticRender>
) : (
content
)}
{content}
</Box>
);
},
@@ -126,6 +126,20 @@ const VirtualizedListItem = memo(
VirtualizedListItem.displayName = 'VirtualizedListItem';
interface VirtualizedListInternalState {
container: DOMElement | null;
itemRefs: Array<DOMElement | null>;
measuredHeights: number[];
measuredKeys: string[];
isInitialScrollSet: boolean;
containerObserver: ResizeObserver | null;
prevOffsetsLength: number;
prevDataLength: number;
prevTotalHeight: number;
prevScrollTop: number;
prevContainerHeight: number;
}
function VirtualizedList<T>(
props: VirtualizedListProps<T>,
ref: React.Ref<VirtualizedListRef<T>>,
@@ -144,15 +158,14 @@ function VirtualizedList<T>(
overflowToBackbuffer,
scrollbar = true,
stableScrollback,
copyModeEnabled = false,
fixedItemHeight = false,
maxScrollbackLength,
} = props;
const dataRef = useRef(data);
useLayoutEffect(() => {
dataRef.current = data;
}, [data]);
const [scrollAnchor, setScrollAnchor] = useState(() => {
const [scrollAnchor, setScrollAnchor] = useState<{
index: number;
offset: number;
isBottom?: boolean;
}>(() => {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
(typeof initialScrollIndex === 'number' &&
@@ -196,23 +209,87 @@ function VirtualizedList<T>(
return scrollToEnd;
});
const containerRef = useRef<DOMElement | null>(null);
const [containerHeight, setContainerHeight] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
const itemRefs = useRef<Array<DOMElement | null>>([]);
const [heights, setHeights] = useState<Record<string, number>>({});
const isInitialScrollSet = useRef(false);
const [measurementVersion, setMeasurementVersion] = useState(0);
const containerObserverRef = useRef<ResizeObserver | null>(null);
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
const state = useRef<VirtualizedListInternalState>({
container: null,
itemRefs: [],
measuredHeights: [],
measuredKeys: [],
isInitialScrollSet: false,
containerObserver: null,
prevOffsetsLength: -1,
prevDataLength: -1,
prevTotalHeight: -1,
prevScrollTop: -1,
prevContainerHeight: -1,
});
const onSetRef = useCallback((index: number, el: DOMElement | null) => {
itemRefs.current[index] = el;
}, []);
const itemsObserver = useMemo(
() =>
new ResizeObserver((entries) => {
let changed = false;
for (const entry of entries) {
// Extract index and key safely from the element
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
const target = entry.target as any;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const index = target._virtualIndex;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const key = target._virtualKey;
if (typeof index === 'number' && key !== undefined) {
const height = Math.round(entry.contentRect.height);
// Ignore 0 height measurements which can happen when an element is unmounting
if (
height > 0 &&
(state.current.measuredHeights[index] !== height ||
state.current.measuredKeys[index] !== key)
) {
state.current.measuredHeights[index] = height;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
state.current.measuredKeys[index] = key;
changed = true;
}
}
}
if (changed) {
setMeasurementVersion((v) => v + 1);
}
}),
[],
);
const onSetRef = useCallback(
(index: number, itemKey: string, el: DOMElement | null) => {
const oldEl = state.current.itemRefs[index];
if (oldEl && oldEl !== el) {
if (!isStatic) {
itemsObserver.unobserve(oldEl);
}
}
state.current.itemRefs[index] = el;
if (el) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
const target = el as any;
target._virtualIndex = index;
target._virtualKey = itemKey;
if (!isStatic) {
itemsObserver.observe(el);
}
}
},
[itemsObserver, isStatic],
);
const containerRefCallback = useCallback((node: DOMElement | null) => {
containerObserverRef.current?.disconnect();
containerRef.current = node;
state.current.containerObserver?.disconnect();
state.current.container = node;
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
@@ -224,52 +301,33 @@ function VirtualizedList<T>(
}
});
observer.observe(node);
containerObserverRef.current = observer;
state.current.containerObserver = observer;
}
}, []);
const itemsObserver = useMemo(
() =>
new ResizeObserver((entries) => {
setHeights((prev) => {
let next: Record<string, number> | null = null;
for (const entry of entries) {
const key = nodeToKeyRef.current.get(entry.target);
if (key !== undefined) {
const height = Math.round(entry.contentRect.height);
if (prev[key] !== height) {
if (!next) {
next = { ...prev };
}
next[key] = height;
}
}
}
return next ?? prev;
});
}),
[],
);
useLayoutEffect(
useEffect(
() => () => {
containerObserverRef.current?.disconnect();
state.current.containerObserver?.disconnect();
itemsObserver.disconnect();
},
[itemsObserver],
);
const { totalHeight, offsets } = useMemo(() => {
const { totalHeight, offsets } = (() => {
const offsets: number[] = [0];
let totalHeight = 0;
for (let i = 0; i < data.length; i++) {
const key = keyExtractor(data[i], i);
const height = heights[key] ?? estimatedItemHeight(i);
const cachedHeight =
state.current.measuredKeys[i] === key
? state.current.measuredHeights[i]
: undefined;
const height = cachedHeight ?? estimatedItemHeight(i);
totalHeight += height;
offsets.push(totalHeight);
}
return { totalHeight, offsets };
}, [heights, data, estimatedItemHeight, keyExtractor]);
})();
const scrollableContainerHeight = props.containerHeight ?? containerHeight;
@@ -277,7 +335,29 @@ function VirtualizedList<T>(
(
scrollTop: number,
offsets: number[],
): { index: number; offset: number } => {
totalHeight: number,
scrollableContainerHeight: number,
): { index: number; offset: number; isBottom?: boolean } => {
const isNearBottom =
totalHeight > 0 &&
scrollTop > (totalHeight - scrollableContainerHeight) / 2;
if (isNearBottom) {
const scrollBottom = scrollTop + scrollableContainerHeight;
const index = findLastIndex(
offsets,
(offset) => offset <= scrollBottom,
);
if (index === -1) {
return { index: 0, offset: 0, isBottom: true };
}
return {
index,
offset: scrollBottom - offsets[index],
isBottom: true,
};
}
const index = findLastIndex(offsets, (offset) => offset <= scrollTop);
if (index === -1) {
return { index: 0, offset: 0 };
@@ -291,7 +371,6 @@ function VirtualizedList<T>(
const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState(
props.targetScrollIndex,
);
const prevOffsetsLength = useRef(offsets.length);
// NOTE: If targetScrollIndex is provided, and we haven't rendered items yet (offsets.length <= 1),
// we do NOT set scrollAnchor yet, because actualScrollTop wouldn't know the real offset!
@@ -301,17 +380,17 @@ function VirtualizedList<T>(
props.targetScrollIndex !== prevTargetScrollIndex &&
offsets.length > 1) ||
(props.targetScrollIndex !== undefined &&
prevOffsetsLength.current <= 1 &&
state.current.prevOffsetsLength <= 1 &&
offsets.length > 1)
) {
if (props.targetScrollIndex !== prevTargetScrollIndex) {
setPrevTargetScrollIndex(props.targetScrollIndex);
}
prevOffsetsLength.current = offsets.length;
state.current.prevOffsetsLength = offsets.length;
setIsStickingToBottom(false);
setScrollAnchor({ index: props.targetScrollIndex, offset: 0 });
} else {
prevOffsetsLength.current = offsets.length;
} else if (offsets.length > 1) {
state.current.prevOffsetsLength = offsets.length;
}
const actualScrollTop = useMemo(() => {
@@ -323,46 +402,58 @@ function VirtualizedList<T>(
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
const item = data[scrollAnchor.index];
const key = item ? keyExtractor(item, scrollAnchor.index) : '';
const itemHeight = heights[key] ?? 0;
const cachedHeight =
state.current.measuredKeys[scrollAnchor.index] === key
? state.current.measuredHeights[scrollAnchor.index]
: undefined;
const itemHeight =
cachedHeight ?? estimatedItemHeight(scrollAnchor.index) ?? 0;
return offset + itemHeight - scrollableContainerHeight;
}
if (scrollAnchor.isBottom) {
return offset + scrollAnchor.offset - scrollableContainerHeight;
}
return offset + scrollAnchor.offset;
}, [
scrollAnchor,
offsets,
heights,
scrollableContainerHeight,
data,
keyExtractor,
estimatedItemHeight,
]);
const scrollTop = isStickingToBottom
? Number.MAX_SAFE_INTEGER
: actualScrollTop;
const prevDataLength = useRef(data.length);
const prevTotalHeight = useRef(totalHeight);
const prevScrollTop = useRef(actualScrollTop);
const prevContainerHeight = useRef(scrollableContainerHeight);
useLayoutEffect(() => {
if (state.current.prevDataLength === -1) {
state.current.prevDataLength = data.length;
state.current.prevTotalHeight = totalHeight;
state.current.prevScrollTop = actualScrollTop;
state.current.prevContainerHeight = scrollableContainerHeight;
return;
}
const contentPreviouslyFit =
prevTotalHeight.current <= prevContainerHeight.current;
state.current.prevTotalHeight <= state.current.prevContainerHeight;
const wasScrolledToBottomPixels =
prevScrollTop.current >=
prevTotalHeight.current - prevContainerHeight.current - 1;
state.current.prevScrollTop >=
state.current.prevTotalHeight - state.current.prevContainerHeight - 1;
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
if (wasAtBottom && actualScrollTop >= state.current.prevScrollTop) {
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
}
const listGrew = data.length > prevDataLength.current;
const listGrew = data.length > state.current.prevDataLength;
const containerChanged =
prevContainerHeight.current !== scrollableContainerHeight;
state.current.prevContainerHeight !== scrollableContainerHeight;
// If targetScrollIndex is provided, we NEVER auto-snap to the bottom
// because the parent is explicitly managing the scroll position.
@@ -393,23 +484,33 @@ function VirtualizedList<T>(
) {
// We still clamp the scroll top if it's completely out of bounds
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
const newAnchor = getAnchorForScrollTop(newScrollTop, offsets);
const newAnchor = getAnchorForScrollTop(
newScrollTop,
offsets,
totalHeight,
scrollableContainerHeight,
);
if (
scrollAnchor.index !== newAnchor.index ||
scrollAnchor.offset !== newAnchor.offset
scrollAnchor.offset !== newAnchor.offset ||
scrollAnchor.isBottom !== newAnchor.isBottom
) {
setScrollAnchor(newAnchor);
}
} else if (data.length === 0) {
if (scrollAnchor.index !== 0 || scrollAnchor.offset !== 0) {
if (
scrollAnchor.index !== 0 ||
scrollAnchor.offset !== 0 ||
scrollAnchor.isBottom !== undefined
) {
setScrollAnchor({ index: 0, offset: 0 });
}
}
prevDataLength.current = data.length;
prevTotalHeight.current = totalHeight;
prevScrollTop.current = actualScrollTop;
prevContainerHeight.current = scrollableContainerHeight;
state.current.prevDataLength = data.length;
state.current.prevTotalHeight = totalHeight;
state.current.prevScrollTop = actualScrollTop;
state.current.prevContainerHeight = scrollableContainerHeight;
}, [
data.length,
totalHeight,
@@ -417,6 +518,7 @@ function VirtualizedList<T>(
scrollableContainerHeight,
scrollAnchor.index,
scrollAnchor.offset,
scrollAnchor.isBottom,
getAnchorForScrollTop,
offsets,
isStickingToBottom,
@@ -425,7 +527,7 @@ function VirtualizedList<T>(
useLayoutEffect(() => {
if (
isInitialScrollSet.current ||
state.current.isInitialScrollSet ||
offsets.length <= 1 ||
totalHeight <= 0 ||
scrollableContainerHeight <= 0
@@ -435,7 +537,7 @@ function VirtualizedList<T>(
if (props.targetScrollIndex !== undefined) {
// If we are strictly driving from targetScrollIndex, do not apply initialScrollIndex
isInitialScrollSet.current = true;
state.current.isInitialScrollSet = true;
return;
}
@@ -451,7 +553,7 @@ function VirtualizedList<T>(
offset: SCROLL_TO_ITEM_END,
});
setIsStickingToBottom(true);
isInitialScrollSet.current = true;
state.current.isInitialScrollSet = true;
return;
}
@@ -464,8 +566,15 @@ function VirtualizedList<T>(
Math.min(totalHeight - scrollableContainerHeight, newScrollTop),
);
setScrollAnchor(getAnchorForScrollTop(clampedScrollTop, offsets));
isInitialScrollSet.current = true;
setScrollAnchor(
getAnchorForScrollTop(
clampedScrollTop,
offsets,
totalHeight,
scrollableContainerHeight,
),
);
state.current.isInitialScrollSet = true;
}
}, [
initialScrollIndex,
@@ -475,7 +584,7 @@ function VirtualizedList<T>(
scrollableContainerHeight,
getAnchorForScrollTop,
data.length,
heights,
measurementVersion,
props.targetScrollIndex,
]);
@@ -493,48 +602,32 @@ function VirtualizedList<T>(
? data.length - 1
: Math.min(data.length - 1, endIndexOffset);
const topSpacerHeight =
renderStatic === true || overflowToBackbuffer === true
? 0
: (offsets[startIndex] ?? 0);
const bottomSpacerHeight = renderStatic
? 0
: totalHeight - (offsets[endIndex + 1] ?? totalHeight);
// Maintain a stable set of observed nodes using useLayoutEffect
const observedNodes = useRef<Set<DOMElement>>(new Set());
useLayoutEffect(() => {
const currentNodes = new Set<DOMElement>();
const observeStart = renderStatic || overflowToBackbuffer ? 0 : startIndex;
const observeEnd = renderStatic ? data.length - 1 : endIndex;
for (let i = observeStart; i <= observeEnd; i++) {
const node = itemRefs.current[i];
const item = data[i];
if (node && item) {
currentNodes.add(node);
const key = keyExtractor(item, i);
// Always update the key mapping because React can reuse nodes at different indices/keys
nodeToKeyRef.current.set(node, key);
if (!isStatic && !fixedItemHeight && !observedNodes.current.has(node)) {
itemsObserver.observe(node);
}
const renderRangeStart = useMemo(() => {
if (overflowToBackbuffer) {
if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) {
const targetOffset = Math.max(0, actualScrollTop - maxScrollbackLength);
const index = findLastIndex(
offsets,
(offset) => offset <= targetOffset,
);
return Math.max(0, index - 1);
}
return 0;
}
for (const node of observedNodes.current) {
if (!currentNodes.has(node)) {
if (!isStatic && !fixedItemHeight) {
itemsObserver.unobserve(node);
}
nodeToKeyRef.current.delete(node);
}
}
observedNodes.current = currentNodes;
});
return startIndex;
}, [
overflowToBackbuffer,
maxScrollbackLength,
actualScrollTop,
offsets,
startIndex,
]);
const renderRangeStart =
renderStatic || overflowToBackbuffer ? 0 : startIndex;
const renderRangeEnd = renderStatic ? data.length - 1 : endIndex;
const topSpacerHeight = offsets[renderRangeStart];
const bottomSpacerHeight =
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
const renderRangeEnd = endIndex;
// Always evaluate shouldBeStatic, width, etc. if we have a known width from the prop.
// If containerHeight or containerWidth is 0 we defer rendering unless a static render or defined width overrides.
@@ -564,18 +657,43 @@ function VirtualizedList<T>(
const content = renderItem({ item, index: i });
const key = keyExtractor(item, i);
items.push(
<VirtualizedListItem
key={key}
itemKey={key}
content={content}
shouldBeStatic={shouldBeStatic}
width={width}
containerWidth={containerWidth}
index={i}
onSetRef={onSetRef}
/>,
);
if (shouldBeStatic) {
items.push(
<MemoizedStaticItem
key={`${key}-static`}
itemKey={`${key}-static-${typeof width === 'number' ? width : containerWidth}`}
width={typeof width === 'number' ? width : containerWidth}
content={content}
/>,
);
} else {
items.push(
<VirtualizedListItem
key={key}
itemKey={key}
content={content}
index={i}
onSetRef={onSetRef}
/>,
);
}
if (
!renderStatic &&
state.current.measuredKeys[i] !== key &&
!shouldBeStatic
) {
const fillerHeight = Math.max(0, estimatedItemHeight(i) - 1);
if (fillerHeight > 0) {
items.push(
<Box
key={key + '-filler'}
height={fillerHeight}
flexShrink={0}
/>,
);
}
}
}
}
return items;
@@ -593,6 +711,7 @@ function VirtualizedList<T>(
width,
containerWidth,
onSetRef,
estimatedItemHeight,
]);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
@@ -614,7 +733,12 @@ function VirtualizedList<T>(
}
setPendingScrollTop(newScrollTop);
setScrollAnchor(
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets),
getAnchorForScrollTop(
Math.min(newScrollTop, maxScroll),
offsets,
totalHeight,
scrollableContainerHeight,
),
);
},
scrollTo: (offset: number) => {
@@ -632,7 +756,14 @@ function VirtualizedList<T>(
setIsStickingToBottom(false);
const newScrollTop = Math.max(0, offset);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
setScrollAnchor(
getAnchorForScrollTop(
newScrollTop,
offsets,
totalHeight,
scrollableContainerHeight,
),
);
}
},
scrollToEnd: () => {
@@ -669,7 +800,14 @@ function VirtualizedList<T>(
),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
setScrollAnchor(
getAnchorForScrollTop(
newScrollTop,
offsets,
totalHeight,
scrollableContainerHeight,
),
);
}
},
scrollToItem: ({
@@ -698,7 +836,14 @@ function VirtualizedList<T>(
),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
setScrollAnchor(
getAnchorForScrollTop(
newScrollTop,
offsets,
totalHeight,
scrollableContainerHeight,
),
);
}
}
},
@@ -727,28 +872,27 @@ 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 height={topSpacerHeight} flexShrink={0} />
<Box flexShrink={0} width="100%" flexDirection="column">
{topSpacerHeight > 0 ? (
<Box height={topSpacerHeight} flexShrink={0} />
) : null}
{renderedItems}
<Box height={bottomSpacerHeight} flexShrink={0} />
{bottomSpacerHeight > 0 ? (
<Box height={bottomSpacerHeight} flexShrink={0} />
) : null}
</Box>
</Box>
);
@@ -7,41 +7,41 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│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 │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -53,7 +53,7 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
│ │
│ │
│ │
Item 997
│ │
│ │
│ │
+7
View File
@@ -505,6 +505,13 @@
"default": true,
"type": "boolean"
},
"maxScrollbackLength": {
"title": "Max Scrollback Length",
"description": "Maximum number of lines to keep in the terminal scrollback buffer.",
"markdownDescription": "Maximum number of lines to keep in the terminal scrollback buffer.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `1000`",
"default": 1000,
"type": "number"
},
"showSpinner": {
"title": "Show Spinner",
"description": "Show the spinner during operations.",