From 9c3f34890f220456235303498736938156d7fefe Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Tue, 10 Jun 2025 15:59:52 -0400 Subject: feat: Add UI for /stats slash command (#883) --- .../src/ui/components/HistoryItemDisplay.test.tsx | 76 +++++++++ .../cli/src/ui/components/HistoryItemDisplay.tsx | 8 + .../cli/src/ui/components/StatsDisplay.test.tsx | 71 +++++++++ packages/cli/src/ui/components/StatsDisplay.tsx | 174 +++++++++++++++++++++ .../__snapshots__/StatsDisplay.test.tsx.snap | 43 +++++ 5 files changed, 372 insertions(+) create mode 100644 packages/cli/src/ui/components/HistoryItemDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/StatsDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/StatsDisplay.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap (limited to 'packages/cli/src/ui/components') 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: () =>
, +})); + +describe('', () => { + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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 = ({ modelVersion={item.modelVersion} /> )} + {item.type === 'stats' && ( + + )} {item.type === 'tool_group' && ( ', () => { + 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( + , + ); + + 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( + , + ); + + 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 }) => ( + + {label} + {value} + +); + +/** + * 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 ( + + {title} + + + + + + + {/* Divider Line */} + + + + + ); +}; + +// --- Main Component --- + +export const StatsDisplay: React.FC = ({ + 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 ( + + + Stats + + + + + + + + + {/* Left column for "Last Turn" duration */} + + + + + {/* Right column for "Cumulative" durations */} + + + + + + + ); +}; 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[` > 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[` > 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 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; -- cgit v1.2.3