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
187 changes: 186 additions & 1 deletion packages/cli/src/ui/components/ModelStatsDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as SessionContext from '../contexts/SessionContext.js';
import * as SettingsContext from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.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 @@ -118,6 +118,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
tools: {
Expand Down Expand Up @@ -160,6 +161,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 2,
tool: 0,
},
roles: {},
},
'gemini-2.5-flash': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 50 },
Expand All @@ -172,6 +174,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 0,
tool: 3,
},
roles: {},
},
},
tools: {
Expand Down Expand Up @@ -214,6 +217,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 10,
tool: 5,
},
roles: {},
},
'gemini-2.5-flash': {
api: { totalRequests: 20, totalErrors: 2, totalLatencyMs: 500 },
Expand All @@ -226,6 +230,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 20,
tool: 10,
},
roles: {},
},
},
tools: {
Expand Down Expand Up @@ -271,6 +276,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 111111111,
tool: 222222222,
},
roles: {},
},
},
tools: {
Expand Down Expand Up @@ -309,6 +315,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 2,
tool: 1,
},
roles: {},
},
},
tools: {
Expand Down Expand Up @@ -351,6 +358,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 100,
tool: 50,
},
roles: {},
},
'gemini-3-flash-preview': {
api: { totalRequests: 20, totalErrors: 0, totalLatencyMs: 1000 },
Expand All @@ -363,6 +371,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 200,
tool: 100,
},
roles: {},
},
},
tools: {
Expand Down Expand Up @@ -390,6 +399,64 @@ describe('<ModelStatsDisplay />', () => {
const output = lastFrame();
expect(output).toContain('gemini-3-pro-');
expect(output).toContain('gemini-3-flash-');
});

it('should display role breakdown correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 200 },
tokens: {
input: 20,
prompt: 30,
candidates: 40,
total: 70,
cached: 10,
thoughts: 0,
tool: 0,
},
roles: {
[LlmRole.MAIN]: {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 100,
tokens: {
input: 10,
prompt: 15,
candidates: 20,
total: 35,
cached: 5,
thoughts: 0,
tool: 0,
},
},
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});

const output = lastFrame();
expect(output).toContain('main');
expect(output).toContain('Input');
expect(output).toContain('Output');
expect(output).toContain('Cache Reads');
expect(output).toMatchSnapshot();
});

Expand Down Expand Up @@ -427,6 +494,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
tools: {
Expand Down Expand Up @@ -462,4 +530,121 @@ describe('<ModelStatsDisplay />', () => {
expect(output).toContain('Tier:');
expect(output).toContain('Pro');
});

it('should handle long role name layout', () => {
// Use the longest valid role name to test layout
const longRoleName = LlmRole.UTILITY_LOOP_DETECTOR;

const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
input: 10,
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {
[longRoleName]: {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 100,
tokens: {
input: 10,
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,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});

const output = lastFrame();
expect(output).toContain(longRoleName);
expect(output).toMatchSnapshot();
});

it('should filter out invalid role names', () => {
const invalidRoleName =
'this_is_a_very_long_role_name_that_should_be_wrapped' as LlmRole;
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
input: 10,
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {
[invalidRoleName]: {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 100,
tokens: {
input: 10,
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,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});

const output = lastFrame();
expect(output).not.toContain(invalidRoleName);
expect(output).toMatchSnapshot();
});
});
85 changes: 82 additions & 3 deletions packages/cli/src/ui/components/ModelStatsDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@ import {
calculateCacheHitRate,
calculateErrorRate,
} from '../utils/computeStats.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import {
useSessionStats,
type ModelMetrics,
} from '../contexts/SessionContext.js';
import { Table, type Column } from './Table.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { getDisplayString, isAutoModel } from '@google/gemini-cli-core';
import {
getDisplayString,
isAutoModel,
LlmRole,
} from '@google/gemini-cli-core';
import type { QuotaStats } from '../types.js';
import { QuotaStatsInfo } from './QuotaStatsInfo.js';

Expand All @@ -25,9 +32,11 @@ interface StatRowData {
isSection?: boolean;
isSubtle?: boolean;
// Dynamic keys for model values
[key: string]: string | React.ReactNode | boolean | undefined;
[key: string]: string | React.ReactNode | boolean | undefined | number;
}

type RoleMetrics = NonNullable<NonNullable<ModelMetrics['roles']>[LlmRole]>;

interface ModelStatsDisplayProps {
selectedAuthType?: string;
userEmail?: string;
Expand Down Expand Up @@ -81,6 +90,22 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
([, metrics]) => metrics.tokens.cached > 0,
);

const allRoles = [
...new Set(
activeModels.flatMap(([, metrics]) => Object.keys(metrics.roles ?? {})),
),
]
.filter((role): role is LlmRole => {
const validRoles: string[] = Object.values(LlmRole);
return validRoles.includes(role);
})
.sort((a, b) => {
if (a === b) return 0;
if (a === LlmRole.MAIN) return -1;
if (b === LlmRole.MAIN) return 1;
return a.localeCompare(b);
});

// Helper to create a row with values for each model
const createRow = (
metric: string,
Expand Down Expand Up @@ -204,6 +229,60 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
),
);

// Roles Section
if (allRoles.length > 0) {
// Spacer
rows.push({ metric: '' });
rows.push({ metric: 'Roles', isSection: true });

allRoles.forEach((role) => {
// Role Header Row
const roleHeaderRow: StatRowData = {
metric: role,
isSection: true,
color: theme.text.primary,
};
// We don't populate model values for the role header row
rows.push(roleHeaderRow);

const addRoleMetric = (
metric: string,
getValue: (r: RoleMetrics) => string | React.ReactNode,
) => {
const row: StatRowData = {
metric,
isSubtle: true,
};
activeModels.forEach(([name, metrics]) => {
const roleMetrics = metrics.roles?.[role];
if (roleMetrics) {
row[name] = getValue(roleMetrics);
} else {
row[name] = <Text color={theme.text.secondary}>-</Text>;
}
});
rows.push(row);
};

addRoleMetric('Requests', (r) => r.totalRequests.toLocaleString());
addRoleMetric('Input', (r) => (
<Text color={theme.text.primary}>
{r.tokens.input.toLocaleString()}
</Text>
));
addRoleMetric('Output', (r) => (
<Text color={theme.text.primary}>
{r.tokens.candidates.toLocaleString()}
</Text>
));
addRoleMetric('Cache Reads', (r) => (
<Text color={theme.text.secondary}>
{r.tokens.cached.toLocaleString()}
</Text>
));
});
}

const columns: Array<Column<StatRowData>> = [
{
key: 'metric',
Expand Down
Loading
Loading