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.",