Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion packages/cli/src/ui/components/StatsDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { type SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision } from '@google/gemini-cli-core';
import { ToolCallDecision, LlmRole } from '@google/gemini-cli-core';

// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
Expand Down Expand Up @@ -131,6 +131,66 @@ describe('<StatsDisplay />', () => {
expect(output).toMatchSnapshot();
});

it('renders role breakdown correctly under models', async () => {
const metrics = createTestMetrics({
models: {
'gemini-2.5-flash': {
api: { totalRequests: 10, totalErrors: 0, totalLatencyMs: 10000 },
tokens: {
input: 1000,
prompt: 1200,
candidates: 2000,
total: 3200,
cached: 200,
thoughts: 0,
tool: 0,
},
roles: {
[LlmRole.MAIN]: {
totalRequests: 7,
totalErrors: 0,
totalLatencyMs: 7000,
tokens: {
input: 800,
prompt: 900,
candidates: 1500,
total: 2400,
cached: 100,
thoughts: 0,
tool: 0,
},
},
[LlmRole.UTILITY_TOOL]: {
totalRequests: 3,
totalErrors: 0,
totalLatencyMs: 3000,
tokens: {
input: 200,
prompt: 300,
candidates: 500,
total: 800,
cached: 100,
thoughts: 0,
tool: 0,
},
},
},
},
},
});

const { lastFrame } = await renderWithMockedStats(metrics);
const output = lastFrame();

expect(output).toContain('gemini-2.5-flash');
expect(output).toContain('10'); // Total requests
expect(output).toContain('↳ main');
expect(output).toContain('7'); // main requests
expect(output).toContain('↳ utility_tool');
expect(output).toContain('3'); // tool requests
expect(output).toMatchSnapshot();
});

it('renders all sections when all data is present', async () => {
const metrics = createTestMetrics({
models: {
Expand Down
87 changes: 75 additions & 12 deletions packages/cli/src/ui/components/StatsDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { formatDuration } from '../utils/formatters.js';
import {
useSessionStats,
type ModelMetrics,
type RoleMetrics,
} from '../contexts/SessionContext.js';
import {
getStatusColor,
Expand All @@ -23,6 +24,7 @@ import {
import { computeSessionStats } from '../utils/computeStats.js';
import { useSettings } from '../contexts/SettingsContext.js';
import type { QuotaStats } from '../types.js';
import { LlmRole } from '@google/gemini-cli-core';

// A more flexible and powerful StatRow component
interface StatRowProps {
Expand Down Expand Up @@ -77,13 +79,63 @@ interface ModelUsageTableProps {
models: Record<string, ModelMetrics>;
}

interface ModelRow {
name: string;
displayName: string;
requests: number | string;
cachedTokens: string;
inputTokens: string;
outputTokens: string;
isSubRow: boolean;
}

const ModelUsageTable: React.FC<ModelUsageTableProps> = ({ models }) => {
const nameWidth = 28;
const requestsWidth = 8;
const inputTokensWidth = 14;
const cacheReadsWidth = 14;
const outputTokensWidth = 14;

const rows: ModelRow[] = [];

Object.entries(models).forEach(([name, metrics]) => {
rows.push({
name,
displayName: name,
requests: metrics.api.totalRequests,
cachedTokens: metrics.tokens.cached.toLocaleString(),
inputTokens: metrics.tokens.prompt.toLocaleString(),
outputTokens: metrics.tokens.candidates.toLocaleString(),
isSubRow: false,
});

if (metrics.roles) {
const roleEntries = Object.entries(metrics.roles).filter(
(entry): entry is [string, RoleMetrics] =>
entry[1] !== undefined && entry[1].totalRequests > 0,
);

roleEntries.sort(([a], [b]) => {
if (a === b) return 0;
if (a === LlmRole.MAIN) return -1;
if (b === LlmRole.MAIN) return 1;
return a.localeCompare(b);
});

roleEntries.forEach(([role, roleMetrics]) => {
rows.push({
name: `${name}-${role}`,
displayName: ` ↳ ${role}`,
requests: roleMetrics.totalRequests,
cachedTokens: roleMetrics.tokens.cached.toLocaleString(),
inputTokens: roleMetrics.tokens.prompt.toLocaleString(),
outputTokens: roleMetrics.tokens.candidates.toLocaleString(),
isSubRow: true,
});
});
}
});

return (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
Expand Down Expand Up @@ -131,31 +183,42 @@ const ModelUsageTable: React.FC<ModelUsageTableProps> = ({ models }) => {
</Box>

{/* Rows */}
{Object.entries(models).map(([name, modelMetrics]) => (
<Box key={name}>
{rows.map((row) => (
<Box key={row.name}>
<Box width={nameWidth}>
<Text color={theme.text.primary} wrap="truncate-end">
{name}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
wrap="truncate-end"
>
{row.displayName}
</Text>
</Box>
<Box width={requestsWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.api.totalRequests}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
>
{row.requests}
</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.tokens.prompt.toLocaleString()}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
>
{row.inputTokens}
</Text>
</Box>
<Box width={cacheReadsWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.tokens.cached.toLocaleString()}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
>
{row.cachedTokens}
</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.tokens.candidates.toLocaleString()}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
>
{row.outputTokens}
</Text>
</Box>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,32 @@ exports[`<StatsDisplay /> > renders only the Performance section in its zero sta
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;

exports[`<StatsDisplay /> > renders role breakdown correctly under models 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 10.0s │
│ » API Time: 10.0s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Use /model to view model quota information │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-flash 10 1,200 200 2,000 │
│ ↳ main 7 900 100 1,500 │
│ ↳ utility_tool 3 300 100 500 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
3 changes: 2 additions & 1 deletion packages/cli/src/ui/contexts/SessionContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import type {
SessionMetrics,
ModelMetrics,
RoleMetrics,
ToolCallStats,
} from '@google/gemini-cli-core';
import { uiTelemetryService, sessionId } from '@google/gemini-cli-core';
Expand Down Expand Up @@ -139,7 +140,7 @@ function areMetricsEqual(a: SessionMetrics, b: SessionMetrics): boolean {
return true;
}

export type { SessionMetrics, ModelMetrics };
export type { SessionMetrics, ModelMetrics, RoleMetrics };

export interface SessionStatsState {
sessionId: string;
Expand Down
Loading