summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components
diff options
context:
space:
mode:
authorAbhi <[email protected]>2025-06-10 15:59:52 -0400
committerGitHub <[email protected]>2025-06-10 15:59:52 -0400
commit9c3f34890f220456235303498736938156d7fefe (patch)
tree463b910e7e4bac945e24748fe19bbb5875d7c8eb /packages/cli/src/ui/components
parent04e2fe0bff1dc59d90dd81374a652cccc39dc625 (diff)
feat: Add UI for /stats slash command (#883)
Diffstat (limited to 'packages/cli/src/ui/components')
-rw-r--r--packages/cli/src/ui/components/HistoryItemDisplay.test.tsx76
-rw-r--r--packages/cli/src/ui/components/HistoryItemDisplay.tsx8
-rw-r--r--packages/cli/src/ui/components/StatsDisplay.test.tsx71
-rw-r--r--packages/cli/src/ui/components/StatsDisplay.tsx174
-rw-r--r--packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap43
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 │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;