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