summaryrefslogtreecommitdiff
path: root/packages/cli
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli')
-rw-r--r--packages/cli/src/ui/App.tsx6
-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
-rw-r--r--packages/cli/src/ui/contexts/SessionContext.test.tsx223
-rw-r--r--packages/cli/src/ui/contexts/SessionContext.tsx183
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.test.ts50
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts36
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.test.tsx72
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts11
-rw-r--r--packages/cli/src/ui/types.ts29
-rw-r--r--packages/cli/src/ui/utils/computeStats.test.ts247
-rw-r--r--packages/cli/src/ui/utils/computeStats.ts84
-rw-r--r--packages/cli/src/ui/utils/displayUtils.test.ts58
-rw-r--r--packages/cli/src/ui/utils/displayUtils.ts32
-rw-r--r--packages/cli/src/ui/utils/formatters.ts2
28 files changed, 2419 insertions, 657 deletions
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 0848e330..66d521fc 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -823,11 +823,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
showMemoryUsage={
config.getDebugMode() || config.getShowMemoryUsage()
}
- promptTokenCount={sessionStats.currentResponse.promptTokenCount}
- candidatesTokenCount={
- sessionStats.currentResponse.candidatesTokenCount
- }
- totalTokenCount={sessionStats.currentResponse.totalTokenCount}
+ promptTokenCount={sessionStats.lastPromptTokenCount}
/>
</Box>
</Box>
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. │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
diff --git a/packages/cli/src/ui/contexts/SessionContext.test.tsx b/packages/cli/src/ui/contexts/SessionContext.test.tsx
index fedb5341..5b05c284 100644
--- a/packages/cli/src/ui/contexts/SessionContext.test.tsx
+++ b/packages/cli/src/ui/contexts/SessionContext.test.tsx
@@ -8,28 +8,13 @@ import { type MutableRefObject } from 'react';
import { render } from 'ink-testing-library';
import { renderHook } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
-import { SessionStatsProvider, useSessionStats } from './SessionContext.js';
+import {
+ SessionStatsProvider,
+ useSessionStats,
+ SessionMetrics,
+} from './SessionContext.js';
import { describe, it, expect, vi } from 'vitest';
-import { GenerateContentResponseUsageMetadata } from '@google/genai';
-
-// Mock data that simulates what the Gemini API would return.
-const mockMetadata1: GenerateContentResponseUsageMetadata = {
- promptTokenCount: 100,
- candidatesTokenCount: 200,
- totalTokenCount: 300,
- cachedContentTokenCount: 50,
- toolUsePromptTokenCount: 10,
- thoughtsTokenCount: 20,
-};
-
-const mockMetadata2: GenerateContentResponseUsageMetadata = {
- promptTokenCount: 10,
- candidatesTokenCount: 20,
- totalTokenCount: 30,
- cachedContentTokenCount: 5,
- toolUsePromptTokenCount: 1,
- thoughtsTokenCount: 2,
-};
+import { uiTelemetryService } from '@google/gemini-cli-core';
/**
* A test harness component that uses the hook and exposes the context value
@@ -60,13 +45,11 @@ describe('SessionStatsContext', () => {
const stats = contextRef.current?.stats;
expect(stats?.sessionStartTime).toBeInstanceOf(Date);
- expect(stats?.currentTurn).toBeDefined();
- expect(stats?.cumulative.turnCount).toBe(0);
- expect(stats?.cumulative.totalTokenCount).toBe(0);
- expect(stats?.cumulative.promptTokenCount).toBe(0);
+ expect(stats?.metrics).toBeDefined();
+ expect(stats?.metrics.models).toEqual({});
});
- it('should increment turnCount when startNewTurn is called', () => {
+ it('should update metrics when the uiTelemetryService emits an update', () => {
const contextRef: MutableRefObject<
ReturnType<typeof useSessionStats> | undefined
> = { current: undefined };
@@ -77,150 +60,60 @@ describe('SessionStatsContext', () => {
</SessionStatsProvider>,
);
- act(() => {
- contextRef.current?.startNewTurn();
- });
-
- const stats = contextRef.current?.stats;
- expect(stats?.currentTurn.totalTokenCount).toBe(0);
- expect(stats?.cumulative.turnCount).toBe(1);
- // Ensure token counts are unaffected
- expect(stats?.cumulative.totalTokenCount).toBe(0);
- });
-
- it('should aggregate token usage correctly when addUsage is called', () => {
- const contextRef: MutableRefObject<
- ReturnType<typeof useSessionStats> | undefined
- > = { current: undefined };
-
- render(
- <SessionStatsProvider>
- <TestHarness contextRef={contextRef} />
- </SessionStatsProvider>,
- );
+ const newMetrics: SessionMetrics = {
+ models: {
+ 'gemini-pro': {
+ api: {
+ totalRequests: 1,
+ totalErrors: 0,
+ totalLatencyMs: 123,
+ },
+ tokens: {
+ prompt: 100,
+ candidates: 200,
+ total: 300,
+ cached: 50,
+ thoughts: 20,
+ tool: 10,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 1,
+ totalSuccess: 1,
+ totalFail: 0,
+ totalDurationMs: 456,
+ totalDecisions: {
+ accept: 1,
+ reject: 0,
+ modify: 0,
+ },
+ byName: {
+ 'test-tool': {
+ count: 1,
+ success: 1,
+ fail: 0,
+ durationMs: 456,
+ decisions: {
+ accept: 1,
+ reject: 0,
+ modify: 0,
+ },
+ },
+ },
+ },
+ };
act(() => {
- contextRef.current?.addUsage({ ...mockMetadata1, apiTimeMs: 123 });
+ uiTelemetryService.emit('update', {
+ metrics: newMetrics,
+ lastPromptTokenCount: 100,
+ });
});
const stats = contextRef.current?.stats;
-
- // Check that token counts are updated
- expect(stats?.cumulative.totalTokenCount).toBe(
- mockMetadata1.totalTokenCount ?? 0,
- );
- expect(stats?.cumulative.promptTokenCount).toBe(
- mockMetadata1.promptTokenCount ?? 0,
- );
- expect(stats?.cumulative.apiTimeMs).toBe(123);
-
- // Check that turn count is NOT incremented
- expect(stats?.cumulative.turnCount).toBe(0);
-
- // Check that currentTurn is updated
- expect(stats?.currentTurn?.totalTokenCount).toEqual(
- mockMetadata1.totalTokenCount,
- );
- expect(stats?.currentTurn?.apiTimeMs).toBe(123);
- });
-
- it('should correctly track a full logical turn with multiple API calls', () => {
- const contextRef: MutableRefObject<
- ReturnType<typeof useSessionStats> | undefined
- > = { current: undefined };
-
- render(
- <SessionStatsProvider>
- <TestHarness contextRef={contextRef} />
- </SessionStatsProvider>,
- );
-
- // 1. User starts a new turn
- act(() => {
- contextRef.current?.startNewTurn();
- });
-
- // 2. First API call (e.g., prompt with a tool request)
- act(() => {
- contextRef.current?.addUsage({ ...mockMetadata1, apiTimeMs: 100 });
- });
-
- // 3. Second API call (e.g., sending tool response back)
- act(() => {
- contextRef.current?.addUsage({ ...mockMetadata2, apiTimeMs: 50 });
- });
-
- const stats = contextRef.current?.stats;
-
- // Turn count should only be 1
- expect(stats?.cumulative.turnCount).toBe(1);
-
- // --- Check Cumulative Stats ---
- // These fields should be the SUM of both calls
- expect(stats?.cumulative.totalTokenCount).toBe(300 + 30);
- expect(stats?.cumulative.candidatesTokenCount).toBe(200 + 20);
- expect(stats?.cumulative.thoughtsTokenCount).toBe(20 + 2);
- expect(stats?.cumulative.apiTimeMs).toBe(100 + 50);
-
- // These fields should be the SUM of both calls
- expect(stats?.cumulative.promptTokenCount).toBe(100 + 10);
- expect(stats?.cumulative.cachedContentTokenCount).toBe(50 + 5);
- expect(stats?.cumulative.toolUsePromptTokenCount).toBe(10 + 1);
-
- // --- Check Current Turn Stats ---
- // All fields should be the SUM of both calls for the turn
- expect(stats?.currentTurn.totalTokenCount).toBe(300 + 30);
- expect(stats?.currentTurn.candidatesTokenCount).toBe(200 + 20);
- expect(stats?.currentTurn.thoughtsTokenCount).toBe(20 + 2);
- expect(stats?.currentTurn.promptTokenCount).toBe(100 + 10);
- expect(stats?.currentTurn.cachedContentTokenCount).toBe(50 + 5);
- expect(stats?.currentTurn.toolUsePromptTokenCount).toBe(10 + 1);
- expect(stats?.currentTurn.apiTimeMs).toBe(100 + 50);
- });
-
- it('should overwrite currentResponse with each API call', () => {
- const contextRef: MutableRefObject<
- ReturnType<typeof useSessionStats> | undefined
- > = { current: undefined };
-
- render(
- <SessionStatsProvider>
- <TestHarness contextRef={contextRef} />
- </SessionStatsProvider>,
- );
-
- // 1. First API call
- act(() => {
- contextRef.current?.addUsage({ ...mockMetadata1, apiTimeMs: 100 });
- });
-
- let stats = contextRef.current?.stats;
-
- // currentResponse should match the first call
- expect(stats?.currentResponse.totalTokenCount).toBe(300);
- expect(stats?.currentResponse.apiTimeMs).toBe(100);
-
- // 2. Second API call
- act(() => {
- contextRef.current?.addUsage({ ...mockMetadata2, apiTimeMs: 50 });
- });
-
- stats = contextRef.current?.stats;
-
- // currentResponse should now match the second call
- expect(stats?.currentResponse.totalTokenCount).toBe(30);
- expect(stats?.currentResponse.apiTimeMs).toBe(50);
-
- // 3. Start a new turn
- act(() => {
- contextRef.current?.startNewTurn();
- });
-
- stats = contextRef.current?.stats;
-
- // currentResponse should be reset
- expect(stats?.currentResponse.totalTokenCount).toBe(0);
- expect(stats?.currentResponse.apiTimeMs).toBe(0);
+ expect(stats?.metrics).toEqual(newMetrics);
+ expect(stats?.lastPromptTokenCount).toBe(100);
});
it('should throw an error when useSessionStats is used outside of a provider', () => {
diff --git a/packages/cli/src/ui/contexts/SessionContext.tsx b/packages/cli/src/ui/contexts/SessionContext.tsx
index f59e17e1..b89d19e7 100644
--- a/packages/cli/src/ui/contexts/SessionContext.tsx
+++ b/packages/cli/src/ui/contexts/SessionContext.tsx
@@ -9,39 +9,43 @@ import React, {
useContext,
useState,
useMemo,
- useCallback,
+ useEffect,
} from 'react';
-import { type GenerateContentResponseUsageMetadata } from '@google/genai';
+import {
+ uiTelemetryService,
+ SessionMetrics,
+ ModelMetrics,
+} from '@google/gemini-cli-core';
// --- Interface Definitions ---
-export interface CumulativeStats {
- turnCount: number;
- promptTokenCount: number;
- candidatesTokenCount: number;
- totalTokenCount: number;
- cachedContentTokenCount: number;
- toolUsePromptTokenCount: number;
- thoughtsTokenCount: number;
- apiTimeMs: number;
-}
+export type { SessionMetrics, ModelMetrics };
interface SessionStatsState {
sessionStartTime: Date;
- cumulative: CumulativeStats;
- currentTurn: CumulativeStats;
- currentResponse: CumulativeStats;
+ metrics: SessionMetrics;
+ lastPromptTokenCount: number;
+}
+
+export interface ComputedSessionStats {
+ totalApiTime: number;
+ totalToolTime: number;
+ agentActiveTime: number;
+ apiTimePercent: number;
+ toolTimePercent: number;
+ cacheEfficiency: number;
+ totalDecisions: number;
+ successRate: number;
+ agreementRate: number;
+ totalCachedTokens: number;
+ totalPromptTokens: number;
}
// Defines the final "value" of our context, including the state
// and the functions to update it.
interface SessionStatsContextValue {
stats: SessionStatsState;
- startNewTurn: () => void;
- addUsage: (
- metadata: GenerateContentResponseUsageMetadata & { apiTimeMs?: number },
- ) => void;
}
// --- Context Definition ---
@@ -50,27 +54,6 @@ const SessionStatsContext = createContext<SessionStatsContextValue | undefined>(
undefined,
);
-// --- Helper Functions ---
-
-/**
- * A small, reusable helper function to sum token counts.
- * It unconditionally adds all token values from the source to the target.
- * @param target The object to add the tokens to (e.g., cumulative, currentTurn).
- * @param source The metadata object from the API response.
- */
-const addTokens = (
- target: CumulativeStats,
- source: GenerateContentResponseUsageMetadata & { apiTimeMs?: number },
-) => {
- target.candidatesTokenCount += source.candidatesTokenCount ?? 0;
- target.thoughtsTokenCount += source.thoughtsTokenCount ?? 0;
- target.totalTokenCount += source.totalTokenCount ?? 0;
- target.apiTimeMs += source.apiTimeMs ?? 0;
- target.promptTokenCount += source.promptTokenCount ?? 0;
- target.cachedContentTokenCount += source.cachedContentTokenCount ?? 0;
- target.toolUsePromptTokenCount += source.toolUsePromptTokenCount ?? 0;
-};
-
// --- Provider Component ---
export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
@@ -78,110 +61,42 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
}) => {
const [stats, setStats] = useState<SessionStatsState>({
sessionStartTime: new Date(),
- cumulative: {
- turnCount: 0,
- promptTokenCount: 0,
- candidatesTokenCount: 0,
- totalTokenCount: 0,
- cachedContentTokenCount: 0,
- toolUsePromptTokenCount: 0,
- thoughtsTokenCount: 0,
- apiTimeMs: 0,
- },
- currentTurn: {
- turnCount: 0,
- promptTokenCount: 0,
- candidatesTokenCount: 0,
- totalTokenCount: 0,
- cachedContentTokenCount: 0,
- toolUsePromptTokenCount: 0,
- thoughtsTokenCount: 0,
- apiTimeMs: 0,
- },
- currentResponse: {
- turnCount: 0,
- promptTokenCount: 0,
- candidatesTokenCount: 0,
- totalTokenCount: 0,
- cachedContentTokenCount: 0,
- toolUsePromptTokenCount: 0,
- thoughtsTokenCount: 0,
- apiTimeMs: 0,
- },
+ metrics: uiTelemetryService.getMetrics(),
+ lastPromptTokenCount: 0,
});
- // A single, internal worker function to handle all metadata aggregation.
- const aggregateTokens = useCallback(
- (
- metadata: GenerateContentResponseUsageMetadata & { apiTimeMs?: number },
- ) => {
- setStats((prevState) => {
- const newCumulative = { ...prevState.cumulative };
- const newCurrentTurn = { ...prevState.currentTurn };
- const newCurrentResponse = {
- turnCount: 0,
- promptTokenCount: 0,
- candidatesTokenCount: 0,
- totalTokenCount: 0,
- cachedContentTokenCount: 0,
- toolUsePromptTokenCount: 0,
- thoughtsTokenCount: 0,
- apiTimeMs: 0,
- };
+ useEffect(() => {
+ const handleUpdate = ({
+ metrics,
+ lastPromptTokenCount,
+ }: {
+ metrics: SessionMetrics;
+ lastPromptTokenCount: number;
+ }) => {
+ setStats((prevState) => ({
+ ...prevState,
+ metrics,
+ lastPromptTokenCount,
+ }));
+ };
- // Add all tokens to the current turn's stats as well as cumulative stats.
- addTokens(newCurrentTurn, metadata);
- addTokens(newCumulative, metadata);
- addTokens(newCurrentResponse, metadata);
-
- return {
- ...prevState,
- cumulative: newCumulative,
- currentTurn: newCurrentTurn,
- currentResponse: newCurrentResponse,
- };
- });
- },
- [],
- );
+ uiTelemetryService.on('update', handleUpdate);
+ // Set initial state
+ handleUpdate({
+ metrics: uiTelemetryService.getMetrics(),
+ lastPromptTokenCount: uiTelemetryService.getLastPromptTokenCount(),
+ });
- const startNewTurn = useCallback(() => {
- setStats((prevState) => ({
- ...prevState,
- cumulative: {
- ...prevState.cumulative,
- turnCount: prevState.cumulative.turnCount + 1,
- },
- currentTurn: {
- turnCount: 0, // Reset for the new turn's accumulation.
- promptTokenCount: 0,
- candidatesTokenCount: 0,
- totalTokenCount: 0,
- cachedContentTokenCount: 0,
- toolUsePromptTokenCount: 0,
- thoughtsTokenCount: 0,
- apiTimeMs: 0,
- },
- currentResponse: {
- turnCount: 0,
- promptTokenCount: 0,
- candidatesTokenCount: 0,
- totalTokenCount: 0,
- cachedContentTokenCount: 0,
- toolUsePromptTokenCount: 0,
- thoughtsTokenCount: 0,
- apiTimeMs: 0,
- },
- }));
+ return () => {
+ uiTelemetryService.off('update', handleUpdate);
+ };
}, []);
const value = useMemo(
() => ({
stats,
- startNewTurn,
- addUsage: aggregateTokens,
}),
- [stats, startNewTurn, aggregateTokens],
+ [stats],
);
return (
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
index 01954670..d10ae22b 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
@@ -296,19 +296,9 @@ describe('useSlashCommandProcessor', () => {
describe('/stats command', () => {
it('should show detailed session statistics', async () => {
// Arrange
- const cumulativeStats = {
- totalTokenCount: 900,
- promptTokenCount: 200,
- candidatesTokenCount: 400,
- cachedContentTokenCount: 100,
- turnCount: 1,
- toolUsePromptTokenCount: 50,
- thoughtsTokenCount: 150,
- };
mockUseSessionStats.mockReturnValue({
stats: {
sessionStartTime: new Date('2025-01-01T00:00:00.000Z'),
- cumulative: cumulativeStats,
},
});
@@ -326,7 +316,6 @@ describe('useSlashCommandProcessor', () => {
2, // Called after the user message
expect.objectContaining({
type: MessageType.STATS,
- stats: cumulativeStats,
duration: '1h 2m 3s',
}),
expect.any(Number),
@@ -334,6 +323,44 @@ describe('useSlashCommandProcessor', () => {
vi.useRealTimers();
});
+
+ it('should show model-specific statistics when using /stats model', async () => {
+ // Arrange
+ const { handleSlashCommand } = getProcessor();
+
+ // Act
+ await act(async () => {
+ handleSlashCommand('/stats model');
+ });
+
+ // Assert
+ expect(mockAddItem).toHaveBeenNthCalledWith(
+ 2, // Called after the user message
+ expect.objectContaining({
+ type: MessageType.MODEL_STATS,
+ }),
+ expect.any(Number),
+ );
+ });
+
+ it('should show tool-specific statistics when using /stats tools', async () => {
+ // Arrange
+ const { handleSlashCommand } = getProcessor();
+
+ // Act
+ await act(async () => {
+ handleSlashCommand('/stats tools');
+ });
+
+ // Assert
+ expect(mockAddItem).toHaveBeenNthCalledWith(
+ 2, // Called after the user message
+ expect.objectContaining({
+ type: MessageType.TOOL_STATS,
+ }),
+ expect.any(Number),
+ );
+ });
});
describe('/about command', () => {
@@ -598,7 +625,6 @@ describe('useSlashCommandProcessor', () => {
},
{
type: 'quit',
- stats: expect.any(Object),
duration: '1h 2m 3s',
id: expect.any(Number),
},
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index b7dcbdcb..ffc3d7d1 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -110,14 +110,19 @@ export const useSlashCommandProcessor = (
} else if (message.type === MessageType.STATS) {
historyItemContent = {
type: 'stats',
- stats: message.stats,
- lastTurnStats: message.lastTurnStats,
duration: message.duration,
};
+ } else if (message.type === MessageType.MODEL_STATS) {
+ historyItemContent = {
+ type: 'model_stats',
+ };
+ } else if (message.type === MessageType.TOOL_STATS) {
+ historyItemContent = {
+ type: 'tool_stats',
+ };
} else if (message.type === MessageType.QUIT) {
historyItemContent = {
type: 'quit',
- stats: message.stats,
duration: message.duration,
};
} else if (message.type === MessageType.COMPRESSION) {
@@ -262,16 +267,28 @@ export const useSlashCommandProcessor = (
{
name: 'stats',
altName: 'usage',
- description: 'check session stats',
- action: (_mainCommand, _subCommand, _args) => {
+ description: 'check session stats. Usage: /stats [model|tools]',
+ action: (_mainCommand, subCommand, _args) => {
+ if (subCommand === 'model') {
+ addMessage({
+ type: MessageType.MODEL_STATS,
+ timestamp: new Date(),
+ });
+ return;
+ } else if (subCommand === 'tools') {
+ addMessage({
+ type: MessageType.TOOL_STATS,
+ timestamp: new Date(),
+ });
+ return;
+ }
+
const now = new Date();
- const { sessionStartTime, cumulative, currentTurn } = session.stats;
+ const { sessionStartTime } = session.stats;
const wallDuration = now.getTime() - sessionStartTime.getTime();
addMessage({
type: MessageType.STATS,
- stats: cumulative,
- lastTurnStats: currentTurn,
duration: formatDuration(wallDuration),
timestamp: new Date(),
});
@@ -805,7 +822,7 @@ export const useSlashCommandProcessor = (
description: 'exit the cli',
action: async (mainCommand, _subCommand, _args) => {
const now = new Date();
- const { sessionStartTime, cumulative } = session.stats;
+ const { sessionStartTime } = session.stats;
const wallDuration = now.getTime() - sessionStartTime.getTime();
setQuittingMessages([
@@ -816,7 +833,6 @@ export const useSlashCommandProcessor = (
},
{
type: 'quit',
- stats: cumulative,
duration: formatDuration(wallDuration),
id: now.getTime(),
},
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index 0c8b261e..9751f470 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -604,78 +604,6 @@ describe('useGeminiStream', () => {
});
});
- describe('Session Stats Integration', () => {
- it('should call startNewTurn and addUsage for a simple prompt', async () => {
- const mockMetadata = { totalTokenCount: 123 };
- const mockStream = (async function* () {
- yield { type: 'content', value: 'Response' };
- yield { type: 'usage_metadata', value: mockMetadata };
- })();
- mockSendMessageStream.mockReturnValue(mockStream);
-
- const { result } = renderTestHook();
-
- await act(async () => {
- await result.current.submitQuery('Hello, world!');
- });
-
- expect(mockStartNewTurn).toHaveBeenCalledTimes(1);
- expect(mockAddUsage).toHaveBeenCalledTimes(1);
- expect(mockAddUsage).toHaveBeenCalledWith(mockMetadata);
- });
-
- it('should only call addUsage for a tool continuation prompt', async () => {
- const mockMetadata = { totalTokenCount: 456 };
- const mockStream = (async function* () {
- yield { type: 'content', value: 'Final Answer' };
- yield { type: 'usage_metadata', value: mockMetadata };
- })();
- mockSendMessageStream.mockReturnValue(mockStream);
-
- const { result } = renderTestHook();
-
- await act(async () => {
- await result.current.submitQuery([{ text: 'tool response' }], {
- isContinuation: true,
- });
- });
-
- expect(mockStartNewTurn).not.toHaveBeenCalled();
- expect(mockAddUsage).toHaveBeenCalledTimes(1);
- expect(mockAddUsage).toHaveBeenCalledWith(mockMetadata);
- });
-
- it('should not call addUsage if the stream contains no usage metadata', async () => {
- // Arrange: A stream that yields content but never a usage_metadata event
- const mockStream = (async function* () {
- yield { type: 'content', value: 'Some response text' };
- })();
- mockSendMessageStream.mockReturnValue(mockStream);
-
- const { result } = renderTestHook();
-
- await act(async () => {
- await result.current.submitQuery('Query with no usage data');
- });
-
- expect(mockStartNewTurn).toHaveBeenCalledTimes(1);
- expect(mockAddUsage).not.toHaveBeenCalled();
- });
-
- it('should not call startNewTurn for a slash command', async () => {
- mockHandleSlashCommand.mockReturnValue(true);
-
- const { result } = renderTestHook();
-
- await act(async () => {
- await result.current.submitQuery('/stats');
- });
-
- expect(mockStartNewTurn).not.toHaveBeenCalled();
- expect(mockSendMessageStream).not.toHaveBeenCalled();
- });
- });
-
it('should not flicker streaming state to Idle between tool completion and submission', async () => {
const toolCallResponseParts: PartListUnion = [
{ text: 'tool 1 final response' },
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 3d24ede7..e2226761 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -51,7 +51,6 @@ import {
TrackedCompletedToolCall,
TrackedCancelledToolCall,
} from './useReactToolScheduler.js';
-import { useSessionStats } from '../contexts/SessionContext.js';
export function mergePartListUnions(list: PartListUnion[]): PartListUnion {
const resultParts: PartListUnion = [];
@@ -101,7 +100,6 @@ export const useGeminiStream = (
useStateAndRef<HistoryItemWithoutId | null>(null);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const logger = useLogger();
- const { startNewTurn, addUsage } = useSessionStats();
const gitService = useMemo(() => {
if (!config.getProjectRoot()) {
return;
@@ -461,9 +459,6 @@ export const useGeminiStream = (
case ServerGeminiEventType.ChatCompressed:
handleChatCompressionEvent(event.value);
break;
- case ServerGeminiEventType.UsageMetadata:
- addUsage(event.value);
- break;
case ServerGeminiEventType.ToolCallConfirmation:
case ServerGeminiEventType.ToolCallResponse:
// do nothing
@@ -486,7 +481,6 @@ export const useGeminiStream = (
handleErrorEvent,
scheduleToolCalls,
handleChatCompressionEvent,
- addUsage,
],
);
@@ -516,10 +510,6 @@ export const useGeminiStream = (
return;
}
- if (!options?.isContinuation) {
- startNewTurn();
- }
-
setIsResponding(true);
setInitError(null);
@@ -568,7 +558,6 @@ export const useGeminiStream = (
setPendingHistoryItem,
setInitError,
geminiClient,
- startNewTurn,
onAuthError,
config,
],
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 66a465ba..dd78c0c9 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -8,7 +8,6 @@ import {
ToolCallConfirmationDetails,
ToolResultDisplay,
} from '@google/gemini-cli-core';
-import { CumulativeStats } from './contexts/SessionContext.js';
// Only defining the state enum needed by the UI
export enum StreamingState {
@@ -100,14 +99,19 @@ export type HistoryItemAbout = HistoryItemBase & {
export type HistoryItemStats = HistoryItemBase & {
type: 'stats';
- stats: CumulativeStats;
- lastTurnStats: CumulativeStats;
duration: string;
};
+export type HistoryItemModelStats = HistoryItemBase & {
+ type: 'model_stats';
+};
+
+export type HistoryItemToolStats = HistoryItemBase & {
+ type: 'tool_stats';
+};
+
export type HistoryItemQuit = HistoryItemBase & {
type: 'quit';
- stats: CumulativeStats;
duration: string;
};
@@ -140,6 +144,8 @@ export type HistoryItemWithoutId =
| HistoryItemAbout
| HistoryItemToolGroup
| HistoryItemStats
+ | HistoryItemModelStats
+ | HistoryItemToolStats
| HistoryItemQuit
| HistoryItemCompression;
@@ -152,6 +158,8 @@ export enum MessageType {
USER = 'user',
ABOUT = 'about',
STATS = 'stats',
+ MODEL_STATS = 'model_stats',
+ TOOL_STATS = 'tool_stats',
QUIT = 'quit',
GEMINI = 'gemini',
COMPRESSION = 'compression',
@@ -178,15 +186,22 @@ export type Message =
| {
type: MessageType.STATS;
timestamp: Date;
- stats: CumulativeStats;
- lastTurnStats: CumulativeStats;
duration: string;
content?: string;
}
| {
+ type: MessageType.MODEL_STATS;
+ timestamp: Date;
+ content?: string;
+ }
+ | {
+ type: MessageType.TOOL_STATS;
+ timestamp: Date;
+ content?: string;
+ }
+ | {
type: MessageType.QUIT;
timestamp: Date;
- stats: CumulativeStats;
duration: string;
content?: string;
}
diff --git a/packages/cli/src/ui/utils/computeStats.test.ts b/packages/cli/src/ui/utils/computeStats.test.ts
new file mode 100644
index 00000000..0e32ffe2
--- /dev/null
+++ b/packages/cli/src/ui/utils/computeStats.test.ts
@@ -0,0 +1,247 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ calculateAverageLatency,
+ calculateCacheHitRate,
+ calculateErrorRate,
+ computeSessionStats,
+} from './computeStats.js';
+import { ModelMetrics, SessionMetrics } from '../contexts/SessionContext.js';
+
+describe('calculateErrorRate', () => {
+ it('should return 0 if totalRequests is 0', () => {
+ const metrics: ModelMetrics = {
+ api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
+ tokens: {
+ prompt: 0,
+ candidates: 0,
+ total: 0,
+ cached: 0,
+ thoughts: 0,
+ tool: 0,
+ },
+ };
+ expect(calculateErrorRate(metrics)).toBe(0);
+ });
+
+ it('should calculate the error rate correctly', () => {
+ const metrics: ModelMetrics = {
+ api: { totalRequests: 10, totalErrors: 2, totalLatencyMs: 0 },
+ tokens: {
+ prompt: 0,
+ candidates: 0,
+ total: 0,
+ cached: 0,
+ thoughts: 0,
+ tool: 0,
+ },
+ };
+ expect(calculateErrorRate(metrics)).toBe(20);
+ });
+});
+
+describe('calculateAverageLatency', () => {
+ it('should return 0 if totalRequests is 0', () => {
+ const metrics: ModelMetrics = {
+ api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 1000 },
+ tokens: {
+ prompt: 0,
+ candidates: 0,
+ total: 0,
+ cached: 0,
+ thoughts: 0,
+ tool: 0,
+ },
+ };
+ expect(calculateAverageLatency(metrics)).toBe(0);
+ });
+
+ it('should calculate the average latency correctly', () => {
+ const metrics: ModelMetrics = {
+ api: { totalRequests: 10, totalErrors: 0, totalLatencyMs: 1500 },
+ tokens: {
+ prompt: 0,
+ candidates: 0,
+ total: 0,
+ cached: 0,
+ thoughts: 0,
+ tool: 0,
+ },
+ };
+ expect(calculateAverageLatency(metrics)).toBe(150);
+ });
+});
+
+describe('calculateCacheHitRate', () => {
+ it('should return 0 if prompt tokens is 0', () => {
+ const metrics: ModelMetrics = {
+ api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
+ tokens: {
+ prompt: 0,
+ candidates: 0,
+ total: 0,
+ cached: 100,
+ thoughts: 0,
+ tool: 0,
+ },
+ };
+ expect(calculateCacheHitRate(metrics)).toBe(0);
+ });
+
+ it('should calculate the cache hit rate correctly', () => {
+ const metrics: ModelMetrics = {
+ api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
+ tokens: {
+ prompt: 200,
+ candidates: 0,
+ total: 0,
+ cached: 50,
+ thoughts: 0,
+ tool: 0,
+ },
+ };
+ expect(calculateCacheHitRate(metrics)).toBe(25);
+ });
+});
+
+describe('computeSessionStats', () => {
+ it('should return all zeros for initial empty metrics', () => {
+ const metrics: SessionMetrics = {
+ models: {},
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ };
+
+ const result = computeSessionStats(metrics);
+
+ expect(result).toEqual({
+ totalApiTime: 0,
+ totalToolTime: 0,
+ agentActiveTime: 0,
+ apiTimePercent: 0,
+ toolTimePercent: 0,
+ cacheEfficiency: 0,
+ totalDecisions: 0,
+ successRate: 0,
+ agreementRate: 0,
+ totalPromptTokens: 0,
+ totalCachedTokens: 0,
+ });
+ });
+
+ it('should correctly calculate API and tool time percentages', () => {
+ const metrics: SessionMetrics = {
+ models: {
+ 'gemini-pro': {
+ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 750 },
+ tokens: {
+ prompt: 10,
+ candidates: 10,
+ total: 20,
+ cached: 0,
+ thoughts: 0,
+ tool: 0,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 1,
+ totalSuccess: 1,
+ totalFail: 0,
+ totalDurationMs: 250,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ };
+
+ const result = computeSessionStats(metrics);
+
+ expect(result.totalApiTime).toBe(750);
+ expect(result.totalToolTime).toBe(250);
+ expect(result.agentActiveTime).toBe(1000);
+ expect(result.apiTimePercent).toBe(75);
+ expect(result.toolTimePercent).toBe(25);
+ });
+
+ it('should correctly calculate cache efficiency', () => {
+ const metrics: SessionMetrics = {
+ models: {
+ 'gemini-pro': {
+ api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 1000 },
+ tokens: {
+ prompt: 150,
+ candidates: 10,
+ total: 160,
+ cached: 50,
+ thoughts: 0,
+ tool: 0,
+ },
+ },
+ },
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ };
+
+ const result = computeSessionStats(metrics);
+
+ expect(result.cacheEfficiency).toBeCloseTo(33.33); // 50 / 150
+ });
+
+ it('should correctly calculate success and agreement rates', () => {
+ const metrics: SessionMetrics = {
+ models: {},
+ tools: {
+ totalCalls: 10,
+ totalSuccess: 8,
+ totalFail: 2,
+ totalDurationMs: 1000,
+ totalDecisions: { accept: 6, reject: 2, modify: 2 },
+ byName: {},
+ },
+ };
+
+ const result = computeSessionStats(metrics);
+
+ expect(result.successRate).toBe(80); // 8 / 10
+ expect(result.agreementRate).toBe(60); // 6 / 10
+ });
+
+ it('should handle division by zero gracefully', () => {
+ const metrics: SessionMetrics = {
+ models: {},
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0 },
+ byName: {},
+ },
+ };
+
+ const result = computeSessionStats(metrics);
+
+ expect(result.apiTimePercent).toBe(0);
+ expect(result.toolTimePercent).toBe(0);
+ expect(result.cacheEfficiency).toBe(0);
+ expect(result.successRate).toBe(0);
+ expect(result.agreementRate).toBe(0);
+ });
+});
diff --git a/packages/cli/src/ui/utils/computeStats.ts b/packages/cli/src/ui/utils/computeStats.ts
new file mode 100644
index 00000000..e0483c3b
--- /dev/null
+++ b/packages/cli/src/ui/utils/computeStats.ts
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ SessionMetrics,
+ ComputedSessionStats,
+ ModelMetrics,
+} from '../contexts/SessionContext.js';
+
+export function calculateErrorRate(metrics: ModelMetrics): number {
+ if (metrics.api.totalRequests === 0) {
+ return 0;
+ }
+ return (metrics.api.totalErrors / metrics.api.totalRequests) * 100;
+}
+
+export function calculateAverageLatency(metrics: ModelMetrics): number {
+ if (metrics.api.totalRequests === 0) {
+ return 0;
+ }
+ return metrics.api.totalLatencyMs / metrics.api.totalRequests;
+}
+
+export function calculateCacheHitRate(metrics: ModelMetrics): number {
+ if (metrics.tokens.prompt === 0) {
+ return 0;
+ }
+ return (metrics.tokens.cached / metrics.tokens.prompt) * 100;
+}
+
+export const computeSessionStats = (
+ metrics: SessionMetrics,
+): ComputedSessionStats => {
+ const { models, tools } = metrics;
+ const totalApiTime = Object.values(models).reduce(
+ (acc, model) => acc + model.api.totalLatencyMs,
+ 0,
+ );
+ const totalToolTime = tools.totalDurationMs;
+ const agentActiveTime = totalApiTime + totalToolTime;
+ const apiTimePercent =
+ agentActiveTime > 0 ? (totalApiTime / agentActiveTime) * 100 : 0;
+ const toolTimePercent =
+ agentActiveTime > 0 ? (totalToolTime / agentActiveTime) * 100 : 0;
+
+ const totalCachedTokens = Object.values(models).reduce(
+ (acc, model) => acc + model.tokens.cached,
+ 0,
+ );
+ const totalPromptTokens = Object.values(models).reduce(
+ (acc, model) => acc + model.tokens.prompt,
+ 0,
+ );
+ const cacheEfficiency =
+ totalPromptTokens > 0 ? (totalCachedTokens / totalPromptTokens) * 100 : 0;
+
+ const totalDecisions =
+ tools.totalDecisions.accept +
+ tools.totalDecisions.reject +
+ tools.totalDecisions.modify;
+ const successRate =
+ tools.totalCalls > 0 ? (tools.totalSuccess / tools.totalCalls) * 100 : 0;
+ const agreementRate =
+ totalDecisions > 0
+ ? (tools.totalDecisions.accept / totalDecisions) * 100
+ : 0;
+
+ return {
+ totalApiTime,
+ totalToolTime,
+ agentActiveTime,
+ apiTimePercent,
+ toolTimePercent,
+ cacheEfficiency,
+ totalDecisions,
+ successRate,
+ agreementRate,
+ totalCachedTokens,
+ totalPromptTokens,
+ };
+};
diff --git a/packages/cli/src/ui/utils/displayUtils.test.ts b/packages/cli/src/ui/utils/displayUtils.test.ts
new file mode 100644
index 00000000..7dd9f0e8
--- /dev/null
+++ b/packages/cli/src/ui/utils/displayUtils.test.ts
@@ -0,0 +1,58 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ getStatusColor,
+ TOOL_SUCCESS_RATE_HIGH,
+ TOOL_SUCCESS_RATE_MEDIUM,
+ USER_AGREEMENT_RATE_HIGH,
+ USER_AGREEMENT_RATE_MEDIUM,
+ CACHE_EFFICIENCY_HIGH,
+ CACHE_EFFICIENCY_MEDIUM,
+} from './displayUtils.js';
+import { Colors } from '../colors.js';
+
+describe('displayUtils', () => {
+ describe('getStatusColor', () => {
+ const thresholds = {
+ green: 80,
+ yellow: 50,
+ };
+
+ it('should return green for values >= green threshold', () => {
+ expect(getStatusColor(90, thresholds)).toBe(Colors.AccentGreen);
+ expect(getStatusColor(80, thresholds)).toBe(Colors.AccentGreen);
+ });
+
+ it('should return yellow for values < green and >= yellow threshold', () => {
+ expect(getStatusColor(79, thresholds)).toBe(Colors.AccentYellow);
+ expect(getStatusColor(50, thresholds)).toBe(Colors.AccentYellow);
+ });
+
+ it('should return red for values < yellow threshold', () => {
+ expect(getStatusColor(49, thresholds)).toBe(Colors.AccentRed);
+ expect(getStatusColor(0, thresholds)).toBe(Colors.AccentRed);
+ });
+
+ it('should return defaultColor for values < yellow threshold when provided', () => {
+ expect(
+ getStatusColor(49, thresholds, { defaultColor: Colors.Foreground }),
+ ).toBe(Colors.Foreground);
+ });
+ });
+
+ describe('Threshold Constants', () => {
+ it('should have the correct values', () => {
+ expect(TOOL_SUCCESS_RATE_HIGH).toBe(95);
+ expect(TOOL_SUCCESS_RATE_MEDIUM).toBe(85);
+ expect(USER_AGREEMENT_RATE_HIGH).toBe(75);
+ expect(USER_AGREEMENT_RATE_MEDIUM).toBe(45);
+ expect(CACHE_EFFICIENCY_HIGH).toBe(40);
+ expect(CACHE_EFFICIENCY_MEDIUM).toBe(15);
+ });
+ });
+});
diff --git a/packages/cli/src/ui/utils/displayUtils.ts b/packages/cli/src/ui/utils/displayUtils.ts
new file mode 100644
index 00000000..a52c6ff0
--- /dev/null
+++ b/packages/cli/src/ui/utils/displayUtils.ts
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Colors } from '../colors.js';
+
+// --- Thresholds ---
+export const TOOL_SUCCESS_RATE_HIGH = 95;
+export const TOOL_SUCCESS_RATE_MEDIUM = 85;
+
+export const USER_AGREEMENT_RATE_HIGH = 75;
+export const USER_AGREEMENT_RATE_MEDIUM = 45;
+
+export const CACHE_EFFICIENCY_HIGH = 40;
+export const CACHE_EFFICIENCY_MEDIUM = 15;
+
+// --- Color Logic ---
+export const getStatusColor = (
+ value: number,
+ thresholds: { green: number; yellow: number },
+ options: { defaultColor?: string } = {},
+) => {
+ if (value >= thresholds.green) {
+ return Colors.AccentGreen;
+ }
+ if (value >= thresholds.yellow) {
+ return Colors.AccentYellow;
+ }
+ return options.defaultColor || Colors.AccentRed;
+};
diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts
index 82a78109..2b6af545 100644
--- a/packages/cli/src/ui/utils/formatters.ts
+++ b/packages/cli/src/ui/utils/formatters.ts
@@ -27,7 +27,7 @@ export const formatDuration = (milliseconds: number): string => {
}
if (milliseconds < 1000) {
- return `${milliseconds}ms`;
+ return `${Math.round(milliseconds)}ms`;
}
const totalSeconds = milliseconds / 1000;