mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-25 20:44:46 -07:00
feat(cli) Scrollbar for input prompt (#21992)
This commit is contained in:
@@ -36,6 +36,9 @@ interface ScrollableListProps<T> extends VirtualizedListProps<T> {
|
||||
copyModeEnabled?: boolean;
|
||||
isStatic?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
targetScrollIndex?: number;
|
||||
containerHeight?: number;
|
||||
scrollbarThumbColor?: string;
|
||||
}
|
||||
|
||||
export type ScrollableListRef<T> = VirtualizedListRef<T>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2907,6 +2907,25 @@ export function useTextBuffer({
|
||||
|
||||
const [scrollRowState, setScrollRowState] = useState<number>(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
|
||||
|
||||
Reference in New Issue
Block a user