diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx index b34bf60298..cd98ed400d 100644 --- a/packages/cli/src/ui/components/StatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx @@ -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) => { @@ -131,6 +131,66 @@ describe('', () => { 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: { diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index 4668a7a5a7..233e9f3ed4 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -12,6 +12,7 @@ import { formatDuration } from '../utils/formatters.js'; import { useSessionStats, type ModelMetrics, + type RoleMetrics, } from '../contexts/SessionContext.js'; import { getStatusColor, @@ -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 { @@ -77,6 +79,16 @@ interface ModelUsageTableProps { models: Record; } +interface ModelRow { + name: string; + displayName: string; + requests: number | string; + cachedTokens: string; + inputTokens: string; + outputTokens: string; + isSubRow: boolean; +} + const ModelUsageTable: React.FC = ({ models }) => { const nameWidth = 28; const requestsWidth = 8; @@ -84,6 +96,46 @@ const ModelUsageTable: React.FC = ({ models }) => { 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 ( @@ -131,31 +183,42 @@ const ModelUsageTable: React.FC = ({ models }) => { {/* Rows */} - {Object.entries(models).map(([name, modelMetrics]) => ( - + {rows.map((row) => ( + - - {name} + + {row.displayName} - - {modelMetrics.api.totalRequests} + + {row.requests} - - {modelMetrics.tokens.prompt.toLocaleString()} + + {row.inputTokens} - - {modelMetrics.tokens.cached.toLocaleString()} + + {row.cachedTokens} - - {modelMetrics.tokens.candidates.toLocaleString()} + + {row.outputTokens} 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 a06587aaaf..8a58ee3440 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap @@ -263,3 +263,32 @@ exports[` > renders only the Performance section in its zero sta ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ " `; + +exports[` > 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 │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/contexts/SessionContext.tsx b/packages/cli/src/ui/contexts/SessionContext.tsx index 5ca37a1569..7f313bb443 100644 --- a/packages/cli/src/ui/contexts/SessionContext.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.tsx @@ -17,6 +17,7 @@ import { import type { SessionMetrics, ModelMetrics, + RoleMetrics, ToolCallStats, } from '@google/gemini-cli-core'; import { uiTelemetryService, sessionId } from '@google/gemini-cli-core'; @@ -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;