mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
Co-authored-by: Yuna Seol <yunaseol@google.com>
This commit is contained in:
@@ -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) => {
|
||||
@@ -118,6 +118,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
thoughts: 0,
|
||||
tool: 0,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
@@ -160,6 +161,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
thoughts: 2,
|
||||
tool: 0,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 50 },
|
||||
@@ -172,6 +174,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
thoughts: 0,
|
||||
tool: 3,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
@@ -214,6 +217,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
thoughts: 10,
|
||||
tool: 5,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
api: { totalRequests: 20, totalErrors: 2, totalLatencyMs: 500 },
|
||||
@@ -226,6 +230,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
thoughts: 20,
|
||||
tool: 10,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
@@ -271,6 +276,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
thoughts: 111111111,
|
||||
tool: 222222222,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
@@ -309,6 +315,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
thoughts: 2,
|
||||
tool: 1,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
@@ -351,6 +358,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
thoughts: 100,
|
||||
tool: 50,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
'gemini-3-flash-preview': {
|
||||
api: { totalRequests: 20, totalErrors: 0, totalLatencyMs: 1000 },
|
||||
@@ -363,6 +371,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
thoughts: 200,
|
||||
tool: 100,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -427,6 +494,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
thoughts: 0,
|
||||
tool: 0,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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',
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
thoughts: 300,
|
||||
tool: 200,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
|
||||
@@ -93,6 +93,7 @@ describe('<StatsDisplay />', () => {
|
||||
thoughts: 100,
|
||||
tool: 50,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
api: { totalRequests: 5, totalErrors: 1, totalLatencyMs: 4500 },
|
||||
@@ -105,6 +106,7 @@ describe('<StatsDisplay />', () => {
|
||||
thoughts: 2000,
|
||||
tool: 1000,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -133,6 +135,7 @@ describe('<StatsDisplay />', () => {
|
||||
thoughts: 0,
|
||||
tool: 0,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
@@ -227,6 +230,7 @@ describe('<StatsDisplay />', () => {
|
||||
thoughts: 0,
|
||||
tool: 0,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -411,6 +415,7 @@ describe('<StatsDisplay />', () => {
|
||||
thoughts: 0,
|
||||
tool: 0,
|
||||
},
|
||||
roles: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,6 +44,32 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should display role breakdown correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
│ Requests 2 │
|
||||
│ Errors 0 (0.0%) │
|
||||
│ Avg Latency 100ms │
|
||||
│ Tokens │
|
||||
│ Total 70 │
|
||||
│ ↳ Input 20 │
|
||||
│ ↳ Cache Reads 10 (33.3%) │
|
||||
│ ↳ Output 40 │
|
||||
│ Roles │
|
||||
│ main │
|
||||
│ ↳ Requests 1 │
|
||||
│ ↳ Input 10 │
|
||||
│ ↳ Output 20 │
|
||||
│ ↳ Cache Reads 5 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should display stats for multiple models correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
@@ -66,6 +92,25 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should filter out invalid role names 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
│ Requests 1 │
|
||||
│ Errors 0 (0.0%) │
|
||||
│ Avg Latency 100ms │
|
||||
│ Tokens │
|
||||
│ Total 30 │
|
||||
│ ↳ Input 10 │
|
||||
│ ↳ Output 20 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
@@ -88,6 +133,31 @@ exports[`<ModelStatsDisplay /> > should handle large values without wrapping or
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should handle long role name layout 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
│ Requests 1 │
|
||||
│ Errors 0 (0.0%) │
|
||||
│ Avg Latency 100ms │
|
||||
│ Tokens │
|
||||
│ Total 30 │
|
||||
│ ↳ Input 10 │
|
||||
│ ↳ Output 20 │
|
||||
│ Roles │
|
||||
│ utility_loop_detector │
|
||||
│ ↳ Requests 1 │
|
||||
│ ↳ Input 10 │
|
||||
│ ↳ Output 20 │
|
||||
│ ↳ Cache Reads 0 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { debugLogger, spawnAsync } from '@google/gemini-cli-core';
|
||||
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
|
||||
@@ -279,6 +279,7 @@ Return a JSON object with:
|
||||
},
|
||||
abortSignal: new AbortController().signal,
|
||||
promptId: 'triage-duplicates',
|
||||
role: LlmRole.UTILITY_TOOL,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { debugLogger, spawnAsync } from '@google/gemini-cli-core';
|
||||
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import { TextInput } from '../shared/TextInput.js';
|
||||
@@ -223,6 +223,7 @@ Return a JSON object with:
|
||||
},
|
||||
abortSignal: abortControllerRef.current.signal,
|
||||
promptId: 'triage-issues',
|
||||
role: LlmRole.UTILITY_TOOL,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
Reference in New Issue
Block a user