diff --git a/package-lock.json b/package-lock.json index bd042a2a04..97e41b5cf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "packages/*" ], "dependencies": { - "ink": "npm:@jrichman/ink@7.0.1-beta.0", + "ink": "npm:@jrichman/ink@7.0.1-beta.4", "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.1-beta.0", - "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-7.0.1-beta.0.tgz", - "integrity": "sha512-vRzRMXNspq92OgikN2RTC0cpyErUn/OCQ97cAWikMEcy/dd8MZ/5SLisyZPL9t8Qt0LLIlY2WOB0cLkCj18qtQ==", + "version": "7.0.1-beta.4", + "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-7.0.1-beta.4.tgz", + "integrity": "sha512-gAcZpOu06y5ElVmyRj2KkHTWOq0ffvXqNjjy4WFVUD+8B0HFwg+ndVWHU+1VF/L9sFXS3jL1QdcSuqK4ss0nAQ==", "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.1-beta.0", + "ink": "npm:@jrichman/ink@7.0.1-beta.4", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", diff --git a/package.json b/package.json index b2bc389ca7..a5fe4d13e6 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.1-beta.0", + "ink": "npm:@jrichman/ink@7.0.1-beta.4", "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.1-beta.0", + "ink": "npm:@jrichman/ink@7.0.1-beta.4", "latest-version": "^9.0.0", "node-fetch-native": "^1.6.7", "proper-lockfile": "^4.1.2", diff --git a/packages/cli/GEMINI.md b/packages/cli/GEMINI.md index 8bad8f0721..1afb7a35aa 100644 --- a/packages/cli/GEMINI.md +++ b/packages/cli/GEMINI.md @@ -12,6 +12,10 @@ `MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the element is available, avoiding potential rendering timing issues. - Avoid prop drilling when at all possible. +- **StaticRender**: Unlike Ink's native `` (which is printed above the + application layout and takes no space in the flex container), the custom + `` component preserves its layout and _does_ take up its + measured height in the active flex container. ## Testing diff --git a/packages/cli/package.json b/packages/cli/package.json index fc83c321e4..0259dcc00c 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.1-beta.0", + "ink": "npm:@jrichman/ink@7.0.1-beta.4", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 5f5ae4b8dc..05fbd0747f 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -394,18 +394,18 @@ const instances: InkInstance[] = []; export const render = async ( tree: React.ReactElement, terminalWidth?: number, + terminalHeight?: number, ): Promise< Omit > => { const cols = terminalWidth ?? 100; // We use 1000 rows to avoid windows with incorrect snapshots if a correct - // value was used (e.g. 40 rows). The alternatives to make things worse are - // windows unfortunately with odd duplicate content in the backbuffer - // which does not match actual behavior in xterm.js on windows. - const rows = 1000; + // value was used (e.g. 40 rows). + const rows = terminalHeight ?? 1000; const terminal = new Terminal({ cols, rows, + scrollback: 10000, allowProposedApi: true, convertEol: true, }); @@ -627,6 +627,7 @@ export const renderWithProviders = async ( quotaState: providedQuotaState, inputState: providedInputState, width, + height, mouseEventsEnabled = false, config, uiActions, @@ -640,6 +641,7 @@ export const renderWithProviders = async ( quotaState?: Partial; inputState?: Partial; width?: number; + height?: number; mouseEventsEnabled?: boolean; config?: Config; uiActions?: Partial; @@ -813,6 +815,7 @@ export const renderWithProviders = async ( const renderResult = await render( wrapWithProviders(component), terminalWidth, + height, ); return { diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 574f406991..f4990e8cec 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -205,19 +205,27 @@ export const MainContent = () => { ], ); - const virtualizedData = useMemo( - () => [ - { type: 'header' as const }, - ...augmentedHistory.map((data, index) => ({ + const headerItem = useMemo(() => ({ type: 'header' as const }), []); + + const historyVirtualizedItems = useMemo( + () => + augmentedHistory.map((data, index) => ({ type: 'history' as const, item: data.item, element: historyItems[index], })), - { type: 'pending' as const }, - ], [augmentedHistory, historyItems], ); + const virtualizedData = useMemo( + () => [ + headerItem, + ...historyVirtualizedItems, + { type: 'pending' as const, pendingHistoryItems }, + ], + [headerItem, historyVirtualizedItems, pendingHistoryItems], + ); + const renderItem = useCallback( ({ item }: { item: (typeof virtualizedData)[number] }) => { if (item.type === 'header') { diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx index 586ce89ab2..1feadb8ba4 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { renderWithProviders } from '../../../test-utils/render.js'; import { createMockSettings } from '../../../test-utils/settings.js'; import { waitFor } from '../../../test-utils/async.js'; @@ -22,6 +22,7 @@ import type { SerializableConfirmationDetails, ToolResultDisplay, } from '../../types.js'; +import { VirtualizedListContext } from '../shared/VirtualizedList.js'; describe('DenseToolMessage', () => { const defaultProps = { @@ -563,6 +564,71 @@ describe('DenseToolMessage', () => { // Verify it shows the diff when expanded expect(lastFrame()).toContain('new line'); }); + + it('toggles expansion when header is clicked', async () => { + const toggleExpansion = vi.fn(); + const toggleItem = vi.fn(); + let registeredCallback: (() => void) | undefined; + + const MockVirtualizedListWrapper = ({ + children, + }: { + children: React.ReactNode; + }) => { + const itemKey = 'item-1'; + const mockListContext = { + toggleItem, + registerClickCallback: vi.fn((key, id, cb) => { + if (key === itemKey && id === 'toggle-call-1') { + registeredCallback = cb; + } + }), + unregisterClickCallback: vi.fn(), + registerInteractivity: vi.fn(), + setItemState: vi.fn(), + getItemState: vi.fn(), + isItemToggled: vi.fn().mockReturnValue(false), + }; + + return ( + + } + > + {children} + + ); + }; + + const { waitUntilReady } = await renderWithProviders( + + + , + { + toolActions: { + toggleExpansion, + }, + }, + ); + + await waitUntilReady(); + + await waitFor(() => expect(registeredCallback).toBeDefined()); + + // Trigger the registered callback manually (simulating VirtualizedList behavior) + if (registeredCallback) { + registeredCallback(); + } + + expect(toggleItem).toHaveBeenCalledWith('item-1'); + }); }); describe('Visual Regression', () => { diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx index edfec94a3f..d449703cf2 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useMemo, useContext } from 'react'; +import { useMemo, useContext, useCallback } from 'react'; import { Box, Text } from 'ink'; import { CoreToolCallStatus, @@ -39,6 +39,7 @@ import { colorizeCode } from '../../utils/CodeColorizer.js'; import { useToolActions } from '../../contexts/ToolActionsContext.js'; import { getFileExtension } from '../../utils/fileUtils.js'; import { VirtualizedListContext } from '../shared/VirtualizedList.js'; +import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js'; const PAYLOAD_MARGIN_LEFT = 6; const PAYLOAD_BORDER_CHROME_WIDTH = 4; // paddingX=1 (2 cols) + borders (2 cols) @@ -47,6 +48,7 @@ const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER; interface DenseToolMessageProps extends IndividualToolCallDisplay { itemKey?: string; + groupKey?: string; terminalWidth: number; availableTerminalHeight?: number; } @@ -262,6 +264,7 @@ function getGenericSuccessData( export const DenseToolMessage: React.FC = (props) => { const { itemKey, + groupKey, callId, name, status, @@ -275,7 +278,7 @@ export const DenseToolMessage: React.FC = (props) => { const settings = useSettings(); const isAlternateBuffer = useAlternateBuffer(); - const { isExpanded: isExpandedInContext } = useToolActions(); + const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions(); const virtualizedListContext = useContext(VirtualizedListContext); // Determine expansion state based on list context or fallback to tool actions @@ -286,6 +289,20 @@ export const DenseToolMessage: React.FC = (props) => { return isExpandedInContext ? isExpandedInContext(callId) : false; }, [itemKey, virtualizedListContext, isExpandedInContext, callId]); + const handleToggle = useCallback(() => { + if (itemKey && virtualizedListContext?.toggleItem) { + virtualizedListContext.toggleItem(itemKey); + } else if (toggleExpansion) { + toggleExpansion(callId); + } + }, [itemKey, virtualizedListContext, toggleExpansion, callId]); + + const clickableProps = useVirtualizedListClick( + groupKey ?? itemKey, + `toggle-${callId}`, + handleToggle, + ); + // Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails) const diff = useMemo((): FileDiff | undefined => { if (isFileDiff(resultDisplay)) return resultDisplay; @@ -432,7 +449,12 @@ export const DenseToolMessage: React.FC = (props) => { return ( - + @@ -467,7 +489,7 @@ export const DenseToolMessage: React.FC = (props) => { borderColor={theme.border.default} borderDimColor={true} maxWidth={Math.min( - PAYLOAD_MAX_WIDTH, + PAYLOAD_MAX_WIDTH + PAYLOAD_BORDER_CHROME_WIDTH, terminalWidth - PAYLOAD_MARGIN_LEFT, )} > @@ -478,13 +500,7 @@ export const DenseToolMessage: React.FC = (props) => { keyExtractor={keyExtractor} estimatedItemHeight={() => 1} hasFocus={false} - width={Math.min( - PAYLOAD_MAX_WIDTH, - terminalWidth - - PAYLOAD_MARGIN_LEFT - - PAYLOAD_BORDER_CHROME_WIDTH - - PAYLOAD_SCROLL_GUTTER, - )} + width="100%" /> )} diff --git a/packages/cli/src/ui/components/messages/DenseToolMessageInteractivity.test.tsx b/packages/cli/src/ui/components/messages/DenseToolMessageInteractivity.test.tsx new file mode 100644 index 0000000000..a2a7915da1 --- /dev/null +++ b/packages/cli/src/ui/components/messages/DenseToolMessageInteractivity.test.tsx @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { VirtualizedList } from '../shared/VirtualizedList.js'; +import { DenseToolMessage } from './DenseToolMessage.js'; +import { Box } from 'ink'; +import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core'; +import { createMockSettings } from '../../../test-utils/settings.js'; +import { describe, it, expect } from 'vitest'; + +describe('DenseToolMessage Interactivity in VirtualizedList', () => { + const keyExtractor = (item: { id: string }) => item.id; + + it('toggles expansion when header is clicked in a VirtualizedList', async () => { + const data = [{ id: '1' }]; + const diffResult = { + fileName: 'test.ts', + filePath: 'test.ts', + fileDiff: '--- test.ts\n+++ test.ts\n@@ -1,1 +1,1 @@\n-old\n+new', + diffStat: { model_added_lines: 1, model_removed_lines: 1 }, + originalContent: 'old', + newContent: 'new', + }; + + // We need to monitor if toggleItem is called on the list context + // Actually, VirtualizedList handles its own state. + // We can verify that it renders the payload after click. + + const { simulateClick, waitUntilReady, lastFrame } = + await renderWithProviders( + + 1} + renderItem={() => ( + ['resultDisplay'] + } + terminalWidth={80} + description="test" + confirmationDetails={undefined} + /> + )} + /> + , + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + mouseEventsEnabled: true, + }, + ); + + await waitUntilReady(); + + // Initially it should be collapsed (no payload shown because of alternate buffer mode) + expect(lastFrame()).toContain('edit'); + expect(lastFrame()).toContain('test.ts'); + expect(lastFrame()).not.toContain('new'); + + // Click on the first line (the header), avoiding the left margin + await simulateClick(10, 1); + + // Now it should be expanded and show the diff payload + await waitFor(() => expect(lastFrame()).toContain('new'), { + timeout: 5000, + }); + }); + + it('wakes up static DenseToolMessage and toggles on click', async () => { + const data = [{ id: '1' }]; + const diffResult = { + fileName: 'test.ts', + filePath: 'test.ts', + fileDiff: '--- test.ts\n+++ test.ts\n@@ -1,1 +1,1 @@\n-old\n+new', + diffStat: { model_added_lines: 1, model_removed_lines: 1 }, + originalContent: 'old', + newContent: 'new', + }; + + const { simulateClick, waitUntilReady, lastFrame } = + await renderWithProviders( + + 1} + renderItem={() => ( + ['resultDisplay'] + } + terminalWidth={80} + description="test" + confirmationDetails={undefined} + /> + )} + isStaticItem={() => true} // Force static rendering + /> + , + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + mouseEventsEnabled: true, + }, + ); + + await waitUntilReady(); + + // Static item should still show the header + expect(lastFrame()).toContain('edit'); + expect(lastFrame()).not.toContain('new'); + + // Click to wake up and toggle + await simulateClick(10, 1); + + // Should wake up and expand + await waitFor(() => expect(lastFrame()).toContain('new'), { + timeout: 5000, + }); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index eb2fa83235..8a5cc35bff 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -440,10 +440,11 @@ export const ToolGroupMessage: React.FC = ({ const commonProps = { ...tool, itemKey: uniqueItemKey, + groupKey: itemKey, availableTerminalHeight: availableTerminalHeightPerToolMessage, terminalWidth: contentWidth, emphasis: 'medium' as const, - isFirst: isCompact ? false : isFirstProp, + isFirst: isFirstProp, borderColor, borderDimColor, isExpandable, diff --git a/packages/cli/src/ui/components/messages/TopicMessage.test.tsx b/packages/cli/src/ui/components/messages/TopicMessage.test.tsx index 5da630cb86..6dff92d4e6 100644 --- a/packages/cli/src/ui/components/messages/TopicMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/TopicMessage.test.tsx @@ -14,6 +14,11 @@ import { CoreToolCallStatus, UPDATE_TOPIC_TOOL_NAME, } from '@google/gemini-cli-core'; +import { VirtualizedListContext } from '../shared/VirtualizedList.js'; +import { Box, type DOMElement, getBoundingBox } from 'ink'; +import { useMouse } from '../../contexts/MouseContext.js'; +import { useCallback, useRef } from 'react'; +import type React from 'react'; describe('', () => { const baseArgs = { @@ -30,21 +35,83 @@ describe('', () => { isExpanded?: (callId: string) => boolean; toggleExpansion?: (callId: string) => void; }, - ) => - renderWithProviders( - , + virtualizedListProps?: { + itemKey?: string; + }, + ) => { + const defaultItemKey = virtualizedListProps?.itemKey || 'test-topic-key'; + + const MockVirtualizedListWrapper: React.FC<{ + children: React.ReactNode; + }> = ({ children }) => { + const callbacks = useRef(new Map void>()); + + const mockListContext = { + registerInteractivity: vi.fn(), + setItemState: vi.fn(), + getItemState: vi.fn(), + isItemToggled: vi.fn().mockReturnValue(false), + toggleItem: vi.fn(), + registerClickCallback: vi.fn((key, id, cb) => { + if (key === defaultItemKey) callbacks.current.set(id, cb); + }), + unregisterClickCallback: vi.fn((key, id) => { + if (key === defaultItemKey) callbacks.current.delete(id); + }), + }; + + const containerRef = useRef(null); + const handleMouse = useCallback( + (event: { name: string; col: number; row: number }) => { + if (event.name === 'left-press' && containerRef.current) { + const { + x, + y, + width, + height: elHeight, + } = getBoundingBox(containerRef.current); + const mouseX = event.col - 1; + const mouseY = event.row - 1; + if ( + mouseX >= x && + mouseX < x + width && + mouseY >= y && + mouseY < y + elHeight + ) { + const cb = callbacks.current.get('toggle'); + if (cb) cb(); + } + } + }, + [callbacks], + ); + useMouse(handleMouse, { isActive: true }); + + return ( + + {children} + + ); + }; + + return renderWithProviders( + + + , { toolActions, mouseEventsEnabled: true }, ); + }; it('renders title and intent by default (collapsed)', async () => { const { lastFrame } = await renderTopic(baseArgs, 40); diff --git a/packages/cli/src/ui/components/messages/TopicMessage.tsx b/packages/cli/src/ui/components/messages/TopicMessage.tsx index e58e60f6e1..59be80602d 100644 --- a/packages/cli/src/ui/components/messages/TopicMessage.tsx +++ b/packages/cli/src/ui/components/messages/TopicMessage.tsx @@ -5,8 +5,8 @@ */ import type React from 'react'; -import { useEffect, useId, useRef, useCallback } from 'react'; -import { Box, Text, type DOMElement } from 'ink'; +import { useEffect, useId, useCallback } from 'react'; +import { Box, Text } from 'ink'; import { UPDATE_TOPIC_TOOL_NAME, UPDATE_TOPIC_DISPLAY_NAME, @@ -18,12 +18,13 @@ import type { IndividualToolCallDisplay } from '../../types.js'; import { theme } from '../../semantic-colors.js'; import { useOverflowActions } from '../../contexts/OverflowContext.js'; import { useToolActions } from '../../contexts/ToolActionsContext.js'; -import { useMouseClick } from '../../hooks/useMouseClick.js'; +import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js'; interface TopicMessageProps extends IndividualToolCallDisplay { terminalWidth: number; availableTerminalHeight?: number; isExpandable?: boolean; + itemKey?: string; } export const isTopicTool = (name: string): boolean => @@ -34,6 +35,7 @@ export const TopicMessage: React.FC = ({ args, availableTerminalHeight, isExpandable = true, + itemKey, }) => { const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions(); @@ -47,7 +49,6 @@ export const TopicMessage: React.FC = ({ const overflowActions = useOverflowActions(); const uniqueId = useId(); const overflowId = `topic-${uniqueId}`; - const containerRef = useRef(null); const rawTitle = args?.[TOPIC_PARAM_TITLE]; const title = typeof rawTitle === 'string' ? rawTitle : undefined; @@ -75,9 +76,14 @@ export const TopicMessage: React.FC = ({ } }, [toggleExpansion, hasExtraSummary, callId]); - useMouseClick(containerRef, handleToggle, { - isActive: isExpandable && hasExtraSummary, - }); + const clickableProps = useVirtualizedListClick( + itemKey, + 'toggle', + handleToggle, + { + isActive: isExpandable && hasExtraSummary, + }, + ); useEffect(() => { // Only register if there is more content (summary) and it's currently hidden @@ -95,7 +101,7 @@ export const TopicMessage: React.FC = ({ }, [isExpandable, hasExtraSummary, isExpanded, overflowActions, overflowId]); return ( - + {title || 'Topic'} diff --git a/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx b/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx index 7304c4addc..4f98175462 100644 --- a/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx @@ -345,10 +345,6 @@ function FixedVirtualizedList( prevScrollTop.current = rawDerivedActualScrollTop; prevContainerHeight.current = scrollableContainerHeight; - const scrollTop = currentIsStickingToBottom - ? Number.MAX_SAFE_INTEGER - : derivedActualScrollTop; - const startIndex = Math.max( 0, Math.floor(derivedActualScrollTop / itemHeight) - 1, @@ -362,16 +358,31 @@ function FixedVirtualizedList( Math.ceil((derivedActualScrollTop + viewHeightForEndIndex) / itemHeight), ); + const culledHeight = useMemo(() => { + if ( + overflowToBackbuffer && + typeof maxScrollbackLength === 'number' && + maxScrollbackLength > 0 + ) { + // Keep maxScrollbackLength items before the viewport. + // We add 1 to startIndex to account for the 1-item overscan it includes. + const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength); + return targetIndex * itemHeight; + } + return 0; + }, [overflowToBackbuffer, maxScrollbackLength, startIndex, itemHeight]); + + const scrollTop = currentIsStickingToBottom + ? Number.MAX_SAFE_INTEGER + : Math.max(0, derivedActualScrollTop - culledHeight); + const renderRangeStart = (() => { if (renderStatic) return 0; if (overflowToBackbuffer) { if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) { - const targetOffset = Math.max( - 0, - derivedActualScrollTop - maxScrollbackLength, - ); - const index = Math.floor(targetOffset / itemHeight); - return Math.max(0, index - 1); + // Render from the culled boundary. + const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength); + return targetIndex; } return 0; } @@ -380,7 +391,10 @@ function FixedVirtualizedList( const renderRangeEnd = renderStatic ? maxEndIndex : endIndex; - const topSpacerHeight = renderRangeStart * itemHeight; + const topSpacerHeight = Math.max( + 0, + renderRangeStart * itemHeight - culledHeight, + ); const bottomSpacerHeight = renderStatic ? 0 : totalHeight - (renderRangeEnd + 1) * itemHeight; @@ -446,7 +460,11 @@ function FixedVirtualizedList( ); }, scrollTo: (offset: number) => { - const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); + const effectiveTotalHeight = totalHeight - culledHeight; + const maxScroll = Math.max( + 0, + effectiveTotalHeight - scrollableContainerHeight, + ); if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) { setIsStickingToBottom(true); setPendingScrollTop(Number.MAX_SAFE_INTEGER); @@ -458,7 +476,7 @@ function FixedVirtualizedList( } } else { setIsStickingToBottom(false); - const newScrollTop = Math.max(0, offset); + const newScrollTop = Math.max(0, offset + culledHeight); setPendingScrollTop(newScrollTop); setScrollAnchor(getAnchorForScrollTop(newScrollTop)); } @@ -530,10 +548,17 @@ function FixedVirtualizedList( }, getScrollIndex: () => scrollAnchor.index, getScrollState: () => { - const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); + const effectiveTotalHeight = totalHeight - culledHeight; + const maxScroll = Math.max( + 0, + effectiveTotalHeight - scrollableContainerHeight, + ); return { - scrollTop: Math.min(getScrollTop(), maxScroll), - scrollHeight: totalHeight, + scrollTop: Math.min( + Math.max(0, getScrollTop() - culledHeight), + maxScroll, + ), + scrollHeight: effectiveTotalHeight, innerHeight: scrollableContainerHeight, }; }, @@ -547,6 +572,7 @@ function FixedVirtualizedList( getScrollTop, setPendingScrollTop, itemHeight, + culledHeight, ], ); @@ -554,7 +580,11 @@ function FixedVirtualizedList( backbuffer regression', () => { + const keyExtractor = (item: string) => item; + + it('provides a sufficient history buffer regardless of height estimation', async () => { + // 1000 items, each 1 line high. + const data = Array.from( + { length: 1000 }, + (_, i) => `Item ${String(i).padStart(3, '0')}`, + ); + const ref = createRef>(); + + const { waitUntilReady, unmount } = await render( + + ( + + {item} + + )} + keyExtractor={keyExtractor} + estimatedItemHeight={() => 10} + initialScrollIndex={999} + overflowToBackbuffer={true} + renderStatic={true} + maxScrollbackLength={150} + /> + , + ); + + await waitUntilReady(); + + try { + const state = ref.current?.getScrollState(); + // Viewport is 50, backbuffer is 150. + // Total scrollHeight should be AT LEAST 200 lines. + // Since our fix is item-based, and items are 1 line high, it should be + // exactly or very close to 200. + expect(state?.scrollHeight).toBeGreaterThanOrEqual(200); + expect(state?.innerHeight).toBe(50); + } finally { + unmount(); + } + }); +}); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.fallback.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.fallback.test.tsx new file mode 100644 index 0000000000..45e7f69d32 --- /dev/null +++ b/packages/cli/src/ui/components/shared/VirtualizedList.fallback.test.tsx @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders as render } from '../../../test-utils/render.js'; +import { VirtualizedList } from './VirtualizedList.js'; +import { Text, Box } from 'ink'; +import { describe, it, expect } from 'vitest'; + +describe(' fallback', () => { + const keyExtractor = (item: string) => item; + + it('uses default maxScrollbackLength of 1000 when not provided', async () => { + const longData = Array.from({ length: 2000 }, (_, i) => `Item ${i}`); + const renderedIndices = new Set(); + const renderItem1px = ({ + item, + index, + }: { + item: string; + index: number; + }) => { + renderedIndices.add(index); + return ( + + {item} + + ); + }; + + const { unmount } = await render( + + 1} + initialScrollIndex={1999} + overflowToBackbuffer={true} + // maxScrollbackLength is NOT provided + /> + , + ); + + // Viewport height is 10. + // initialScrollIndex is 1999. + // actualScrollTop = 2000 - 10 = 1990. + // Default fallback maxScrollbackLength = 1000. + // targetOffset = 1990 - 1000 = 990. + // renderRangeStart should be around 989/990. + // Items below 980 should NOT be rendered. + // Items around 1000 SHOULD be rendered. + + // Check viewport items are rendered + expect(renderedIndices.has(1995)).toBe(true); + expect(renderedIndices.has(1999)).toBe(true); + + // Check items in maxScrollbackLength (1000) are rendered + expect(renderedIndices.has(1000)).toBe(true); + expect(renderedIndices.has(1100)).toBe(true); + + // Check items beyond maxScrollbackLength are NOT rendered + expect(renderedIndices.has(0)).toBe(false); + expect(renderedIndices.has(500)).toBe(false); + expect(renderedIndices.has(900)).toBe(false); + + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx index efc34279f2..594b56eeb5 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx @@ -377,6 +377,46 @@ describe('', () => { unmount(); }); + it('crops the document height when maxScrollbackLength is exceeded', async () => { + const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`); + const ref = createRef>(); + const { unmount, waitUntilReady } = await render( + + ( + + {item} + + )} + keyExtractor={(item) => item} + estimatedItemHeight={() => 1} + initialScrollIndex={99} + overflowToBackbuffer={true} + maxScrollbackLength={10} + /> + , + ); + + await waitUntilReady(); + + // Viewport height is 10. + // maxScrollbackLength = 10. + // Total expected scrollHeight = 10 + 10 = 20. + const state = ref.current?.getScrollState(); + expect(state?.scrollHeight).toBe(20); + + // The top of the projected document (offset 0) should correspond to absolute offset 80. + // getAnchorForScrollTop(80) will return index 90 because it's near the bottom and uses a bottom anchor. + await act(async () => { + ref.current?.scrollTo(0); + }); + expect(ref.current?.getScrollIndex()).toBe(90); + + unmount(); + }); + it('does not forget item heights when items are prepended', async () => { const ref = createRef>(); const data = ['Item 1', 'Item 2']; diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index 941d1db60a..1611a970b0 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -27,6 +27,7 @@ import { StaticRender, getBoundingBox, getScrollTop as getInkScrollTop, + useApp, } from 'ink'; import { useMouse, @@ -38,6 +39,11 @@ import { debugLogger } from '@google/gemini-cli-core'; export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER; +export interface ClickableArea { + id: string; + box: { x: number; y: number; width: number; height: number }; +} + export interface VirtualizedListContextValue { registerInteractivity: ( itemKey: string, @@ -47,6 +53,12 @@ export interface VirtualizedListContextValue { getItemState: (itemKey: string, stateKey: string) => unknown; isItemToggled: (itemKey: string) => boolean; toggleItem: (itemKey: string) => void; + registerClickCallback: ( + itemKey: string, + areaId: string, + callback: () => void, + ) => void; + unregisterClickCallback: (itemKey: string, areaId: string) => void; } export const VirtualizedListContext = @@ -108,6 +120,60 @@ function findLastIndex( return -1; } +const extractClickableAreas = (rootNode: DOMElement): ClickableArea[] => { + const rootBox = getBoundingBox(rootNode); + const results: ClickableArea[] = []; + + const traverse = (current: DOMElement) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const currentHack = current as unknown as { + attributes?: Record; + yogaNode?: { + getComputedWidth: () => number; + getComputedHeight: () => number; + }; + }; + const attributes = currentHack.attributes; + + const clickableId = + attributes?.['data-clickable'] || + attributes?.['id'] || + attributes?.['name'] || + attributes?.['clickableId'] || + attributes?.['nodeType']; + + if (clickableId && typeof clickableId === 'string') { + const childBox = getBoundingBox(current); + + // If getBoundingBox returns null/0 for dimensions, try to look at internal layout if available + const width = childBox.width ?? currentHack.yogaNode?.getComputedWidth(); + const height = + childBox.height ?? currentHack.yogaNode?.getComputedHeight(); + + results.push({ + id: clickableId, + box: { + x: (childBox.x ?? 0) - (rootBox.x ?? 0), + y: (childBox.y ?? 0) - (rootBox.y ?? 0), + width: width ?? 0, + height: height ?? 0, + }, + }); + } + + for (const child of current.childNodes || []) { + if (child.nodeName && child.nodeName !== '#text') { + // Ensure it's a DOMElement + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + traverse(child as any as DOMElement); + } + } + }; + + traverse(rootNode); + return results; +}; + const VirtualizedListItem = memo( ({ content, @@ -128,7 +194,14 @@ const VirtualizedListItem = memo( ); return ( - + {content} ); @@ -169,9 +242,13 @@ function VirtualizedList( overflowToBackbuffer, scrollbar = true, stableScrollback, - maxScrollbackLength, + maxScrollbackLength: maxScrollbackLengthProp, } = props; + const app = useApp(); + const maxScrollbackLength = + maxScrollbackLengthProp ?? app.options.maxScrollbackLength ?? 1000; + const [scrollAnchor, setScrollAnchor] = useState<{ index: number; offset: number; @@ -229,6 +306,8 @@ function VirtualizedList( ); const itemStates = useRef(new Map>()); const [toggledKeys, setToggledKeys] = useState(() => new Set()); + const toggledKeysRef = useRef(toggledKeys); + toggledKeysRef.current = toggledKeys; const [temporarilyInteractiveIndexes, setTemporarilyInteractiveIndexes] = useState(() => new Set()); const renderedAsStatic = useRef([]); @@ -238,6 +317,11 @@ function VirtualizedList( event: MouseEvent; } | null>(null); + const itemClickableAreas = useRef>(new Map()); + const clickCallbacks = useRef void>>>( + new Map(), + ); + const virtualizedListContextValue = useMemo( () => ({ registerInteractivity: (itemKey, options) => { @@ -265,6 +349,23 @@ function VirtualizedList( return next; }); }, + registerClickCallback: (itemKey, areaId, callback) => { + let itemMap = clickCallbacks.current.get(itemKey); + if (!itemMap) { + itemMap = new Map(); + clickCallbacks.current.set(itemKey, itemMap); + } + itemMap.set(areaId, callback); + }, + unregisterClickCallback: (itemKey, areaId) => { + const itemMap = clickCallbacks.current.get(itemKey); + if (itemMap) { + itemMap.delete(areaId); + if (itemMap.size === 0) { + clickCallbacks.current.delete(itemKey); + } + } + }, }), [toggledKeys], ); @@ -285,7 +386,13 @@ function VirtualizedList( const onStaticRender = useCallback( (index: number, key: string, node: DOMElement) => { - const height = Math.round(getBoundingBox(node).height); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const currentHack = node as unknown as { + yogaNode?: { + getComputedHeight: () => number; + }; + }; + const height = Math.round(currentHack.yogaNode?.getComputedHeight() ?? 0); if ( height > 0 && (state.current.measuredHeights[index] !== height || @@ -295,6 +402,26 @@ function VirtualizedList( state.current.measuredKeys[index] = key; setMeasurementVersion((v) => v + 1); } + + if (itemClickableAreas.current.has(key)) { + // If we already have areas for this item, don't re-extract. + // This is especially important for static items because children might + // have null dimensions in some environments (like tests) or might be + // cleared from the DOM after caching. + return; + } + + const areas = extractClickableAreas(node); + if (areas.length > 0) { + // In some test environments, dimensions might be null/0. + // We only overwrite if we get valid dimensions or if we don't have areas yet. + const hasValidDimensions = areas.some( + (a) => a.box.width > 0 || a.box.height > 0, + ); + if (hasValidDimensions || !itemClickableAreas.current.has(key)) { + itemClickableAreas.current.set(key, areas); + } + } }, [], ); @@ -324,6 +451,14 @@ function VirtualizedList( state.current.measuredKeys[index] = key; changed = true; } + if (height > 0) { + const areas = extractClickableAreas(entry.target); + if (areas.length > 0) { + itemClickableAreas.current.set(key, areas); + } else { + itemClickableAreas.current.delete(key); + } + } } } if (changed) { @@ -497,9 +632,38 @@ function VirtualizedList( estimatedItemHeight, ]); + const startIndex = Math.max( + 0, + findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1, + ); + const viewHeightForEndIndex = + scrollableContainerHeight > 0 ? scrollableContainerHeight : 50; + const endIndexOffset = offsets.findIndex( + (offset) => offset > actualScrollTop + viewHeightForEndIndex, + ); + const endIndex = + endIndexOffset === -1 + ? data.length - 1 + : Math.min(data.length - 1, endIndexOffset); + + const culledHeight = useMemo(() => { + if ( + overflowToBackbuffer && + typeof maxScrollbackLength === 'number' && + maxScrollbackLength > 0 + ) { + // Keep maxScrollbackLength items before the viewport to satisfy the backbuffer budget. + // We use items as a proxy for lines to be robust against estimation errors. + // We add 1 to startIndex to account for the 1-item overscan it includes. + const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength); + return offsets[targetIndex] ?? 0; + } + return 0; + }, [overflowToBackbuffer, maxScrollbackLength, startIndex, offsets]); + const scrollTop = isStickingToBottom ? Number.MAX_SAFE_INTEGER - : actualScrollTop; + : actualScrollTop - culledHeight; useLayoutEffect(() => { if (state.current.prevDataLength === -1) { @@ -660,20 +824,6 @@ function VirtualizedList( props.targetScrollIndex, ]); - const startIndex = Math.max( - 0, - findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1, - ); - const viewHeightForEndIndex = - scrollableContainerHeight > 0 ? scrollableContainerHeight : 50; - const endIndexOffset = offsets.findIndex( - (offset) => offset > actualScrollTop + viewHeightForEndIndex, - ); - const endIndex = - endIndexOffset === -1 - ? data.length - 1 - : Math.min(data.length - 1, endIndexOffset); - useEffect(() => { setTemporarilyInteractiveIndexes((prev) => { if (prev.size === 0) return prev; @@ -692,25 +842,17 @@ function VirtualizedList( const renderRangeStart = useMemo(() => { if (overflowToBackbuffer) { if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) { - const targetOffset = Math.max(0, actualScrollTop - maxScrollbackLength); - const index = findLastIndex( - offsets, - (offset) => offset <= targetOffset, - ); - return Math.max(0, index - 1); + // We render everything from the culledHeight boundary to ensure the + // backbuffer is fully populated. + const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength); + return targetIndex; } return 0; } return startIndex; - }, [ - overflowToBackbuffer, - maxScrollbackLength, - actualScrollTop, - offsets, - startIndex, - ]); + }, [overflowToBackbuffer, maxScrollbackLength, startIndex]); - const topSpacerHeight = offsets[renderRangeStart]; + const topSpacerHeight = Math.max(0, offsets[renderRangeStart] - culledHeight); let renderRangeEnd = endIndex; if (maxRenderRangeEnd.current > endIndex) { @@ -735,8 +877,10 @@ function VirtualizedList( } maxRenderRangeEnd.current = renderRangeEnd; - const bottomSpacerHeight = - totalHeight - (offsets[renderRangeEnd + 1] ?? totalHeight); + const bottomSpacerHeight = Math.max( + 0, + 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. @@ -744,10 +888,23 @@ function VirtualizedList( // BUT the initial render MUST render *something* with a width if width prop is provided to avoid layout shifts. // We MUST wait for containerHeight > 0 before rendering, especially if renderStatic is true. // If containerHeight is 0, we will misclassify items as isOutsideViewport and permanently print them to StaticRender! + const itemCache = useRef( + new Map< + string, + { + item: T; + element: React.ReactElement; + shouldBeStatic: boolean; + width: number | string | undefined; + containerWidth: number; + index: number; + isToggled: boolean; + } + >(), + ); + const isReady = - containerHeight > 0 || - process.env['NODE_ENV'] === 'test' || - (width !== undefined && typeof width === 'number'); + containerHeight > 0 || (width !== undefined && typeof width === 'number'); const renderedItems = useMemo(() => { if (!isReady) { @@ -768,31 +925,57 @@ function VirtualizedList( const shouldBeStatic = isStaticByDefault && !isTemporarilyInteractive; renderedAsStatic.current[i] = shouldBeStatic; - const content = renderItem({ item, index: i }); const key = keyExtractor(item, i); + const cached = itemCache.current.get(key); - if (shouldBeStatic) { - items.push( - onStaticRender(i, key, node)} - > - {() => content} - , - ); + const isToggled = toggledKeys.has(key); + + let contentElement: React.ReactElement; + if ( + cached && + cached.item === item && + cached.shouldBeStatic === shouldBeStatic && + cached.width === width && + cached.containerWidth === containerWidth && + cached.index === i && + cached.isToggled === isToggled + ) { + contentElement = cached.element; } else { - items.push( - , - ); + if (shouldBeStatic) { + contentElement = ( + onStaticRender(i, key, node)} + > + {() => renderItem({ item, index: i })} + + ); + } else { + contentElement = ( + + ); + } + itemCache.current.set(key, { + item, + element: contentElement, + shouldBeStatic, + width, + containerWidth, + index: i, + isToggled, + }); } + items.push(contentElement); + if ( !renderStatic && state.current.measuredKeys[i] !== key && @@ -811,6 +994,30 @@ function VirtualizedList( } } } + + // Cleanup cache to avoid memory leaks + if ( + itemCache.current.size > + Math.max(100, (renderRangeEnd - renderRangeStart + 1) * 3) + ) { + const keysToKeep = new Set(); + for ( + let i = Math.max(0, renderRangeStart - 50); + i <= Math.min(data.length - 1, renderRangeEnd + 50); + i++ + ) { + const item = data[i]; + if (item) { + keysToKeep.add(keyExtractor(item, i)); + } + } + for (const key of itemCache.current.keys()) { + if (!keysToKeep.has(key)) { + itemCache.current.delete(key); + } + } + } + return items; }, [ isReady, @@ -829,6 +1036,7 @@ function VirtualizedList( estimatedItemHeight, temporarilyInteractiveIndexes, onStaticRender, + toggledKeys, ]); const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop); @@ -873,31 +1081,88 @@ function VirtualizedList( ) { // 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 trueScrollTop = + getInkScrollTop(state.current.container) + culledHeight; 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); + // Determine if the click was exactly on the first line of the item + const itemStartY = offsets[index] ?? 0; + const isFirstLineClick = isClick && absoluteY === itemStartY; + + // Hit-test against explicitly defined clickable areas inside the item + if (isClick && itemClickableAreas.current.has(itemKey)) { + const mouseRelativeY = absoluteY - itemStartY; + const areas = itemClickableAreas.current.get(itemKey) ?? []; + + for (const area of areas) { + if ( + relativeX >= area.box.x && + relativeX < area.box.x + area.box.width && + mouseRelativeY >= area.box.y && + mouseRelativeY < area.box.y + area.box.height + ) { + debugLogger.log( + `[Mouse] Clicked inside tagged area: ${area.id} in itemKey: ${itemKey}`, + ); + + if (renderedAsStatic.current[index]) { + debugLogger.log( + `[Mouse] Waking up static item index=${index} due to click on area=${area.id}`, + ); + setTemporarilyInteractiveIndexes((prev) => { + const next = new Set(prev); + next.add(index); + return next; + }); + setPendingReplayEvent({ index, event }); + // Also toggle immediately if it was a first-line click + if (isFirstLineClick) { + virtualizedListContextValue.toggleItem(itemKey); + } + return; + } + + const callback = clickCallbacks.current + .get(itemKey) + ?.get(area.id); + if (callback) { + debugLogger.log( + `[Mouse] Dispatching click callback for area=${area.id} in itemKey=${itemKey}`, + ); + callback(); + return; + } + + break; + } + } + } + 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) { + if (renderedAsStatic.current[index]) { + debugLogger.log( + `[Mouse] Waking up static item index=${index} due to first-line click`, + ); + setTemporarilyInteractiveIndexes((prev) => { + const next = new Set(prev); + next.add(index); + return next; + }); + setPendingReplayEvent({ index, event }); + return; + } debugLogger.log( `[Mouse] First line click detected. Toggling itemKey=${itemKey}.`, ); @@ -920,7 +1185,7 @@ function VirtualizedList( } } }, - [offsets, data, keyExtractor, virtualizedListContextValue], + [offsets, data, keyExtractor, virtualizedListContextValue, culledHeight], ); useMouse(handleMouse, { isActive: true }); @@ -951,7 +1216,11 @@ function VirtualizedList( ); }, scrollTo: (offset: number) => { - const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); + const effectiveTotalHeight = totalHeight - culledHeight; + const maxScroll = Math.max( + 0, + effectiveTotalHeight - scrollableContainerHeight, + ); if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) { setIsStickingToBottom(true); setPendingScrollTop(Number.MAX_SAFE_INTEGER); @@ -963,7 +1232,7 @@ function VirtualizedList( } } else { setIsStickingToBottom(false); - const newScrollTop = Math.max(0, offset); + const newScrollTop = Math.max(0, offset + culledHeight); setPendingScrollTop(newScrollTop); setScrollAnchor( getAnchorForScrollTop( @@ -1058,10 +1327,17 @@ function VirtualizedList( }, getScrollIndex: () => scrollAnchor.index, getScrollState: () => { - const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); + const effectiveTotalHeight = totalHeight - culledHeight; + const maxScroll = Math.max( + 0, + effectiveTotalHeight - scrollableContainerHeight, + ); return { - scrollTop: Math.min(getScrollTop(), maxScroll), - scrollHeight: totalHeight, + scrollTop: Math.min( + Math.max(0, getScrollTop() - culledHeight), + maxScroll, + ), + scrollHeight: effectiveTotalHeight, innerHeight: scrollableContainerHeight, }; }, @@ -1075,6 +1351,7 @@ function VirtualizedList( scrollableContainerHeight, getScrollTop, setPendingScrollTop, + culledHeight, ], ); @@ -1084,7 +1361,11 @@ function VirtualizedList( ref={containerRefCallback} overflowY="scroll" overflowX="hidden" - scrollTop={scrollTop} + scrollTop={ + isStickingToBottom + ? Number.MAX_SAFE_INTEGER + : Math.max(0, getScrollTop() - culledHeight) + } scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary} backgroundColor={props.backgroundColor} width="100%" diff --git a/packages/cli/src/ui/components/shared/VirtualizedListInteractivity.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedListInteractivity.test.tsx new file mode 100644 index 0000000000..53dddf3eb0 --- /dev/null +++ b/packages/cli/src/ui/components/shared/VirtualizedListInteractivity.test.tsx @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { VirtualizedList } from './VirtualizedList.js'; +import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js'; +import { Box, Text } from 'ink'; +import { useState } from 'react'; +import { describe, it, expect, vi } from 'vitest'; + +describe('VirtualizedList Interactivity', () => { + const keyExtractor = (item: { id: string }) => item.id; + + const InteractiveItem = ({ + id, + onToggle, + }: { + id: string; + onToggle: () => void; + }) => { + const { ref } = useVirtualizedListClick(id, 'toggle', onToggle); + return ( + + Item {id} + + ); + }; + + it('triggers callback when tagged area is clicked', async () => { + const onToggle = vi.fn(); + const data = [{ id: '1' }]; + + const { simulateClick, waitUntilReady, lastFrame } = + await renderWithProviders( + + 1} + renderItem={({ item }) => ( + + )} + /> + , + { mouseEventsEnabled: true }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('Item 1'); + + // Simulate click on the first line (Item 1) + // VirtualizedList is at (0,0) and Item 1 is at (0,0) relative to list. + // simulateClick expects absolute coordinates. + // In renderWithProviders, the wrapper Box is at (0,0)? + // Actually getBoundingBox(state.current.container) in VirtualizedList will give absolute coords. + await simulateClick(1, 1); + + await waitFor(() => expect(onToggle).toHaveBeenCalled()); + }); + + it('wakes up static item and triggers callback on click', async () => { + const onToggle = vi.fn(); + const data = [{ id: '1' }]; + + const TestComponent = () => { + const [isStatic, setIsStatic] = useState(false); + return ( + + 1} + renderItem={({ item }) => ( + + )} + isStaticItem={() => isStatic} + /> + { + if (el) { + setTimeout(() => setIsStatic(true), 100); + } + }} + /> + + ); + }; + + const { simulateClick, waitUntilReady, lastFrame } = + await renderWithProviders(, { + mouseEventsEnabled: true, + }); + + await waitUntilReady(); + // Wait for the transition to static to happen and be recorded + await new Promise((r) => setTimeout(r, 200)); + + expect(lastFrame()).toContain('Item 1'); + + // Click to wake up and trigger + await simulateClick(1, 1); + + await waitFor(() => expect(onToggle).toHaveBeenCalled()); + }); +}); diff --git a/packages/cli/src/ui/hooks/useVirtualizedListClick.ts b/packages/cli/src/ui/hooks/useVirtualizedListClick.ts new file mode 100644 index 0000000000..8981e20b1d --- /dev/null +++ b/packages/cli/src/ui/hooks/useVirtualizedListClick.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useContext, useEffect, useCallback } from 'react'; +import { VirtualizedListContext } from '../components/shared/VirtualizedList.js'; +import { type DOMElement } from 'ink'; + +/** + * A hook to register a clickable area within a VirtualizedList item. + * This works seamlessly with both static and dynamic rendering. + * + * @param itemKey The unique key for the list item. + * @param areaId A unique identifier for this clickable area within the list item. + * @param callback The function to execute when the area is clicked. + * @param options Configuration options. + * @returns Props to spread onto the clickable component. + */ +export const useVirtualizedListClick = ( + itemKey: string | undefined, + areaId: string, + callback: () => void, + options: { isActive?: boolean } = {}, +) => { + const { isActive = true } = options; + const listContext = useContext(VirtualizedListContext); + + useEffect(() => { + if (isActive && listContext && itemKey) { + listContext.registerClickCallback(itemKey, areaId, callback); + return () => { + listContext.unregisterClickCallback(itemKey, areaId); + }; + } + return undefined; + }, [isActive, listContext, itemKey, areaId, callback]); + + const ref = useCallback( + (el: DOMElement | null) => { + if (el) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const elHack = el as unknown as { attributes: Record }; + if (!elHack.attributes) { + elHack.attributes = {}; + } + elHack.attributes['data-clickable'] = areaId; + } + }, + [areaId], + ); + + return { ref }; +};