diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx index 1feadb8ba4..7bb943791b 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx @@ -565,6 +565,45 @@ describe('DenseToolMessage', () => { expect(lastFrame()).toContain('new line'); }); + it('shows diff content when globally expanded inside a VirtualizedList context', async () => { + const mockListContext = { + registerInteractivity: vi.fn(), + setItemState: vi.fn(), + getItemState: vi.fn(), + isItemToggled: vi.fn().mockReturnValue(false), + toggleItem: vi.fn(), + registerClickCallback: vi.fn(), + unregisterClickCallback: vi.fn(), + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + + } + > + + , + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + toolActions: { + isExpanded: () => true, + }, + }, + ); + await waitUntilReady(); + + expect(lastFrame()).toContain('new line'); + }); + it('toggles expansion when header is clicked', async () => { const toggleExpansion = vi.fn(); const toggleItem = vi.fn(); diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx index d449703cf2..5d762666c4 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx @@ -283,10 +283,15 @@ export const DenseToolMessage: React.FC = (props) => { // Determine expansion state based on list context or fallback to tool actions const isExpanded = useMemo(() => { + const isExpandedGlobally = isExpandedInContext + ? isExpandedInContext(callId) + : false; if (itemKey && virtualizedListContext) { - return virtualizedListContext.isItemToggled(itemKey); + return ( + virtualizedListContext.isItemToggled(itemKey) || isExpandedGlobally + ); } - return isExpandedInContext ? isExpandedInContext(callId) : false; + return isExpandedGlobally; }, [itemKey, virtualizedListContext, isExpandedInContext, callId]); const handleToggle = useCallback(() => { diff --git a/packages/cli/src/ui/components/messages/TopicMessage.tsx b/packages/cli/src/ui/components/messages/TopicMessage.tsx index 59be80602d..e51f421d47 100644 --- a/packages/cli/src/ui/components/messages/TopicMessage.tsx +++ b/packages/cli/src/ui/components/messages/TopicMessage.tsx @@ -24,6 +24,7 @@ interface TopicMessageProps extends IndividualToolCallDisplay { terminalWidth: number; availableTerminalHeight?: number; isExpandable?: boolean; + // TopicMessage is only interactive when rendered inside VirtualizedList. itemKey?: string; } diff --git a/packages/cli/src/ui/components/shared/FixedScrollableList.tsx b/packages/cli/src/ui/components/shared/FixedScrollableList.tsx index b382579d94..1d21ff4ed8 100644 --- a/packages/cli/src/ui/components/shared/FixedScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/FixedScrollableList.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ diff --git a/packages/cli/src/ui/components/shared/FixedVirtualizedList.test.tsx b/packages/cli/src/ui/components/shared/FixedVirtualizedList.test.tsx new file mode 100644 index 0000000000..363eec1501 --- /dev/null +++ b/packages/cli/src/ui/components/shared/FixedVirtualizedList.test.tsx @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { Box, Text } from 'ink'; +import { describe, expect, it } from 'vitest'; +import { renderWithProviders as render } from '../../../test-utils/render.js'; +import { + FixedVirtualizedList, + SCROLL_TO_ITEM_END, +} from './FixedVirtualizedList.js'; + +describe('', () => { + const renderList = (data: string[]) => ( + + ( + + {item} + + )} + itemHeight={1} + keyExtractor={(item) => item} + initialScrollIndex={SCROLL_TO_ITEM_END} + initialScrollOffsetInIndex={SCROLL_TO_ITEM_END} + width={80} + maxHeight={5} + /> + + ); + + it('sticks to the bottom when data grows', async () => { + const initialData = Array.from({ length: 10 }, (_, i) => `Item ${i}`); + const { lastFrame, rerender, waitUntilReady, unmount } = await render( + renderList(initialData), + ); + await waitUntilReady(); + + expect(lastFrame()).toContain('Item 9'); + + const newData = [...initialData, 'Item 10', 'Item 11']; + await act(async () => { + rerender(renderList(newData)); + }); + await waitUntilReady(); + + expect(lastFrame()).toContain('Item 11'); + expect(lastFrame()).not.toContain('Item 0'); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx b/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx index 4f98175462..c6a21735e6 100644 --- a/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/FixedVirtualizedList.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -172,23 +172,21 @@ function FixedVirtualizedList( props.targetScrollIndex, ); const prevDataLength = useRef(data.length); + const previousDataLength = prevDataLength.current; if ( (props.targetScrollIndex !== undefined && props.targetScrollIndex !== prevTargetScrollIndex && data.length > 0) || (props.targetScrollIndex !== undefined && - prevDataLength.current === 0 && + previousDataLength === 0 && data.length > 0) ) { if (props.targetScrollIndex !== prevTargetScrollIndex) { setPrevTargetScrollIndex(props.targetScrollIndex); } - prevDataLength.current = data.length; setIsStickingToBottom(false); setScrollAnchor({ index: props.targetScrollIndex, offset: 0 }); - } else { - prevDataLength.current = data.length; } const rawStateActualScrollTop = (() => { @@ -236,7 +234,7 @@ function FixedVirtualizedList( } } - const listGrew = data.length > prevDataLength.current; + const listGrew = data.length > previousDataLength; const containerChanged = prevContainerHeight.current !== scrollableContainerHeight; const shouldAutoScroll = props.targetScrollIndex === undefined; diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx index eb7a77848a..37ea63cafe 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx @@ -119,6 +119,41 @@ describe('', () => { unmount(); }); + it('rerenders cached items when renderItem changes', async () => { + const data = ['Item 0']; + const renderWithLabel = (label: string) => ( + + ( + + + {label} {item} + + + )} + keyExtractor={keyExtractor} + estimatedItemHeight={() => itemHeight} + /> + + ); + + const { lastFrame, rerender, waitUntilReady, unmount } = await render( + renderWithLabel('Initial'), + ); + await waitUntilReady(); + expect(lastFrame()).toContain('Initial Item 0'); + + await act(async () => { + rerender(renderWithLabel('Updated')); + }); + await waitUntilReady(); + + expect(lastFrame()).toContain('Updated Item 0'); + expect(lastFrame()).not.toContain('Initial Item 0'); + unmount(); + }); + it('scrolls down to show new items when requested via ref', async () => { const ref = createRef>(); const { lastFrame, waitUntilReady, unmount } = await render( diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index 0d9e2ecdce..fa22387b39 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -926,6 +926,7 @@ function VirtualizedList( containerWidth: number; index: number; isToggled: boolean; + renderItem: typeof renderItem; } >(), ); @@ -965,7 +966,8 @@ function VirtualizedList( cached.width === width && cached.containerWidth === containerWidth && cached.index === i && - cached.isToggled === isToggled + cached.isToggled === isToggled && + cached.renderItem === renderItem ) { contentElement = cached.element; } else { @@ -998,6 +1000,7 @@ function VirtualizedList( containerWidth, index: i, isToggled, + renderItem, }); } diff --git a/packages/cli/src/ui/hooks/useMouseClick.ts b/packages/cli/src/ui/hooks/useMouseClick.ts index 0b0d5caecc..1509eee40f 100644 --- a/packages/cli/src/ui/hooks/useMouseClick.ts +++ b/packages/cli/src/ui/hooks/useMouseClick.ts @@ -12,7 +12,6 @@ import { type MouseEvent, type MouseEventName, } from '../contexts/MouseContext.js'; -import { debugLogger } from '@google/gemini-cli-core'; export const useMouseClick = ( containerRef: React.RefObject, @@ -32,9 +31,6 @@ export const useMouseClick = ( const eventName = name ?? (button === 'left' ? 'left-press' : 'right-release'); - debugLogger.log( - `[useMouseClick] received event=${event.name} expected=${eventName} hasContainer=${!!containerRef.current}`, - ); if (event.name === eventName && containerRef.current) { const { x, y, width, height } = getBoundingBox(containerRef.current); // Terminal mouse events are 1-based, Ink layout is 0-based. @@ -44,17 +40,12 @@ export const useMouseClick = ( const relativeX = mouseX - x; const relativeY = mouseY - y; - debugLogger.log( - `[useMouseClick] bounds x=${x} y=${y} w=${width} h=${height} mouseX=${mouseX} mouseY=${mouseY} relX=${relativeX} relY=${relativeY}`, - ); - if ( relativeX >= 0 && relativeX < width && relativeY >= 0 && relativeY < height ) { - debugLogger.log(`[useMouseClick] Triggering handler!`); handlerRef.current(event, relativeX, relativeY); } }