diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 49dd08ac53..c9a7cd7f89 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -220,6 +220,7 @@ describe('InputPrompt', () => { col = newText.length; } mockBuffer.cursor = [0, col]; + mockBuffer.allVisualLines = [newText]; mockBuffer.viewportVisualLines = [newText]; mockBuffer.allVisualLines = [newText]; mockBuffer.visualToLogicalMap = [[0, 0]]; @@ -2273,6 +2274,7 @@ describe('InputPrompt', () => { async ({ text, visualCursor }) => { mockBuffer.text = text; mockBuffer.lines = [text]; + mockBuffer.allVisualLines = [text]; mockBuffer.viewportVisualLines = [text]; mockBuffer.visualCursor = visualCursor as [number, number]; props.config.getUseBackgroundColor = () => false; @@ -2322,6 +2324,7 @@ describe('InputPrompt', () => { async ({ text, visualCursor, visualToLogicalMap }) => { mockBuffer.text = text; mockBuffer.lines = text.split('\n'); + mockBuffer.allVisualLines = text.split('\n'); mockBuffer.viewportVisualLines = text.split('\n'); mockBuffer.visualCursor = visualCursor as [number, number]; mockBuffer.visualToLogicalMap = visualToLogicalMap as Array< @@ -2342,6 +2345,7 @@ describe('InputPrompt', () => { const text = 'first line\n\nthird line'; mockBuffer.text = text; mockBuffer.lines = text.split('\n'); + mockBuffer.allVisualLines = text.split('\n'); mockBuffer.viewportVisualLines = text.split('\n'); mockBuffer.visualCursor = [1, 0]; // cursor on the blank line mockBuffer.visualToLogicalMap = [ @@ -2361,11 +2365,98 @@ describe('InputPrompt', () => { }); }); + describe('scrolling large inputs', () => { + it('should correctly render scrolling down and up for large inputs', async () => { + const lines = Array.from({ length: 50 }).map((_, i) => `testline ${i}`); + + // Since we need to test how the React component tree responds to TextBuffer state changes, + // we must provide a fake TextBuffer implementation that triggers re-renders like the real one. + + const TestWrapper = () => { + const [bufferState, setBufferState] = useState({ + text: lines.join('\n'), + lines, + allVisualLines: lines, + viewportVisualLines: lines.slice(0, 10), + visualToLogicalMap: lines.map((_, i) => [i, 0]), + visualCursor: [0, 0] as [number, number], + visualScrollRow: 0, + viewportHeight: 10, + }); + + const fakeBuffer = { + ...mockBuffer, + ...bufferState, + handleInput: vi.fn().mockImplementation((key) => { + let newRow = bufferState.visualCursor[0]; + let newScroll = bufferState.visualScrollRow; + if (key.name === 'down') { + newRow = Math.min(49, newRow + 1); + if (newRow >= newScroll + 10) newScroll++; + } else if (key.name === 'up') { + newRow = Math.max(0, newRow - 1); + if (newRow < newScroll) newScroll--; + } + setBufferState({ + ...bufferState, + visualCursor: [newRow, 0], + visualScrollRow: newScroll, + viewportVisualLines: lines.slice(newScroll, newScroll + 10), + }); + return true; + }), + } as unknown as TextBuffer; + + return ; + }; + + const { stdout, unmount, stdin } = await renderWithProviders( + , + { + uiActions, + }, + ); + + // Verify initial render + await waitFor(() => { + expect(stdout.lastFrame()).toContain('testline 0'); + expect(stdout.lastFrame()).not.toContain('testline 49'); + }); + + // Move cursor to bottom + for (let i = 0; i < 49; i++) { + act(() => { + stdin.write('\x1b[B'); // Arrow Down + }); + } + + await waitFor(() => { + expect(stdout.lastFrame()).toContain('testline 49'); + expect(stdout.lastFrame()).not.toContain('testline 0'); + }); + + // Move cursor back to top + for (let i = 0; i < 49; i++) { + act(() => { + stdin.write('\x1b[A'); // Arrow Up + }); + } + + await waitFor(() => { + expect(stdout.lastFrame()).toContain('testline 0'); + expect(stdout.lastFrame()).not.toContain('testline 49'); + }); + + unmount(); + }); + }); + describe('multiline rendering', () => { it('should correctly render multiline input including blank lines', async () => { const text = 'hello\n\nworld'; mockBuffer.text = text; mockBuffer.lines = text.split('\n'); + mockBuffer.allVisualLines = text.split('\n'); mockBuffer.viewportVisualLines = text.split('\n'); mockBuffer.allVisualLines = text.split('\n'); mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world" @@ -3592,7 +3683,9 @@ describe('InputPrompt', () => { async ({ relX, relY, mouseCol, mouseRow }) => { props.buffer.text = 'hello world\nsecond line'; props.buffer.lines = ['hello world', 'second line']; + props.buffer.allVisualLines = ['hello world', 'second line']; props.buffer.viewportVisualLines = ['hello world', 'second line']; + props.buffer.viewportHeight = 10; props.buffer.visualToLogicalMap = [ [0, 0], [1, 0], @@ -3630,6 +3723,7 @@ describe('InputPrompt', () => { it('should unfocus embedded shell on click', async () => { props.buffer.text = 'hello'; props.buffer.lines = ['hello']; + props.buffer.allVisualLines = ['hello']; props.buffer.viewportVisualLines = ['hello']; props.buffer.visualToLogicalMap = [[0, 0]]; props.isEmbeddedShellFocused = true; @@ -3671,6 +3765,7 @@ describe('InputPrompt', () => { lines: currentLines, viewportVisualLines: currentLines, allVisualLines: currentLines, + viewportHeight: 10, pastedContent: { [id]: largeText }, transformationsByLine: isExpanded ? currentLines.map(() => []) @@ -3759,6 +3854,7 @@ describe('InputPrompt', () => { lines: currentLines, viewportVisualLines: currentLines, allVisualLines: currentLines, + viewportHeight: 10, pastedContent: { [id]: largeText }, transformationsByLine: isExpanded ? currentLines.map(() => []) @@ -3830,7 +3926,9 @@ describe('InputPrompt', () => { props.config.getUseBackgroundColor = () => false; props.buffer.text = 'hello world'; props.buffer.lines = ['hello world']; + props.buffer.allVisualLines = ['hello world']; props.buffer.viewportVisualLines = ['hello world']; + props.buffer.viewportHeight = 10; props.buffer.visualToLogicalMap = [[0, 0]]; props.buffer.visualCursor = [0, 11]; props.buffer.visualScrollRow = 0; @@ -4137,6 +4235,7 @@ describe('InputPrompt', () => { const text = 'hello'; mockBuffer.text = text; mockBuffer.lines = [text]; + mockBuffer.allVisualLines = [text]; mockBuffer.viewportVisualLines = [text]; mockBuffer.visualToLogicalMap = [[0, 0]]; mockBuffer.visualCursor = [0, 3]; // Cursor after 'hel' @@ -4167,6 +4266,7 @@ describe('InputPrompt', () => { const text = 'πŸ‘hello'; mockBuffer.text = text; mockBuffer.lines = [text]; + mockBuffer.allVisualLines = [text]; mockBuffer.viewportVisualLines = [text]; mockBuffer.visualToLogicalMap = [[0, 0]]; mockBuffer.visualCursor = [0, 2]; // Cursor after 'πŸ‘h' (Note: 'πŸ‘' is one code point but width 2) @@ -4196,6 +4296,7 @@ describe('InputPrompt', () => { const text = 'πŸ˜€πŸ˜€πŸ˜€'; mockBuffer.text = text; mockBuffer.lines = [text]; + mockBuffer.allVisualLines = [text]; mockBuffer.viewportVisualLines = [text]; mockBuffer.visualToLogicalMap = [[0, 0]]; mockBuffer.visualCursor = [0, 2]; // Cursor after 2 emojis (each 1 code point, width 2) @@ -4225,7 +4326,9 @@ describe('InputPrompt', () => { const lines = ['πŸ˜€πŸ˜€', 'hello πŸ˜€', 'world']; mockBuffer.text = lines.join('\n'); mockBuffer.lines = lines; + mockBuffer.allVisualLines = lines; mockBuffer.viewportVisualLines = lines; + mockBuffer.viewportHeight = 10; mockBuffer.visualToLogicalMap = [ [0, 0], [1, 0], @@ -4262,7 +4365,9 @@ describe('InputPrompt', () => { const lines = ['first line', 'second line', 'third line']; mockBuffer.text = lines.join('\n'); mockBuffer.lines = lines; + mockBuffer.allVisualLines = lines; mockBuffer.viewportVisualLines = lines; + mockBuffer.viewportHeight = 10; mockBuffer.visualToLogicalMap = [ [0, 0], [1, 0], @@ -4303,6 +4408,7 @@ describe('InputPrompt', () => { it('should report cursor position 0 when input is empty and placeholder is shown', async () => { mockBuffer.text = ''; mockBuffer.lines = ['']; + mockBuffer.allVisualLines = ['']; mockBuffer.viewportVisualLines = ['']; mockBuffer.visualToLogicalMap = [[0, 0]]; mockBuffer.visualCursor = [0, 0]; @@ -4335,6 +4441,7 @@ describe('InputPrompt', () => { const applyVisualState = (visualLine: string, cursorCol: number): void => { mockBuffer.text = logicalLine; mockBuffer.lines = [logicalLine]; + mockBuffer.allVisualLines = [visualLine]; mockBuffer.viewportVisualLines = [visualLine]; mockBuffer.allVisualLines = [visualLine]; mockBuffer.visualToLogicalMap = [[0, 0]]; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 4547c19d8a..a8248bdd85 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -12,6 +12,10 @@ import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js'; 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'; import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js'; import { type TextBuffer, @@ -95,6 +99,10 @@ export function isTerminalPasteTrusted( return kittyProtocolSupported; } +export type ScrollableItem = + | { type: 'visualLine'; lineText: string; absoluteVisualIdx: number } + | { type: 'ghostLine'; ghostLine: string; index: number }; + export interface InputPromptProps { buffer: TextBuffer; onSubmit: (value: string) => void; @@ -268,6 +276,7 @@ export const InputPrompt: React.FC = ({ const pasteTimeoutRef = useRef(null); const innerBoxRef = useRef(null); const hasUserNavigatedSuggestions = useRef(false); + const listRef = useRef>(null); const [reverseSearchActive, setReverseSearchActive] = useState(false); const [commandSearchActive, setCommandSearchActive] = useState(false); @@ -556,7 +565,10 @@ export const InputPrompt: React.FC = ({ if (isEmbeddedShellFocused) { setEmbeddedShellFocused(false); } - const visualRow = buffer.visualScrollRow + relY; + const currentScrollTop = Math.round( + listRef.current?.getScrollState().scrollTop ?? buffer.visualScrollRow, + ); + const visualRow = currentScrollTop + relY; buffer.moveToVisualPosition(visualRow, relX); }, { isActive: focus }, @@ -570,7 +582,10 @@ export const InputPrompt: React.FC = ({ (_event, relX, relY) => { if (!isAlternateBuffer) return; - const visualLine = buffer.viewportVisualLines[relY]; + const currentScrollTop = Math.round( + listRef.current?.getScrollState().scrollTop ?? buffer.visualScrollRow, + ); + const visualLine = buffer.allVisualLines[currentScrollTop + relY]; if (!visualLine) return; // Even if we click past the end of the line, we might want to collapse an expanded paste @@ -578,10 +593,7 @@ export const InputPrompt: React.FC = ({ const logicalPos = isPastEndOfLine ? null - : buffer.getLogicalPositionFromVisual( - buffer.visualScrollRow + relY, - relX, - ); + : buffer.getLogicalPositionFromVisual(currentScrollTop + relY, relX); // Check for paste placeholder (collapsed state) if (logicalPos) { @@ -603,7 +615,9 @@ export const InputPrompt: React.FC = ({ // If we didn't click a placeholder to expand, check if we are inside or after // an expanded paste region and collapse it. - const row = buffer.visualScrollRow + relY; + const visualRow = currentScrollTop + relY; + const mapEntry = buffer.visualToLogicalMap[visualRow]; + const row = mapEntry ? mapEntry[0] : visualRow; const expandedId = buffer.getExpandedPasteAtLine(row); if (expandedId) { buffer.togglePasteExpansion( @@ -1350,10 +1364,8 @@ export const InputPrompt: React.FC = ({ priority: true, }); - const linesToRender = buffer.viewportVisualLines; const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = buffer.visualCursor; - const scrollVisualRow = buffer.visualScrollRow; const getGhostTextLines = useCallback(() => { if ( @@ -1468,6 +1480,155 @@ export const InputPrompt: React.FC = ({ const { inlineGhost, additionalLines } = getGhostTextLines(); + const scrollableData = useMemo(() => { + const items: ScrollableItem[] = buffer.allVisualLines.map( + (lineText, index) => ({ + type: 'visualLine', + lineText, + absoluteVisualIdx: index, + }), + ); + + additionalLines.forEach((ghostLine, index) => { + items.push({ + type: 'ghostLine', + ghostLine, + index, + }); + }); + + return items; + }, [buffer.allVisualLines, additionalLines]); + + const renderItem = useCallback( + ({ item }: { item: ScrollableItem; index: number }) => { + if (item.type === 'ghostLine') { + const padding = Math.max(0, inputWidth - stringWidth(item.ghostLine)); + return ( + + + {item.ghostLine} + {' '.repeat(padding)} + + + ); + } + + const { lineText, absoluteVisualIdx } = item; + // console.log('renderItem called with:', lineText); + const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx]; + if (!mapEntry) return ; + + const isOnCursorLine = + focus && absoluteVisualIdx === cursorVisualRowAbsolute; + const renderedLine: React.ReactNode[] = []; + const [logicalLineIdx] = mapEntry; + const logicalLine = buffer.lines[logicalLineIdx] || ''; + const transformations = + buffer.transformationsByLine[logicalLineIdx] ?? []; + const tokens = parseInputForHighlighting( + logicalLine, + logicalLineIdx, + transformations, + ...(focus && buffer.cursor[0] === logicalLineIdx + ? [buffer.cursor[1]] + : []), + ); + const visualStartCol = + buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0; + const visualEndCol = visualStartCol + cpLen(lineText); + const segments = parseSegmentsFromTokens( + tokens, + visualStartCol, + visualEndCol, + ); + let charCount = 0; + segments.forEach((seg, segIdx) => { + const segLen = cpLen(seg.text); + let display = seg.text; + if (isOnCursorLine) { + const relCol = cursorVisualColAbsolute; + const segStart = charCount; + const segEnd = segStart + segLen; + if (relCol >= segStart && relCol < segEnd) { + const charToHighlight = cpSlice( + display, + relCol - segStart, + relCol - segStart + 1, + ); + const highlighted = showCursor + ? chalk.inverse(charToHighlight) + : charToHighlight; + display = + cpSlice(display, 0, relCol - segStart) + + highlighted + + cpSlice(display, relCol - segStart + 1); + } + charCount = segEnd; + } else { + charCount += segLen; + } + const color = + seg.type === 'command' || seg.type === 'file' || seg.type === 'paste' + ? theme.text.accent + : theme.text.primary; + renderedLine.push( + + {display} + , + ); + }); + + const currentLineGhost = isOnCursorLine ? inlineGhost : ''; + if ( + isOnCursorLine && + cursorVisualColAbsolute === cpLen(lineText) && + !currentLineGhost + ) { + renderedLine.push( + + {showCursor ? chalk.inverse(' ') : ' '} + , + ); + } + const showCursorBeforeGhost = + focus && + isOnCursorLine && + cursorVisualColAbsolute === cpLen(lineText) && + currentLineGhost; + return ( + + + {renderedLine} + {showCursorBeforeGhost && (showCursor ? chalk.inverse(' ') : ' ')} + {currentLineGhost && ( + {currentLineGhost} + )} + + + ); + }, + [ + buffer.visualToLogicalMap, + buffer.lines, + buffer.transformationsByLine, + buffer.cursor, + buffer.visualToTransformedMap, + focus, + cursorVisualRowAbsolute, + cursorVisualColAbsolute, + showCursor, + inlineGhost, + inputWidth, + ], + ); + const useBackgroundColor = config.getUseBackgroundColor(); const isLowColor = isLowColorDepth(); const terminalBg = theme.background.primary || 'black'; @@ -1485,6 +1646,46 @@ export const InputPrompt: React.FC = ({ return false; }, [useBackgroundColor, isLowColor, terminalBg]); + const prevCursorRef = useRef(buffer.visualCursor); + const prevTextRef = useRef(buffer.text); + + // Effect to ensure cursor remains visible after interactions + useEffect(() => { + const cursorChanged = prevCursorRef.current !== buffer.visualCursor; + const textChanged = prevTextRef.current !== buffer.text; + + prevCursorRef.current = buffer.visualCursor; + prevTextRef.current = buffer.text; + + if (!cursorChanged && !textChanged) return; + + if (!listRef.current || !focus) return; + const { scrollTop, innerHeight } = listRef.current.getScrollState(); + if (innerHeight === 0) return; + + const cursorVisualRow = buffer.visualCursor[0]; + const actualScrollTop = Math.round(scrollTop); + + // If cursor is out of the currently visible viewport... + if ( + cursorVisualRow < actualScrollTop || + cursorVisualRow >= actualScrollTop + innerHeight + ) { + // Calculate minimal scroll to make it visible + let newScrollTop = actualScrollTop; + if (cursorVisualRow < actualScrollTop) { + newScrollTop = cursorVisualRow; + } else if (cursorVisualRow >= actualScrollTop + innerHeight) { + newScrollTop = cursorVisualRow - innerHeight + 1; + } + + listRef.current.scrollToIndex({ index: newScrollTop }); + } + }, [buffer.visualCursor, buffer.text, focus]); + + const listBackgroundColor = + useLineFallback || !useBackgroundColor ? undefined : theme.background.input; + useEffect(() => { if (onSuggestionsVisibilityChange) { onSuggestionsVisibilityChange(shouldShowSuggestions); @@ -1615,153 +1816,30 @@ export const InputPrompt: React.FC = ({ {placeholder} ) ) : ( - linesToRender - .map((lineText: string, visualIdxInRenderedSet: number) => { - const absoluteVisualIdx = - scrollVisualRow + visualIdxInRenderedSet; - const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx]; - if (!mapEntry) return null; - - const cursorVisualRow = - cursorVisualRowAbsolute - scrollVisualRow; - const isOnCursorLine = - focus && visualIdxInRenderedSet === cursorVisualRow; - - const renderedLine: React.ReactNode[] = []; - - const [logicalLineIdx] = mapEntry; - const logicalLine = buffer.lines[logicalLineIdx] || ''; - const transformations = - buffer.transformationsByLine[logicalLineIdx] ?? []; - const tokens = parseInputForHighlighting( - logicalLine, - logicalLineIdx, - transformations, - ...(focus && buffer.cursor[0] === logicalLineIdx - ? [buffer.cursor[1]] - : []), - ); - const startColInTransformed = - buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0; - const visualStartCol = startColInTransformed; - const visualEndCol = visualStartCol + cpLen(lineText); - const segments = parseSegmentsFromTokens( - tokens, - visualStartCol, - visualEndCol, - ); - let charCount = 0; - segments.forEach((seg, segIdx) => { - const segLen = cpLen(seg.text); - let display = seg.text; - - if (isOnCursorLine) { - const relativeVisualColForHighlight = - cursorVisualColAbsolute; - const segStart = charCount; - const segEnd = segStart + segLen; - if ( - relativeVisualColForHighlight >= segStart && - relativeVisualColForHighlight < segEnd - ) { - const charToHighlight = cpSlice( - display, - relativeVisualColForHighlight - segStart, - relativeVisualColForHighlight - segStart + 1, - ); - const highlighted = showCursor - ? chalk.inverse(charToHighlight) - : charToHighlight; - display = - cpSlice( - display, - 0, - relativeVisualColForHighlight - segStart, - ) + - highlighted + - cpSlice( - display, - relativeVisualColForHighlight - segStart + 1, - ); - } - charCount = segEnd; - } else { - // Advance the running counter even when not on cursor line - charCount += segLen; - } - - const color = - seg.type === 'command' || - seg.type === 'file' || - seg.type === 'paste' - ? theme.text.accent - : theme.text.primary; - - renderedLine.push( - - {display} - , - ); - }); - - const currentLineGhost = isOnCursorLine ? inlineGhost : ''; - if ( - isOnCursorLine && - cursorVisualColAbsolute === cpLen(lineText) - ) { - if (!currentLineGhost) { - renderedLine.push( - - {showCursor ? chalk.inverse(' ') : ' '} - , - ); - } + + 1} + keyExtractor={(item) => + item.type === 'visualLine' + ? `line-${item.absoluteVisualIdx}` + : `ghost-${item.index}` } - - const showCursorBeforeGhost = - focus && - isOnCursorLine && - cursorVisualColAbsolute === cpLen(lineText) && - currentLineGhost; - - return ( - - - {renderedLine} - {showCursorBeforeGhost && - (showCursor ? chalk.inverse(' ') : ' ')} - {currentLineGhost && ( - - {currentLineGhost} - - )} - - - ); - }) - .concat( - additionalLines.map((ghostLine, index) => { - const padding = Math.max( - 0, - inputWidth - stringWidth(ghostLine), - ); - return ( - - {ghostLine} - {' '.repeat(padding)} - - ); - }), - ) + width="100%" + backgroundColor={listBackgroundColor} + containerHeight={Math.min( + buffer.viewportHeight, + scrollableData.length, + )} + /> + )} diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index ab6fe9b928..caa270d8c4 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -93,7 +93,7 @@ exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = ` "β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ > second message -β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„ + " `; @@ -120,30 +120,30 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = ` "β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ (r:) commit -β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„ - git commit -m "feat: add search" in src/app + + " `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = ` "β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ (r:) commit -β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„ - git commit -m "feat: add search" in src/app + + " `; exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = ` "β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ > [Image ...reenshot2x.png] -β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„ + " `; exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = ` "β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€β–€ > @/path/to/screenshots/screenshot2x.png -β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„β–„ + " `; diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index 326005726f..c857e97b70 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -36,6 +36,9 @@ interface ScrollableListProps extends VirtualizedListProps { copyModeEnabled?: boolean; isStatic?: boolean; fixedItemHeight?: boolean; + targetScrollIndex?: number; + containerHeight?: number; + scrollbarThumbColor?: string; } export type ScrollableListRef = VirtualizedListRef; diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index e7b756b649..b527724492 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -29,6 +29,8 @@ export type VirtualizedListProps = { keyExtractor: (item: T, index: number) => string; initialScrollIndex?: number; initialScrollOffsetInIndex?: number; + targetScrollIndex?: number; + backgroundColor?: string; scrollbarThumbColor?: string; renderStatic?: boolean; isStatic?: boolean; @@ -39,6 +41,7 @@ export type VirtualizedListProps = { stableScrollback?: boolean; copyModeEnabled?: boolean; fixedItemHeight?: boolean; + containerHeight?: number; }; export type VirtualizedListRef = { @@ -159,6 +162,17 @@ function VirtualizedList( }; } + if (typeof props.targetScrollIndex === 'number') { + // NOTE: When targetScrollIndex is specified, we rely on the component + // correctly tracking targetScrollIndex instead of initialScrollIndex. + // We set isInitialScrollSet.current = true inside the second layout effect + // to avoid it overwriting the targetScrollIndex. + return { + index: props.targetScrollIndex, + offset: 0, + }; + } + return { index: 0, offset: 0 }; }); @@ -242,7 +256,7 @@ function VirtualizedList( return { totalHeight, offsets }; }, [heights, data, estimatedItemHeight, keyExtractor]); - const scrollableContainerHeight = containerHeight; + const scrollableContainerHeight = props.containerHeight ?? containerHeight; const getAnchorForScrollTop = useCallback( ( @@ -259,6 +273,32 @@ 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! + // We wait until offsets populate. + if ( + (props.targetScrollIndex !== undefined && + props.targetScrollIndex !== prevTargetScrollIndex && + offsets.length > 1) || + (props.targetScrollIndex !== undefined && + prevOffsetsLength.current <= 1 && + offsets.length > 1) + ) { + if (props.targetScrollIndex !== prevTargetScrollIndex) { + setPrevTargetScrollIndex(props.targetScrollIndex); + } + prevOffsetsLength.current = offsets.length; + setIsStickingToBottom(false); + setScrollAnchor({ index: props.targetScrollIndex, offset: 0 }); + } else { + prevOffsetsLength.current = offsets.length; + } + const actualScrollTop = useMemo(() => { const offset = offsets[scrollAnchor.index]; if (typeof offset !== 'number') { @@ -309,9 +349,14 @@ function VirtualizedList( const containerChanged = prevContainerHeight.current !== scrollableContainerHeight; + // If targetScrollIndex is provided, we NEVER auto-snap to the bottom + // because the parent is explicitly managing the scroll position. + const shouldAutoScroll = props.targetScrollIndex === undefined; + if ( - (listGrew && (isStickingToBottom || wasAtBottom)) || - (isStickingToBottom && containerChanged) + shouldAutoScroll && + ((listGrew && (isStickingToBottom || wasAtBottom)) || + (isStickingToBottom && containerChanged)) ) { const newIndex = data.length > 0 ? data.length - 1 : 0; if ( @@ -331,6 +376,7 @@ function VirtualizedList( actualScrollTop > totalHeight - scrollableContainerHeight) && data.length > 0 ) { + // 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); if ( @@ -359,6 +405,7 @@ function VirtualizedList( getAnchorForScrollTop, offsets, isStickingToBottom, + props.targetScrollIndex, ]); useLayoutEffect(() => { @@ -366,11 +413,17 @@ function VirtualizedList( isInitialScrollSet.current || offsets.length <= 1 || totalHeight <= 0 || - containerHeight <= 0 + scrollableContainerHeight <= 0 ) { return; } + if (props.targetScrollIndex !== undefined) { + // If we are strictly driving from targetScrollIndex, do not apply initialScrollIndex + isInitialScrollSet.current = true; + return; + } + if (typeof initialScrollIndex === 'number') { const scrollToEnd = initialScrollIndex === SCROLL_TO_ITEM_END || @@ -404,19 +457,21 @@ function VirtualizedList( initialScrollOffsetInIndex, offsets, totalHeight, - containerHeight, + scrollableContainerHeight, getAnchorForScrollTop, data.length, heights, - scrollableContainerHeight, + props.targetScrollIndex, ]); const startIndex = Math.max( 0, findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1, ); + const viewHeightForEndIndex = + scrollableContainerHeight > 0 ? scrollableContainerHeight : 50; const endIndexOffset = offsets.findIndex( - (offset) => offset > actualScrollTop + scrollableContainerHeight, + (offset) => offset > actualScrollTop + viewHeightForEndIndex, ); const endIndex = endIndexOffset === -1 @@ -618,11 +673,11 @@ function VirtualizedList( }, getScrollIndex: () => scrollAnchor.index, getScrollState: () => { - const maxScroll = Math.max(0, totalHeight - containerHeight); + const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); return { scrollTop: Math.min(getScrollTop(), maxScroll), scrollHeight: totalHeight, - innerHeight: containerHeight, + innerHeight: scrollableContainerHeight, }; }, }), @@ -635,7 +690,6 @@ function VirtualizedList( scrollableContainerHeight, getScrollTop, setPendingScrollTop, - containerHeight, ], ); @@ -646,6 +700,7 @@ function VirtualizedList( overflowX="hidden" scrollTop={copyModeEnabled ? 0 : scrollTop} scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary} + backgroundColor={props.backgroundColor} width="100%" height="100%" flexDirection="column" diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 72d842ec98..d6b95d6016 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -2907,6 +2907,25 @@ export function useTextBuffer({ const [scrollRowState, setScrollRowState] = useState(0); + const { height } = viewport; + const totalVisualLines = visualLines.length; + const maxScrollStart = Math.max(0, totalVisualLines - height); + let newVisualScrollRow = scrollRowState; + + if (visualCursor[0] < scrollRowState) { + newVisualScrollRow = visualCursor[0]; + } else if (visualCursor[0] >= scrollRowState + height) { + newVisualScrollRow = visualCursor[0] - height + 1; + } + + newVisualScrollRow = clamp(newVisualScrollRow, 0, maxScrollStart); + + if (newVisualScrollRow !== scrollRowState) { + setScrollRowState(newVisualScrollRow); + } + + const actualScrollRowState = newVisualScrollRow; + useEffect(() => { if (onChange) { onChange(text); @@ -2920,28 +2939,6 @@ export function useTextBuffer({ }); }, [viewport.width, viewport.height]); - // Update visual scroll (vertical) - useEffect(() => { - const { height } = viewport; - const totalVisualLines = visualLines.length; - const maxScrollStart = Math.max(0, totalVisualLines - height); - let newVisualScrollRow = scrollRowState; - - if (visualCursor[0] < scrollRowState) { - newVisualScrollRow = visualCursor[0]; - } else if (visualCursor[0] >= scrollRowState + height) { - newVisualScrollRow = visualCursor[0] - height + 1; - } - - // When the number of visual lines shrinks (e.g., after widening the viewport), - // ensure scroll never starts beyond the last valid start so we can render a full window. - newVisualScrollRow = clamp(newVisualScrollRow, 0, maxScrollStart); - - if (newVisualScrollRow !== scrollRowState) { - setScrollRowState(newVisualScrollRow); - } - }, [visualCursor, scrollRowState, viewport, visualLines.length]); - const insert = useCallback( (ch: string, { paste = false }: { paste?: boolean } = {}): void => { if (typeof ch !== 'string') { @@ -3495,10 +3492,10 @@ export function useTextBuffer({ const visualScrollRow = useMemo(() => { const totalVisualLines = visualLines.length; return Math.min( - scrollRowState, + actualScrollRowState, Math.max(0, totalVisualLines - viewport.height), ); - }, [visualLines.length, scrollRowState, viewport.height]); + }, [visualLines.length, actualScrollRowState, viewport.height]); const renderedVisualLines = useMemo( () => visualLines.slice(visualScrollRow, visualScrollRow + viewport.height), @@ -3694,6 +3691,7 @@ export function useTextBuffer({ viewportVisualLines: renderedVisualLines, visualCursor, visualScrollRow, + viewportHeight: viewport.height, visualToLogicalMap, transformedToLogicalMaps, visualToTransformedMap, @@ -3799,6 +3797,7 @@ export function useTextBuffer({ renderedVisualLines, visualCursor, visualScrollRow, + viewport.height, visualToLogicalMap, transformedToLogicalMaps, visualToTransformedMap, @@ -3914,6 +3913,7 @@ export interface TextBuffer { viewportVisualLines: string[]; // The subset of visual lines to be rendered based on visualScrollRow and viewport.height visualCursor: [number, number]; // Visual cursor [row, col] relative to the start of all visualLines visualScrollRow: number; // Scroll position for visual lines (index of the first visible visual line) + viewportHeight: number; // The maximum height of the viewport /** * For each visual line (by absolute index in allVisualLines) provides a tuple * [logicalLineIndex, startColInLogical] that maps where that visual line