From 87a7b34c8273b4dd48fc6221c2f9f5843c762a43 Mon Sep 17 00:00:00 2001 From: jacob314 Date: Fri, 24 Apr 2026 17:11:18 -0700 Subject: [PATCH] Optimize scrolling checkpoint --- package-lock.json | 10 +- package.json | 4 +- packages/cli/package.json | 2 +- .../src/ui/components/HistoryItemDisplay.tsx | 5 + .../cli/src/ui/components/MainContent.tsx | 3 +- .../components/messages/DenseToolMessage.tsx | 55 ++-- .../ui/components/messages/GeminiMessage.tsx | 3 + .../messages/GeminiMessageContent.tsx | 3 + .../components/messages/ToolGroupMessage.tsx | 14 +- .../ui/components/messages/ToolMessage.tsx | 3 + .../components/messages/ToolResultDisplay.tsx | 4 + .../components/shared/FixedScrollableList.tsx | 31 ++ .../src/ui/components/shared/Scrollable.tsx | 28 +- .../ui/components/shared/ScrollableList.tsx | 38 ++- .../ui/components/shared/VirtualizedList.tsx | 269 ++++++++++++++++-- packages/cli/src/ui/contexts/MouseContext.tsx | 31 +- packages/cli/src/ui/hooks/useMouseClick.ts | 10 + packages/cli/src/ui/utils/MarkdownDisplay.tsx | 1 + 18 files changed, 422 insertions(+), 92 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3ae3903e6c..59f4c82fab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "packages/*" ], "dependencies": { - "ink": "npm:@jrichman/ink@7.0.0-beta.2", + "ink": "npm:@jrichman/ink@7.0.0-beta.3", "latest-version": "^9.0.0", "node-fetch-native": "^1.6.7", "proper-lockfile": "^4.1.2", @@ -10020,9 +10020,9 @@ }, "node_modules/ink": { "name": "@jrichman/ink", - "version": "7.0.0-beta.2", - "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-7.0.0-beta.2.tgz", - "integrity": "sha512-cc222452y0FK1gl7/p+veunoABGL1LAfF57RfDYCGYcTxxogN3IaM/KbkaY0pKQLngLBj8mz7GyOabq+O4DY2A==", + "version": "7.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-7.0.0-beta.3.tgz", + "integrity": "sha512-8qBJlbUg1HRfOtLlGo6NjGL+HwYrwkoyvLXOl6Ogzcf2l0n0hudiaPpD+0VFTCQX3o01Nm5bMr4zY5TXwEXvgA==", "license": "MIT", "dependencies": { "ansi-escapes": "^7.0.0", @@ -18228,7 +18228,7 @@ "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@7.0.0-beta.2", + "ink": "npm:@jrichman/ink@7.0.0-beta.3", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", diff --git a/package.json b/package.json index a4f59d3809..9d50f0fa7d 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "pre-commit": "node scripts/pre-commit.js" }, "overrides": { - "ink": "npm:@jrichman/ink@7.0.0-beta.2", + "ink": "npm:@jrichman/ink@7.0.0-beta.3", "wrap-ansi": "9.0.2", "cliui": { "wrap-ansi": "7.0.0" @@ -143,7 +143,7 @@ "yargs": "^17.7.2" }, "dependencies": { - "ink": "npm:@jrichman/ink@7.0.0-beta.2", + "ink": "npm:@jrichman/ink@7.0.0-beta.3", "latest-version": "^9.0.0", "node-fetch-native": "^1.6.7", "proper-lockfile": "^4.1.2", diff --git a/packages/cli/package.json b/packages/cli/package.json index a20bdcfd7b..3ba4e50129 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,7 +49,7 @@ "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@7.0.0-beta.2", + "ink": "npm:@jrichman/ink@7.0.0-beta.3", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 081a206272..f1c2933693 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -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 = ({ item, + itemKey, availableTerminalHeight, terminalWidth, isPending, @@ -100,6 +102,7 @@ export const HistoryItemDisplay: React.FC = ({ )} {itemForDisplay.type === 'gemini' && ( = ({ )} {itemForDisplay.type === 'gemini_content' && ( = ({ )} {itemForDisplay.type === 'tool_group' && ( { isToolGroupBoundary, }) => ( { // 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', [], ); diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx index f5e4b31c66..edfec94a3f 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx @@ -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 = (props) => { const { + itemKey, callId, name, status, @@ -273,16 +275,16 @@ export const DenseToolMessage: React.FC = (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(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 = (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 = (props) => { {summary && ( - + {summary} )} @@ -494,11 +472,12 @@ export const DenseToolMessage: React.FC = (props) => { )} > 1} - hasFocus={isFocused} + hasFocus={false} width={Math.min( PAYLOAD_MAX_WIDTH, terminalWidth - diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index bc2246d23f..55d52314f2 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -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 = ({ text, + itemKey, isPending, availableTerminalHeight, terminalWidth, @@ -37,6 +39,7 @@ export const GeminiMessage: React.FC = ({ = ({ text, + itemKey, isPending, availableTerminalHeight, terminalWidth, @@ -35,6 +37,7 @@ export const GeminiMessageContent: React.FC = ({ return ( { }; 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 = ({ + itemKey, item, toolCalls: allToolCalls, availableTerminalHeight, @@ -141,6 +144,11 @@ export const ToolGroupMessage: React.FC = ({ } = 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 = ({ 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, diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 269f00b12d..6281ad686d 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -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 = ({ callId, + itemKey, name, description, resultDisplay, @@ -139,6 +141,7 @@ export const ToolMessage: React.FC = ({ /> )} = ({ + itemKey, resultDisplay, availableTerminalHeight, terminalWidth, @@ -194,6 +196,7 @@ export const ToolResultDisplay: React.FC = ({ return ( = ({ return ( extends FixedVirtualizedListProps { + itemKey?: string; hasFocus: boolean; width: number; scrollbar?: boolean; @@ -50,6 +54,7 @@ function FixedScrollableList( const settings = useSettings(); const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength; const { + itemKey, hasFocus, width, maxHeight, @@ -59,6 +64,32 @@ function FixedScrollableList( const fixedVirtualizedListRef = useRef>(null); const containerRef = useRef(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, () => ({ diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index d9c3fb8c7a..e13d87a7d4 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -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 = ({ children, + itemKey, width, height, maxWidth, @@ -53,7 +57,16 @@ export const Scrollable: React.FC = ({ 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(null); const contentRef = useRef(null); const overflowActions = useOverflowActions(); @@ -73,6 +86,19 @@ export const Scrollable: React.FC = ({ 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); diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index 0aa264fba8..f3b936bb01 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -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 extends VirtualizedListProps { + itemKey?: string; hasFocus: boolean; width?: string | number; scrollbar?: boolean; @@ -49,10 +53,42 @@ function ScrollableList( 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>(null); const containerRef = useRef(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, () => ({ diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index 6791016f4c..6354f34788 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -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(null); + export type VirtualizedListProps = { data: T[]; renderItem: (info: { item: T; index: number }) => React.ReactElement; @@ -213,6 +242,51 @@ function VirtualizedList( const [containerWidth, setContainerWidth] = useState(0); const [measurementVersion, setMeasurementVersion] = useState(0); + const interactiveKeys = useRef( + new Map(), + ); + const itemStates = useRef(new Map>()); + const [toggledKeys, setToggledKeys] = useState(() => new Set()); + const [temporarilyInteractiveIndexes, setTemporarilyInteractiveIndexes] = + useState(() => new Set()); + const renderedAsStatic = useRef([]); + const maxRenderRangeEnd = useRef(0); + const [pendingReplayEvent, setPendingReplayEvent] = useState<{ + index: number; + event: MouseEvent; + } | null>(null); + + const virtualizedListContextValue = useMemo( + () => ({ + 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({ container: null, itemRefs: [], @@ -602,6 +676,21 @@ function VirtualizedList( ? 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( ]); 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( 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( 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( ); return ( - - - {topSpacerHeight > 0 ? ( - - ) : null} - {renderedItems} - {bottomSpacerHeight > 0 ? ( - - ) : null} + + + + {topSpacerHeight > 0 ? ( + + ) : null} + {renderedItems} + {bottomSpacerHeight > 0 ? ( + + ) : null} + - + ); } diff --git a/packages/cli/src/ui/contexts/MouseContext.tsx b/packages/cli/src/ui/contexts/MouseContext.tsx index 15ebd33ff8..46493d8760 100644 --- a/packages/cli/src/ui/contexts/MouseContext.tsx +++ b/packages/cli/src/ui/contexts/MouseContext.tsx @@ -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(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 ( diff --git a/packages/cli/src/ui/hooks/useMouseClick.ts b/packages/cli/src/ui/hooks/useMouseClick.ts index 5fd7509470..0b0d5caecc 100644 --- a/packages/cli/src/ui/hooks/useMouseClick.ts +++ b/packages/cli/src/ui/hooks/useMouseClick.ts @@ -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, @@ -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); } } diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index b3e88d9a01..6ec64ade34 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -15,6 +15,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; interface MarkdownDisplayProps { text: string; + itemKey?: string; isPending: boolean; availableTerminalHeight?: number; terminalWidth: number;