Optimize scrolling checkpoint

This commit is contained in:
jacob314
2026-04-24 17:11:18 -07:00
parent c597c2b64b
commit c612957caf
18 changed files with 422 additions and 92 deletions

View File

@@ -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}

View File

@@ -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',
[],
);

View File

@@ -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 -

View File

@@ -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={

View File

@@ -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={

View File

@@ -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,

View File

@@ -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}

View File

@@ -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}

View File

@@ -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,
() => ({

View File

@@ -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);

View File

@@ -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,
() => ({

View File

@@ -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>
);
}

View File

@@ -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 (

View File

@@ -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);
}
}

View File

@@ -15,6 +15,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
interface MarkdownDisplayProps {
text: string;
itemKey?: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;