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