diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
new file mode 100644
index 00000000..0fe739df
--- /dev/null
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from 'ink-testing-library';
+import { describe, it, expect, vi } from 'vitest';
+import { HistoryItemDisplay } from './HistoryItemDisplay.js';
+import { HistoryItem, MessageType } from '../types.js';
+import { CumulativeStats } from '../contexts/SessionContext.js';
+
+// Mock child components
+vi.mock('./messages/ToolGroupMessage.js', () => ({
+ ToolGroupMessage: () =>
,
+}));
+
+describe('', () => {
+ const baseItem = {
+ id: 1,
+ timestamp: 12345,
+ isPending: false,
+ availableTerminalHeight: 100,
+ };
+
+ it('renders UserMessage for "user" type', () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: MessageType.USER,
+ text: 'Hello',
+ };
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toContain('Hello');
+ });
+
+ it('renders StatsDisplay for "stats" type', () => {
+ const stats: CumulativeStats = {
+ turnCount: 1,
+ promptTokenCount: 10,
+ candidatesTokenCount: 20,
+ totalTokenCount: 30,
+ cachedContentTokenCount: 5,
+ toolUsePromptTokenCount: 2,
+ thoughtsTokenCount: 3,
+ apiTimeMs: 123,
+ };
+ const item: HistoryItem = {
+ ...baseItem,
+ type: MessageType.STATS,
+ stats,
+ lastTurnStats: stats,
+ duration: '1s',
+ };
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toContain('Stats');
+ });
+
+ it('renders AboutBox for "about" type', () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: MessageType.ABOUT,
+ cliVersion: '1.0.0',
+ osVersion: 'test-os',
+ sandboxEnv: 'test-env',
+ modelVersion: 'test-model',
+ };
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toContain('About Gemini CLI');
+ });
+});
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
index 5ab6b3c9..8c4fede9 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
@@ -15,6 +15,7 @@ import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
+import { StatsDisplay } from './StatsDisplay.js';
import { Config } from '@gemini-cli/core';
interface HistoryItemDisplayProps {
@@ -58,6 +59,13 @@ export const HistoryItemDisplay: React.FC = ({
modelVersion={item.modelVersion}
/>
)}
+ {item.type === 'stats' && (
+
+ )}
{item.type === 'tool_group' && (
', () => {
+ const mockStats: CumulativeStats = {
+ turnCount: 10,
+ promptTokenCount: 1000,
+ candidatesTokenCount: 2000,
+ totalTokenCount: 3500,
+ cachedContentTokenCount: 500,
+ toolUsePromptTokenCount: 200,
+ thoughtsTokenCount: 300,
+ apiTimeMs: 50234,
+ };
+
+ const mockLastTurnStats: CumulativeStats = {
+ turnCount: 1,
+ promptTokenCount: 100,
+ candidatesTokenCount: 200,
+ totalTokenCount: 350,
+ cachedContentTokenCount: 50,
+ toolUsePromptTokenCount: 20,
+ thoughtsTokenCount: 30,
+ apiTimeMs: 1234,
+ };
+
+ const mockDuration = '1h 23m 45s';
+
+ it('renders correctly with given stats and duration', () => {
+ const { lastFrame } = render(
+ ,
+ );
+
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders zero state correctly', () => {
+ const zeroStats: CumulativeStats = {
+ turnCount: 0,
+ promptTokenCount: 0,
+ candidatesTokenCount: 0,
+ totalTokenCount: 0,
+ cachedContentTokenCount: 0,
+ toolUsePromptTokenCount: 0,
+ thoughtsTokenCount: 0,
+ apiTimeMs: 0,
+ };
+
+ const { lastFrame } = render(
+ ,
+ );
+
+ expect(lastFrame()).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx
new file mode 100644
index 00000000..be447595
--- /dev/null
+++ b/packages/cli/src/ui/components/StatsDisplay.tsx
@@ -0,0 +1,174 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { Box, Text } from 'ink';
+import { Colors } from '../colors.js';
+import { formatDuration } from '../utils/formatters.js';
+import { CumulativeStats } from '../contexts/SessionContext.js';
+
+// --- Constants ---
+
+const COLUMN_WIDTH = '48%';
+
+// --- Prop and Data Structures ---
+
+interface StatsDisplayProps {
+ stats: CumulativeStats;
+ lastTurnStats: CumulativeStats;
+ duration: string;
+}
+
+interface FormattedStats {
+ inputTokens: number;
+ outputTokens: number;
+ toolUseTokens: number;
+ thoughtsTokens: number;
+ cachedTokens: number;
+ totalTokens: number;
+}
+
+// --- Helper Components ---
+
+/**
+ * Renders a single row with a colored label on the left and a value on the right.
+ */
+const StatRow: React.FC<{
+ label: string;
+ value: string | number;
+ valueColor?: string;
+}> = ({ label, value, valueColor }) => (
+
+ {label}
+ {value}
+
+);
+
+/**
+ * Renders a full column for either "Last Turn" or "Cumulative" stats.
+ */
+const StatsColumn: React.FC<{
+ title: string;
+ stats: FormattedStats;
+ isCumulative?: boolean;
+}> = ({ title, stats, isCumulative = false }) => {
+ const cachedDisplay =
+ isCumulative && stats.totalTokens > 0
+ ? `${stats.cachedTokens.toLocaleString()} (${((stats.cachedTokens / stats.totalTokens) * 100).toFixed(1)}%)`
+ : stats.cachedTokens.toLocaleString();
+
+ const cachedColor =
+ isCumulative && stats.cachedTokens > 0 ? Colors.AccentGreen : undefined;
+
+ return (
+
+ {title}
+
+
+
+
+
+
+ {/* Divider Line */}
+
+
+
+
+ );
+};
+
+// --- Main Component ---
+
+export const StatsDisplay: React.FC = ({
+ stats,
+ lastTurnStats,
+ duration,
+}) => {
+ const lastTurnFormatted: FormattedStats = {
+ inputTokens: lastTurnStats.promptTokenCount,
+ outputTokens: lastTurnStats.candidatesTokenCount,
+ toolUseTokens: lastTurnStats.toolUsePromptTokenCount,
+ thoughtsTokens: lastTurnStats.thoughtsTokenCount,
+ cachedTokens: lastTurnStats.cachedContentTokenCount,
+ totalTokens: lastTurnStats.totalTokenCount,
+ };
+
+ const cumulativeFormatted: FormattedStats = {
+ inputTokens: stats.promptTokenCount,
+ outputTokens: stats.candidatesTokenCount,
+ toolUseTokens: stats.toolUsePromptTokenCount,
+ thoughtsTokens: stats.thoughtsTokenCount,
+ cachedTokens: stats.cachedContentTokenCount,
+ totalTokens: stats.totalTokenCount,
+ };
+
+ return (
+
+
+ Stats
+
+
+
+
+
+
+
+
+ {/* Left column for "Last Turn" duration */}
+
+
+
+
+ {/* Right column for "Cumulative" durations */}
+
+
+
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
new file mode 100644
index 00000000..f8fa3d4f
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap
@@ -0,0 +1,43 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > renders correctly with given stats and duration 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Stats │
+│ │
+│ Last Turn Cumulative (10 Turns) │
+│ │
+│ Input Tokens 100 Input Tokens 1,000 │
+│ Output Tokens 200 Output Tokens 2,000 │
+│ Tool Use Tokens 20 Tool Use Tokens 200 │
+│ Thoughts Tokens 30 Thoughts Tokens 300 │
+│ Cached Tokens 50 Cached Tokens 500 (14.3%) │
+│ ───────────────────────────────────────────── ───────────────────────────────────────────── │
+│ Total Tokens 350 Total Tokens 3,500 │
+│ │
+│ Turn Duration (API) 1.2s Total duration (API) 50.2s │
+│ Total duration (wall) 1h 23m 45s │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
+
+exports[` > renders zero state correctly 1`] = `
+"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ │
+│ Stats │
+│ │
+│ Last Turn Cumulative (0 Turns) │
+│ │
+│ Input Tokens 0 Input Tokens 0 │
+│ Output Tokens 0 Output Tokens 0 │
+│ Tool Use Tokens 0 Tool Use Tokens 0 │
+│ Thoughts Tokens 0 Thoughts Tokens 0 │
+│ Cached Tokens 0 Cached Tokens 0 │
+│ ───────────────────────────────────────────── ───────────────────────────────────────────── │
+│ Total Tokens 0 Total Tokens 0 │
+│ │
+│ Turn Duration (API) 0s Total duration (API) 0s │
+│ Total duration (wall) 0s │
+│ │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
+`;
diff --git a/packages/cli/src/ui/contexts/SessionContext.test.tsx b/packages/cli/src/ui/contexts/SessionContext.test.tsx
index fedf3d74..b00a5d75 100644
--- a/packages/cli/src/ui/contexts/SessionContext.test.tsx
+++ b/packages/cli/src/ui/contexts/SessionContext.test.tsx
@@ -59,7 +59,7 @@ describe('SessionStatsContext', () => {
const stats = contextRef.current?.stats;
expect(stats?.sessionStartTime).toBeInstanceOf(Date);
- expect(stats?.lastTurn).toBeNull();
+ expect(stats?.currentTurn).toBeDefined();
expect(stats?.cumulative.turnCount).toBe(0);
expect(stats?.cumulative.totalTokenCount).toBe(0);
expect(stats?.cumulative.promptTokenCount).toBe(0);
@@ -81,6 +81,7 @@ describe('SessionStatsContext', () => {
});
const stats = contextRef.current?.stats;
+ expect(stats?.currentTurn.totalTokenCount).toBe(0);
expect(stats?.cumulative.turnCount).toBe(1);
// Ensure token counts are unaffected
expect(stats?.cumulative.totalTokenCount).toBe(0);
@@ -98,7 +99,7 @@ describe('SessionStatsContext', () => {
);
act(() => {
- contextRef.current?.addUsage(mockMetadata1);
+ contextRef.current?.addUsage({ ...mockMetadata1, apiTimeMs: 123 });
});
const stats = contextRef.current?.stats;
@@ -110,12 +111,16 @@ describe('SessionStatsContext', () => {
expect(stats?.cumulative.promptTokenCount).toBe(
mockMetadata1.promptTokenCount ?? 0,
);
+ expect(stats?.cumulative.apiTimeMs).toBe(123);
// Check that turn count is NOT incremented
expect(stats?.cumulative.turnCount).toBe(0);
- // Check that lastTurn is updated
- expect(stats?.lastTurn?.metadata).toEqual(mockMetadata1);
+ // Check that currentTurn is updated
+ expect(stats?.currentTurn?.totalTokenCount).toEqual(
+ mockMetadata1.totalTokenCount,
+ );
+ expect(stats?.currentTurn?.apiTimeMs).toBe(123);
});
it('should correctly track a full logical turn with multiple API calls', () => {
@@ -136,12 +141,12 @@ describe('SessionStatsContext', () => {
// 2. First API call (e.g., prompt with a tool request)
act(() => {
- contextRef.current?.addUsage(mockMetadata1);
+ contextRef.current?.addUsage({ ...mockMetadata1, apiTimeMs: 100 });
});
// 3. Second API call (e.g., sending tool response back)
act(() => {
- contextRef.current?.addUsage(mockMetadata2);
+ contextRef.current?.addUsage({ ...mockMetadata2, apiTimeMs: 50 });
});
const stats = contextRef.current?.stats;
@@ -149,18 +154,27 @@ describe('SessionStatsContext', () => {
// Turn count should only be 1
expect(stats?.cumulative.turnCount).toBe(1);
+ // --- Check Cumulative Stats ---
// These fields should be the SUM of both calls
- expect(stats?.cumulative.totalTokenCount).toBe(330); // 300 + 30
- expect(stats?.cumulative.candidatesTokenCount).toBe(220); // 200 + 20
- expect(stats?.cumulative.thoughtsTokenCount).toBe(22); // 20 + 2
+ expect(stats?.cumulative.totalTokenCount).toBe(300 + 30);
+ expect(stats?.cumulative.candidatesTokenCount).toBe(200 + 20);
+ expect(stats?.cumulative.thoughtsTokenCount).toBe(20 + 2);
+ expect(stats?.cumulative.apiTimeMs).toBe(100 + 50);
- // These fields should ONLY be from the FIRST call, because isNewTurnForAggregation was true
- expect(stats?.cumulative.promptTokenCount).toBe(100);
- expect(stats?.cumulative.cachedContentTokenCount).toBe(50);
- expect(stats?.cumulative.toolUsePromptTokenCount).toBe(10);
+ // These fields should be the SUM of both calls
+ expect(stats?.cumulative.promptTokenCount).toBe(100 + 10);
+ expect(stats?.cumulative.cachedContentTokenCount).toBe(50 + 5);
+ expect(stats?.cumulative.toolUsePromptTokenCount).toBe(10 + 1);
- // Last turn should hold the metadata from the most recent call
- expect(stats?.lastTurn?.metadata).toEqual(mockMetadata2);
+ // --- Check Current Turn Stats ---
+ // All fields should be the SUM of both calls for the turn
+ expect(stats?.currentTurn.totalTokenCount).toBe(300 + 30);
+ expect(stats?.currentTurn.candidatesTokenCount).toBe(200 + 20);
+ expect(stats?.currentTurn.thoughtsTokenCount).toBe(20 + 2);
+ expect(stats?.currentTurn.promptTokenCount).toBe(100 + 10);
+ expect(stats?.currentTurn.cachedContentTokenCount).toBe(50 + 5);
+ expect(stats?.currentTurn.toolUsePromptTokenCount).toBe(10 + 1);
+ expect(stats?.currentTurn.apiTimeMs).toBe(100 + 50);
});
it('should throw an error when useSessionStats is used outside of a provider', () => {
diff --git a/packages/cli/src/ui/contexts/SessionContext.tsx b/packages/cli/src/ui/contexts/SessionContext.tsx
index 0549e3e1..0d574e75 100644
--- a/packages/cli/src/ui/contexts/SessionContext.tsx
+++ b/packages/cli/src/ui/contexts/SessionContext.tsx
@@ -16,7 +16,7 @@ import { type GenerateContentResponseUsageMetadata } from '@google/genai';
// --- Interface Definitions ---
-interface CumulativeStats {
+export interface CumulativeStats {
turnCount: number;
promptTokenCount: number;
candidatesTokenCount: number;
@@ -24,18 +24,13 @@ interface CumulativeStats {
cachedContentTokenCount: number;
toolUsePromptTokenCount: number;
thoughtsTokenCount: number;
-}
-
-interface LastTurnStats {
- metadata: GenerateContentResponseUsageMetadata;
- // TODO(abhipatel12): Add apiTime, etc. here in a future step.
+ apiTimeMs: number;
}
interface SessionStatsState {
sessionStartTime: Date;
cumulative: CumulativeStats;
- lastTurn: LastTurnStats | null;
- isNewTurnForAggregation: boolean;
+ currentTurn: CumulativeStats;
}
// Defines the final "value" of our context, including the state
@@ -43,7 +38,9 @@ interface SessionStatsState {
interface SessionStatsContextValue {
stats: SessionStatsState;
startNewTurn: () => void;
- addUsage: (metadata: GenerateContentResponseUsageMetadata) => void;
+ addUsage: (
+ metadata: GenerateContentResponseUsageMetadata & { apiTimeMs?: number },
+ ) => void;
}
// --- Context Definition ---
@@ -52,6 +49,27 @@ const SessionStatsContext = createContext(
undefined,
);
+// --- Helper Functions ---
+
+/**
+ * A small, reusable helper function to sum token counts.
+ * It unconditionally adds all token values from the source to the target.
+ * @param target The object to add the tokens to (e.g., cumulative, currentTurn).
+ * @param source The metadata object from the API response.
+ */
+const addTokens = (
+ target: CumulativeStats,
+ source: GenerateContentResponseUsageMetadata & { apiTimeMs?: number },
+) => {
+ target.candidatesTokenCount += source.candidatesTokenCount ?? 0;
+ target.thoughtsTokenCount += source.thoughtsTokenCount ?? 0;
+ target.totalTokenCount += source.totalTokenCount ?? 0;
+ target.apiTimeMs += source.apiTimeMs ?? 0;
+ target.promptTokenCount += source.promptTokenCount ?? 0;
+ target.cachedContentTokenCount += source.cachedContentTokenCount ?? 0;
+ target.toolUsePromptTokenCount += source.toolUsePromptTokenCount ?? 0;
+};
+
// --- Provider Component ---
export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
@@ -67,36 +85,37 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
cachedContentTokenCount: 0,
toolUsePromptTokenCount: 0,
thoughtsTokenCount: 0,
+ apiTimeMs: 0,
+ },
+ currentTurn: {
+ turnCount: 0,
+ promptTokenCount: 0,
+ candidatesTokenCount: 0,
+ totalTokenCount: 0,
+ cachedContentTokenCount: 0,
+ toolUsePromptTokenCount: 0,
+ thoughtsTokenCount: 0,
+ apiTimeMs: 0,
},
- lastTurn: null,
- isNewTurnForAggregation: true,
});
// A single, internal worker function to handle all metadata aggregation.
const aggregateTokens = useCallback(
- (metadata: GenerateContentResponseUsageMetadata) => {
+ (
+ metadata: GenerateContentResponseUsageMetadata & { apiTimeMs?: number },
+ ) => {
setStats((prevState) => {
- const { isNewTurnForAggregation } = prevState;
const newCumulative = { ...prevState.cumulative };
+ const newCurrentTurn = { ...prevState.currentTurn };
- newCumulative.candidatesTokenCount +=
- metadata.candidatesTokenCount ?? 0;
- newCumulative.thoughtsTokenCount += metadata.thoughtsTokenCount ?? 0;
- newCumulative.totalTokenCount += metadata.totalTokenCount ?? 0;
-
- if (isNewTurnForAggregation) {
- newCumulative.promptTokenCount += metadata.promptTokenCount ?? 0;
- newCumulative.cachedContentTokenCount +=
- metadata.cachedContentTokenCount ?? 0;
- newCumulative.toolUsePromptTokenCount +=
- metadata.toolUsePromptTokenCount ?? 0;
- }
+ // Add all tokens to the current turn's stats as well as cumulative stats.
+ addTokens(newCurrentTurn, metadata);
+ addTokens(newCumulative, metadata);
return {
...prevState,
cumulative: newCumulative,
- lastTurn: { metadata },
- isNewTurnForAggregation: false,
+ currentTurn: newCurrentTurn,
};
});
},
@@ -110,7 +129,16 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
...prevState.cumulative,
turnCount: prevState.cumulative.turnCount + 1,
},
- isNewTurnForAggregation: true,
+ currentTurn: {
+ turnCount: 0, // Reset for the new turn's accumulation.
+ promptTokenCount: 0,
+ candidatesTokenCount: 0,
+ totalTokenCount: 0,
+ cachedContentTokenCount: 0,
+ toolUsePromptTokenCount: 0,
+ thoughtsTokenCount: 0,
+ apiTimeMs: 0,
+ },
}));
}, []);
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
index 7466e2a6..6ec356aa 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
@@ -255,18 +255,19 @@ describe('useSlashCommandProcessor', () => {
describe('/stats command', () => {
it('should show detailed session statistics', async () => {
// Arrange
+ const cumulativeStats = {
+ totalTokenCount: 900,
+ promptTokenCount: 200,
+ candidatesTokenCount: 400,
+ cachedContentTokenCount: 100,
+ turnCount: 1,
+ toolUsePromptTokenCount: 50,
+ thoughtsTokenCount: 150,
+ };
mockUseSessionStats.mockReturnValue({
stats: {
sessionStartTime: new Date('2025-01-01T00:00:00.000Z'),
- cumulative: {
- totalTokenCount: 900,
- promptTokenCount: 200,
- candidatesTokenCount: 400,
- cachedContentTokenCount: 100,
- turnCount: 1,
- toolUsePromptTokenCount: 50,
- thoughtsTokenCount: 150,
- },
+ cumulative: cumulativeStats,
},
});
@@ -280,24 +281,12 @@ describe('useSlashCommandProcessor', () => {
});
// Assert
- const expectedContent = [
- ` ⎿ Total duration (wall): 1h 2m 3s`,
- ` Total Token usage:`,
- ` Turns: 1`,
- ` Total: 900`,
- ` ├─ Input: 200`,
- ` ├─ Output: 400`,
- ` ├─ Cached: 100`,
- ` └─ Overhead: 200`,
- ` ├─ Model thoughts: 150`,
- ` └─ Tool-use prompts: 50`,
- ].join('\n');
-
expect(mockAddItem).toHaveBeenNthCalledWith(
2, // Called after the user message
expect.objectContaining({
- type: MessageType.INFO,
- text: expectedContent,
+ type: MessageType.STATS,
+ stats: cumulativeStats,
+ duration: '1h 2m 3s',
}),
expect.any(Number),
);
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index fa1e4016..9e82b6cf 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -20,7 +20,7 @@ import { Message, MessageType, HistoryItemWithoutId } from '../types.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { createShowMemoryAction } from './useShowMemoryCommand.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
-import { formatMemoryUsage } from '../utils/formatters.js';
+import { formatDuration, formatMemoryUsage } from '../utils/formatters.js';
import { getCliVersion } from '../../utils/version.js';
export interface SlashCommandActionReturn {
@@ -69,6 +69,13 @@ export const useSlashCommandProcessor = (
sandboxEnv: message.sandboxEnv,
modelVersion: message.modelVersion,
};
+ } else if (message.type === MessageType.STATS) {
+ historyItemContent = {
+ type: 'stats',
+ stats: message.stats,
+ lastTurnStats: message.lastTurnStats,
+ duration: message.duration,
+ };
} else {
historyItemContent = {
type: message.type as
@@ -152,41 +159,14 @@ export const useSlashCommandProcessor = (
description: 'check session stats',
action: (_mainCommand, _subCommand, _args) => {
const now = new Date();
- const { sessionStartTime, cumulative } = session.stats;
-
- const duration = now.getTime() - sessionStartTime.getTime();
- const durationInSeconds = Math.floor(duration / 1000);
- const hours = Math.floor(durationInSeconds / 3600);
- const minutes = Math.floor((durationInSeconds % 3600) / 60);
- const seconds = durationInSeconds % 60;
-
- const durationString = [
- hours > 0 ? `${hours}h` : '',
- minutes > 0 ? `${minutes}m` : '',
- `${seconds}s`,
- ]
- .filter(Boolean)
- .join(' ');
-
- const overheadTotal =
- cumulative.thoughtsTokenCount + cumulative.toolUsePromptTokenCount;
-
- const statsContent = [
- ` ⎿ Total duration (wall): ${durationString}`,
- ` Total Token usage:`,
- ` Turns: ${cumulative.turnCount.toLocaleString()}`,
- ` Total: ${cumulative.totalTokenCount.toLocaleString()}`,
- ` ├─ Input: ${cumulative.promptTokenCount.toLocaleString()}`,
- ` ├─ Output: ${cumulative.candidatesTokenCount.toLocaleString()}`,
- ` ├─ Cached: ${cumulative.cachedContentTokenCount.toLocaleString()}`,
- ` └─ Overhead: ${overheadTotal.toLocaleString()}`,
- ` ├─ Model thoughts: ${cumulative.thoughtsTokenCount.toLocaleString()}`,
- ` └─ Tool-use prompts: ${cumulative.toolUsePromptTokenCount.toLocaleString()}`,
- ].join('\n');
+ const { sessionStartTime, cumulative, currentTurn } = session.stats;
+ const wallDuration = now.getTime() - sessionStartTime.getTime();
addMessage({
- type: MessageType.INFO,
- content: statsContent,
+ type: MessageType.STATS,
+ stats: cumulative,
+ lastTurnStats: currentTurn,
+ duration: formatDuration(wallDuration),
timestamp: new Date(),
});
},
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index ed0f2aac..e39feb01 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -598,5 +598,18 @@ describe('useGeminiStream', () => {
expect(mockStartNewTurn).toHaveBeenCalledTimes(1);
expect(mockAddUsage).not.toHaveBeenCalled();
});
+
+ it('should not call startNewTurn for a slash command', async () => {
+ mockHandleSlashCommand.mockReturnValue(true);
+
+ const { result } = renderTestHook();
+
+ await act(async () => {
+ await result.current.submitQuery('/stats');
+ });
+
+ expect(mockStartNewTurn).not.toHaveBeenCalled();
+ expect(mockSendMessageStream).not.toHaveBeenCalled();
+ });
});
});
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index bad9f78a..725d8737 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -432,10 +432,6 @@ export const useGeminiStream = (
const userMessageTimestamp = Date.now();
setShowHelp(false);
- if (!options?.isContinuation) {
- startNewTurn();
- }
-
abortControllerRef.current = new AbortController();
const abortSignal = abortControllerRef.current.signal;
@@ -449,6 +445,10 @@ export const useGeminiStream = (
return;
}
+ if (!options?.isContinuation) {
+ startNewTurn();
+ }
+
if (!geminiClient) {
const errorMsg = 'Gemini client is not available.';
setInitError(errorMsg);
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 1594d821..559a30a3 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -8,6 +8,7 @@ import {
ToolCallConfirmationDetails,
ToolResultDisplay,
} from '@gemini-cli/core';
+import { CumulativeStats } from './contexts/SessionContext.js';
// Only defining the state enum needed by the UI
export enum StreamingState {
@@ -89,6 +90,13 @@ export type HistoryItemAbout = HistoryItemBase & {
modelVersion: string;
};
+export type HistoryItemStats = HistoryItemBase & {
+ type: 'stats';
+ stats: CumulativeStats;
+ lastTurnStats: CumulativeStats;
+ duration: string;
+};
+
export type HistoryItemToolGroup = HistoryItemBase & {
type: 'tool_group';
tools: IndividualToolCallDisplay[];
@@ -111,7 +119,8 @@ export type HistoryItemWithoutId =
| HistoryItemInfo
| HistoryItemError
| HistoryItemAbout
- | HistoryItemToolGroup;
+ | HistoryItemToolGroup
+ | HistoryItemStats;
export type HistoryItem = HistoryItemWithoutId & { id: number };
@@ -121,6 +130,7 @@ export enum MessageType {
ERROR = 'error',
USER = 'user',
ABOUT = 'about',
+ STATS = 'stats',
// Add GEMINI if needed by other commands
}
@@ -139,6 +149,14 @@ export type Message =
sandboxEnv: string;
modelVersion: string;
content?: string; // Optional content, not really used for ABOUT
+ }
+ | {
+ type: MessageType.STATS;
+ timestamp: Date;
+ stats: CumulativeStats;
+ lastTurnStats: CumulativeStats;
+ duration: string;
+ content?: string;
};
export interface ConsoleMessageItem {
diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts
new file mode 100644
index 00000000..cb3d1324
--- /dev/null
+++ b/packages/cli/src/ui/utils/formatters.test.ts
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { formatDuration, formatMemoryUsage } from './formatters.js';
+
+describe('formatters', () => {
+ describe('formatMemoryUsage', () => {
+ it('should format bytes into KB', () => {
+ expect(formatMemoryUsage(12345)).toBe('12.1 KB');
+ });
+
+ it('should format bytes into MB', () => {
+ expect(formatMemoryUsage(12345678)).toBe('11.8 MB');
+ });
+
+ it('should format bytes into GB', () => {
+ expect(formatMemoryUsage(12345678901)).toBe('11.50 GB');
+ });
+ });
+
+ describe('formatDuration', () => {
+ it('should format milliseconds less than a second', () => {
+ expect(formatDuration(500)).toBe('500ms');
+ });
+
+ it('should format a duration of 0', () => {
+ expect(formatDuration(0)).toBe('0s');
+ });
+
+ it('should format an exact number of seconds', () => {
+ expect(formatDuration(5000)).toBe('5.0s');
+ });
+
+ it('should format a duration in seconds with one decimal place', () => {
+ expect(formatDuration(12345)).toBe('12.3s');
+ });
+
+ it('should format an exact number of minutes', () => {
+ expect(formatDuration(120000)).toBe('2m');
+ });
+
+ it('should format a duration in minutes and seconds', () => {
+ expect(formatDuration(123000)).toBe('2m 3s');
+ });
+
+ it('should format an exact number of hours', () => {
+ expect(formatDuration(3600000)).toBe('1h');
+ });
+
+ it('should format a duration in hours and seconds', () => {
+ expect(formatDuration(3605000)).toBe('1h 5s');
+ });
+
+ it('should format a duration in hours, minutes, and seconds', () => {
+ expect(formatDuration(3723000)).toBe('1h 2m 3s');
+ });
+
+ it('should handle large durations', () => {
+ expect(formatDuration(86400000 + 3600000 + 120000 + 1000)).toBe(
+ '25h 2m 1s',
+ );
+ });
+
+ it('should handle negative durations', () => {
+ expect(formatDuration(-100)).toBe('0s');
+ });
+ });
+});
diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts
index ab02160e..82a78109 100644
--- a/packages/cli/src/ui/utils/formatters.ts
+++ b/packages/cli/src/ui/utils/formatters.ts
@@ -14,3 +14,50 @@ export const formatMemoryUsage = (bytes: number): string => {
}
return `${gb.toFixed(2)} GB`;
};
+
+/**
+ * Formats a duration in milliseconds into a concise, human-readable string (e.g., "1h 5s").
+ * It omits any time units that are zero.
+ * @param milliseconds The duration in milliseconds.
+ * @returns A formatted string representing the duration.
+ */
+export const formatDuration = (milliseconds: number): string => {
+ if (milliseconds <= 0) {
+ return '0s';
+ }
+
+ if (milliseconds < 1000) {
+ return `${milliseconds}ms`;
+ }
+
+ const totalSeconds = milliseconds / 1000;
+
+ if (totalSeconds < 60) {
+ return `${totalSeconds.toFixed(1)}s`;
+ }
+
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = Math.floor(totalSeconds % 60);
+
+ const parts: string[] = [];
+
+ if (hours > 0) {
+ parts.push(`${hours}h`);
+ }
+ if (minutes > 0) {
+ parts.push(`${minutes}m`);
+ }
+ if (seconds > 0) {
+ parts.push(`${seconds}s`);
+ }
+
+ // If all parts are zero (e.g., exactly 1 hour), return the largest unit.
+ if (parts.length === 0) {
+ if (hours > 0) return `${hours}h`;
+ if (minutes > 0) return `${minutes}m`;
+ return `${seconds}s`;
+ }
+
+ return parts.join(' ');
+};
diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts
index 2217e5da..aeb30229 100644
--- a/packages/core/src/core/turn.test.ts
+++ b/packages/core/src/core/turn.test.ts
@@ -239,6 +239,8 @@ describe('Turn', () => {
candidates: [{ content: { parts: [{ text: 'First response' }] } }],
usageMetadata: mockMetadata1,
} as unknown as GenerateContentResponse;
+ // Add a small delay to ensure apiTimeMs is > 0
+ await new Promise((resolve) => setTimeout(resolve, 10));
yield {
functionCalls: [{ name: 'aTool' }],
usageMetadata: mockMetadata2,
@@ -262,7 +264,10 @@ describe('Turn', () => {
expect(metadataEvent.type).toBe(GeminiEventType.UsageMetadata);
// The value should be the *last* metadata object received.
- expect(metadataEvent.value).toEqual(mockMetadata2);
+ expect(metadataEvent.value).toEqual(
+ expect.objectContaining(mockMetadata2),
+ );
+ expect(metadataEvent.value.apiTimeMs).toBeGreaterThan(0);
// Also check the public getter
expect(turn.getUsageMetadata()).toEqual(mockMetadata2);
diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts
index 34e4a494..71c02d83 100644
--- a/packages/core/src/core/turn.ts
+++ b/packages/core/src/core/turn.ts
@@ -104,7 +104,7 @@ export type ServerGeminiChatCompressedEvent = {
export type ServerGeminiUsageMetadataEvent = {
type: GeminiEventType.UsageMetadata;
- value: GenerateContentResponseUsageMetadata;
+ value: GenerateContentResponseUsageMetadata & { apiTimeMs?: number };
};
// The original union type, now composed of the individual types
@@ -137,6 +137,7 @@ export class Turn {
req: PartListUnion,
signal: AbortSignal,
): AsyncGenerator {
+ const startTime = Date.now();
try {
const responseStream = await this.chat.sendMessageStream({
message: req,
@@ -174,9 +175,10 @@ export class Turn {
}
if (this.lastUsageMetadata) {
+ const durationMs = Date.now() - startTime;
yield {
type: GeminiEventType.UsageMetadata,
- value: this.lastUsageMetadata,
+ value: { ...this.lastUsageMetadata, apiTimeMs: durationMs },
};
}
} catch (error) {