Checkpoint VirtualizedListClick

This commit is contained in:
jacob314
2026-05-14 13:20:05 -07:00
parent 8f7d79afa0
commit 9e76fa1276
19 changed files with 1101 additions and 143 deletions
+5 -5
View File
@@ -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",
+2 -2
View File
@@ -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",
+4
View File
@@ -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 `<Static>` (which is printed above the
application layout and takes no space in the flex container), the custom
`<StaticRender>` component preserves its layout and _does_ take up its
measured height in the active flex container.
## Testing
+1 -1
View File
@@ -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",
+7 -4
View File
@@ -394,18 +394,18 @@ const instances: InkInstance[] = [];
export const render = async (
tree: React.ReactElement,
terminalWidth?: number,
terminalHeight?: number,
): Promise<
Omit<RenderInstance, 'capturedOverflowState' | 'capturedOverflowActions'>
> => {
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<QuotaState>;
inputState?: Partial<InputState>;
width?: number;
height?: number;
mouseEventsEnabled?: boolean;
config?: Config;
uiActions?: Partial<UIActions>;
@@ -813,6 +815,7 @@ export const renderWithProviders = async (
const renderResult = await render(
wrapWithProviders(component),
terminalWidth,
height,
);
return {
+14 -6
View File
@@ -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') {
@@ -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 (
<VirtualizedListContext.Provider
value={
mockListContext as unknown as React.ContextType<
typeof VirtualizedListContext
>
}
>
{children}
</VirtualizedListContext.Provider>
);
};
const { waitUntilReady } = await renderWithProviders(
<MockVirtualizedListWrapper>
<DenseToolMessage
{...defaultProps}
callId="call-1"
itemKey="item-1"
/>
</MockVirtualizedListWrapper>,
{
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', () => {
@@ -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<DenseToolMessageProps> = (props) => {
const {
itemKey,
groupKey,
callId,
name,
status,
@@ -275,7 +278,7 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (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<DenseToolMessageProps> = (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<DenseToolMessageProps> = (props) => {
return (
<Box flexDirection="column">
<Box marginLeft={2} flexDirection="row" flexWrap="wrap">
<Box
ref={clickableProps.ref}
marginLeft={2}
flexDirection="row"
flexWrap="wrap"
>
<Box flexDirection="row" flexShrink={1}>
<ToolStatusIndicator status={status} name={name} />
<Box maxWidth={25} flexShrink={0} flexGrow={0}>
@@ -467,7 +489,7 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (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<DenseToolMessageProps> = (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%"
/>
</Box>
)}
@@ -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(
<Box height={20} width={80}>
<VirtualizedList
data={data}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
renderItem={() => (
<DenseToolMessage
callId="call-1"
itemKey="1-tool-call-1"
groupKey="1"
name="edit"
status={CoreToolCallStatus.Success}
resultDisplay={
diffResult as unknown as React.ComponentProps<
typeof DenseToolMessage
>['resultDisplay']
}
terminalWidth={80}
description="test"
confirmationDetails={undefined}
/>
)}
/>
</Box>,
{
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(
<Box height={20} width={80}>
<VirtualizedList
data={data}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
renderItem={() => (
<DenseToolMessage
callId="call-1"
itemKey="1-tool-call-1"
groupKey="1"
name="edit"
status={CoreToolCallStatus.Success}
resultDisplay={
diffResult as unknown as React.ComponentProps<
typeof DenseToolMessage
>['resultDisplay']
}
terminalWidth={80}
description="test"
confirmationDetails={undefined}
/>
)}
isStaticItem={() => true} // Force static rendering
/>
</Box>,
{
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,
});
});
});
@@ -440,10 +440,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const commonProps = {
...tool,
itemKey: uniqueItemKey,
groupKey: itemKey,
availableTerminalHeight: availableTerminalHeightPerToolMessage,
terminalWidth: contentWidth,
emphasis: 'medium' as const,
isFirst: isCompact ? false : isFirstProp,
isFirst: isFirstProp,
borderColor,
borderDimColor,
isExpandable,
@@ -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('<TopicMessage />', () => {
const baseArgs = {
@@ -30,21 +35,83 @@ describe('<TopicMessage />', () => {
isExpanded?: (callId: string) => boolean;
toggleExpansion?: (callId: string) => void;
},
) =>
renderWithProviders(
<TopicMessage
args={args}
terminalWidth={80}
availableTerminalHeight={height}
callId="test-topic"
name={UPDATE_TOPIC_TOOL_NAME}
description="Updating topic"
status={CoreToolCallStatus.Success}
confirmationDetails={undefined}
resultDisplay={undefined}
/>,
virtualizedListProps?: {
itemKey?: string;
},
) => {
const defaultItemKey = virtualizedListProps?.itemKey || 'test-topic-key';
const MockVirtualizedListWrapper: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const callbacks = useRef(new Map<string, () => 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<DOMElement>(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 (
<VirtualizedListContext.Provider value={mockListContext}>
<Box ref={containerRef}>{children}</Box>
</VirtualizedListContext.Provider>
);
};
return renderWithProviders(
<MockVirtualizedListWrapper>
<TopicMessage
args={args}
itemKey={defaultItemKey}
terminalWidth={80}
availableTerminalHeight={height}
callId="test-topic"
name={UPDATE_TOPIC_TOOL_NAME}
description="Updating topic"
status={CoreToolCallStatus.Success}
confirmationDetails={undefined}
resultDisplay={undefined}
/>
</MockVirtualizedListWrapper>,
{ toolActions, mouseEventsEnabled: true },
);
};
it('renders title and intent by default (collapsed)', async () => {
const { lastFrame } = await renderTopic(baseArgs, 40);
@@ -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<TopicMessageProps> = ({
args,
availableTerminalHeight,
isExpandable = true,
itemKey,
}) => {
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
@@ -47,7 +49,6 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
const overflowActions = useOverflowActions();
const uniqueId = useId();
const overflowId = `topic-${uniqueId}`;
const containerRef = useRef<DOMElement>(null);
const rawTitle = args?.[TOPIC_PARAM_TITLE];
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
@@ -75,9 +76,14 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
}
}, [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<TopicMessageProps> = ({
}, [isExpandable, hasExtraSummary, isExpanded, overflowActions, overflowId]);
return (
<Box ref={containerRef} flexDirection="column" marginLeft={2}>
<Box ref={clickableProps.ref} flexDirection="column" marginLeft={2}>
<Box flexDirection="row" flexWrap="wrap">
<Text color={theme.text.primary} bold wrap="truncate-end">
{title || 'Topic'}
@@ -345,10 +345,6 @@ function FixedVirtualizedList<T>(
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<T>(
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<T>(
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<T>(
);
},
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<T>(
}
} 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<T>(
},
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<T>(
getScrollTop,
setPendingScrollTop,
itemHeight,
culledHeight,
],
);
@@ -554,7 +580,11 @@ function FixedVirtualizedList<T>(
<Box
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%"
@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders as render } from '../../../test-utils/render.js';
import { VirtualizedList } from './VirtualizedList.js';
import type { VirtualizedListRef } from './VirtualizedList.js';
import { Text, Box } from 'ink';
import { describe, it, expect } from 'vitest';
import { createRef } from 'react';
describe('<VirtualizedList /> 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<VirtualizedListRef<string>>();
const { waitUntilReady, unmount } = await render(
<Box height={50} width={100}>
<VirtualizedList
ref={ref}
data={data}
renderItem={({ item }) => (
<Box height={1}>
<Text>{item}</Text>
</Box>
)}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 10}
initialScrollIndex={999}
overflowToBackbuffer={true}
renderStatic={true}
maxScrollbackLength={150}
/>
</Box>,
);
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();
}
});
});
@@ -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('<VirtualizedList /> 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<number>();
const renderItem1px = ({
item,
index,
}: {
item: string;
index: number;
}) => {
renderedIndices.add(index);
return (
<Box height={1}>
<Text>{item}</Text>
</Box>
);
};
const { unmount } = await render(
<Box height={10} width={100}>
<VirtualizedList
data={longData}
renderItem={renderItem1px}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
initialScrollIndex={1999}
overflowToBackbuffer={true}
// maxScrollbackLength is NOT provided
/>
</Box>,
);
// 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();
});
});
@@ -377,6 +377,46 @@ describe('<VirtualizedList />', () => {
unmount();
});
it('crops the document height when maxScrollbackLength is exceeded', async () => {
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
const ref = createRef<VirtualizedListRef<string>>();
const { unmount, waitUntilReady } = await render(
<Box height={10} width={100}>
<VirtualizedList
ref={ref}
data={longData}
renderItem={({ item }) => (
<Box height={1}>
<Text>{item}</Text>
</Box>
)}
keyExtractor={(item) => item}
estimatedItemHeight={() => 1}
initialScrollIndex={99}
overflowToBackbuffer={true}
maxScrollbackLength={10}
/>
</Box>,
);
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<VirtualizedListRef<string>>();
const data = ['Item 1', 'Item 2'];
@@ -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<T>(
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<string, unknown>;
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 (
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
<Box
width="100%"
flexDirection="column"
flexShrink={0}
ref={itemRef}
// @ts-expect-error custom attribute for testing
nodeType="item-root"
>
{content}
</Box>
);
@@ -169,9 +242,13 @@ function VirtualizedList<T>(
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<T>(
);
const itemStates = useRef(new Map<string, Map<string, unknown>>());
const [toggledKeys, setToggledKeys] = useState(() => new Set<string>());
const toggledKeysRef = useRef(toggledKeys);
toggledKeysRef.current = toggledKeys;
const [temporarilyInteractiveIndexes, setTemporarilyInteractiveIndexes] =
useState(() => new Set<number>());
const renderedAsStatic = useRef<boolean[]>([]);
@@ -238,6 +317,11 @@ function VirtualizedList<T>(
event: MouseEvent;
} | null>(null);
const itemClickableAreas = useRef<Map<string, ClickableArea[]>>(new Map());
const clickCallbacks = useRef<Map<string, Map<string, () => void>>>(
new Map(),
);
const virtualizedListContextValue = useMemo<VirtualizedListContextValue>(
() => ({
registerInteractivity: (itemKey, options) => {
@@ -265,6 +349,23 @@ function VirtualizedList<T>(
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<T>(
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<T>(
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<T>(
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<T>(
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<T>(
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<T>(
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<T>(
}
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<T>(
// 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<T>(
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(
<StaticRender
key={`${key}-static-${typeof width === 'number' ? width : containerWidth}`}
width={typeof width === 'number' ? width : containerWidth}
onRender={(node: DOMElement) => onStaticRender(i, key, node)}
>
{() => content}
</StaticRender>,
);
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(
<VirtualizedListItem
key={key}
itemKey={key}
content={content}
index={i}
onSetRef={onSetRef}
/>,
);
if (shouldBeStatic) {
contentElement = (
<StaticRender
key={`${key}-static-${typeof width === 'number' ? width : containerWidth}`}
width={typeof width === 'number' ? width : containerWidth}
onRender={(node: DOMElement) => onStaticRender(i, key, node)}
>
{() => renderItem({ item, index: i })}
</StaticRender>
);
} else {
contentElement = (
<VirtualizedListItem
key={key}
itemKey={key}
content={renderItem({ item, index: i })}
index={i}
onSetRef={onSetRef}
/>
);
}
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<T>(
}
}
}
// Cleanup cache to avoid memory leaks
if (
itemCache.current.size >
Math.max(100, (renderRangeEnd - renderRangeStart + 1) * 3)
) {
const keysToKeep = new Set<string>();
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<T>(
estimatedItemHeight,
temporarilyInteractiveIndexes,
onStaticRender,
toggledKeys,
]);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
@@ -873,31 +1081,88 @@ function VirtualizedList<T>(
) {
// 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<T>(
}
}
},
[offsets, data, keyExtractor, virtualizedListContextValue],
[offsets, data, keyExtractor, virtualizedListContextValue, culledHeight],
);
useMouse(handleMouse, { isActive: true });
@@ -951,7 +1216,11 @@ function VirtualizedList<T>(
);
},
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<T>(
}
} 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<T>(
},
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<T>(
scrollableContainerHeight,
getScrollTop,
setPendingScrollTop,
culledHeight,
],
);
@@ -1084,7 +1361,11 @@ function VirtualizedList<T>(
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%"
@@ -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 (
<Box height={1} width={80} ref={ref}>
<Text>Item {id}</Text>
</Box>
);
};
it('triggers callback when tagged area is clicked', async () => {
const onToggle = vi.fn();
const data = [{ id: '1' }];
const { simulateClick, waitUntilReady, lastFrame } =
await renderWithProviders(
<Box height={10} width={80}>
<VirtualizedList
data={data}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
renderItem={({ item }) => (
<InteractiveItem id={item.id} onToggle={onToggle} />
)}
/>
</Box>,
{ 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 (
<Box height={10} width={80}>
<VirtualizedList
data={data}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
renderItem={({ item }) => (
<InteractiveItem id={item.id} onToggle={onToggle} />
)}
isStaticItem={() => isStatic}
/>
<Box
ref={(el) => {
if (el) {
setTimeout(() => setIsStatic(true), 100);
}
}}
/>
</Box>
);
};
const { simulateClick, waitUntilReady, lastFrame } =
await renderWithProviders(<TestComponent />, {
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());
});
});
@@ -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<string, unknown> };
if (!elHack.attributes) {
elHack.attributes = {};
}
elHack.attributes['data-clickable'] = areaId;
}
},
[areaId],
);
return { ref };
};