feat: Add UI for /stats slash command (#883)

This commit is contained in:
Abhi
2025-06-10 15:59:52 -04:00
committed by GitHub
parent 04e2fe0bff
commit 9c3f34890f
16 changed files with 649 additions and 109 deletions

View File

@@ -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');
});
});

View File

@@ -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}

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View File

@@ -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 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;