mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-01 19:03:42 +00:00
Optimize scrolling checkpoint
This commit is contained in:
@@ -42,6 +42,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
itemKey?: string;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
isPending: boolean;
|
||||
@@ -55,6 +56,7 @@ interface HistoryItemDisplayProps {
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
item,
|
||||
itemKey,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
isPending,
|
||||
@@ -100,6 +102,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini' && (
|
||||
<GeminiMessage
|
||||
itemKey={itemKey}
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
@@ -110,6 +113,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_content' && (
|
||||
<GeminiMessageContent
|
||||
itemKey={itemKey}
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
@@ -186,6 +190,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_group' && (
|
||||
<ToolGroupMessage
|
||||
itemKey={itemKey}
|
||||
item={itemForDisplay}
|
||||
toolCalls={itemForDisplay.tools}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
|
||||
@@ -116,6 +116,7 @@ export const MainContent = () => {
|
||||
isToolGroupBoundary,
|
||||
}) => (
|
||||
<MemoizedHistoryItemDisplay
|
||||
itemKey={item.id.toString()}
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight || !isExpandable
|
||||
@@ -251,7 +252,7 @@ export const MainContent = () => {
|
||||
// interactive. Gemini messages and Tool results that are not scrollable,
|
||||
// collapsible, or clickable should also be tagged as static in the future.
|
||||
const isStaticItem = useCallback(
|
||||
(item: (typeof virtualizedData)[number]) => item.type === 'header',
|
||||
(item: (typeof virtualizedData)[number]) => item.type !== 'pending',
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import { useMemo, useContext } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
type FileDiff,
|
||||
@@ -32,13 +32,13 @@ import {
|
||||
isNewFile,
|
||||
parseDiffWithLineNumbers,
|
||||
} from './DiffRenderer.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { COMPACT_TOOL_SUBVIEW_MAX_LINES } from '../../constants.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { colorizeCode } from '../../utils/CodeColorizer.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { getFileExtension } from '../../utils/fileUtils.js';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
|
||||
const PAYLOAD_MARGIN_LEFT = 6;
|
||||
const PAYLOAD_BORDER_CHROME_WIDTH = 4; // paddingX=1 (2 cols) + borders (2 cols)
|
||||
@@ -46,6 +46,7 @@ const PAYLOAD_SCROLL_GUTTER = 4;
|
||||
const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER;
|
||||
|
||||
interface DenseToolMessageProps extends IndividualToolCallDisplay {
|
||||
itemKey?: string;
|
||||
terminalWidth: number;
|
||||
availableTerminalHeight?: number;
|
||||
}
|
||||
@@ -260,6 +261,7 @@ function getGenericSuccessData(
|
||||
|
||||
export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
const {
|
||||
itemKey,
|
||||
callId,
|
||||
name,
|
||||
status,
|
||||
@@ -273,16 +275,16 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
|
||||
const settings = useSettings();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
|
||||
const { isExpanded: isExpandedInContext } = useToolActions();
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
// Handle optional context members
|
||||
const [localIsExpanded, setLocalIsExpanded] = useState(false);
|
||||
const isExpanded = isExpandedInContext
|
||||
? isExpandedInContext(callId)
|
||||
: localIsExpanded;
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const toggleRef = useRef<DOMElement>(null);
|
||||
// Determine expansion state based on list context or fallback to tool actions
|
||||
const isExpanded = useMemo(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
return virtualizedListContext.isItemToggled(itemKey);
|
||||
}
|
||||
return isExpandedInContext ? isExpandedInContext(callId) : false;
|
||||
}, [itemKey, virtualizedListContext, isExpandedInContext, callId]);
|
||||
|
||||
// Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails)
|
||||
const diff = useMemo((): FileDiff | undefined => {
|
||||
@@ -301,25 +303,6 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
return undefined;
|
||||
}, [resultDisplay, confirmationDetails]);
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = !isExpanded;
|
||||
if (!next) {
|
||||
setIsFocused(false);
|
||||
} else {
|
||||
setIsFocused(true);
|
||||
}
|
||||
|
||||
if (toggleExpansion) {
|
||||
toggleExpansion(callId);
|
||||
} else {
|
||||
setLocalIsExpanded(next);
|
||||
}
|
||||
};
|
||||
|
||||
useMouseClick(toggleRef, handleToggle, {
|
||||
isActive: isAlternateBuffer && !!diff,
|
||||
});
|
||||
|
||||
// State-to-View Coordination
|
||||
const viewParts = useMemo((): ViewParts => {
|
||||
if (diff) {
|
||||
@@ -463,12 +446,7 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
</Box>
|
||||
|
||||
{summary && (
|
||||
<Box
|
||||
key="tool-summary"
|
||||
ref={isAlternateBuffer && diff ? toggleRef : undefined}
|
||||
marginLeft={1}
|
||||
flexGrow={0}
|
||||
>
|
||||
<Box key="tool-summary" marginLeft={1} flexGrow={0}>
|
||||
{summary}
|
||||
</Box>
|
||||
)}
|
||||
@@ -494,11 +472,12 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
)}
|
||||
>
|
||||
<ScrollableList
|
||||
itemKey={itemKey}
|
||||
data={diffLines}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
hasFocus={isFocused}
|
||||
hasFocus={false}
|
||||
width={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
terminalWidth -
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface GeminiMessageProps {
|
||||
text: string;
|
||||
itemKey?: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
@@ -20,6 +21,7 @@ interface GeminiMessageProps {
|
||||
|
||||
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
text,
|
||||
itemKey,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -37,6 +39,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
itemKey={itemKey}
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface GeminiMessageContentProps {
|
||||
text: string;
|
||||
itemKey?: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
@@ -24,6 +25,7 @@ interface GeminiMessageContentProps {
|
||||
*/
|
||||
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
text,
|
||||
itemKey,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -35,6 +37,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={prefixWidth}>
|
||||
<MarkdownDisplay
|
||||
itemKey={itemKey}
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, Fragment } from 'react';
|
||||
import { useMemo, Fragment, useContext } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type {
|
||||
HistoryItem,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
TOOL_RESULT_STATIC_HEIGHT,
|
||||
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
|
||||
} from '../../utils/toolLayoutUtils.js';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
|
||||
const COMPACT_OUTPUT_ALLOWLIST = new Set([
|
||||
EDIT_DISPLAY_NAME,
|
||||
@@ -94,6 +95,7 @@ export const hasDensePayload = (tool: IndividualToolCallDisplay): boolean => {
|
||||
};
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
itemKey?: string;
|
||||
item: HistoryItem | HistoryItemWithoutId;
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
availableTerminalHeight?: number;
|
||||
@@ -108,6 +110,7 @@ interface ToolGroupMessageProps {
|
||||
const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4;
|
||||
|
||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
itemKey,
|
||||
item,
|
||||
toolCalls: allToolCalls,
|
||||
availableTerminalHeight,
|
||||
@@ -141,6 +144,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
} = useUIState();
|
||||
|
||||
const config = useConfig();
|
||||
const { registerInteractivity } = useContext(VirtualizedListContext) ?? {};
|
||||
|
||||
if (itemKey && registerInteractivity) {
|
||||
registerInteractivity(itemKey, { click: true, scroll: true });
|
||||
}
|
||||
|
||||
const { borderColor, borderDimColor } = useMemo(
|
||||
() =>
|
||||
@@ -425,9 +433,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const tool = group;
|
||||
const isShellToolCall = isShellTool(tool.name);
|
||||
const uniqueItemKey = itemKey
|
||||
? `${itemKey}-tool-${tool.callId}`
|
||||
: undefined;
|
||||
|
||||
const commonProps = {
|
||||
...tool,
|
||||
itemKey: uniqueItemKey,
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
terminalWidth: contentWidth,
|
||||
emphasis: 'medium' as const,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
export type { TextEmphasis };
|
||||
|
||||
export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||
itemKey?: string;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
emphasis?: TextEmphasis;
|
||||
@@ -44,6 +45,7 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||
|
||||
export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
callId,
|
||||
itemKey,
|
||||
name,
|
||||
description,
|
||||
resultDisplay,
|
||||
@@ -139,6 +141,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
/>
|
||||
)}
|
||||
<ToolResultDisplay
|
||||
itemKey={itemKey}
|
||||
resultDisplay={resultDisplay}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
|
||||
export interface ToolResultDisplayProps {
|
||||
itemKey?: string;
|
||||
resultDisplay: string | object | undefined;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
@@ -44,6 +45,7 @@ interface FileDiffResult {
|
||||
}
|
||||
|
||||
export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
itemKey,
|
||||
resultDisplay,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
@@ -194,6 +196,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
return (
|
||||
<Scrollable
|
||||
itemKey={itemKey}
|
||||
width={childWidth}
|
||||
maxHeight={effectiveMaxHeight}
|
||||
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
||||
@@ -227,6 +230,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
||||
<FixedScrollableList
|
||||
itemKey={itemKey}
|
||||
width={childWidth}
|
||||
maxHeight={listHeight}
|
||||
data={data}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
@@ -26,10 +28,12 @@ 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';
|
||||
import { VirtualizedListContext } from './VirtualizedList.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
interface FixedScrollableListProps<T> extends FixedVirtualizedListProps<T> {
|
||||
itemKey?: string;
|
||||
hasFocus: boolean;
|
||||
width: number;
|
||||
scrollbar?: boolean;
|
||||
@@ -50,6 +54,7 @@ function FixedScrollableList<T>(
|
||||
const settings = useSettings();
|
||||
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
|
||||
const {
|
||||
itemKey,
|
||||
hasFocus,
|
||||
width,
|
||||
maxHeight,
|
||||
@@ -59,6 +64,32 @@ function FixedScrollableList<T>(
|
||||
const fixedVirtualizedListRef = useRef<FixedVirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const restoredTop = virtualizedListContext.getItemState(
|
||||
itemKey,
|
||||
'scrollTop',
|
||||
);
|
||||
if (typeof restoredTop === 'number') {
|
||||
fixedVirtualizedListRef.current?.scrollTo(restoredTop);
|
||||
}
|
||||
}
|
||||
}, [itemKey, virtualizedListContext]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const top = fixedVirtualizedListRef.current?.getScrollState().scrollTop;
|
||||
if (top !== undefined) {
|
||||
virtualizedListContext.setItemState(itemKey, 'scrollTop', top);
|
||||
}
|
||||
}
|
||||
},
|
||||
[itemKey, virtualizedListContext],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useId,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { Box, ResizeObserver, type DOMElement } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
@@ -22,9 +23,11 @@ import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { VirtualizedListContext } from './VirtualizedList.js';
|
||||
|
||||
interface ScrollableProps {
|
||||
children?: React.ReactNode;
|
||||
itemKey?: string;
|
||||
width?: number;
|
||||
height?: number | string;
|
||||
maxWidth?: number;
|
||||
@@ -40,6 +43,7 @@ interface ScrollableProps {
|
||||
|
||||
export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
children,
|
||||
itemKey,
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
@@ -53,7 +57,16 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
stableScrollback,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
const [scrollTop, setScrollTop] = useState(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const state = virtualizedListContext.getItemState(itemKey, 'scrollTop');
|
||||
return typeof state === 'number' ? state : 0;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const viewportRef = useRef<DOMElement | null>(null);
|
||||
const contentRef = useRef<DOMElement | null>(null);
|
||||
const overflowActions = useOverflowActions();
|
||||
@@ -73,6 +86,19 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
scrollTopRef.current = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
virtualizedListContext.setItemState(
|
||||
itemKey,
|
||||
'scrollTop',
|
||||
scrollTopRef.current,
|
||||
);
|
||||
}
|
||||
},
|
||||
[itemKey, virtualizedListContext],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (reportOverflow && size.scrollHeight > size.innerHeight) {
|
||||
overflowActions?.addOverflowingId?.(id);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
@@ -26,10 +28,12 @@ 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';
|
||||
import { VirtualizedListContext } from './VirtualizedList.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
|
||||
itemKey?: string;
|
||||
hasFocus: boolean;
|
||||
width?: string | number;
|
||||
scrollbar?: boolean;
|
||||
@@ -49,10 +53,42 @@ function ScrollableList<T>(
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const settings = useSettings();
|
||||
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
|
||||
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
|
||||
const {
|
||||
hasFocus,
|
||||
width,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
itemKey,
|
||||
} = props;
|
||||
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const restoredTop = virtualizedListContext.getItemState(
|
||||
itemKey,
|
||||
'scrollTop',
|
||||
);
|
||||
if (typeof restoredTop === 'number') {
|
||||
virtualizedListRef.current?.scrollTo(restoredTop);
|
||||
}
|
||||
}
|
||||
}, [itemKey, virtualizedListContext]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const top = virtualizedListRef.current?.getScrollState().scrollTop;
|
||||
if (top !== undefined) {
|
||||
virtualizedListContext.setItemState(itemKey, 'scrollTop', top);
|
||||
}
|
||||
}
|
||||
},
|
||||
[itemKey, virtualizedListContext],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
|
||||
@@ -14,15 +14,44 @@ import {
|
||||
useCallback,
|
||||
memo,
|
||||
useEffect,
|
||||
createContext,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
|
||||
import { type DOMElement, Box, ResizeObserver, StaticRender } from 'ink';
|
||||
import {
|
||||
type DOMElement,
|
||||
Box,
|
||||
ResizeObserver,
|
||||
StaticRender,
|
||||
getBoundingBox,
|
||||
getScrollTop as getInkScrollTop,
|
||||
} from 'ink';
|
||||
import {
|
||||
useMouse,
|
||||
useMouseContext,
|
||||
type MouseEvent,
|
||||
} from '../../contexts/MouseContext.js';
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export interface VirtualizedListContextValue {
|
||||
registerInteractivity: (
|
||||
itemKey: string,
|
||||
options: { scroll?: boolean; click?: boolean },
|
||||
) => void;
|
||||
setItemState: (itemKey: string, stateKey: string, value: unknown) => void;
|
||||
getItemState: (itemKey: string, stateKey: string) => unknown;
|
||||
isItemToggled: (itemKey: string) => boolean;
|
||||
toggleItem: (itemKey: string) => void;
|
||||
}
|
||||
|
||||
export const VirtualizedListContext =
|
||||
createContext<VirtualizedListContextValue | null>(null);
|
||||
|
||||
export type VirtualizedListProps<T> = {
|
||||
data: T[];
|
||||
renderItem: (info: { item: T; index: number }) => React.ReactElement;
|
||||
@@ -213,6 +242,51 @@ function VirtualizedList<T>(
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [measurementVersion, setMeasurementVersion] = useState(0);
|
||||
|
||||
const interactiveKeys = useRef(
|
||||
new Map<string, { scroll?: boolean; click?: boolean }>(),
|
||||
);
|
||||
const itemStates = useRef(new Map<string, Map<string, unknown>>());
|
||||
const [toggledKeys, setToggledKeys] = useState(() => new Set<string>());
|
||||
const [temporarilyInteractiveIndexes, setTemporarilyInteractiveIndexes] =
|
||||
useState(() => new Set<number>());
|
||||
const renderedAsStatic = useRef<boolean[]>([]);
|
||||
const maxRenderRangeEnd = useRef(0);
|
||||
const [pendingReplayEvent, setPendingReplayEvent] = useState<{
|
||||
index: number;
|
||||
event: MouseEvent;
|
||||
} | null>(null);
|
||||
|
||||
const virtualizedListContextValue = useMemo<VirtualizedListContextValue>(
|
||||
() => ({
|
||||
registerInteractivity: (itemKey, options) => {
|
||||
interactiveKeys.current.set(itemKey, options);
|
||||
},
|
||||
setItemState: (itemKey, stateKey, value) => {
|
||||
let stateMap = itemStates.current.get(itemKey);
|
||||
if (!stateMap) {
|
||||
stateMap = new Map();
|
||||
itemStates.current.set(itemKey, stateMap);
|
||||
}
|
||||
stateMap.set(stateKey, value);
|
||||
},
|
||||
getItemState: (itemKey, stateKey) =>
|
||||
itemStates.current.get(itemKey)?.get(stateKey),
|
||||
isItemToggled: (itemKey) => toggledKeys.has(itemKey),
|
||||
toggleItem: (itemKey) => {
|
||||
setToggledKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(itemKey)) {
|
||||
next.delete(itemKey);
|
||||
} else {
|
||||
next.add(itemKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
}),
|
||||
[toggledKeys],
|
||||
);
|
||||
|
||||
const state = useRef<VirtualizedListInternalState>({
|
||||
container: null,
|
||||
itemRefs: [],
|
||||
@@ -602,6 +676,21 @@ function VirtualizedList<T>(
|
||||
? data.length - 1
|
||||
: Math.min(data.length - 1, endIndexOffset);
|
||||
|
||||
useEffect(() => {
|
||||
setTemporarilyInteractiveIndexes((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
let changed = false;
|
||||
const next = new Set(prev);
|
||||
for (const index of prev) {
|
||||
if (index > endIndex) {
|
||||
next.delete(index);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [endIndex]);
|
||||
|
||||
const renderRangeStart = useMemo(() => {
|
||||
if (overflowToBackbuffer) {
|
||||
if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) {
|
||||
@@ -624,10 +713,32 @@ function VirtualizedList<T>(
|
||||
]);
|
||||
|
||||
const topSpacerHeight = offsets[renderRangeStart];
|
||||
const bottomSpacerHeight =
|
||||
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
|
||||
|
||||
const renderRangeEnd = endIndex;
|
||||
let renderRangeEnd = endIndex;
|
||||
if (maxRenderRangeEnd.current > endIndex) {
|
||||
let allStatic = true;
|
||||
const currentMax = Math.min(
|
||||
maxRenderRangeEnd.current,
|
||||
data.length > 0 ? data.length - 1 : 0,
|
||||
);
|
||||
for (let i = endIndex + 1; i <= currentMax; i++) {
|
||||
const item = data[i];
|
||||
if (!item) continue;
|
||||
const isStaticByDefault =
|
||||
renderStatic === true || isStaticItem?.(item, i) === true;
|
||||
if (!isStaticByDefault) {
|
||||
allStatic = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allStatic) {
|
||||
renderRangeEnd = currentMax;
|
||||
}
|
||||
}
|
||||
maxRenderRangeEnd.current = renderRangeEnd;
|
||||
|
||||
const bottomSpacerHeight =
|
||||
totalHeight - (offsets[renderRangeEnd + 1] ?? totalHeight);
|
||||
|
||||
// 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.
|
||||
@@ -650,10 +761,15 @@ function VirtualizedList<T>(
|
||||
const item = data[i];
|
||||
if (item) {
|
||||
const isOutsideViewport = i < startIndex || i > endIndex;
|
||||
const shouldBeStatic =
|
||||
const isStaticByDefault =
|
||||
(renderStatic === true && isOutsideViewport) ||
|
||||
isStaticItem?.(item, i) === true;
|
||||
|
||||
const isTemporarilyInteractive =
|
||||
temporarilyInteractiveIndexes.has(i) && i <= endIndex;
|
||||
const shouldBeStatic = isStaticByDefault && !isTemporarilyInteractive;
|
||||
renderedAsStatic.current[i] = shouldBeStatic;
|
||||
|
||||
const content = renderItem({ item, index: i });
|
||||
const key = keyExtractor(item, i);
|
||||
|
||||
@@ -712,10 +828,103 @@ function VirtualizedList<T>(
|
||||
containerWidth,
|
||||
onSetRef,
|
||||
estimatedItemHeight,
|
||||
temporarilyInteractiveIndexes,
|
||||
]);
|
||||
|
||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||
|
||||
const { broadcast } = useMouseContext();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (pendingReplayEvent) {
|
||||
const { index, event } = pendingReplayEvent;
|
||||
// Replay if the item has been mounted
|
||||
if (state.current.itemRefs[index]) {
|
||||
setTimeout(() => {
|
||||
debugLogger.log(`[Mouse] Replaying event index=${index}`);
|
||||
broadcast(event);
|
||||
}, 150); // Allow Ink's Yoga engine time to calculate bounding boxes
|
||||
setPendingReplayEvent(null);
|
||||
}
|
||||
}
|
||||
}, [pendingReplayEvent, broadcast]);
|
||||
|
||||
const handleMouse = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!state.current.container) return;
|
||||
|
||||
const isClick = event.name === 'left-press';
|
||||
const isScroll =
|
||||
event.name === 'scroll-up' || event.name === 'scroll-down';
|
||||
if (!isClick && !isScroll) return;
|
||||
|
||||
const { x, y, width, height } = getBoundingBox(state.current.container);
|
||||
const mouseX = event.col - 1;
|
||||
const mouseY = event.row - 1;
|
||||
|
||||
const relativeX = mouseX - x;
|
||||
const relativeY = mouseY - y;
|
||||
|
||||
if (
|
||||
relativeX >= 0 &&
|
||||
relativeX < width &&
|
||||
relativeY >= 0 &&
|
||||
relativeY < height
|
||||
) {
|
||||
// getScrollTop() might return MAX_SAFE_INTEGER if stuck to bottom.
|
||||
// We need the true rendered layout scroll top which ink exposes directly via getScrollTop.
|
||||
const trueScrollTop = getInkScrollTop(state.current.container);
|
||||
const absoluteY = trueScrollTop + relativeY;
|
||||
|
||||
const index = findLastIndex(offsets, (offset) => offset <= absoluteY);
|
||||
|
||||
// DEBUG LOGGING
|
||||
debugLogger.log(
|
||||
`[Mouse] event=${event.name} index=${index} static=${renderedAsStatic.current[index]}`,
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
const item = data[index];
|
||||
if (item) {
|
||||
const itemKey = keyExtractor(item, index);
|
||||
const options = interactiveKeys.current.get(itemKey);
|
||||
|
||||
debugLogger.log(
|
||||
`[Mouse] itemKey=${itemKey} options=${JSON.stringify(options)}`,
|
||||
);
|
||||
if (options) {
|
||||
// Determine if the click was exactly on the first line of the item
|
||||
const itemStartY = offsets[index];
|
||||
const isFirstLineClick = isClick && absoluteY === itemStartY;
|
||||
|
||||
if (isFirstLineClick && options.click) {
|
||||
debugLogger.log(
|
||||
`[Mouse] First line click detected. Toggling itemKey=${itemKey}.`,
|
||||
);
|
||||
virtualizedListContextValue.toggleItem(itemKey);
|
||||
} else if (
|
||||
renderedAsStatic.current[index] &&
|
||||
isScroll &&
|
||||
options.scroll
|
||||
) {
|
||||
// Only wake up the item for scroll events
|
||||
setTemporarilyInteractiveIndexes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(index);
|
||||
return next;
|
||||
});
|
||||
setPendingReplayEvent({ index, event });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[offsets, data, keyExtractor, virtualizedListContextValue],
|
||||
);
|
||||
|
||||
useMouse(handleMouse, { isActive: true });
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -870,31 +1079,33 @@ function VirtualizedList<T>(
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRefCallback}
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={1}
|
||||
overflowToBackbuffer={overflowToBackbuffer}
|
||||
scrollbar={scrollbar}
|
||||
stableScrollback={stableScrollback}
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
{topSpacerHeight > 0 ? (
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
) : null}
|
||||
{renderedItems}
|
||||
{bottomSpacerHeight > 0 ? (
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
) : null}
|
||||
<VirtualizedListContext.Provider value={virtualizedListContextValue}>
|
||||
<Box
|
||||
ref={containerRefCallback}
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={1}
|
||||
overflowToBackbuffer={overflowToBackbuffer}
|
||||
scrollbar={scrollbar}
|
||||
stableScrollback={stableScrollback}
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
{topSpacerHeight > 0 ? (
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
) : null}
|
||||
{renderedItems}
|
||||
{bottomSpacerHeight > 0 ? (
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
@@ -35,6 +36,7 @@ const MAX_MOUSE_BUFFER_SIZE = 4096;
|
||||
interface MouseContextValue {
|
||||
subscribe: (handler: MouseHandler) => void;
|
||||
unsubscribe: (handler: MouseHandler) => void;
|
||||
broadcast: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const MouseContext = createContext<MouseContextValue | undefined>(undefined);
|
||||
@@ -50,7 +52,7 @@ export function useMouseContext() {
|
||||
export function useMouse(handler: MouseHandler, { isActive = true } = {}) {
|
||||
const { subscribe, unsubscribe } = useMouseContext();
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
@@ -92,14 +94,8 @@ export function MouseProvider({
|
||||
[subscribers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mouseEventsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mouseBuffer = '';
|
||||
|
||||
const broadcast = (event: MouseEvent) => {
|
||||
const broadcast = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
let handled = false;
|
||||
for (const handler of subscribers) {
|
||||
if (handler(event) === true) {
|
||||
@@ -143,7 +139,16 @@ export function MouseProvider({
|
||||
// events not the terminal.
|
||||
appEvents.emit(AppEvent.SelectionWarning);
|
||||
}
|
||||
};
|
||||
},
|
||||
[subscribers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mouseEventsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mouseBuffer = '';
|
||||
|
||||
const handleData = (data: Buffer | string) => {
|
||||
mouseBuffer += typeof data === 'string' ? data : data.toString('utf-8');
|
||||
@@ -190,11 +195,11 @@ export function MouseProvider({
|
||||
return () => {
|
||||
stdin.removeListener('data', handleData);
|
||||
};
|
||||
}, [stdin, mouseEventsEnabled, subscribers, debugKeystrokeLogging]);
|
||||
}, [stdin, mouseEventsEnabled, broadcast, debugKeystrokeLogging]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ subscribe, unsubscribe }),
|
||||
[subscribe, unsubscribe],
|
||||
() => ({ subscribe, unsubscribe, broadcast }),
|
||||
[subscribe, unsubscribe, broadcast],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type MouseEvent,
|
||||
type MouseEventName,
|
||||
} from '../contexts/MouseContext.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
export const useMouseClick = (
|
||||
containerRef: React.RefObject<DOMElement | null>,
|
||||
@@ -30,6 +31,10 @@ export const useMouseClick = (
|
||||
(event: MouseEvent) => {
|
||||
const eventName =
|
||||
name ?? (button === 'left' ? 'left-press' : 'right-release');
|
||||
|
||||
debugLogger.log(
|
||||
`[useMouseClick] received event=${event.name} expected=${eventName} hasContainer=${!!containerRef.current}`,
|
||||
);
|
||||
if (event.name === eventName && containerRef.current) {
|
||||
const { x, y, width, height } = getBoundingBox(containerRef.current);
|
||||
// Terminal mouse events are 1-based, Ink layout is 0-based.
|
||||
@@ -39,12 +44,17 @@ export const useMouseClick = (
|
||||
const relativeX = mouseX - x;
|
||||
const relativeY = mouseY - y;
|
||||
|
||||
debugLogger.log(
|
||||
`[useMouseClick] bounds x=${x} y=${y} w=${width} h=${height} mouseX=${mouseX} mouseY=${mouseY} relX=${relativeX} relY=${relativeY}`,
|
||||
);
|
||||
|
||||
if (
|
||||
relativeX >= 0 &&
|
||||
relativeX < width &&
|
||||
relativeY >= 0 &&
|
||||
relativeY < height
|
||||
) {
|
||||
debugLogger.log(`[useMouseClick] Triggering handler!`);
|
||||
handlerRef.current(event, relativeX, relativeY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface MarkdownDisplayProps {
|
||||
text: string;
|
||||
itemKey?: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
|
||||
Reference in New Issue
Block a user