feat(cli): add user identity info to stats command (#17612)

This commit is contained in:
Sehoon Shon
2026-01-28 16:26:33 -05:00
committed by GitHub
parent 30065c51fb
commit bee1267e2a
15 changed files with 429 additions and 112 deletions

View File

@@ -57,6 +57,7 @@ they appear in the UI.
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` | | Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` | | Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | | Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | | Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | | Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | | Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |

View File

@@ -244,6 +244,10 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Show the model name in the chat for each model turn. - **Description:** Show the model name in the chat for each model turn.
- **Default:** `false` - **Default:** `false`
- **`ui.showUserIdentity`** (boolean):
- **Description:** Show the logged-in user's identity (e.g. email) in the UI.
- **Default:** `true`
- **`ui.useAlternateBuffer`** (boolean): - **`ui.useAlternateBuffer`** (boolean):
- **Description:** Use an alternate screen buffer for the UI, preserving shell - **Description:** Use an alternate screen buffer for the UI, preserving shell
history. history.

View File

@@ -524,6 +524,16 @@ const SETTINGS_SCHEMA = {
description: 'Show the model name in the chat for each model turn.', description: 'Show the model name in the chat for each model turn.',
showInDialog: true, showInDialog: true,
}, },
showUserIdentity: {
type: 'boolean',
label: 'Show User Identity',
category: 'UI',
requiresRestart: false,
default: true,
description:
"Show the logged-in user's identity (e.g. email) in the UI.",
showInDialog: true,
},
useAlternateBuffer: { useAlternateBuffer: {
type: 'boolean', type: 'boolean',
label: 'Use Alternate Screen Buffer', label: 'Use Alternate Screen Buffer',

View File

@@ -12,6 +12,17 @@ import { MessageType } from '../types.js';
import { formatDuration } from '../utils/formatters.js'; import { formatDuration } from '../utils/formatters.js';
import type { Config } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
UserAccountManager: vi.fn().mockImplementation(() => ({
getCachedGoogleAccount: vi.fn().mockReturnValue('mock@example.com'),
})),
};
});
describe('statsCommand', () => { describe('statsCommand', () => {
let mockContext: CommandContext; let mockContext: CommandContext;
const startTime = new Date('2025-07-14T10:00:00.000Z'); const startTime = new Date('2025-07-14T10:00:00.000Z');
@@ -40,6 +51,9 @@ describe('statsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith({ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.STATS, type: MessageType.STATS,
duration: expectedDuration, duration: expectedDuration,
selectedAuthType: '',
tier: undefined,
userEmail: 'mock@example.com',
}); });
}); });
@@ -48,8 +62,10 @@ describe('statsCommand', () => {
const mockQuota = { buckets: [] }; const mockQuota = { buckets: [] };
const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota); const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota);
const mockGetUserTierName = vi.fn().mockReturnValue('Basic');
mockContext.services.config = { mockContext.services.config = {
refreshUserQuota: mockRefreshUserQuota, refreshUserQuota: mockRefreshUserQuota,
getUserTierName: mockGetUserTierName,
} as unknown as Config; } as unknown as Config;
await statsCommand.action(mockContext, ''); await statsCommand.action(mockContext, '');
@@ -58,6 +74,7 @@ describe('statsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
quotas: mockQuota, quotas: mockQuota,
tier: 'Basic',
}), }),
); );
}); });
@@ -73,6 +90,9 @@ describe('statsCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith({ expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.MODEL_STATS, type: MessageType.MODEL_STATS,
selectedAuthType: '',
tier: undefined,
userEmail: 'mock@example.com',
}); });
}); });

View File

@@ -4,15 +4,33 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { HistoryItemStats } from '../types.js'; import type {
HistoryItemStats,
HistoryItemModelStats,
HistoryItemToolStats,
} from '../types.js';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
import { formatDuration } from '../utils/formatters.js'; import { formatDuration } from '../utils/formatters.js';
import { UserAccountManager } from '@google/gemini-cli-core';
import { import {
type CommandContext, type CommandContext,
type SlashCommand, type SlashCommand,
CommandKind, CommandKind,
} from './types.js'; } from './types.js';
function getUserIdentity(context: CommandContext) {
const selectedAuthType =
context.services.settings.merged.security.auth.selectedType || '';
const userAccountManager = new UserAccountManager();
const cachedAccount = userAccountManager.getCachedGoogleAccount();
const userEmail = cachedAccount ?? undefined;
const tier = context.services.config?.getUserTierName();
return { selectedAuthType, userEmail, tier };
}
async function defaultSessionView(context: CommandContext) { async function defaultSessionView(context: CommandContext) {
const now = new Date(); const now = new Date();
const { sessionStartTime } = context.session.stats; const { sessionStartTime } = context.session.stats;
@@ -25,9 +43,14 @@ async function defaultSessionView(context: CommandContext) {
} }
const wallDuration = now.getTime() - sessionStartTime.getTime(); const wallDuration = now.getTime() - sessionStartTime.getTime();
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
const statsItem: HistoryItemStats = { const statsItem: HistoryItemStats = {
type: MessageType.STATS, type: MessageType.STATS,
duration: formatDuration(wallDuration), duration: formatDuration(wallDuration),
selectedAuthType,
userEmail,
tier,
}; };
if (context.services.config) { if (context.services.config) {
@@ -65,9 +88,13 @@ export const statsCommand: SlashCommand = {
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
autoExecute: true, autoExecute: true,
action: (context: CommandContext) => { action: (context: CommandContext) => {
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
context.ui.addItem({ context.ui.addItem({
type: MessageType.MODEL_STATS, type: MessageType.MODEL_STATS,
}); selectedAuthType,
userEmail,
tier,
} as HistoryItemModelStats);
}, },
}, },
{ {
@@ -78,7 +105,7 @@ export const statsCommand: SlashCommand = {
action: (context: CommandContext) => { action: (context: CommandContext) => {
context.ui.addItem({ context.ui.addItem({
type: MessageType.TOOL_STATS, type: MessageType.TOOL_STATS,
}); } as HistoryItemToolStats);
}, },
}, },
], ],

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { render } from '../../test-utils/render.js'; import { renderWithProviders } from '../../test-utils/render.js';
import { AboutBox } from './AboutBox.js'; import { AboutBox } from './AboutBox.js';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
@@ -25,7 +25,7 @@ describe('AboutBox', () => {
}; };
it('renders with required props', () => { it('renders with required props', () => {
const { lastFrame } = render(<AboutBox {...defaultProps} />); const { lastFrame } = renderWithProviders(<AboutBox {...defaultProps} />);
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('About Gemini CLI'); expect(output).toContain('About Gemini CLI');
expect(output).toContain('1.0.0'); expect(output).toContain('1.0.0');
@@ -42,7 +42,7 @@ describe('AboutBox', () => {
['tier', 'Enterprise', 'Tier'], ['tier', 'Enterprise', 'Tier'],
])('renders optional prop %s', (prop, value, label) => { ])('renders optional prop %s', (prop, value, label) => {
const props = { ...defaultProps, [prop]: value }; const props = { ...defaultProps, [prop]: value };
const { lastFrame } = render(<AboutBox {...props} />); const { lastFrame } = renderWithProviders(<AboutBox {...props} />);
const output = lastFrame(); const output = lastFrame();
expect(output).toContain(label); expect(output).toContain(label);
expect(output).toContain(value); expect(output).toContain(value);
@@ -50,14 +50,14 @@ describe('AboutBox', () => {
it('renders Auth Method with email when userEmail is provided', () => { it('renders Auth Method with email when userEmail is provided', () => {
const props = { ...defaultProps, userEmail: 'test@example.com' }; const props = { ...defaultProps, userEmail: 'test@example.com' };
const { lastFrame } = render(<AboutBox {...props} />); const { lastFrame } = renderWithProviders(<AboutBox {...props} />);
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('Logged in with Google (test@example.com)'); expect(output).toContain('Logged in with Google (test@example.com)');
}); });
it('renders Auth Method correctly when not oauth', () => { it('renders Auth Method correctly when not oauth', () => {
const props = { ...defaultProps, selectedAuthType: 'api-key' }; const props = { ...defaultProps, selectedAuthType: 'api-key' };
const { lastFrame } = render(<AboutBox {...props} />); const { lastFrame } = renderWithProviders(<AboutBox {...props} />);
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('api-key'); expect(output).toContain('api-key');
}); });

View File

@@ -8,6 +8,7 @@ import type React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface AboutBoxProps { interface AboutBoxProps {
cliVersion: string; cliVersion: string;
@@ -31,123 +32,130 @@ export const AboutBox: React.FC<AboutBoxProps> = ({
ideClient, ideClient,
userEmail, userEmail,
tier, tier,
}) => ( }) => {
<Box const settings = useSettings();
borderStyle="round" const showUserIdentity = settings.merged.ui.showUserIdentity;
borderColor={theme.border.default}
flexDirection="column" return (
padding={1} <Box
marginY={1} borderStyle="round"
width="100%" borderColor={theme.border.default}
> flexDirection="column"
<Box marginBottom={1}> padding={1}
<Text bold color={theme.text.accent}> marginY={1}
About Gemini CLI width="100%"
</Text> >
</Box> <Box marginBottom={1}>
<Box flexDirection="row"> <Text bold color={theme.text.accent}>
<Box width="35%"> About Gemini CLI
<Text bold color={theme.text.link}>
CLI Version
</Text> </Text>
</Box> </Box>
<Box>
<Text color={theme.text.primary}>{cliVersion}</Text>
</Box>
</Box>
{GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && (
<Box flexDirection="row"> <Box flexDirection="row">
<Box width="35%"> <Box width="35%">
<Text bold color={theme.text.link}> <Text bold color={theme.text.link}>
Git Commit CLI Version
</Text> </Text>
</Box> </Box>
<Box> <Box>
<Text color={theme.text.primary}>{GIT_COMMIT_INFO}</Text> <Text color={theme.text.primary}>{cliVersion}</Text>
</Box> </Box>
</Box> </Box>
)} {GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && (
<Box flexDirection="row"> <Box flexDirection="row">
<Box width="35%"> <Box width="35%">
<Text bold color={theme.text.link}> <Text bold color={theme.text.link}>
Model Git Commit
</Text> </Text>
</Box> </Box>
<Box> <Box>
<Text color={theme.text.primary}>{modelVersion}</Text> <Text color={theme.text.primary}>{GIT_COMMIT_INFO}</Text>
</Box> </Box>
</Box> </Box>
<Box flexDirection="row"> )}
<Box width="35%">
<Text bold color={theme.text.link}>
Sandbox
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{sandboxEnv}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
OS
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{osVersion}</Text>
</Box>
</Box>
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
Auth Method
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>
{selectedAuthType.startsWith('oauth')
? userEmail
? `Logged in with Google (${userEmail})`
: 'Logged in with Google'
: selectedAuthType}
</Text>
</Box>
</Box>
{tier && (
<Box flexDirection="row"> <Box flexDirection="row">
<Box width="35%"> <Box width="35%">
<Text bold color={theme.text.link}> <Text bold color={theme.text.link}>
Tier Model
</Text> </Text>
</Box> </Box>
<Box> <Box>
<Text color={theme.text.primary}>{tier}</Text> <Text color={theme.text.primary}>{modelVersion}</Text>
</Box> </Box>
</Box> </Box>
)}
{gcpProject && (
<Box flexDirection="row"> <Box flexDirection="row">
<Box width="35%"> <Box width="35%">
<Text bold color={theme.text.link}> <Text bold color={theme.text.link}>
GCP Project Sandbox
</Text> </Text>
</Box> </Box>
<Box> <Box>
<Text color={theme.text.primary}>{gcpProject}</Text> <Text color={theme.text.primary}>{sandboxEnv}</Text>
</Box> </Box>
</Box> </Box>
)}
{ideClient && (
<Box flexDirection="row"> <Box flexDirection="row">
<Box width="35%"> <Box width="35%">
<Text bold color={theme.text.link}> <Text bold color={theme.text.link}>
IDE Client OS
</Text> </Text>
</Box> </Box>
<Box> <Box>
<Text color={theme.text.primary}>{ideClient}</Text> <Text color={theme.text.primary}>{osVersion}</Text>
</Box> </Box>
</Box> </Box>
)} {showUserIdentity && (
</Box> <Box flexDirection="row">
); <Box width="35%">
<Text bold color={theme.text.link}>
Auth Method
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>
{selectedAuthType.startsWith('oauth')
? userEmail
? `Logged in with Google (${userEmail})`
: 'Logged in with Google'
: selectedAuthType}
</Text>
</Box>
</Box>
)}
{showUserIdentity && tier && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
Tier
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{tier}</Text>
</Box>
</Box>
)}
{gcpProject && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
GCP Project
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{gcpProject}</Text>
</Box>
</Box>
)}
{ideClient && (
<Box flexDirection="row">
<Box width="35%">
<Text bold color={theme.text.link}>
IDE Client
</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{ideClient}</Text>
</Box>
</Box>
)}
</Box>
);
};

View File

@@ -122,9 +122,18 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<StatsDisplay <StatsDisplay
duration={itemForDisplay.duration} duration={itemForDisplay.duration}
quotas={itemForDisplay.quotas} quotas={itemForDisplay.quotas}
selectedAuthType={itemForDisplay.selectedAuthType}
userEmail={itemForDisplay.userEmail}
tier={itemForDisplay.tier}
/>
)}
{itemForDisplay.type === 'model_stats' && (
<ModelStatsDisplay
selectedAuthType={itemForDisplay.selectedAuthType}
userEmail={itemForDisplay.userEmail}
tier={itemForDisplay.tier}
/> />
)} )}
{itemForDisplay.type === 'model_stats' && <ModelStatsDisplay />}
{itemForDisplay.type === 'tool_stats' && <ToolStatsDisplay />} {itemForDisplay.type === 'tool_stats' && <ToolStatsDisplay />}
{itemForDisplay.type === 'model' && ( {itemForDisplay.type === 'model' && (
<ModelMessage model={itemForDisplay.model} /> <ModelMessage model={itemForDisplay.model} />

View File

@@ -8,6 +8,8 @@ import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { ModelStatsDisplay } from './ModelStatsDisplay.js'; import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js'; 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 type { SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision } from '@google/gemini-cli-core'; import { ToolCallDecision } from '@google/gemini-cli-core';
@@ -20,7 +22,16 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
}; };
}); });
vi.mock('../contexts/SettingsContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SettingsContext>();
return {
...actual,
useSettings: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const useSettingsMock = vi.mocked(SettingsContext.useSettings);
const renderWithMockedStats = (metrics: SessionMetrics, width?: number) => { const renderWithMockedStats = (metrics: SessionMetrics, width?: number) => {
useSessionStatsMock.mockReturnValue({ useSessionStatsMock.mockReturnValue({
@@ -36,6 +47,14 @@ const renderWithMockedStats = (metrics: SessionMetrics, width?: number) => {
startNewPrompt: vi.fn(), startNewPrompt: vi.fn(),
}); });
useSettingsMock.mockReturnValue({
merged: {
ui: {
showUserIdentity: true,
},
},
} as unknown as LoadedSettings);
return render(<ModelStatsDisplay />, width); return render(<ModelStatsDisplay />, width);
}; };
@@ -368,4 +387,74 @@ describe('<ModelStatsDisplay />', () => {
expect(output).toContain('gemini-3-flash-'); expect(output).toContain('gemini-3-flash-');
expect(output).toMatchSnapshot(); expect(output).toMatchSnapshot();
}); });
it('should render user identity information when provided', () => {
useSettingsMock.mockReturnValue({
merged: {
ui: {
showUserIdentity: true,
},
},
} as unknown as LoadedSettings);
const { lastFrame } = render(
<ModelStatsDisplay
selectedAuthType="oauth"
userEmail="test@example.com"
tier="Pro"
/>,
);
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session',
sessionStartTime: new Date(),
metrics: {
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,
},
},
},
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,
},
},
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
const output = lastFrame();
expect(output).toContain('Auth Method:');
expect(output).toContain('Logged in with Google');
expect(output).toContain('(test@example.com)');
expect(output).toContain('Tier:');
expect(output).toContain('Pro');
});
}); });

View File

@@ -15,6 +15,7 @@ import {
} from '../utils/computeStats.js'; } from '../utils/computeStats.js';
import { useSessionStats } from '../contexts/SessionContext.js'; import { useSessionStats } from '../contexts/SessionContext.js';
import { Table, type Column } from './Table.js'; import { Table, type Column } from './Table.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface StatRowData { interface StatRowData {
metric: string; metric: string;
@@ -24,9 +25,21 @@ interface StatRowData {
[key: string]: string | React.ReactNode | boolean | undefined; [key: string]: string | React.ReactNode | boolean | undefined;
} }
export const ModelStatsDisplay: React.FC = () => { interface ModelStatsDisplayProps {
selectedAuthType?: string;
userEmail?: string;
tier?: string;
}
export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
selectedAuthType,
userEmail,
tier,
}) => {
const { stats } = useSessionStats(); const { stats } = useSessionStats();
const { models } = stats.metrics; const { models } = stats.metrics;
const settings = useSettings();
const showUserIdentity = settings.merged.ui.showUserIdentity;
const activeModels = Object.entries(models).filter( const activeModels = Object.entries(models).filter(
([, metrics]) => metrics.api.totalRequests > 0, ([, metrics]) => metrics.api.totalRequests > 0,
); );
@@ -75,10 +88,12 @@ export const ModelStatsDisplay: React.FC = () => {
return row; return row;
}; };
const rows: StatRowData[] = [ const rows: StatRowData[] = [];
// API Section
{ metric: 'API', isSection: true }, // API Section
createRow('Requests', (m) => m.api.totalRequests.toLocaleString()), rows.push({ metric: 'API', isSection: true });
rows.push(createRow('Requests', (m) => m.api.totalRequests.toLocaleString()));
rows.push(
createRow('Errors', (m) => { createRow('Errors', (m) => {
const errorRate = calculateErrorRate(m); const errorRate = calculateErrorRate(m);
return ( return (
@@ -91,18 +106,24 @@ export const ModelStatsDisplay: React.FC = () => {
</Text> </Text>
); );
}), }),
);
rows.push(
createRow('Avg Latency', (m) => formatDuration(calculateAverageLatency(m))), createRow('Avg Latency', (m) => formatDuration(calculateAverageLatency(m))),
);
// Spacer // Spacer
{ metric: '' }, rows.push({ metric: '' });
// Tokens Section // Tokens Section
{ metric: 'Tokens', isSection: true }, rows.push({ metric: 'Tokens', isSection: true });
rows.push(
createRow('Total', (m) => ( createRow('Total', (m) => (
<Text color={theme.text.secondary}> <Text color={theme.text.secondary}>
{m.tokens.total.toLocaleString()} {m.tokens.total.toLocaleString()}
</Text> </Text>
)), )),
);
rows.push(
createRow( createRow(
'Input', 'Input',
(m) => ( (m) => (
@@ -112,7 +133,7 @@ export const ModelStatsDisplay: React.FC = () => {
), ),
{ isSubtle: true }, { isSubtle: true },
), ),
]; );
if (hasCached) { if (hasCached) {
rows.push( rows.push(
@@ -214,6 +235,31 @@ export const ModelStatsDisplay: React.FC = () => {
Model Stats For Nerds Model Stats For Nerds
</Text> </Text>
<Box height={1} /> <Box height={1} />
{showUserIdentity && selectedAuthType && (
<Box>
<Box width={28}>
<Text color={theme.text.link}>Auth Method:</Text>
</Box>
<Text color={theme.text.primary}>
{selectedAuthType.startsWith('oauth')
? userEmail
? `Logged in with Google (${userEmail})`
: 'Logged in with Google'
: selectedAuthType}
</Text>
</Box>
)}
{showUserIdentity && tier && (
<Box>
<Box width={28}>
<Text color={theme.text.link}>Tier:</Text>
</Box>
<Text color={theme.text.primary}>{tier}</Text>
</Box>
)}
{showUserIdentity && (selectedAuthType || tier) && <Box height={1} />}
<Table data={rows} columns={columns} /> <Table data={rows} columns={columns} />
</Box> </Box>
); );

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { render } from '../../test-utils/render.js'; import { renderWithProviders } from '../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js'; import * as SessionContext from '../contexts/SessionContext.js';
@@ -35,7 +35,9 @@ const renderWithMockedStats = (metrics: SessionMetrics) => {
startNewPrompt: vi.fn(), startNewPrompt: vi.fn(),
}); });
return render(<SessionSummaryDisplay duration="1h 23m 45s" />); return renderWithProviders(<SessionSummaryDisplay duration="1h 23m 45s" />, {
width: 100,
});
}; };
describe('<SessionSummaryDisplay />', () => { describe('<SessionSummaryDisplay />', () => {

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { render } from '../../test-utils/render.js'; import { renderWithProviders } from '../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js'; import { StatsDisplay } from './StatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js'; import * as SessionContext from '../contexts/SessionContext.js';
@@ -39,7 +39,7 @@ const renderWithMockedStats = (metrics: SessionMetrics) => {
startNewPrompt: vi.fn(), startNewPrompt: vi.fn(),
}); });
return render(<StatsDisplay duration="1s" />); return renderWithProviders(<StatsDisplay duration="1s" />, { width: 100 });
}; };
// Helper to create metrics with default zero values // Helper to create metrics with default zero values
@@ -381,8 +381,9 @@ describe('<StatsDisplay />', () => {
startNewPrompt: vi.fn(), startNewPrompt: vi.fn(),
}); });
const { lastFrame } = render( const { lastFrame } = renderWithProviders(
<StatsDisplay duration="1s" title="Agent powering down. Goodbye!" />, <StatsDisplay duration="1s" title="Agent powering down. Goodbye!" />,
{ width: 100 },
); );
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!'); expect(output).toContain('Agent powering down. Goodbye!');
@@ -439,8 +440,9 @@ describe('<StatsDisplay />', () => {
startNewPrompt: vi.fn(), startNewPrompt: vi.fn(),
}); });
const { lastFrame } = render( const { lastFrame } = renderWithProviders(
<StatsDisplay duration="1s" quotas={quotas} />, <StatsDisplay duration="1s" quotas={quotas} />,
{ width: 100 },
); );
const output = lastFrame(); const output = lastFrame();
@@ -484,8 +486,9 @@ describe('<StatsDisplay />', () => {
startNewPrompt: vi.fn(), startNewPrompt: vi.fn(),
}); });
const { lastFrame } = render( const { lastFrame } = renderWithProviders(
<StatsDisplay duration="1s" quotas={quotas} />, <StatsDisplay duration="1s" quotas={quotas} />,
{ width: 100 },
); );
const output = lastFrame(); const output = lastFrame();
@@ -498,4 +501,64 @@ describe('<StatsDisplay />', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
describe('User Identity Display', () => {
it('renders User row with Auth Method and Tier', () => {
const metrics = createTestMetrics();
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session-id',
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
const { lastFrame } = renderWithProviders(
<StatsDisplay
duration="1s"
selectedAuthType="oauth"
userEmail="test@example.com"
tier="Pro"
/>,
{ width: 100 },
);
const output = lastFrame();
expect(output).toContain('Auth Method:');
expect(output).toContain('Logged in with Google (test@example.com)');
expect(output).toContain('Tier:');
expect(output).toContain('Pro');
});
it('renders User row with API Key and no Tier', () => {
const metrics = createTestMetrics();
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session-id',
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
const { lastFrame } = renderWithProviders(
<StatsDisplay duration="1s" selectedAuthType="Google API Key" />,
{ width: 100 },
);
const output = lastFrame();
expect(output).toContain('Auth Method:');
expect(output).toContain('Google API Key');
expect(output).not.toContain('Tier:');
});
});
}); });

View File

@@ -25,6 +25,7 @@ import {
type RetrieveUserQuotaResponse, type RetrieveUserQuotaResponse,
VALID_GEMINI_MODELS, VALID_GEMINI_MODELS,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { useSettings } from '../contexts/SettingsContext.js';
// A more flexible and powerful StatRow component // A more flexible and powerful StatRow component
interface StatRowProps { interface StatRowProps {
@@ -364,17 +365,25 @@ interface StatsDisplayProps {
duration: string; duration: string;
title?: string; title?: string;
quotas?: RetrieveUserQuotaResponse; quotas?: RetrieveUserQuotaResponse;
selectedAuthType?: string;
userEmail?: string;
tier?: string;
} }
export const StatsDisplay: React.FC<StatsDisplayProps> = ({ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
duration, duration,
title, title,
quotas, quotas,
selectedAuthType,
userEmail,
tier,
}) => { }) => {
const { stats } = useSessionStats(); const { stats } = useSessionStats();
const { metrics } = stats; const { metrics } = stats;
const { models, tools, files } = metrics; const { models, tools, files } = metrics;
const computed = computeSessionStats(metrics); const computed = computeSessionStats(metrics);
const settings = useSettings();
const showUserIdentity = settings.merged.ui.showUserIdentity;
const successThresholds = { const successThresholds = {
green: TOOL_SUCCESS_RATE_HIGH, green: TOOL_SUCCESS_RATE_HIGH,
@@ -417,6 +426,22 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
<StatRow title="Session ID:"> <StatRow title="Session ID:">
<Text color={theme.text.primary}>{stats.sessionId}</Text> <Text color={theme.text.primary}>{stats.sessionId}</Text>
</StatRow> </StatRow>
{showUserIdentity && selectedAuthType && (
<StatRow title="Auth Method:">
<Text color={theme.text.primary}>
{selectedAuthType.startsWith('oauth')
? userEmail
? `Logged in with Google (${userEmail})`
: 'Logged in with Google'
: selectedAuthType}
</Text>
</StatRow>
)}
{showUserIdentity && tier && (
<StatRow title="Tier:">
<Text color={theme.text.primary}>{tier}</Text>
</StatRow>
)}
<StatRow title="Tool Calls:"> <StatRow title="Tool Calls:">
<Text color={theme.text.primary}> <Text color={theme.text.primary}>
{tools.totalCalls} ({' '} {tools.totalCalls} ({' '}

View File

@@ -157,10 +157,16 @@ export type HistoryItemStats = HistoryItemBase & {
type: 'stats'; type: 'stats';
duration: string; duration: string;
quotas?: RetrieveUserQuotaResponse; quotas?: RetrieveUserQuotaResponse;
selectedAuthType?: string;
userEmail?: string;
tier?: string;
}; };
export type HistoryItemModelStats = HistoryItemBase & { export type HistoryItemModelStats = HistoryItemBase & {
type: 'model_stats'; type: 'model_stats';
selectedAuthType?: string;
userEmail?: string;
tier?: string;
}; };
export type HistoryItemToolStats = HistoryItemBase & { export type HistoryItemToolStats = HistoryItemBase & {

View File

@@ -302,6 +302,13 @@
"default": false, "default": false,
"type": "boolean" "type": "boolean"
}, },
"showUserIdentity": {
"title": "Show User Identity",
"description": "Show the logged-in user's identity (e.g. email) in the UI.",
"markdownDescription": "Show the logged-in user's identity (e.g. email) in the UI.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"useAlternateBuffer": { "useAlternateBuffer": {
"title": "Use Alternate Screen Buffer", "title": "Use Alternate Screen Buffer",
"description": "Use an alternate screen buffer for the UI, preserving shell history.", "description": "Use an alternate screen buffer for the UI, preserving shell history.",