diff options
Diffstat (limited to 'packages/cli/src/ui/components')
5 files changed, 372 insertions, 0 deletions
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx new file mode 100644 index 00000000..0fe739df --- /dev/null +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi } from 'vitest'; +import { HistoryItemDisplay } from './HistoryItemDisplay.js'; +import { HistoryItem, MessageType } from '../types.js'; +import { CumulativeStats } from '../contexts/SessionContext.js'; + +// Mock child components +vi.mock('./messages/ToolGroupMessage.js', () => ({ + ToolGroupMessage: () => <div />, +})); + +describe('<HistoryItemDisplay />', () => { + const baseItem = { + id: 1, + timestamp: 12345, + isPending: false, + availableTerminalHeight: 100, + }; + + it('renders UserMessage for "user" type', () => { + const item: HistoryItem = { + ...baseItem, + type: MessageType.USER, + text: 'Hello', + }; + const { lastFrame } = render( + <HistoryItemDisplay {...baseItem} item={item} />, + ); + expect(lastFrame()).toContain('Hello'); + }); + + it('renders StatsDisplay for "stats" type', () => { + const stats: CumulativeStats = { + turnCount: 1, + promptTokenCount: 10, + candidatesTokenCount: 20, + totalTokenCount: 30, + cachedContentTokenCount: 5, + toolUsePromptTokenCount: 2, + thoughtsTokenCount: 3, + apiTimeMs: 123, + }; + const item: HistoryItem = { + ...baseItem, + type: MessageType.STATS, + stats, + lastTurnStats: stats, + duration: '1s', + }; + const { lastFrame } = render( + <HistoryItemDisplay {...baseItem} item={item} />, + ); + expect(lastFrame()).toContain('Stats'); + }); + + it('renders AboutBox for "about" type', () => { + const item: HistoryItem = { + ...baseItem, + type: MessageType.ABOUT, + cliVersion: '1.0.0', + osVersion: 'test-os', + sandboxEnv: 'test-env', + modelVersion: 'test-model', + }; + const { lastFrame } = render( + <HistoryItemDisplay {...baseItem} item={item} />, + ); + expect(lastFrame()).toContain('About Gemini CLI'); + }); +}); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 5ab6b3c9..8c4fede9 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -15,6 +15,7 @@ import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; import { GeminiMessageContent } from './messages/GeminiMessageContent.js'; import { Box } from 'ink'; import { AboutBox } from './AboutBox.js'; +import { StatsDisplay } from './StatsDisplay.js'; import { Config } from '@gemini-cli/core'; interface HistoryItemDisplayProps { @@ -58,6 +59,13 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({ modelVersion={item.modelVersion} /> )} + {item.type === 'stats' && ( + <StatsDisplay + stats={item.stats} + lastTurnStats={item.lastTurnStats} + duration={item.duration} + /> + )} {item.type === 'tool_group' && ( <ToolGroupMessage toolCalls={item.tools} diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx new file mode 100644 index 00000000..c7b574a5 --- /dev/null +++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect } from 'vitest'; +import { StatsDisplay } from './StatsDisplay.js'; +import { type CumulativeStats } from '../contexts/SessionContext.js'; + +describe('<StatsDisplay />', () => { + const mockStats: CumulativeStats = { + turnCount: 10, + promptTokenCount: 1000, + candidatesTokenCount: 2000, + totalTokenCount: 3500, + cachedContentTokenCount: 500, + toolUsePromptTokenCount: 200, + thoughtsTokenCount: 300, + apiTimeMs: 50234, + }; + + const mockLastTurnStats: CumulativeStats = { + turnCount: 1, + promptTokenCount: 100, + candidatesTokenCount: 200, + totalTokenCount: 350, + cachedContentTokenCount: 50, + toolUsePromptTokenCount: 20, + thoughtsTokenCount: 30, + apiTimeMs: 1234, + }; + + const mockDuration = '1h 23m 45s'; + + it('renders correctly with given stats and duration', () => { + const { lastFrame } = render( + <StatsDisplay + stats={mockStats} + lastTurnStats={mockLastTurnStats} + duration={mockDuration} + />, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders zero state correctly', () => { + const zeroStats: CumulativeStats = { + turnCount: 0, + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0, + cachedContentTokenCount: 0, + toolUsePromptTokenCount: 0, + thoughtsTokenCount: 0, + apiTimeMs: 0, + }; + + const { lastFrame } = render( + <StatsDisplay + stats={zeroStats} + lastTurnStats={zeroStats} + duration="0s" + />, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx new file mode 100644 index 00000000..be447595 --- /dev/null +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../colors.js'; +import { formatDuration } from '../utils/formatters.js'; +import { CumulativeStats } from '../contexts/SessionContext.js'; + +// --- Constants --- + +const COLUMN_WIDTH = '48%'; + +// --- Prop and Data Structures --- + +interface StatsDisplayProps { + stats: CumulativeStats; + lastTurnStats: CumulativeStats; + duration: string; +} + +interface FormattedStats { + inputTokens: number; + outputTokens: number; + toolUseTokens: number; + thoughtsTokens: number; + cachedTokens: number; + totalTokens: number; +} + +// --- Helper Components --- + +/** + * Renders a single row with a colored label on the left and a value on the right. + */ +const StatRow: React.FC<{ + label: string; + value: string | number; + valueColor?: string; +}> = ({ label, value, valueColor }) => ( + <Box justifyContent="space-between"> + <Text color={Colors.LightBlue}>{label}</Text> + <Text color={valueColor}>{value}</Text> + </Box> +); + +/** + * Renders a full column for either "Last Turn" or "Cumulative" stats. + */ +const StatsColumn: React.FC<{ + title: string; + stats: FormattedStats; + isCumulative?: boolean; +}> = ({ title, stats, isCumulative = false }) => { + const cachedDisplay = + isCumulative && stats.totalTokens > 0 + ? `${stats.cachedTokens.toLocaleString()} (${((stats.cachedTokens / stats.totalTokens) * 100).toFixed(1)}%)` + : stats.cachedTokens.toLocaleString(); + + const cachedColor = + isCumulative && stats.cachedTokens > 0 ? Colors.AccentGreen : undefined; + + return ( + <Box flexDirection="column" width={COLUMN_WIDTH}> + <Text bold>{title}</Text> + <Box marginTop={1} flexDirection="column"> + <StatRow + label="Input Tokens" + value={stats.inputTokens.toLocaleString()} + /> + <StatRow + label="Output Tokens" + value={stats.outputTokens.toLocaleString()} + /> + <StatRow + label="Tool Use Tokens" + value={stats.toolUseTokens.toLocaleString()} + /> + <StatRow + label="Thoughts Tokens" + value={stats.thoughtsTokens.toLocaleString()} + /> + <StatRow + label="Cached Tokens" + value={cachedDisplay} + valueColor={cachedColor} + /> + {/* Divider Line */} + <Box + borderTop={true} + borderLeft={false} + borderRight={false} + borderBottom={false} + borderStyle="single" + /> + <StatRow + label="Total Tokens" + value={stats.totalTokens.toLocaleString()} + /> + </Box> + </Box> + ); +}; + +// --- Main Component --- + +export const StatsDisplay: React.FC<StatsDisplayProps> = ({ + stats, + lastTurnStats, + duration, +}) => { + const lastTurnFormatted: FormattedStats = { + inputTokens: lastTurnStats.promptTokenCount, + outputTokens: lastTurnStats.candidatesTokenCount, + toolUseTokens: lastTurnStats.toolUsePromptTokenCount, + thoughtsTokens: lastTurnStats.thoughtsTokenCount, + cachedTokens: lastTurnStats.cachedContentTokenCount, + totalTokens: lastTurnStats.totalTokenCount, + }; + + const cumulativeFormatted: FormattedStats = { + inputTokens: stats.promptTokenCount, + outputTokens: stats.candidatesTokenCount, + toolUseTokens: stats.toolUsePromptTokenCount, + thoughtsTokens: stats.thoughtsTokenCount, + cachedTokens: stats.cachedContentTokenCount, + totalTokens: stats.totalTokenCount, + }; + + return ( + <Box + borderStyle="round" + borderColor="gray" + flexDirection="column" + paddingY={1} + paddingX={2} + > + <Text bold color={Colors.AccentPurple}> + Stats + </Text> + + <Box flexDirection="row" justifyContent="space-between" marginTop={1}> + <StatsColumn title="Last Turn" stats={lastTurnFormatted} /> + <StatsColumn + title={`Cumulative (${stats.turnCount} Turns)`} + stats={cumulativeFormatted} + isCumulative={true} + /> + </Box> + + <Box flexDirection="row" justifyContent="space-between" marginTop={1}> + {/* Left column for "Last Turn" duration */} + <Box width={COLUMN_WIDTH} flexDirection="column"> + <StatRow + label="Turn Duration (API)" + value={formatDuration(lastTurnStats.apiTimeMs)} + /> + </Box> + + {/* Right column for "Cumulative" durations */} + <Box width={COLUMN_WIDTH} flexDirection="column"> + <StatRow + label="Total duration (API)" + value={formatDuration(stats.apiTimeMs)} + /> + <StatRow label="Total duration (wall)" value={duration} /> + </Box> + </Box> + </Box> + ); +}; diff --git a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap new file mode 100644 index 00000000..f8fa3d4f --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap @@ -0,0 +1,43 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`<StatsDisplay /> > renders correctly with given stats and duration 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Stats │ +│ │ +│ Last Turn Cumulative (10 Turns) │ +│ │ +│ Input Tokens 100 Input Tokens 1,000 │ +│ Output Tokens 200 Output Tokens 2,000 │ +│ Tool Use Tokens 20 Tool Use Tokens 200 │ +│ Thoughts Tokens 30 Thoughts Tokens 300 │ +│ Cached Tokens 50 Cached Tokens 500 (14.3%) │ +│ ───────────────────────────────────────────── ───────────────────────────────────────────── │ +│ Total Tokens 350 Total Tokens 3,500 │ +│ │ +│ Turn Duration (API) 1.2s Total duration (API) 50.2s │ +│ Total duration (wall) 1h 23m 45s │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`<StatsDisplay /> > renders zero state correctly 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Stats │ +│ │ +│ Last Turn Cumulative (0 Turns) │ +│ │ +│ Input Tokens 0 Input Tokens 0 │ +│ Output Tokens 0 Output Tokens 0 │ +│ Tool Use Tokens 0 Tool Use Tokens 0 │ +│ Thoughts Tokens 0 Thoughts Tokens 0 │ +│ Cached Tokens 0 Cached Tokens 0 │ +│ ───────────────────────────────────────────── ───────────────────────────────────────────── │ +│ Total Tokens 0 Total Tokens 0 │ +│ │ +│ Turn Duration (API) 0s Total duration (API) 0s │ +│ Total duration (wall) 0s │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; |
