From 4ee4bfcb02d29bccf6c82293a85562e87d6954fb Mon Sep 17 00:00:00 2001 From: jacob314 Date: Mon, 6 Apr 2026 21:30:30 -0700 Subject: [PATCH] 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. --- docs/cli/settings.md | 1 + docs/reference/configuration.md | 6 + package-lock.json | 10 +- package.json | 4 +- packages/cli/package.json | 2 +- packages/cli/src/config/settingsSchema.ts | 10 + packages/cli/src/interactiveCli.tsx | 6 +- packages/cli/src/ui/App.tsx | 7 +- packages/cli/src/ui/AppContainer.tsx | 13 +- .../src/ui/__snapshots__/App.test.tsx.snap | 9 + packages/cli/src/ui/components/Composer.tsx | 1 + .../src/ui/components/InputPrompt.test.tsx | 1 + .../cli/src/ui/components/InputPrompt.tsx | 21 +- .../src/ui/components/MainContent.test.tsx | 2 +- .../cli/src/ui/components/MainContent.tsx | 7 +- .../__snapshots__/MainContent.test.tsx.snap | 21 - .../components/messages/ToolResultDisplay.tsx | 21 +- .../components/shared/FixedScrollableList.tsx | 299 +++++++++ .../shared/FixedVirtualizedList.tsx | 588 ++++++++++++++++++ .../ui/components/shared/ScrollableList.tsx | 6 +- .../shared/VirtualizedList.test.tsx | 162 ++++- .../ui/components/shared/VirtualizedList.tsx | 482 +++++++++----- .../VirtualizedList.test.tsx.snap | 26 +- schemas/settings.schema.json | 7 + 24 files changed, 1453 insertions(+), 259 deletions(-) create mode 100644 packages/cli/src/ui/components/shared/FixedScrollableList.tsx create mode 100644 packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx 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.",