mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 17:41:24 -07:00
207 lines
5.8 KiB
TypeScript
207 lines
5.8 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { renderWithProviders } from '../../../test-utils/render.js';
|
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
|
import { ToolCallStatus } from '../../types.js';
|
|
import {
|
|
ScrollableList,
|
|
type ScrollableListRef,
|
|
} from '../shared/ScrollableList.js';
|
|
import { Box, Text } from 'ink';
|
|
import { act, useRef, useEffect } from 'react';
|
|
import { waitFor } from '../../../test-utils/async.js';
|
|
import { SHELL_COMMAND_NAME } from '../../constants.js';
|
|
|
|
// Mock child components that might be complex
|
|
vi.mock('../TerminalOutput.js', () => ({
|
|
TerminalOutput: () => <Text>MockTerminalOutput</Text>,
|
|
}));
|
|
|
|
vi.mock('../AnsiOutput.js', () => ({
|
|
AnsiOutputText: () => <Text>MockAnsiOutput</Text>,
|
|
}));
|
|
|
|
vi.mock('../GeminiRespondingSpinner.js', () => ({
|
|
GeminiRespondingSpinner: () => <Text>MockRespondingSpinner</Text>,
|
|
}));
|
|
|
|
vi.mock('./DiffRenderer.js', () => ({
|
|
DiffRenderer: () => <Text>MockDiff</Text>,
|
|
}));
|
|
|
|
vi.mock('../../utils/MarkdownDisplay.js', () => ({
|
|
MarkdownDisplay: ({ text }: { text: string }) => <Text>{text}</Text>,
|
|
}));
|
|
|
|
describe('ToolMessage Sticky Header Regression', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const createToolCall = (id: string, name: string, resultPrefix: string) => ({
|
|
callId: id,
|
|
name,
|
|
description: `Description for ${name}`,
|
|
resultDisplay: Array.from(
|
|
{ length: 10 },
|
|
(_, i) => `${resultPrefix}-${String(i + 1).padStart(2, '0')}`,
|
|
).join('\n'),
|
|
status: ToolCallStatus.Success,
|
|
confirmationDetails: undefined,
|
|
renderOutputAsMarkdown: false,
|
|
});
|
|
|
|
it('verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers', async () => {
|
|
const toolCalls = [
|
|
createToolCall('1', 'tool-1', 'c1'),
|
|
createToolCall('2', 'tool-2', 'c2'),
|
|
];
|
|
|
|
const terminalWidth = 80;
|
|
const terminalHeight = 5;
|
|
|
|
let listRef: ScrollableListRef<string> | null = null;
|
|
|
|
const TestComponent = () => {
|
|
const internalRef = useRef<ScrollableListRef<string>>(null);
|
|
useEffect(() => {
|
|
listRef = internalRef.current;
|
|
}, []);
|
|
|
|
return (
|
|
<ScrollableList
|
|
ref={internalRef}
|
|
data={['item1']}
|
|
renderItem={() => (
|
|
<ToolGroupMessage
|
|
groupId={1}
|
|
toolCalls={toolCalls}
|
|
terminalWidth={terminalWidth - 2} // Account for ScrollableList padding
|
|
/>
|
|
)}
|
|
estimatedItemHeight={() => 30}
|
|
keyExtractor={(item) => item}
|
|
hasFocus={true}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const { lastFrame } = renderWithProviders(
|
|
<Box height={terminalHeight}>
|
|
<TestComponent />
|
|
</Box>,
|
|
{
|
|
width: terminalWidth,
|
|
uiState: { terminalWidth },
|
|
},
|
|
);
|
|
|
|
// Initial state: tool-1 should be visible
|
|
await waitFor(() => {
|
|
expect(lastFrame()).toContain('tool-1');
|
|
});
|
|
expect(lastFrame()).toContain('Description for tool-1');
|
|
expect(lastFrame()).toMatchSnapshot();
|
|
|
|
// Scroll down so that tool-1's header should be stuck
|
|
await act(async () => {
|
|
listRef?.scrollBy(5);
|
|
});
|
|
|
|
// tool-1 header should still be visible because it is sticky
|
|
await waitFor(() => {
|
|
expect(lastFrame()).toContain('tool-1');
|
|
});
|
|
expect(lastFrame()).toContain('Description for tool-1');
|
|
// Content lines 1-4 should be scrolled off
|
|
expect(lastFrame()).not.toContain('c1-01');
|
|
expect(lastFrame()).not.toContain('c1-04');
|
|
// Line 6 and 7 should be visible (terminalHeight=5 means only 2 lines of content show below 3-line header)
|
|
expect(lastFrame()).toContain('c1-06');
|
|
expect(lastFrame()).toContain('c1-07');
|
|
expect(lastFrame()).toMatchSnapshot();
|
|
|
|
// Scroll further so tool-1 is completely gone and tool-2's header should be stuck
|
|
await act(async () => {
|
|
listRef?.scrollBy(17);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(lastFrame()).toContain('tool-2');
|
|
});
|
|
expect(lastFrame()).toContain('Description for tool-2');
|
|
// tool-1 should be gone now (both header and content)
|
|
expect(lastFrame()).not.toContain('tool-1');
|
|
expect(lastFrame()).toMatchSnapshot();
|
|
});
|
|
|
|
it('verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers', async () => {
|
|
const toolCalls = [
|
|
{
|
|
...createToolCall('1', SHELL_COMMAND_NAME, 'shell'),
|
|
status: ToolCallStatus.Success,
|
|
},
|
|
];
|
|
|
|
const terminalWidth = 80;
|
|
const terminalHeight = 5;
|
|
|
|
let listRef: ScrollableListRef<string> | null = null;
|
|
|
|
const TestComponent = () => {
|
|
const internalRef = useRef<ScrollableListRef<string>>(null);
|
|
useEffect(() => {
|
|
listRef = internalRef.current;
|
|
}, []);
|
|
|
|
return (
|
|
<ScrollableList
|
|
ref={internalRef}
|
|
data={['item1']}
|
|
renderItem={() => (
|
|
<ToolGroupMessage
|
|
groupId={1}
|
|
toolCalls={toolCalls}
|
|
terminalWidth={terminalWidth - 2}
|
|
/>
|
|
)}
|
|
estimatedItemHeight={() => 30}
|
|
keyExtractor={(item) => item}
|
|
hasFocus={true}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const { lastFrame } = renderWithProviders(
|
|
<Box height={terminalHeight}>
|
|
<TestComponent />
|
|
</Box>,
|
|
{
|
|
width: terminalWidth,
|
|
uiState: { terminalWidth },
|
|
},
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(lastFrame()).toContain(SHELL_COMMAND_NAME);
|
|
});
|
|
expect(lastFrame()).toMatchSnapshot();
|
|
|
|
// Scroll down
|
|
await act(async () => {
|
|
listRef?.scrollBy(5);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(lastFrame()).toContain(SHELL_COMMAND_NAME);
|
|
});
|
|
expect(lastFrame()).toContain('shell-06');
|
|
expect(lastFrame()).toMatchSnapshot();
|
|
});
|
|
});
|