feat(cli) Scrollbar for input prompt (#21992)

This commit is contained in:
Jacob Richman
2026-04-03 15:10:04 -07:00
committed by GitHub
parent 893ae4d29a
commit d5a5995281
6 changed files with 439 additions and 196 deletions
@@ -29,6 +29,8 @@ export type VirtualizedListProps<T> = {
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<T> = {
stableScrollback?: boolean;
copyModeEnabled?: boolean;
fixedItemHeight?: boolean;
containerHeight?: number;
};
export type VirtualizedListRef<T> = {
@@ -159,6 +162,17 @@ function VirtualizedList<T>(
};
}
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<T>(
return { totalHeight, offsets };
}, [heights, data, estimatedItemHeight, keyExtractor]);
const scrollableContainerHeight = containerHeight;
const scrollableContainerHeight = props.containerHeight ?? containerHeight;
const getAnchorForScrollTop = useCallback(
(
@@ -259,6 +273,32 @@ function VirtualizedList<T>(
[],
);
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<T>(
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<T>(
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<T>(
getAnchorForScrollTop,
offsets,
isStickingToBottom,
props.targetScrollIndex,
]);
useLayoutEffect(() => {
@@ -366,11 +413,17 @@ function VirtualizedList<T>(
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<T>(
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<T>(
},
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<T>(
scrollableContainerHeight,
getScrollTop,
setPendingScrollTop,
containerHeight,
],
);
@@ -646,6 +700,7 @@ function VirtualizedList<T>(
overflowX="hidden"
scrollTop={copyModeEnabled ? 0 : scrollTop}
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
backgroundColor={props.backgroundColor}
width="100%"
height="100%"
flexDirection="column"