diff --git a/docs/cli/settings.md b/docs/cli/settings.md
index c5e8a3d51b..286e79e389 100644
--- a/docs/cli/settings.md
+++ b/docs/cli/settings.md
@@ -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"` |
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 0897a69fa0..57615b2d57 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -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`
diff --git a/package-lock.json b/package-lock.json
index 9ced540f9a..3ae3903e6c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 6699efbd60..a4f59d3809 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 404aaecbaa..a20bdcfd7b 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -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",
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index d27457bcd6..12d50a2584 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -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',
diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx
index fd8d71f57f..6be4a5a1ad 100644
--- a/packages/cli/src/interactiveCli.tsx
+++ b/packages/cli/src/interactiveCli.tsx
@@ -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,
},
);
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 2c3e424ae4..46270b7e61 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -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 ? : }
);
-};
+});
+
+App.displayName = 'App';
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index d8b1e1d277..e9676dea7a 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -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,
diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap
index 611f2e0908..1934c3e2a7 100644
--- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap
+++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap
@@ -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
"
`;
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index 52bb2b294f..768b9f3513 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -143,6 +143,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{uiState.isInputActive && (
{
vi.mocked(clipboardy.read).mockResolvedValue('');
props = {
+ maxAvailableWidth: 80,
onQueueMessage: vi.fn(),
buffer: mockBuffer,
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 67fefe0656..ff50cc5421 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -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 = ({
+ maxAvailableWidth,
onSubmit,
onClearScreen,
config,
@@ -283,7 +285,7 @@ export const InputPrompt: React.FC = ({
const pasteTimeoutRef = useRef(null);
const innerBoxRef = useRef(null);
const hasUserNavigatedSuggestions = useRef(false);
- const listRef = useRef>(null);
+ const listRef = useRef>(null);
const { isRecording, handleVoiceInput, resetTurnBaseline } = useVoiceMode({
buffer,
@@ -1855,22 +1857,21 @@ export const InputPrompt: React.FC = ({
height={Math.min(buffer.viewportHeight, scrollableData.length)}
width="100%"
>
- {config.getUseTerminalBuffer() ? (
- 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,
)}
diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx
index 0aea3236ce..c34d95e732 100644
--- a/packages/cli/src/ui/components/MainContent.test.tsx
+++ b/packages/cli/src/ui/components/MainContent.test.tsx
@@ -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();
});
diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx
index 046550de51..c52fb2d8b1 100644
--- a/packages/cli/src/ui/components/MainContent.tsx
+++ b/packages/cli/src/ui/components/MainContent.tsx
@@ -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) {
diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
index 9090335b03..ffbb9666d4 100644
--- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap
@@ -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."
-`;
diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
index 16c6019c98..43b5931fa2 100644
--- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
+++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
@@ -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 = ({
// 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 = ({
return (
- 1}
- fixedItemHeight={true}
+ itemHeight={1}
keyExtractor={keyExtractor}
initialScrollIndex={initialScrollIndex}
hasFocus={hasFocus}
diff --git a/packages/cli/src/ui/components/shared/FixedScrollableList.tsx b/packages/cli/src/ui/components/shared/FixedScrollableList.tsx
new file mode 100644
index 0000000000..886fa1ff05
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/FixedScrollableList.tsx
@@ -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 extends FixedVirtualizedListProps {
+ hasFocus: boolean;
+ width: number;
+ scrollbar?: boolean;
+ stableScrollback?: boolean;
+ isStatic?: boolean;
+ fixedItemHeight?: boolean;
+ targetScrollIndex?: number;
+ scrollbarThumbColor?: string;
+}
+
+export type FixedScrollableListRef = FixedVirtualizedListRef;
+
+function FixedScrollableList(
+ props: FixedScrollableListProps,
+ ref: React.Ref>,
+) {
+ const keyMatchers = useKeyMatchers();
+ const settings = useSettings();
+ const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
+ const {
+ hasFocus,
+ width,
+ maxHeight,
+ scrollbar = true,
+ stableScrollback,
+ } = props;
+ const fixedVirtualizedListRef = useRef>(null);
+ const containerRef = useRef(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,
+ getScrollState,
+ scrollBy: scrollByWithAnimation,
+ scrollTo: smoothScrollTo,
+ hasFocus: hasFocusCallback,
+ flashScrollbar,
+ }),
+ [
+ getScrollState,
+ hasFocusCallback,
+ flashScrollbar,
+ scrollByWithAnimation,
+ smoothScrollTo,
+ ],
+ );
+
+ useScrollable(scrollableEntry, true);
+
+ return (
+
+
+
+ );
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+const FixedScrollableListWithForwardRef = forwardRef(FixedScrollableList) as <
+ T,
+>(
+ props: FixedScrollableListProps & {
+ ref?: React.Ref>;
+ },
+) => React.ReactElement;
+
+export { FixedScrollableListWithForwardRef as FixedScrollableList };
diff --git a/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx b/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx
new file mode 100644
index 0000000000..7304c4addc
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx
@@ -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 = {
+ 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 = {
+ 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;
+ }) => (
+
+ {shouldBeStatic ? (
+
+ {() => content}
+
+ ) : (
+ content
+ )}
+
+ ),
+);
+
+FixedVirtualizedListItem.displayName = 'FixedVirtualizedListItem';
+
+function FixedVirtualizedList(
+ props: FixedVirtualizedListProps,
+ ref: React.Ref>,
+) {
+ 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(
+ ,
+ );
+ }
+ }
+ 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 (
+
+
+
+ {renderedItems}
+
+
+
+ );
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+const FixedVirtualizedListWithForwardRef = forwardRef(FixedVirtualizedList) as <
+ T,
+>(
+ props: FixedVirtualizedListProps & {
+ ref?: React.Ref>;
+ },
+) => React.ReactElement;
+
+export { FixedVirtualizedListWithForwardRef as FixedVirtualizedList };
+
+FixedVirtualizedList.displayName = 'FixedVirtualizedList';
diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx
index c857e97b70..0aa264fba8 100644
--- a/packages/cli/src/ui/components/shared/ScrollableList.tsx
+++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx
@@ -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 extends VirtualizedListProps {
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(
ref: React.Ref>,
) {
const keyMatchers = useKeyMatchers();
+ const settings = useSettings();
+ const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
const virtualizedListRef = useRef>(null);
const containerRef = useRef(null);
@@ -265,6 +266,7 @@ function ScrollableList(
scrollbar={scrollbar}
scrollbarThumbColor={scrollbarColor}
stableScrollback={stableScrollback}
+ maxScrollbackLength={maxScrollbackLength}
/>
);
diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx
index 98e7790538..7e0232d3e8 100644
--- a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx
+++ b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx
@@ -170,7 +170,7 @@ describe('', () => {
(_, i) => `Item ${i}`,
);
- const { lastFrame, unmount } = await render(
+ const { lastFrame, unmount, waitUntilReady } = await render(
', () => {
,
);
+ 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('', () => {
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(
-
+ const renderedIndices = new Set();
+ const renderItem1px = ({
+ item,
+ index,
+ }: {
+ item: string;
+ index: number;
+ }) => {
+ renderedIndices.add(index);
+ return (
+
+ {item}
+
+ );
+ };
+
+ const { unmount } = await render(
+
item}
+ estimatedItemHeight={() => 1}
+ initialScrollIndex={99}
+ overflowToBackbuffer={true}
+ maxScrollbackLength={10}
+ />
+ ,
+ );
+
+ // 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>();
+ const data = ['Item 1', 'Item 2'];
+ const { rerender, waitUntilReady, unmount } = await render(
+
+ (
{item}
)}
keyExtractor={(item) => item}
- estimatedItemHeight={() => 1}
- initialScrollIndex={50}
- copyModeEnabled={true}
+ estimatedItemHeight={() => 1000}
/>
,
);
- // 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(
+
+ (
+
+ {item}
+
+ )}
+ keyExtractor={(item) => item}
+ estimatedItemHeight={() => 1000}
+ />
+ ,
+ );
+ });
+ // 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>();
+ const longData = Array.from({ length: 10 }, (_, i) => `Item ${i}`);
+ const itemHeight = 1;
+ const renderItem1px = ({ item }: { item: string }) => (
+
+ {item}
+
+ );
+ const keyExtractor = (item: string) => item;
+
+ const { unmount } = await render(
+
+ 1000}
+ />
+ ,
+ );
+
+ 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();
});
});
diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx
index c3f888ba5f..6791016f4c 100644
--- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx
+++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx
@@ -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 = {
overflowToBackbuffer?: boolean;
scrollbar?: boolean;
stableScrollback?: boolean;
- copyModeEnabled?: boolean;
fixedItemHeight?: boolean;
containerHeight?: number;
+ maxScrollbackLength?: number;
};
export type VirtualizedListRef = {
@@ -78,47 +79,46 @@ function findLastIndex(
return -1;
}
+const MemoizedStaticItem = memo(
+ ({
+ content,
+ width,
+ itemKey,
+ }: {
+ content: React.ReactElement;
+ width: number;
+ itemKey: string;
+ }) => (
+
+ {() => content}
+
+ ),
+);
+
+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 (
- {shouldBeStatic ? (
-
- {content}
-
- ) : (
- content
- )}
+ {content}
);
},
@@ -126,6 +126,20 @@ const VirtualizedListItem = memo(
VirtualizedListItem.displayName = 'VirtualizedListItem';
+interface VirtualizedListInternalState {
+ container: DOMElement | null;
+ itemRefs: Array;
+ measuredHeights: number[];
+ measuredKeys: string[];
+ isInitialScrollSet: boolean;
+ containerObserver: ResizeObserver | null;
+ prevOffsetsLength: number;
+ prevDataLength: number;
+ prevTotalHeight: number;
+ prevScrollTop: number;
+ prevContainerHeight: number;
+}
+
function VirtualizedList(
props: VirtualizedListProps,
ref: React.Ref>,
@@ -144,15 +158,14 @@ function VirtualizedList(
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(
return scrollToEnd;
});
- const containerRef = useRef(null);
const [containerHeight, setContainerHeight] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
- const itemRefs = useRef>([]);
- const [heights, setHeights] = useState>({});
- const isInitialScrollSet = useRef(false);
+ const [measurementVersion, setMeasurementVersion] = useState(0);
- const containerObserverRef = useRef(null);
- const nodeToKeyRef = useRef(new WeakMap());
+ const state = useRef({
+ 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(
}
});
observer.observe(node);
- containerObserverRef.current = observer;
+ state.current.containerObserver = observer;
}
}, []);
- const itemsObserver = useMemo(
- () =>
- new ResizeObserver((entries) => {
- setHeights((prev) => {
- let next: Record | 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(
(
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(
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(
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(
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(
) {
// 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(
scrollableContainerHeight,
scrollAnchor.index,
scrollAnchor.offset,
+ scrollAnchor.isBottom,
getAnchorForScrollTop,
offsets,
isStickingToBottom,
@@ -425,7 +527,7 @@ function VirtualizedList(
useLayoutEffect(() => {
if (
- isInitialScrollSet.current ||
+ state.current.isInitialScrollSet ||
offsets.length <= 1 ||
totalHeight <= 0 ||
scrollableContainerHeight <= 0
@@ -435,7 +537,7 @@ function VirtualizedList(
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(
offset: SCROLL_TO_ITEM_END,
});
setIsStickingToBottom(true);
- isInitialScrollSet.current = true;
+ state.current.isInitialScrollSet = true;
return;
}
@@ -464,8 +566,15 @@ function VirtualizedList(
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(
scrollableContainerHeight,
getAnchorForScrollTop,
data.length,
- heights,
+ measurementVersion,
props.targetScrollIndex,
]);
@@ -493,48 +602,32 @@ function VirtualizedList(
? 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>(new Set());
- useLayoutEffect(() => {
- const currentNodes = new Set();
- 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(
const content = renderItem({ item, index: i });
const key = keyExtractor(item, i);
- items.push(
- ,
- );
+ if (shouldBeStatic) {
+ items.push(
+ ,
+ );
+ } else {
+ items.push(
+ ,
+ );
+ }
+
+ if (
+ !renderStatic &&
+ state.current.measuredKeys[i] !== key &&
+ !shouldBeStatic
+ ) {
+ const fillerHeight = Math.max(0, estimatedItemHeight(i) - 1);
+ if (fillerHeight > 0) {
+ items.push(
+ ,
+ );
+ }
+ }
}
}
return items;
@@ -593,6 +711,7 @@ function VirtualizedList(
width,
containerWidth,
onSetRef,
+ estimatedItemHeight,
]);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
@@ -614,7 +733,12 @@ function VirtualizedList(
}
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(
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(
),
);
setPendingScrollTop(newScrollTop);
- setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
+ setScrollAnchor(
+ getAnchorForScrollTop(
+ newScrollTop,
+ offsets,
+ totalHeight,
+ scrollableContainerHeight,
+ ),
+ );
}
},
scrollToItem: ({
@@ -698,7 +836,14 @@ function VirtualizedList(
),
);
setPendingScrollTop(newScrollTop);
- setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
+ setScrollAnchor(
+ getAnchorForScrollTop(
+ newScrollTop,
+ offsets,
+ totalHeight,
+ scrollableContainerHeight,
+ ),
+ );
}
}
},
@@ -727,28 +872,27 @@ function VirtualizedList(
return (
-
-
+
+ {topSpacerHeight > 0 ? (
+
+ ) : null}
{renderedItems}
-
+ {bottomSpacerHeight > 0 ? (
+
+ ) : null}
);
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/VirtualizedList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/VirtualizedList.test.tsx.snap
index 1df8316b89..f74d437a1f 100644
--- a/packages/cli/src/ui/components/shared/__snapshots__/VirtualizedList.test.tsx.snap
+++ b/packages/cli/src/ui/components/shared/__snapshots__/VirtualizedList.test.tsx.snap
@@ -7,41 +7,41 @@ exports[` > with 10px height and 100 items > mounts only visi
│ │
│ │
│ │
+│ │
+│ │
+│ │
+│ │
│Item 1 │
│ │
│ │
│ │
│ │
-│Item 2 │
│ │
│ │
│ │
│ │
-│Item 3 │
-│ │
-│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[` > 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[` > with 10px height and 100 items > mounts only visi
│ │
│ │
│ │
-│Item 997 │
+│ │
│ │
│ │
│ │
diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json
index 6e307f6966..8e52d9775b 100644
--- a/schemas/settings.schema.json
+++ b/schemas/settings.schema.json
@@ -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.",