summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/components')
-rw-r--r--packages/cli/src/ui/components/Footer.tsx6
-rw-r--r--packages/cli/src/ui/components/HistoryItemDisplay.test.tsx63
-rw-r--r--packages/cli/src/ui/components/HistoryItemDisplay.tsx16
-rw-r--r--packages/cli/src/ui/components/ModelStatsDisplay.test.tsx235
-rw-r--r--packages/cli/src/ui/components/ModelStatsDisplay.tsx197
-rw-r--r--packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx106
-rw-r--r--packages/cli/src/ui/components/SessionSummaryDisplay.tsx52
-rw-r--r--packages/cli/src/ui/components/StatsDisplay.test.tsx292
-rw-r--r--packages/cli/src/ui/components/StatsDisplay.tsx260
-rw-r--r--packages/cli/src/ui/components/ToolStatsDisplay.test.tsx176
-rw-r--r--packages/cli/src/ui/components/ToolStatsDisplay.tsx208
-rw-r--r--packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap121
-rw-r--r--packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap54
-rw-r--r--packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap166
-rw-r--r--packages/cli/src/ui/components/__snapshots__/ToolStatsDisplay.test.tsx.snap91
15 files changed, 1803 insertions, 240 deletions
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index 4ece6c92..48f37ee8 100644
--- a/packages/cli/src/ui/components/Footer.tsx
+++ b/packages/cli/src/ui/components/Footer.tsx
@@ -23,8 +23,6 @@ interface FooterProps {
showErrorDetails: boolean;
showMemoryUsage?: boolean;
promptTokenCount: number;
- candidatesTokenCount: number;
- totalTokenCount: number;
}
export const Footer: React.FC<FooterProps> = ({
@@ -37,10 +35,10 @@ export const Footer: React.FC<FooterProps> = ({
errorCount,
showErrorDetails,
showMemoryUsage,
- totalTokenCount,
+ promptTokenCount,
}) => {
const limit = tokenLimit(model);
- const percentage = totalTokenCount / limit;
+ const percentage = promptTokenCount / limit;
return (
<Box marginTop={1} justifyContent="space-between" width="100%">
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
index 5816f7b4..b40b20bc 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
@@ -8,7 +8,7 @@ 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';
+import { SessionStatsProvider } from '../contexts/SessionContext.js';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
@@ -36,25 +36,15 @@ describe('<HistoryItemDisplay />', () => {
});
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} />,
+ <SessionStatsProvider>
+ <HistoryItemDisplay {...baseItem} item={item} />
+ </SessionStatsProvider>,
);
expect(lastFrame()).toContain('Stats');
});
@@ -76,25 +66,46 @@ describe('<HistoryItemDisplay />', () => {
expect(lastFrame()).toContain('About Gemini CLI');
});
- it('renders SessionSummaryDisplay for "quit" type', () => {
- const stats: CumulativeStats = {
- turnCount: 1,
- promptTokenCount: 10,
- candidatesTokenCount: 20,
- totalTokenCount: 30,
- cachedContentTokenCount: 5,
- toolUsePromptTokenCount: 2,
- thoughtsTokenCount: 3,
- apiTimeMs: 123,
+ it('renders ModelStatsDisplay for "model_stats" type', () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: 'model_stats',
+ };
+ const { lastFrame } = render(
+ <SessionStatsProvider>
+ <HistoryItemDisplay {...baseItem} item={item} />
+ </SessionStatsProvider>,
+ );
+ expect(lastFrame()).toContain(
+ 'No API calls have been made in this session.',
+ );
+ });
+
+ it('renders ToolStatsDisplay for "tool_stats" type', () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: 'tool_stats',
};
+ const { lastFrame } = render(
+ <SessionStatsProvider>
+ <HistoryItemDisplay {...baseItem} item={item} />
+ </SessionStatsProvider>,
+ );
+ expect(lastFrame()).toContain(
+ 'No tool calls have been made in this session.',
+ );
+ });
+
+ it('renders SessionSummaryDisplay for "quit" type', () => {
const item: HistoryItem = {
...baseItem,
type: 'quit',
- stats,
duration: '1s',
};
const { lastFrame } = render(
- <HistoryItemDisplay {...baseItem} item={item} />,
+ <SessionStatsProvider>
+ <HistoryItemDisplay {...baseItem} item={item} />
+ </SessionStatsProvider>,
);
expect(lastFrame()).toContain('Agent powering down. Goodbye!');
});
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
index 76b6ba6e..eba4ea47 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
@@ -17,6 +17,8 @@ import { CompressionMessage } from './messages/CompressionMessage.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
import { StatsDisplay } from './StatsDisplay.js';
+import { ModelStatsDisplay } from './ModelStatsDisplay.js';
+import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import { Config } from '@google/gemini-cli-core';
@@ -69,16 +71,10 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
gcpProject={item.gcpProject}
/>
)}
- {item.type === 'stats' && (
- <StatsDisplay
- stats={item.stats}
- lastTurnStats={item.lastTurnStats}
- duration={item.duration}
- />
- )}
- {item.type === 'quit' && (
- <SessionSummaryDisplay stats={item.stats} duration={item.duration} />
- )}
+ {item.type === 'stats' && <StatsDisplay duration={item.duration} />}
+ {item.type === 'model_stats' && <ModelStatsDisplay />}
+ {item.type === 'tool_stats' && <ToolStatsDisplay />}
+ {item.type === 'quit' && <SessionSummaryDisplay duration={item.duration} />}
{item.type === 'tool_group' && (
<ToolGroupMessage
toolCalls={item.tools}
diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx
new file mode 100644
index 00000000..6c41b775
--- /dev/null
+++ b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx
@@ -0,0 +1,235 @@
+/**
+ * @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 { ModelStatsDisplay } from './ModelStatsDisplay.js';
+import * as SessionContext from '../contexts/SessionContext.js';
+import { SessionMetrics } from '../contexts/SessionContext.js';
+
+// Mock the context to provide controlled data for testing
+vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
+ const actual = await importOriginal<typeof SessionContext>();
+ return {
+ ...actual,
+ useSessionStats: vi.fn(),
+ };
+});
+
+const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
+
+const renderWithMockedStats = (metrics: SessionMetrics) => {
+ useSessionStatsMock.mockReturnValue({
+ stats: {
+ sessionStartTime: new Date(),
+ metrics,
+ lastPromptTokenCount: 0,
+ },
+ });
+
+ return render(<ModelStatsDisplay />);
+};
+
+describe('<ModelStatsDisplay />', () => {
+ it('should render "no API calls" message when there are no active models', () => {
+ const { lastFrame } = renderWithMockedStats({
+ models: {},
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ });
+
+ expect(lastFrame()).toContain(
+ 'No API calls have been made in this session.',
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('should not display conditional rows if no model has data for them', () => {
+ const { lastFrame } = renderWithMockedStats({
+ models: {
+ 'gemini-2.5-pro': {
+ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
+ tokens: {
+ prompt: 10,
+ candidates: 20,
+ total: 30,
+ cached: 0,
+ thoughts: 0,
+ tool: 0,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ });
+
+ const output = lastFrame();
+ expect(output).not.toContain('Cached');
+ expect(output).not.toContain('Thoughts');
+ expect(output).not.toContain('Tool');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('should display conditional rows if at least one model has data', () => {
+ const { lastFrame } = renderWithMockedStats({
+ models: {
+ 'gemini-2.5-pro': {
+ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
+ tokens: {
+ prompt: 10,
+ candidates: 20,
+ total: 30,
+ cached: 5,
+ thoughts: 2,
+ tool: 0,
+ },
+ },
+ 'gemini-2.5-flash': {
+ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 50 },
+ tokens: {
+ prompt: 5,
+ candidates: 10,
+ total: 15,
+ cached: 0,
+ thoughts: 0,
+ tool: 3,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ });
+
+ const output = lastFrame();
+ expect(output).toContain('Cached');
+ expect(output).toContain('Thoughts');
+ expect(output).toContain('Tool');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('should display stats for multiple models correctly', () => {
+ const { lastFrame } = renderWithMockedStats({
+ models: {
+ 'gemini-2.5-pro': {
+ api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 1000 },
+ tokens: {
+ prompt: 100,
+ candidates: 200,
+ total: 300,
+ cached: 50,
+ thoughts: 10,
+ tool: 5,
+ },
+ },
+ 'gemini-2.5-flash': {
+ api: { totalRequests: 20, totalErrors: 2, totalLatencyMs: 500 },
+ tokens: {
+ prompt: 200,
+ candidates: 400,
+ total: 600,
+ cached: 100,
+ thoughts: 20,
+ tool: 10,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ });
+
+ const output = lastFrame();
+ expect(output).toContain('gemini-2.5-pro');
+ expect(output).toContain('gemini-2.5-flash');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('should handle large values without wrapping or overlapping', () => {
+ const { lastFrame } = renderWithMockedStats({
+ models: {
+ 'gemini-2.5-pro': {
+ api: {
+ totalRequests: 999999999,
+ totalErrors: 123456789,
+ totalLatencyMs: 9876,
+ },
+ tokens: {
+ prompt: 987654321,
+ candidates: 123456789,
+ total: 999999999,
+ cached: 123456789,
+ thoughts: 111111111,
+ tool: 222222222,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ });
+
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('should display a single model correctly', () => {
+ const { lastFrame } = renderWithMockedStats({
+ models: {
+ 'gemini-2.5-pro': {
+ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
+ tokens: {
+ prompt: 10,
+ candidates: 20,
+ total: 30,
+ cached: 5,
+ thoughts: 2,
+ tool: 1,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ });
+
+ const output = lastFrame();
+ expect(output).toContain('gemini-2.5-pro');
+ expect(output).not.toContain('gemini-2.5-flash');
+ expect(output).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.tsx
new file mode 100644
index 00000000..1911e757
--- /dev/null
+++ b/packages/cli/src/ui/components/ModelStatsDisplay.tsx
@@ -0,0 +1,197 @@
+/**
+ * @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 {
+ calculateAverageLatency,
+ calculateCacheHitRate,
+ calculateErrorRate,
+} from '../utils/computeStats.js';
+import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
+
+const METRIC_COL_WIDTH = 28;
+const MODEL_COL_WIDTH = 22;
+
+interface StatRowProps {
+ title: string;
+ values: Array<string | React.ReactElement>;
+ isSubtle?: boolean;
+ isSection?: boolean;
+}
+
+const StatRow: React.FC<StatRowProps> = ({
+ title,
+ values,
+ isSubtle = false,
+ isSection = false,
+}) => (
+ <Box>
+ <Box width={METRIC_COL_WIDTH}>
+ <Text bold={isSection} color={isSection ? undefined : Colors.LightBlue}>
+ {isSubtle ? ` ↳ ${title}` : title}
+ </Text>
+ </Box>
+ {values.map((value, index) => (
+ <Box width={MODEL_COL_WIDTH} key={index}>
+ <Text>{value}</Text>
+ </Box>
+ ))}
+ </Box>
+);
+
+export const ModelStatsDisplay: React.FC = () => {
+ const { stats } = useSessionStats();
+ const { models } = stats.metrics;
+ const activeModels = Object.entries(models).filter(
+ ([, metrics]) => metrics.api.totalRequests > 0,
+ );
+
+ if (activeModels.length === 0) {
+ return (
+ <Box
+ borderStyle="round"
+ borderColor={Colors.Gray}
+ paddingY={1}
+ paddingX={2}
+ >
+ <Text>No API calls have been made in this session.</Text>
+ </Box>
+ );
+ }
+
+ const modelNames = activeModels.map(([name]) => name);
+
+ const getModelValues = (
+ getter: (metrics: ModelMetrics) => string | React.ReactElement,
+ ) => activeModels.map(([, metrics]) => getter(metrics));
+
+ const hasThoughts = activeModels.some(
+ ([, metrics]) => metrics.tokens.thoughts > 0,
+ );
+ const hasTool = activeModels.some(([, metrics]) => metrics.tokens.tool > 0);
+ const hasCached = activeModels.some(
+ ([, metrics]) => metrics.tokens.cached > 0,
+ );
+
+ return (
+ <Box
+ borderStyle="round"
+ borderColor={Colors.Gray}
+ flexDirection="column"
+ paddingY={1}
+ paddingX={2}
+ >
+ <Text bold color={Colors.AccentPurple}>
+ Model Stats For Nerds
+ </Text>
+ <Box height={1} />
+
+ {/* Header */}
+ <Box>
+ <Box width={METRIC_COL_WIDTH}>
+ <Text bold>Metric</Text>
+ </Box>
+ {modelNames.map((name) => (
+ <Box width={MODEL_COL_WIDTH} key={name}>
+ <Text bold>{name}</Text>
+ </Box>
+ ))}
+ </Box>
+
+ {/* Divider */}
+ <Box
+ borderStyle="single"
+ borderBottom={true}
+ borderTop={false}
+ borderLeft={false}
+ borderRight={false}
+ />
+
+ {/* API Section */}
+ <StatRow title="API" values={[]} isSection />
+ <StatRow
+ title="Requests"
+ values={getModelValues((m) => m.api.totalRequests.toLocaleString())}
+ />
+ <StatRow
+ title="Errors"
+ values={getModelValues((m) => {
+ const errorRate = calculateErrorRate(m);
+ return (
+ <Text
+ color={
+ m.api.totalErrors > 0 ? Colors.AccentRed : Colors.Foreground
+ }
+ >
+ {m.api.totalErrors.toLocaleString()} ({errorRate.toFixed(1)}%)
+ </Text>
+ );
+ })}
+ />
+ <StatRow
+ title="Avg Latency"
+ values={getModelValues((m) => {
+ const avgLatency = calculateAverageLatency(m);
+ return formatDuration(avgLatency);
+ })}
+ />
+
+ <Box height={1} />
+
+ {/* Tokens Section */}
+ <StatRow title="Tokens" values={[]} isSection />
+ <StatRow
+ title="Total"
+ values={getModelValues((m) => (
+ <Text color={Colors.AccentYellow}>
+ {m.tokens.total.toLocaleString()}
+ </Text>
+ ))}
+ />
+ <StatRow
+ title="Prompt"
+ isSubtle
+ values={getModelValues((m) => m.tokens.prompt.toLocaleString())}
+ />
+ {hasCached && (
+ <StatRow
+ title="Cached"
+ isSubtle
+ values={getModelValues((m) => {
+ const cacheHitRate = calculateCacheHitRate(m);
+ return (
+ <Text color={Colors.AccentGreen}>
+ {m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%)
+ </Text>
+ );
+ })}
+ />
+ )}
+ {hasThoughts && (
+ <StatRow
+ title="Thoughts"
+ isSubtle
+ values={getModelValues((m) => m.tokens.thoughts.toLocaleString())}
+ />
+ )}
+ {hasTool && (
+ <StatRow
+ title="Tool"
+ isSubtle
+ values={getModelValues((m) => m.tokens.tool.toLocaleString())}
+ />
+ )}
+ <StatRow
+ title="Output"
+ isSubtle
+ values={getModelValues((m) => m.tokens.candidates.toLocaleString())}
+ />
+ </Box>
+ );
+};
diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx
index 14d8a277..afb822e5 100644
--- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx
+++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx
@@ -5,48 +5,92 @@
*/
import { render } from 'ink-testing-library';
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
-import { type CumulativeStats } from '../contexts/SessionContext.js';
+import * as SessionContext from '../contexts/SessionContext.js';
+import { SessionMetrics } from '../contexts/SessionContext.js';
-describe('<SessionSummaryDisplay />', () => {
- const mockStats: CumulativeStats = {
- turnCount: 10,
- promptTokenCount: 1000,
- candidatesTokenCount: 2000,
- totalTokenCount: 3500,
- cachedContentTokenCount: 500,
- toolUsePromptTokenCount: 200,
- thoughtsTokenCount: 300,
- apiTimeMs: 50234,
+vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
+ const actual = await importOriginal<typeof SessionContext>();
+ return {
+ ...actual,
+ useSessionStats: vi.fn(),
};
+});
- const mockDuration = '1h 23m 45s';
+const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
- it('renders correctly with given stats and duration', () => {
- const { lastFrame } = render(
- <SessionSummaryDisplay stats={mockStats} duration={mockDuration} />,
- );
+const renderWithMockedStats = (metrics: SessionMetrics) => {
+ useSessionStatsMock.mockReturnValue({
+ stats: {
+ sessionStartTime: new Date(),
+ metrics,
+ lastPromptTokenCount: 0,
+ },
+ });
- expect(lastFrame()).toMatchSnapshot();
+ return render(<SessionSummaryDisplay duration="1h 23m 45s" />);
+};
+
+describe('<SessionSummaryDisplay />', () => {
+ it('correctly sums and displays stats from multiple models', () => {
+ const metrics: SessionMetrics = {
+ models: {
+ 'gemini-2.5-pro': {
+ api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 50234 },
+ tokens: {
+ prompt: 1000,
+ candidates: 2000,
+ total: 3500,
+ cached: 500,
+ thoughts: 300,
+ tool: 200,
+ },
+ },
+ 'gemini-2.5-flash': {
+ api: { totalRequests: 5, totalErrors: 0, totalLatencyMs: 12345 },
+ tokens: {
+ prompt: 500,
+ candidates: 1000,
+ total: 1500,
+ cached: 100,
+ thoughts: 50,
+ tool: 20,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ };
+
+ const { lastFrame } = renderWithMockedStats(metrics);
+ const output = lastFrame();
+
+ // Verify totals are summed correctly
+ expect(output).toContain('Cumulative Stats (15 API calls)');
+ expect(output).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 zeroMetrics: SessionMetrics = {
+ models: {},
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
};
- const { lastFrame } = render(
- <SessionSummaryDisplay stats={zeroStats} duration="0s" />,
- );
-
+ const { lastFrame } = renderWithMockedStats(zeroMetrics);
expect(lastFrame()).toMatchSnapshot();
});
});
diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx
index d3ee0f5f..a009f3d8 100644
--- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx
+++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx
@@ -9,31 +9,57 @@ import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
-import { CumulativeStats } from '../contexts/SessionContext.js';
+import { useSessionStats } from '../contexts/SessionContext.js';
+import { computeSessionStats } from '../utils/computeStats.js';
import { FormattedStats, StatRow, StatsColumn } from './Stats.js';
// --- Prop and Data Structures ---
interface SessionSummaryDisplayProps {
- stats: CumulativeStats;
duration: string;
}
// --- Main Component ---
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
- stats,
duration,
}) => {
+ const { stats } = useSessionStats();
+ const { metrics } = stats;
+ const computed = computeSessionStats(metrics);
+
const cumulativeFormatted: FormattedStats = {
- inputTokens: stats.promptTokenCount,
- outputTokens: stats.candidatesTokenCount,
- toolUseTokens: stats.toolUsePromptTokenCount,
- thoughtsTokens: stats.thoughtsTokenCount,
- cachedTokens: stats.cachedContentTokenCount,
- totalTokens: stats.totalTokenCount,
+ inputTokens: Object.values(metrics.models).reduce(
+ (acc, model) => acc + model.tokens.prompt,
+ 0,
+ ),
+ outputTokens: Object.values(metrics.models).reduce(
+ (acc, model) => acc + model.tokens.candidates,
+ 0,
+ ),
+ toolUseTokens: Object.values(metrics.models).reduce(
+ (acc, model) => acc + model.tokens.tool,
+ 0,
+ ),
+ thoughtsTokens: Object.values(metrics.models).reduce(
+ (acc, model) => acc + model.tokens.thoughts,
+ 0,
+ ),
+ cachedTokens: Object.values(metrics.models).reduce(
+ (acc, model) => acc + model.tokens.cached,
+ 0,
+ ),
+ totalTokens: Object.values(metrics.models).reduce(
+ (acc, model) => acc + model.tokens.total,
+ 0,
+ ),
};
+ const totalRequests = Object.values(metrics.models).reduce(
+ (acc, model) => acc + model.api.totalRequests,
+ 0,
+ );
+
const title = 'Agent powering down. Goodbye!';
return (
@@ -57,14 +83,18 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
<Box marginTop={1}>
<StatsColumn
- title={`Cumulative Stats (${stats.turnCount} Turns)`}
+ title={`Cumulative Stats (${totalRequests} API calls)`}
stats={cumulativeFormatted}
isCumulative={true}
>
<Box marginTop={1} flexDirection="column">
<StatRow
label="Total duration (API)"
- value={formatDuration(stats.apiTimeMs)}
+ value={formatDuration(computed.totalApiTime)}
+ />
+ <StatRow
+ label="Total duration (Tools)"
+ value={formatDuration(computed.totalToolTime)}
/>
<StatRow label="Total duration (wall)" value={duration} />
</Box>
diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx
index c7b574a5..29f322f4 100644
--- a/packages/cli/src/ui/components/StatsDisplay.test.tsx
+++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx
@@ -5,67 +5,259 @@
*/
import { render } from 'ink-testing-library';
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js';
-import { type CumulativeStats } from '../contexts/SessionContext.js';
+import * as SessionContext from '../contexts/SessionContext.js';
+import { SessionMetrics } 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,
+// Mock the context to provide controlled data for testing
+vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
+ const actual = await importOriginal<typeof SessionContext>();
+ return {
+ ...actual,
+ useSessionStats: vi.fn(),
};
+});
- const mockLastTurnStats: CumulativeStats = {
- turnCount: 1,
- promptTokenCount: 100,
- candidatesTokenCount: 200,
- totalTokenCount: 350,
- cachedContentTokenCount: 50,
- toolUsePromptTokenCount: 20,
- thoughtsTokenCount: 30,
- apiTimeMs: 1234,
- };
+const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
+
+const renderWithMockedStats = (metrics: SessionMetrics) => {
+ useSessionStatsMock.mockReturnValue({
+ stats: {
+ sessionStartTime: new Date(),
+ metrics,
+ lastPromptTokenCount: 0,
+ },
+ });
+
+ return render(<StatsDisplay duration="1s" />);
+};
+
+describe('<StatsDisplay />', () => {
+ it('renders only the Performance section in its zero state', () => {
+ const zeroMetrics: SessionMetrics = {
+ models: {},
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ };
+
+ const { lastFrame } = renderWithMockedStats(zeroMetrics);
+ const output = lastFrame();
+
+ expect(output).toContain('Performance');
+ expect(output).not.toContain('Interaction Summary');
+ expect(output).not.toContain('Efficiency & Optimizations');
+ expect(output).not.toContain('Model'); // The table header
+ expect(output).toMatchSnapshot();
+ });
- const mockDuration = '1h 23m 45s';
+ it('renders a table with two models correctly', () => {
+ const metrics: SessionMetrics = {
+ models: {
+ 'gemini-2.5-pro': {
+ api: { totalRequests: 3, totalErrors: 0, totalLatencyMs: 15000 },
+ tokens: {
+ prompt: 1000,
+ candidates: 2000,
+ total: 43234,
+ cached: 500,
+ thoughts: 100,
+ tool: 50,
+ },
+ },
+ 'gemini-2.5-flash': {
+ api: { totalRequests: 5, totalErrors: 1, totalLatencyMs: 4500 },
+ tokens: {
+ prompt: 25000,
+ candidates: 15000,
+ total: 150000000,
+ cached: 10000,
+ thoughts: 2000,
+ tool: 1000,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ };
- it('renders correctly with given stats and duration', () => {
- const { lastFrame } = render(
- <StatsDisplay
- stats={mockStats}
- lastTurnStats={mockLastTurnStats}
- duration={mockDuration}
- />,
- );
+ const { lastFrame } = renderWithMockedStats(metrics);
+ const output = lastFrame();
- expect(lastFrame()).toMatchSnapshot();
+ expect(output).toContain('gemini-2.5-pro');
+ expect(output).toContain('gemini-2.5-flash');
+ expect(output).toContain('1,000');
+ expect(output).toContain('25,000');
+ expect(output).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,
+ it('renders all sections when all data is present', () => {
+ const metrics: SessionMetrics = {
+ models: {
+ 'gemini-2.5-pro': {
+ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
+ tokens: {
+ prompt: 100,
+ candidates: 100,
+ total: 250,
+ cached: 50,
+ thoughts: 0,
+ tool: 0,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 2,
+ totalSuccess: 1,
+ totalFail: 1,
+ totalDurationMs: 123,
+ totalDecisions: { accept: 1, reject: 0, modify: 0 },
+ byName: {
+ 'test-tool': {
+ count: 2,
+ success: 1,
+ fail: 1,
+ durationMs: 123,
+ decisions: { accept: 1, reject: 0, modify: 0 },
+ },
+ },
+ },
};
- const { lastFrame } = render(
- <StatsDisplay
- stats={zeroStats}
- lastTurnStats={zeroStats}
- duration="0s"
- />,
- );
+ const { lastFrame } = renderWithMockedStats(metrics);
+ const output = lastFrame();
+
+ expect(output).toContain('Performance');
+ expect(output).toContain('Interaction Summary');
+ expect(output).toContain('User Agreement');
+ expect(output).toContain('Savings Highlight');
+ expect(output).toContain('gemini-2.5-pro');
+ expect(output).toMatchSnapshot();
+ });
+
+ describe('Conditional Rendering Tests', () => {
+ it('hides User Agreement when no decisions are made', () => {
+ const metrics: SessionMetrics = {
+ models: {},
+ tools: {
+ totalCalls: 2,
+ totalSuccess: 1,
+ totalFail: 1,
+ totalDurationMs: 123,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 }, // No decisions
+ byName: {
+ 'test-tool': {
+ count: 2,
+ success: 1,
+ fail: 1,
+ durationMs: 123,
+ decisions: { accept: 0, reject: 0, modify: 0 },
+ },
+ },
+ },
+ };
+
+ const { lastFrame } = renderWithMockedStats(metrics);
+ const output = lastFrame();
+
+ expect(output).toContain('Interaction Summary');
+ expect(output).toContain('Success Rate');
+ expect(output).not.toContain('User Agreement');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('hides Efficiency section when cache is not used', () => {
+ const metrics: SessionMetrics = {
+ models: {
+ 'gemini-2.5-pro': {
+ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
+ tokens: {
+ prompt: 100,
+ candidates: 100,
+ total: 200,
+ cached: 0,
+ thoughts: 0,
+ tool: 0,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ };
+
+ const { lastFrame } = renderWithMockedStats(metrics);
+ const output = lastFrame();
+
+ expect(output).not.toContain('Efficiency & Optimizations');
+ expect(output).toMatchSnapshot();
+ });
+ });
+
+ describe('Conditional Color Tests', () => {
+ it('renders success rate in green for high values', () => {
+ const metrics: SessionMetrics = {
+ models: {},
+ tools: {
+ totalCalls: 10,
+ totalSuccess: 10,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ };
+ const { lastFrame } = renderWithMockedStats(metrics);
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders success rate in yellow for medium values', () => {
+ const metrics: SessionMetrics = {
+ models: {},
+ tools: {
+ totalCalls: 10,
+ totalSuccess: 9,
+ totalFail: 1,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ };
+ const { lastFrame } = renderWithMockedStats(metrics);
+ expect(lastFrame()).toMatchSnapshot();
+ });
- expect(lastFrame()).toMatchSnapshot();
+ it('renders success rate in red for low values', () => {
+ const metrics: SessionMetrics = {
+ models: {},
+ tools: {
+ totalCalls: 10,
+ totalSuccess: 5,
+ totalFail: 5,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ };
+ const { lastFrame } = renderWithMockedStats(metrics);
+ expect(lastFrame()).toMatchSnapshot();
+ });
});
});
diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx
index 76d48821..249fc106 100644
--- a/packages/cli/src/ui/components/StatsDisplay.tsx
+++ b/packages/cli/src/ui/components/StatsDisplay.tsx
@@ -8,90 +8,230 @@ 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';
-import { FormattedStats, StatRow, StatsColumn } from './Stats.js';
+import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
+import {
+ getStatusColor,
+ TOOL_SUCCESS_RATE_HIGH,
+ TOOL_SUCCESS_RATE_MEDIUM,
+ USER_AGREEMENT_RATE_HIGH,
+ USER_AGREEMENT_RATE_MEDIUM,
+} from '../utils/displayUtils.js';
+import { computeSessionStats } from '../utils/computeStats.js';
-// --- Constants ---
+// A more flexible and powerful StatRow component
+interface StatRowProps {
+ title: string;
+ children: React.ReactNode; // Use children to allow for complex, colored values
+}
+
+const StatRow: React.FC<StatRowProps> = ({ title, children }) => (
+ <Box>
+ {/* Fixed width for the label creates a clean "gutter" for alignment */}
+ <Box width={28}>
+ <Text color={Colors.LightBlue}>{title}</Text>
+ </Box>
+ {children}
+ </Box>
+);
+
+// A SubStatRow for indented, secondary information
+interface SubStatRowProps {
+ title: string;
+ children: React.ReactNode;
+}
+
+const SubStatRow: React.FC<SubStatRowProps> = ({ title, children }) => (
+ <Box paddingLeft={2}>
+ {/* Adjust width for the "» " prefix */}
+ <Box width={26}>
+ <Text>» {title}</Text>
+ </Box>
+ {children}
+ </Box>
+);
+
+// A Section component to group related stats
+interface SectionProps {
+ title: string;
+ children: React.ReactNode;
+}
+
+const Section: React.FC<SectionProps> = ({ title, children }) => (
+ <Box flexDirection="column" width="100%" marginBottom={1}>
+ <Text bold>{title}</Text>
+ {children}
+ </Box>
+);
-const COLUMN_WIDTH = '48%';
+const ModelUsageTable: React.FC<{
+ models: Record<string, ModelMetrics>;
+ totalCachedTokens: number;
+ cacheEfficiency: number;
+}> = ({ models, totalCachedTokens, cacheEfficiency }) => {
+ const nameWidth = 25;
+ const requestsWidth = 8;
+ const inputTokensWidth = 15;
+ const outputTokensWidth = 15;
-// --- Prop and Data Structures ---
+ return (
+ <Box flexDirection="column" marginTop={1}>
+ {/* Header */}
+ <Box>
+ <Box width={nameWidth}>
+ <Text bold>Model Usage</Text>
+ </Box>
+ <Box width={requestsWidth} justifyContent="flex-end">
+ <Text bold>Reqs</Text>
+ </Box>
+ <Box width={inputTokensWidth} justifyContent="flex-end">
+ <Text bold>Input Tokens</Text>
+ </Box>
+ <Box width={outputTokensWidth} justifyContent="flex-end">
+ <Text bold>Output Tokens</Text>
+ </Box>
+ </Box>
+ {/* Divider */}
+ <Box
+ borderStyle="round"
+ borderBottom={true}
+ borderTop={false}
+ borderLeft={false}
+ borderRight={false}
+ width={nameWidth + requestsWidth + inputTokensWidth + outputTokensWidth}
+ ></Box>
+
+ {/* Rows */}
+ {Object.entries(models).map(([name, modelMetrics]) => (
+ <Box key={name}>
+ <Box width={nameWidth}>
+ <Text>{name.replace('-001', '')}</Text>
+ </Box>
+ <Box width={requestsWidth} justifyContent="flex-end">
+ <Text>{modelMetrics.api.totalRequests}</Text>
+ </Box>
+ <Box width={inputTokensWidth} justifyContent="flex-end">
+ <Text color={Colors.AccentYellow}>
+ {modelMetrics.tokens.prompt.toLocaleString()}
+ </Text>
+ </Box>
+ <Box width={outputTokensWidth} justifyContent="flex-end">
+ <Text color={Colors.AccentYellow}>
+ {modelMetrics.tokens.candidates.toLocaleString()}
+ </Text>
+ </Box>
+ </Box>
+ ))}
+ {cacheEfficiency > 0 && (
+ <Box flexDirection="column" marginTop={1}>
+ <Text>
+ <Text color={Colors.AccentGreen}>Savings Highlight:</Text>{' '}
+ {totalCachedTokens.toLocaleString()} ({cacheEfficiency.toFixed(1)}
+ %) of input tokens were served from the cache, reducing costs.
+ </Text>
+ <Box height={1} />
+ <Text color={Colors.Gray}>
+ » Tip: For a full token breakdown, run `/stats model`.
+ </Text>
+ </Box>
+ )}
+ </Box>
+ );
+};
interface StatsDisplayProps {
- stats: CumulativeStats;
- lastTurnStats: CumulativeStats;
duration: string;
}
-// --- Main Component ---
+export const StatsDisplay: React.FC<StatsDisplayProps> = ({ duration }) => {
+ const { stats } = useSessionStats();
+ const { metrics } = stats;
+ const { models, tools } = metrics;
+ const computed = computeSessionStats(metrics);
-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 successThresholds = {
+ green: TOOL_SUCCESS_RATE_HIGH,
+ yellow: TOOL_SUCCESS_RATE_MEDIUM,
};
-
- const cumulativeFormatted: FormattedStats = {
- inputTokens: stats.promptTokenCount,
- outputTokens: stats.candidatesTokenCount,
- toolUseTokens: stats.toolUsePromptTokenCount,
- thoughtsTokens: stats.thoughtsTokenCount,
- cachedTokens: stats.cachedContentTokenCount,
- totalTokens: stats.totalTokenCount,
+ const agreementThresholds = {
+ green: USER_AGREEMENT_RATE_HIGH,
+ yellow: USER_AGREEMENT_RATE_MEDIUM,
};
+ const successColor = getStatusColor(computed.successRate, successThresholds);
+ const agreementColor = getStatusColor(
+ computed.agreementRate,
+ agreementThresholds,
+ );
return (
<Box
borderStyle="round"
- borderColor="gray"
+ borderColor={Colors.Gray}
flexDirection="column"
paddingY={1}
paddingX={2}
>
<Text bold color={Colors.AccentPurple}>
- Stats
+ Session Stats
</Text>
+ <Box height={1} />
- <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
- <StatsColumn
- title="Last Turn"
- stats={lastTurnFormatted}
- width={COLUMN_WIDTH}
- />
- <StatsColumn
- title={`Cumulative (${stats.turnCount} Turns)`}
- stats={cumulativeFormatted}
- isCumulative={true}
- width={COLUMN_WIDTH}
- />
- </Box>
+ {tools.totalCalls > 0 && (
+ <Section title="Interaction Summary">
+ <StatRow title="Tool Calls:">
+ <Text>
+ {tools.totalCalls} ({' '}
+ <Text color={Colors.AccentGreen}>✔ {tools.totalSuccess}</Text>{' '}
+ <Text color={Colors.AccentRed}>✖ {tools.totalFail}</Text> )
+ </Text>
+ </StatRow>
+ <StatRow title="Success Rate:">
+ <Text color={successColor}>{computed.successRate.toFixed(1)}%</Text>
+ </StatRow>
+ {computed.totalDecisions > 0 && (
+ <StatRow title="User Agreement:">
+ <Text color={agreementColor}>
+ {computed.agreementRate.toFixed(1)}%{' '}
+ <Text color={Colors.Gray}>
+ ({computed.totalDecisions} reviewed)
+ </Text>
+ </Text>
+ </StatRow>
+ )}
+ </Section>
+ )}
- <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>
+ <Section title="Performance">
+ <StatRow title="Wall Time:">
+ <Text>{duration}</Text>
+ </StatRow>
+ <StatRow title="Agent Active:">
+ <Text>{formatDuration(computed.agentActiveTime)}</Text>
+ </StatRow>
+ <SubStatRow title="API Time:">
+ <Text>
+ {formatDuration(computed.totalApiTime)}{' '}
+ <Text color={Colors.Gray}>
+ ({computed.apiTimePercent.toFixed(1)}%)
+ </Text>
+ </Text>
+ </SubStatRow>
+ <SubStatRow title="Tool Time:">
+ <Text>
+ {formatDuration(computed.totalToolTime)}{' '}
+ <Text color={Colors.Gray}>
+ ({computed.toolTimePercent.toFixed(1)}%)
+ </Text>
+ </Text>
+ </SubStatRow>
+ </Section>
- {/* 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>
+ {Object.keys(models).length > 0 && (
+ <ModelUsageTable
+ models={models}
+ totalCachedTokens={computed.totalCachedTokens}
+ cacheEfficiency={computed.cacheEfficiency}
+ />
+ )}
</Box>
);
};
diff --git a/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx b/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx
new file mode 100644
index 00000000..54902788
--- /dev/null
+++ b/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx
@@ -0,0 +1,176 @@
+/**
+ * @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 { ToolStatsDisplay } from './ToolStatsDisplay.js';
+import * as SessionContext from '../contexts/SessionContext.js';
+import { SessionMetrics } from '../contexts/SessionContext.js';
+
+// Mock the context to provide controlled data for testing
+vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
+ const actual = await importOriginal<typeof SessionContext>();
+ return {
+ ...actual,
+ useSessionStats: vi.fn(),
+ };
+});
+
+const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
+
+const renderWithMockedStats = (metrics: SessionMetrics) => {
+ useSessionStatsMock.mockReturnValue({
+ stats: {
+ sessionStartTime: new Date(),
+ metrics,
+ lastPromptTokenCount: 0,
+ },
+ });
+
+ return render(<ToolStatsDisplay />);
+};
+
+describe('<ToolStatsDisplay />', () => {
+ it('should render "no tool calls" message when there are no active tools', () => {
+ const { lastFrame } = renderWithMockedStats({
+ models: {},
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ });
+
+ expect(lastFrame()).toContain(
+ 'No tool calls have been made in this session.',
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('should display stats for a single tool correctly', () => {
+ const { lastFrame } = renderWithMockedStats({
+ models: {},
+ tools: {
+ totalCalls: 1,
+ totalSuccess: 1,
+ totalFail: 0,
+ totalDurationMs: 100,
+ totalDecisions: { accept: 1, reject: 0, modify: 0 },
+ byName: {
+ 'test-tool': {
+ count: 1,
+ success: 1,
+ fail: 0,
+ durationMs: 100,
+ decisions: { accept: 1, reject: 0, modify: 0 },
+ },
+ },
+ },
+ });
+
+ const output = lastFrame();
+ expect(output).toContain('test-tool');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('should display stats for multiple tools correctly', () => {
+ const { lastFrame } = renderWithMockedStats({
+ models: {},
+ tools: {
+ totalCalls: 3,
+ totalSuccess: 2,
+ totalFail: 1,
+ totalDurationMs: 300,
+ totalDecisions: { accept: 1, reject: 1, modify: 1 },
+ byName: {
+ 'tool-a': {
+ count: 2,
+ success: 1,
+ fail: 1,
+ durationMs: 200,
+ decisions: { accept: 1, reject: 1, modify: 0 },
+ },
+ 'tool-b': {
+ count: 1,
+ success: 1,
+ fail: 0,
+ durationMs: 100,
+ decisions: { accept: 0, reject: 0, modify: 1 },
+ },
+ },
+ },
+ });
+
+ const output = lastFrame();
+ expect(output).toContain('tool-a');
+ expect(output).toContain('tool-b');
+ expect(output).toMatchSnapshot();
+ });
+
+ it('should handle large values without wrapping or overlapping', () => {
+ const { lastFrame } = renderWithMockedStats({
+ models: {},
+ tools: {
+ totalCalls: 999999999,
+ totalSuccess: 888888888,
+ totalFail: 111111111,
+ totalDurationMs: 987654321,
+ totalDecisions: {
+ accept: 123456789,
+ reject: 98765432,
+ modify: 12345,
+ },
+ byName: {
+ 'long-named-tool-for-testing-wrapping-and-such': {
+ count: 999999999,
+ success: 888888888,
+ fail: 111111111,
+ durationMs: 987654321,
+ decisions: {
+ accept: 123456789,
+ reject: 98765432,
+ modify: 12345,
+ },
+ },
+ },
+ },
+ });
+
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('should handle zero decisions gracefully', () => {
+ const { lastFrame } = renderWithMockedStats({
+ models: {},
+ tools: {
+ totalCalls: 1,
+ totalSuccess: 1,
+ totalFail: 0,
+ totalDurationMs: 100,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {
+ 'test-tool': {
+ count: 1,
+ success: 1,
+ fail: 0,
+ durationMs: 100,
+ decisions: { accept: 0, reject: 0, modify: 0 },
+ },
+ },
+ },
+ });
+
+ const output = lastFrame();
+ expect(output).toContain('Total Reviewed Suggestions:');
+ expect(output).toContain('0');
+ expect(output).toContain('Overall Agreement Rate:');
+ expect(output).toContain('--');
+ expect(output).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/ToolStatsDisplay.tsx b/packages/cli/src/ui/components/ToolStatsDisplay.tsx
new file mode 100644
index 00000000..f2335d9e
--- /dev/null
+++ b/packages/cli/src/ui/components/ToolStatsDisplay.tsx
@@ -0,0 +1,208 @@
+/**
+ * @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 {
+ getStatusColor,
+ TOOL_SUCCESS_RATE_HIGH,
+ TOOL_SUCCESS_RATE_MEDIUM,
+ USER_AGREEMENT_RATE_HIGH,
+ USER_AGREEMENT_RATE_MEDIUM,
+} from '../utils/displayUtils.js';
+import { useSessionStats } from '../contexts/SessionContext.js';
+import { ToolCallStats } from '@google/gemini-cli-core';
+
+const TOOL_NAME_COL_WIDTH = 25;
+const CALLS_COL_WIDTH = 8;
+const SUCCESS_RATE_COL_WIDTH = 15;
+const AVG_DURATION_COL_WIDTH = 15;
+
+const StatRow: React.FC<{
+ name: string;
+ stats: ToolCallStats;
+}> = ({ name, stats }) => {
+ const successRate = stats.count > 0 ? (stats.success / stats.count) * 100 : 0;
+ const avgDuration = stats.count > 0 ? stats.durationMs / stats.count : 0;
+ const successColor = getStatusColor(successRate, {
+ green: TOOL_SUCCESS_RATE_HIGH,
+ yellow: TOOL_SUCCESS_RATE_MEDIUM,
+ });
+
+ return (
+ <Box>
+ <Box width={TOOL_NAME_COL_WIDTH}>
+ <Text color={Colors.LightBlue}>{name}</Text>
+ </Box>
+ <Box width={CALLS_COL_WIDTH} justifyContent="flex-end">
+ <Text>{stats.count}</Text>
+ </Box>
+ <Box width={SUCCESS_RATE_COL_WIDTH} justifyContent="flex-end">
+ <Text color={successColor}>{successRate.toFixed(1)}%</Text>
+ </Box>
+ <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
+ <Text>{formatDuration(avgDuration)}</Text>
+ </Box>
+ </Box>
+ );
+};
+
+export const ToolStatsDisplay: React.FC = () => {
+ const { stats } = useSessionStats();
+ const { tools } = stats.metrics;
+ const activeTools = Object.entries(tools.byName).filter(
+ ([, metrics]) => metrics.count > 0,
+ );
+
+ if (activeTools.length === 0) {
+ return (
+ <Box
+ borderStyle="round"
+ borderColor={Colors.Gray}
+ paddingY={1}
+ paddingX={2}
+ >
+ <Text>No tool calls have been made in this session.</Text>
+ </Box>
+ );
+ }
+
+ const totalDecisions = Object.values(tools.byName).reduce(
+ (acc, tool) => {
+ acc.accept += tool.decisions.accept;
+ acc.reject += tool.decisions.reject;
+ acc.modify += tool.decisions.modify;
+ return acc;
+ },
+ { accept: 0, reject: 0, modify: 0 },
+ );
+
+ const totalReviewed =
+ totalDecisions.accept + totalDecisions.reject + totalDecisions.modify;
+ const agreementRate =
+ totalReviewed > 0 ? (totalDecisions.accept / totalReviewed) * 100 : 0;
+ const agreementColor = getStatusColor(agreementRate, {
+ green: USER_AGREEMENT_RATE_HIGH,
+ yellow: USER_AGREEMENT_RATE_MEDIUM,
+ });
+
+ return (
+ <Box
+ borderStyle="round"
+ borderColor={Colors.Gray}
+ flexDirection="column"
+ paddingY={1}
+ paddingX={2}
+ width={70}
+ >
+ <Text bold color={Colors.AccentPurple}>
+ Tool Stats For Nerds
+ </Text>
+ <Box height={1} />
+
+ {/* Header */}
+ <Box>
+ <Box width={TOOL_NAME_COL_WIDTH}>
+ <Text bold>Tool Name</Text>
+ </Box>
+ <Box width={CALLS_COL_WIDTH} justifyContent="flex-end">
+ <Text bold>Calls</Text>
+ </Box>
+ <Box width={SUCCESS_RATE_COL_WIDTH} justifyContent="flex-end">
+ <Text bold>Success Rate</Text>
+ </Box>
+ <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
+ <Text bold>Avg Duration</Text>
+ </Box>
+ </Box>
+
+ {/* Divider */}
+ <Box
+ borderStyle="single"
+ borderBottom={true}
+ borderTop={false}
+ borderLeft={false}
+ borderRight={false}
+ width="100%"
+ />
+
+ {/* Tool Rows */}
+ {activeTools.map(([name, stats]) => (
+ <StatRow key={name} name={name} stats={stats as ToolCallStats} />
+ ))}
+
+ <Box height={1} />
+
+ {/* User Decision Summary */}
+ <Text bold>User Decision Summary</Text>
+ <Box>
+ <Box
+ width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
+ >
+ <Text color={Colors.LightBlue}>Total Reviewed Suggestions:</Text>
+ </Box>
+ <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
+ <Text>{totalReviewed}</Text>
+ </Box>
+ </Box>
+ <Box>
+ <Box
+ width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
+ >
+ <Text> » Accepted:</Text>
+ </Box>
+ <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
+ <Text color={Colors.AccentGreen}>{totalDecisions.accept}</Text>
+ </Box>
+ </Box>
+ <Box>
+ <Box
+ width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
+ >
+ <Text> » Rejected:</Text>
+ </Box>
+ <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
+ <Text color={Colors.AccentRed}>{totalDecisions.reject}</Text>
+ </Box>
+ </Box>
+ <Box>
+ <Box
+ width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
+ >
+ <Text> » Modified:</Text>
+ </Box>
+ <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
+ <Text color={Colors.AccentYellow}>{totalDecisions.modify}</Text>
+ </Box>
+ </Box>
+
+ {/* Divider */}
+ <Box
+ borderStyle="single"
+ borderBottom={true}
+ borderTop={false}
+ borderLeft={false}
+ borderRight={false}
+ width="100%"
+ />
+
+ <Box>
+ <Box
+ width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
+ >
+ <Text> Overall Agreement Rate:</Text>
+ </Box>
+ <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
+ <Text bold color={totalReviewed > 0 ? agreementColor : undefined}>
+ {totalReviewed > 0 ? `${agreementRate.toFixed(1)}%` : '--'}
+ </Text>
+ </Box>
+ </Box>
+ </Box>
+ );
+};
diff --git a/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap
new file mode 100644
index 00000000..efc0862b
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap
@@ -0,0 +1,121 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ Metric gemini-2.5-pro │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 1 │
+│ Errors 0 (0.0%) │
+│ Avg Latency 100ms │
+│ │
+│ Tokens │
+│ Total 30 │
+│ ↳ Prompt 10 │
+│ ↳ Cached 5 (50.0%) │
+│ ↳ Thoughts 2 │
+│ ↳ Tool 1 │
+│ ↳ Output 20 │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<ModelStatsDisplay /> > should display conditional rows if at least one model has data 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ Metric gemini-2.5-pro gemini-2.5-flash │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 1 1 │
+│ Errors 0 (0.0%) 0 (0.0%) │
+│ Avg Latency 100ms 50ms │
+│ │
+│ Tokens │
+│ Total 30 15 │
+│ ↳ Prompt 10 5 │
+│ ↳ Cached 5 (50.0%) 0 (0.0%) │
+│ ↳ Thoughts 2 0 │
+│ ↳ Tool 0 3 │
+│ ↳ Output 20 10 │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<ModelStatsDisplay /> > should display stats for multiple models correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ Metric gemini-2.5-pro gemini-2.5-flash │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 10 20 │
+│ Errors 1 (10.0%) 2 (10.0%) │
+│ Avg Latency 100ms 25ms │
+│ │
+│ Tokens │
+│ Total 300 600 │
+│ ↳ Prompt 100 200 │
+│ ↳ Cached 50 (50.0%) 100 (50.0%) │
+│ ↳ Thoughts 10 20 │
+│ ↳ Tool 5 10 │
+│ ↳ Output 200 400 │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<ModelStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ Metric gemini-2.5-pro │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 999,999,999 │
+│ Errors 123,456,789 (12.3%) │
+│ Avg Latency 0ms │
+│ │
+│ Tokens │
+│ Total 999,999,999 │
+│ ↳ Prompt 987,654,321 │
+│ ↳ Cached 123,456,789 (12.5%) │
+│ ↳ Thoughts 111,111,111 │
+│ ↳ Tool 222,222,222 │
+│ ↳ Output 123,456,789 │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<ModelStatsDisplay /> > should not display conditional rows if no model has data for them 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Model Stats For Nerds │
+│ │
+│ Metric gemini-2.5-pro │
+│ ────────────────────────────────────────────────────────────────────────────────────────────── │
+│ API │
+│ Requests 1 │
+│ Errors 0 (0.0%) │
+│ Avg Latency 100ms │
+│ │
+│ Tokens │
+│ Total 30 │
+│ ↳ Prompt 10 │
+│ ↳ Output 20 │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<ModelStatsDisplay /> > should render "no API calls" message when there are no active models 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ No API calls have been made in this session. │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
index 3d2c373c..06dc2116 100644
--- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap
@@ -1,43 +1,45 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`<SessionSummaryDisplay /> > renders correctly with given stats and duration 1`] = `
+exports[`<SessionSummaryDisplay /> > correctly sums and displays stats from multiple models 1`] = `
"╭─────────────────────────────────────╮
│ │
│ Agent powering down. Goodbye! │
│ │
│ │
-│ Cumulative Stats (10 Turns) │
+│ Cumulative Stats (15 API calls) │
│ │
-│ Input Tokens 1,000 │
-│ Output Tokens 2,000 │
-│ Tool Use Tokens 200 │
-│ Thoughts Tokens 300 │
-│ Cached Tokens 500 (14.3%) │
+│ Input Tokens 1,500 │
+│ Output Tokens 3,000 │
+│ Tool Use Tokens 220 │
+│ Thoughts Tokens 350 │
+│ Cached Tokens 600 (12.0%) │
│ ───────────────────────────────── │
-│ Total Tokens 3,500 │
+│ Total Tokens 5,000 │
│ │
-│ Total duration (API) 50.2s │
+│ Total duration (API) 1m 2s │
+│ Total duration (Tools) 0s │
│ Total duration (wall) 1h 23m 45s │
│ │
╰─────────────────────────────────────╯"
`;
exports[`<SessionSummaryDisplay /> > renders zero state correctly 1`] = `
-"╭─────────────────────────────────╮
-│ │
-│ Agent powering down. Goodbye! │
-│ │
-│ │
-│ Cumulative Stats (0 Turns) │
-│ │
-│ Input Tokens 0 │
-│ Output Tokens 0 │
-│ Thoughts Tokens 0 │
-│ ────────────────────────── │
-│ Total Tokens 0 │
-│ │
-│ Total duration (API) 0s │
-│ Total duration (wall) 0s │
-│ │
-╰─────────────────────────────────╯"
+"╭─────────────────────────────────────╮
+│ │
+│ Agent powering down. Goodbye! │
+│ │
+│ │
+│ Cumulative Stats (0 API calls) │
+│ │
+│ Input Tokens 0 │
+│ Output Tokens 0 │
+│ Thoughts Tokens 0 │
+│ ───────────────────────────────── │
+│ Total Tokens 0 │
+│ │
+│ Total duration (API) 0s │
+│ Total duration (Tools) 0s │
+│ Total duration (wall) 1h 23m 45s │
+│ │
+╰─────────────────────────────────────╯"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
index b8a070a3..6fc2565e 100644
--- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
@@ -1,41 +1,163 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`<StatsDisplay /> > renders correctly with given stats and duration 1`] = `
+exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in green for high values 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
-│ Stats │
+│ Session Stats │
│ │
-│ Last Turn Cumulative (10 Turns) │
+│ Interaction Summary │
+│ Tool Calls: 10 ( ✔ 10 ✖ 0 ) │
+│ Success Rate: 100.0% │
│ │
-│ 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 │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 0s │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 0s (0.0%) │
│ │
-│ Turn Duration (API) 1.2s Total duration (API) 50.2s │
-│ Total duration (wall) 1h 23m 45s │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
-exports[`<StatsDisplay /> > renders zero state correctly 1`] = `
+exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in red for low values 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
-│ Stats │
+│ Session Stats │
│ │
-│ Last Turn Cumulative (0 Turns) │
+│ Interaction Summary │
+│ Tool Calls: 10 ( ✔ 5 ✖ 5 ) │
+│ Success Rate: 50.0% │
│ │
-│ Input Tokens 0 Input Tokens 0 │
-│ Output Tokens 0 Output Tokens 0 │
-│ Thoughts Tokens 0 Thoughts Tokens 0 │
-│ ───────────────────────────────────────────── ───────────────────────────────────────────── │
-│ Total Tokens 0 Total Tokens 0 │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 0s │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in yellow for medium values 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Tool Calls: 10 ( ✔ 9 ✖ 1 ) │
+│ Success Rate: 90.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 0s │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency section when cache is not used 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 100ms │
+│ » API Time: 100ms (100.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+│ │
+│ Model Usage Reqs Input Tokens Output Tokens │
+│ ─────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 1 100 100 │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement when no decisions are made 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
+│ Success Rate: 50.0% │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 123ms │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 123ms (100.0%) │
+│ │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 19.5s │
+│ » API Time: 19.5s (100.0%) │
+│ » Tool Time: 0s (0.0%) │
+│ │
+│ │
+│ Model Usage Reqs Input Tokens Output Tokens │
+│ ─────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 3 1,000 2,000 │
+│ gemini-2.5-flash 5 25,000 15,000 │
+│ │
+│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
+│ │
+│ » Tip: For a full token breakdown, run \`/stats model\`. │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<StatsDisplay /> > renders all sections when all data is present 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Interaction Summary │
+│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
+│ Success Rate: 50.0% │
+│ User Agreement: 100.0% (1 reviewed) │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 223ms │
+│ » API Time: 100ms (44.8%) │
+│ » Tool Time: 123ms (55.2%) │
+│ │
+│ │
+│ Model Usage Reqs Input Tokens Output Tokens │
+│ ─────────────────────────────────────────────────────────────── │
+│ gemini-2.5-pro 1 100 100 │
+│ │
+│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
+│ │
+│ » Tip: For a full token breakdown, run \`/stats model\`. │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<StatsDisplay /> > renders only the Performance section in its zero state 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Session Stats │
+│ │
+│ Performance │
+│ Wall Time: 1s │
+│ Agent Active: 0s │
+│ » API Time: 0s (0.0%) │
+│ » Tool Time: 0s (0.0%) │
│ │
-│ Turn Duration (API) 0s Total duration (API) 0s │
-│ Total duration (wall) 0s │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ToolStatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolStatsDisplay.test.tsx.snap
new file mode 100644
index 00000000..61fb3efc
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ToolStatsDisplay.test.tsx.snap
@@ -0,0 +1,91 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`<ToolStatsDisplay /> > should display stats for a single tool correctly 1`] = `
+"╭────────────────────────────────────────────────────────────────────╮
+│ │
+│ Tool Stats For Nerds │
+│ │
+│ Tool Name Calls Success Rate Avg Duration │
+│ ──────────────────────────────────────────────────────────────── │
+│ test-tool 1 100.0% 100ms │
+│ │
+│ User Decision Summary │
+│ Total Reviewed Suggestions: 1 │
+│ » Accepted: 1 │
+│ » Rejected: 0 │
+│ » Modified: 0 │
+│ ──────────────────────────────────────────────────────────────── │
+│ Overall Agreement Rate: 100.0% │
+│ │
+╰────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<ToolStatsDisplay /> > should display stats for multiple tools correctly 1`] = `
+"╭────────────────────────────────────────────────────────────────────╮
+│ │
+│ Tool Stats For Nerds │
+│ │
+│ Tool Name Calls Success Rate Avg Duration │
+│ ──────────────────────────────────────────────────────────────── │
+│ tool-a 2 50.0% 100ms │
+│ tool-b 1 100.0% 100ms │
+│ │
+│ User Decision Summary │
+│ Total Reviewed Suggestions: 3 │
+│ » Accepted: 1 │
+│ » Rejected: 1 │
+│ » Modified: 1 │
+│ ──────────────────────────────────────────────────────────────── │
+│ Overall Agreement Rate: 33.3% │
+│ │
+╰────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<ToolStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
+"╭────────────────────────────────────────────────────────────────────╮
+│ │
+│ Tool Stats For Nerds │
+│ │
+│ Tool Name Calls Success Rate Avg Duration │
+│ ──────────────────────────────────────────────────────────────── │
+│ long-named-tool-for-testi99999999 88.9% 1ms │
+│ ng-wrapping-and-such 9 │
+│ │
+│ User Decision Summary │
+│ Total Reviewed Suggestions: 222234566 │
+│ » Accepted: 123456789 │
+│ » Rejected: 98765432 │
+│ » Modified: 12345 │
+│ ──────────────────────────────────────────────────────────────── │
+│ Overall Agreement Rate: 55.6% │
+│ │
+╰────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<ToolStatsDisplay /> > should handle zero decisions gracefully 1`] = `
+"╭────────────────────────────────────────────────────────────────────╮
+│ │
+│ Tool Stats For Nerds │
+│ │
+│ Tool Name Calls Success Rate Avg Duration │
+│ ──────────────────────────────────────────────────────────────── │
+│ test-tool 1 100.0% 100ms │
+│ │
+│ User Decision Summary │
+│ Total Reviewed Suggestions: 0 │
+│ » Accepted: 0 │
+│ » Rejected: 0 │
+│ » Modified: 0 │
+│ ──────────────────────────────────────────────────────────────── │
+│ Overall Agreement Rate: -- │
+│ │
+╰────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[`<ToolStatsDisplay /> > should render "no tool calls" message when there are no active tools 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ No tool calls have been made in this session. │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;