From 770f862832dfef477705bee69bd2a84397d105a8 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:44:33 -0400 Subject: feat: Change /stats to include more detailed breakdowns (#2615) --- packages/cli/src/ui/components/Footer.tsx | 6 +- .../src/ui/components/HistoryItemDisplay.test.tsx | 63 +++-- .../cli/src/ui/components/HistoryItemDisplay.tsx | 16 +- .../src/ui/components/ModelStatsDisplay.test.tsx | 235 +++++++++++++++++ .../cli/src/ui/components/ModelStatsDisplay.tsx | 197 ++++++++++++++ .../ui/components/SessionSummaryDisplay.test.tsx | 106 +++++--- .../src/ui/components/SessionSummaryDisplay.tsx | 52 +++- .../cli/src/ui/components/StatsDisplay.test.tsx | 292 +++++++++++++++++---- packages/cli/src/ui/components/StatsDisplay.tsx | 262 +++++++++++++----- .../src/ui/components/ToolStatsDisplay.test.tsx | 176 +++++++++++++ .../cli/src/ui/components/ToolStatsDisplay.tsx | 208 +++++++++++++++ .../__snapshots__/ModelStatsDisplay.test.tsx.snap | 121 +++++++++ .../SessionSummaryDisplay.test.tsx.snap | 54 ++-- .../__snapshots__/StatsDisplay.test.tsx.snap | 166 ++++++++++-- .../__snapshots__/ToolStatsDisplay.test.tsx.snap | 91 +++++++ 15 files changed, 1804 insertions(+), 241 deletions(-) create mode 100644 packages/cli/src/ui/components/ModelStatsDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/ModelStatsDisplay.tsx create mode 100644 packages/cli/src/ui/components/ToolStatsDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/ToolStatsDisplay.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/ToolStatsDisplay.test.tsx.snap (limited to 'packages/cli/src/ui/components') diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 4ece6c92..48f37ee8 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -23,8 +23,6 @@ interface FooterProps { showErrorDetails: boolean; showMemoryUsage?: boolean; promptTokenCount: number; - candidatesTokenCount: number; - totalTokenCount: number; } export const Footer: React.FC = ({ @@ -37,10 +35,10 @@ export const Footer: React.FC = ({ errorCount, showErrorDetails, showMemoryUsage, - totalTokenCount, + promptTokenCount, }) => { const limit = tokenLimit(model); - const percentage = totalTokenCount / limit; + const percentage = promptTokenCount / limit; return ( diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 5816f7b4..b40b20bc 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -8,7 +8,7 @@ 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'; +import { SessionStatsProvider } from '../contexts/SessionContext.js'; // Mock child components vi.mock('./messages/ToolGroupMessage.js', () => ({ @@ -36,25 +36,15 @@ describe('', () => { }); 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'); }); @@ -76,25 +66,46 @@ describe('', () => { expect(lastFrame()).toContain('About Gemini CLI'); }); - it('renders SessionSummaryDisplay for "quit" type', () => { - const stats: CumulativeStats = { - turnCount: 1, - promptTokenCount: 10, - candidatesTokenCount: 20, - totalTokenCount: 30, - cachedContentTokenCount: 5, - toolUsePromptTokenCount: 2, - thoughtsTokenCount: 3, - apiTimeMs: 123, + it('renders ModelStatsDisplay for "model_stats" type', () => { + const item: HistoryItem = { + ...baseItem, + type: 'model_stats', + }; + const { lastFrame } = render( + + + , + ); + expect(lastFrame()).toContain( + 'No API calls have been made in this session.', + ); + }); + + it('renders ToolStatsDisplay for "tool_stats" type', () => { + const item: HistoryItem = { + ...baseItem, + type: 'tool_stats', }; + const { lastFrame } = render( + + + , + ); + expect(lastFrame()).toContain( + 'No tool calls have been made in this session.', + ); + }); + + it('renders SessionSummaryDisplay for "quit" type', () => { const item: HistoryItem = { ...baseItem, type: 'quit', - stats, duration: '1s', }; const { lastFrame } = render( - , + + + , ); expect(lastFrame()).toContain('Agent powering down. Goodbye!'); }); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 76b6ba6e..eba4ea47 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -17,6 +17,8 @@ import { CompressionMessage } from './messages/CompressionMessage.js'; import { Box } from 'ink'; import { AboutBox } from './AboutBox.js'; import { StatsDisplay } from './StatsDisplay.js'; +import { ModelStatsDisplay } from './ModelStatsDisplay.js'; +import { ToolStatsDisplay } from './ToolStatsDisplay.js'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import { Config } from '@google/gemini-cli-core'; @@ -69,16 +71,10 @@ export const HistoryItemDisplay: React.FC = ({ gcpProject={item.gcpProject} /> )} - {item.type === 'stats' && ( - - )} - {item.type === 'quit' && ( - - )} + {item.type === 'stats' && } + {item.type === 'model_stats' && } + {item.type === 'tool_stats' && } + {item.type === 'quit' && } {item.type === 'tool_group' && ( { + const actual = await importOriginal(); + return { + ...actual, + useSessionStats: vi.fn(), + }; +}); + +const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); + +const renderWithMockedStats = (metrics: SessionMetrics) => { + useSessionStatsMock.mockReturnValue({ + stats: { + sessionStartTime: new Date(), + metrics, + lastPromptTokenCount: 0, + }, + }); + + return render(); +}; + +describe('', () => { + it('should render "no API calls" message when there are no active models', () => { + const { lastFrame } = renderWithMockedStats({ + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }); + + expect(lastFrame()).toContain( + 'No API calls have been made in this session.', + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should not display conditional rows if no model has data for them', () => { + const { lastFrame } = renderWithMockedStats({ + models: { + 'gemini-2.5-pro': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + 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 }, + byName: {}, + }, + }); + + const output = lastFrame(); + expect(output).not.toContain('Cached'); + expect(output).not.toContain('Thoughts'); + expect(output).not.toContain('Tool'); + expect(output).toMatchSnapshot(); + }); + + it('should display conditional rows if at least one model has data', () => { + const { lastFrame } = renderWithMockedStats({ + models: { + 'gemini-2.5-pro': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 10, + candidates: 20, + total: 30, + cached: 5, + thoughts: 2, + tool: 0, + }, + }, + 'gemini-2.5-flash': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 50 }, + tokens: { + prompt: 5, + candidates: 10, + total: 15, + cached: 0, + thoughts: 0, + tool: 3, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }); + + const output = lastFrame(); + expect(output).toContain('Cached'); + expect(output).toContain('Thoughts'); + expect(output).toContain('Tool'); + expect(output).toMatchSnapshot(); + }); + + it('should display stats for multiple models correctly', () => { + const { lastFrame } = renderWithMockedStats({ + models: { + 'gemini-2.5-pro': { + api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 1000 }, + tokens: { + prompt: 100, + candidates: 200, + total: 300, + cached: 50, + thoughts: 10, + tool: 5, + }, + }, + 'gemini-2.5-flash': { + api: { totalRequests: 20, totalErrors: 2, totalLatencyMs: 500 }, + tokens: { + prompt: 200, + candidates: 400, + total: 600, + cached: 100, + thoughts: 20, + tool: 10, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }); + + const output = lastFrame(); + expect(output).toContain('gemini-2.5-pro'); + expect(output).toContain('gemini-2.5-flash'); + expect(output).toMatchSnapshot(); + }); + + it('should handle large values without wrapping or overlapping', () => { + const { lastFrame } = renderWithMockedStats({ + models: { + 'gemini-2.5-pro': { + api: { + totalRequests: 999999999, + totalErrors: 123456789, + totalLatencyMs: 9876, + }, + tokens: { + prompt: 987654321, + candidates: 123456789, + total: 999999999, + cached: 123456789, + thoughts: 111111111, + tool: 222222222, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should display a single model correctly', () => { + const { lastFrame } = renderWithMockedStats({ + models: { + 'gemini-2.5-pro': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 10, + candidates: 20, + total: 30, + cached: 5, + thoughts: 2, + tool: 1, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }); + + const output = lastFrame(); + expect(output).toContain('gemini-2.5-pro'); + expect(output).not.toContain('gemini-2.5-flash'); + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.tsx new file mode 100644 index 00000000..1911e757 --- /dev/null +++ b/packages/cli/src/ui/components/ModelStatsDisplay.tsx @@ -0,0 +1,197 @@ +/** + * @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 { + calculateAverageLatency, + calculateCacheHitRate, + calculateErrorRate, +} from '../utils/computeStats.js'; +import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js'; + +const METRIC_COL_WIDTH = 28; +const MODEL_COL_WIDTH = 22; + +interface StatRowProps { + title: string; + values: Array; + isSubtle?: boolean; + isSection?: boolean; +} + +const StatRow: React.FC = ({ + title, + values, + isSubtle = false, + isSection = false, +}) => ( + + + + {isSubtle ? ` ↳ ${title}` : title} + + + {values.map((value, index) => ( + + {value} + + ))} + +); + +export const ModelStatsDisplay: React.FC = () => { + const { stats } = useSessionStats(); + const { models } = stats.metrics; + const activeModels = Object.entries(models).filter( + ([, metrics]) => metrics.api.totalRequests > 0, + ); + + if (activeModels.length === 0) { + return ( + + No API calls have been made in this session. + + ); + } + + const modelNames = activeModels.map(([name]) => name); + + const getModelValues = ( + getter: (metrics: ModelMetrics) => string | React.ReactElement, + ) => activeModels.map(([, metrics]) => getter(metrics)); + + const hasThoughts = activeModels.some( + ([, metrics]) => metrics.tokens.thoughts > 0, + ); + const hasTool = activeModels.some(([, metrics]) => metrics.tokens.tool > 0); + const hasCached = activeModels.some( + ([, metrics]) => metrics.tokens.cached > 0, + ); + + return ( + + + Model Stats For Nerds + + + + {/* Header */} + + + Metric + + {modelNames.map((name) => ( + + {name} + + ))} + + + {/* Divider */} + + + {/* API Section */} + + m.api.totalRequests.toLocaleString())} + /> + { + const errorRate = calculateErrorRate(m); + return ( + 0 ? Colors.AccentRed : Colors.Foreground + } + > + {m.api.totalErrors.toLocaleString()} ({errorRate.toFixed(1)}%) + + ); + })} + /> + { + const avgLatency = calculateAverageLatency(m); + return formatDuration(avgLatency); + })} + /> + + + + {/* Tokens Section */} + + ( + + {m.tokens.total.toLocaleString()} + + ))} + /> + m.tokens.prompt.toLocaleString())} + /> + {hasCached && ( + { + const cacheHitRate = calculateCacheHitRate(m); + return ( + + {m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%) + + ); + })} + /> + )} + {hasThoughts && ( + m.tokens.thoughts.toLocaleString())} + /> + )} + {hasTool && ( + m.tokens.tool.toLocaleString())} + /> + )} + m.tokens.candidates.toLocaleString())} + /> + + ); +}; diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index 14d8a277..afb822e5 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -5,48 +5,92 @@ */ import { render } from 'ink-testing-library'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; -import { type CumulativeStats } from '../contexts/SessionContext.js'; +import * as SessionContext from '../contexts/SessionContext.js'; +import { SessionMetrics } from '../contexts/SessionContext.js'; -describe('', () => { - const mockStats: CumulativeStats = { - turnCount: 10, - promptTokenCount: 1000, - candidatesTokenCount: 2000, - totalTokenCount: 3500, - cachedContentTokenCount: 500, - toolUsePromptTokenCount: 200, - thoughtsTokenCount: 300, - apiTimeMs: 50234, +vi.mock('../contexts/SessionContext.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSessionStats: vi.fn(), }; +}); - const mockDuration = '1h 23m 45s'; +const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); - it('renders correctly with given stats and duration', () => { - const { lastFrame } = render( - , - ); +const renderWithMockedStats = (metrics: SessionMetrics) => { + useSessionStatsMock.mockReturnValue({ + stats: { + sessionStartTime: new Date(), + metrics, + lastPromptTokenCount: 0, + }, + }); - expect(lastFrame()).toMatchSnapshot(); + return render(); +}; + +describe('', () => { + it('correctly sums and displays stats from multiple models', () => { + const metrics: SessionMetrics = { + models: { + 'gemini-2.5-pro': { + api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 50234 }, + tokens: { + prompt: 1000, + candidates: 2000, + total: 3500, + cached: 500, + thoughts: 300, + tool: 200, + }, + }, + 'gemini-2.5-flash': { + api: { totalRequests: 5, totalErrors: 0, totalLatencyMs: 12345 }, + tokens: { + prompt: 500, + candidates: 1000, + total: 1500, + cached: 100, + thoughts: 50, + tool: 20, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }; + + const { lastFrame } = renderWithMockedStats(metrics); + const output = lastFrame(); + + // Verify totals are summed correctly + expect(output).toContain('Cumulative Stats (15 API calls)'); + expect(output).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 zeroMetrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, }; - const { lastFrame } = render( - , - ); - + const { lastFrame } = renderWithMockedStats(zeroMetrics); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index d3ee0f5f..a009f3d8 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -9,31 +9,57 @@ import { Box, Text } from 'ink'; import Gradient from 'ink-gradient'; import { Colors } from '../colors.js'; import { formatDuration } from '../utils/formatters.js'; -import { CumulativeStats } from '../contexts/SessionContext.js'; +import { useSessionStats } from '../contexts/SessionContext.js'; +import { computeSessionStats } from '../utils/computeStats.js'; import { FormattedStats, StatRow, StatsColumn } from './Stats.js'; // --- Prop and Data Structures --- interface SessionSummaryDisplayProps { - stats: CumulativeStats; duration: string; } // --- Main Component --- export const SessionSummaryDisplay: React.FC = ({ - stats, duration, }) => { + const { stats } = useSessionStats(); + const { metrics } = stats; + const computed = computeSessionStats(metrics); + const cumulativeFormatted: FormattedStats = { - inputTokens: stats.promptTokenCount, - outputTokens: stats.candidatesTokenCount, - toolUseTokens: stats.toolUsePromptTokenCount, - thoughtsTokens: stats.thoughtsTokenCount, - cachedTokens: stats.cachedContentTokenCount, - totalTokens: stats.totalTokenCount, + inputTokens: Object.values(metrics.models).reduce( + (acc, model) => acc + model.tokens.prompt, + 0, + ), + outputTokens: Object.values(metrics.models).reduce( + (acc, model) => acc + model.tokens.candidates, + 0, + ), + toolUseTokens: Object.values(metrics.models).reduce( + (acc, model) => acc + model.tokens.tool, + 0, + ), + thoughtsTokens: Object.values(metrics.models).reduce( + (acc, model) => acc + model.tokens.thoughts, + 0, + ), + cachedTokens: Object.values(metrics.models).reduce( + (acc, model) => acc + model.tokens.cached, + 0, + ), + totalTokens: Object.values(metrics.models).reduce( + (acc, model) => acc + model.tokens.total, + 0, + ), }; + const totalRequests = Object.values(metrics.models).reduce( + (acc, model) => acc + model.api.totalRequests, + 0, + ); + const title = 'Agent powering down. Goodbye!'; return ( @@ -57,14 +83,18 @@ export const SessionSummaryDisplay: React.FC = ({ + diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx index c7b574a5..29f322f4 100644 --- a/packages/cli/src/ui/components/StatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx @@ -5,67 +5,259 @@ */ import { render } from 'ink-testing-library'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { StatsDisplay } from './StatsDisplay.js'; -import { type CumulativeStats } from '../contexts/SessionContext.js'; +import * as SessionContext from '../contexts/SessionContext.js'; +import { SessionMetrics } from '../contexts/SessionContext.js'; -describe('', () => { - const mockStats: CumulativeStats = { - turnCount: 10, - promptTokenCount: 1000, - candidatesTokenCount: 2000, - totalTokenCount: 3500, - cachedContentTokenCount: 500, - toolUsePromptTokenCount: 200, - thoughtsTokenCount: 300, - apiTimeMs: 50234, +// Mock the context to provide controlled data for testing +vi.mock('../contexts/SessionContext.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSessionStats: vi.fn(), }; +}); - const mockLastTurnStats: CumulativeStats = { - turnCount: 1, - promptTokenCount: 100, - candidatesTokenCount: 200, - totalTokenCount: 350, - cachedContentTokenCount: 50, - toolUsePromptTokenCount: 20, - thoughtsTokenCount: 30, - apiTimeMs: 1234, - }; +const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); + +const renderWithMockedStats = (metrics: SessionMetrics) => { + useSessionStatsMock.mockReturnValue({ + stats: { + sessionStartTime: new Date(), + metrics, + lastPromptTokenCount: 0, + }, + }); + + return render(); +}; + +describe('', () => { + it('renders only the Performance section in its zero state', () => { + const zeroMetrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }; + + const { lastFrame } = renderWithMockedStats(zeroMetrics); + const output = lastFrame(); + + expect(output).toContain('Performance'); + expect(output).not.toContain('Interaction Summary'); + expect(output).not.toContain('Efficiency & Optimizations'); + expect(output).not.toContain('Model'); // The table header + expect(output).toMatchSnapshot(); + }); - const mockDuration = '1h 23m 45s'; + it('renders a table with two models correctly', () => { + const metrics: SessionMetrics = { + models: { + 'gemini-2.5-pro': { + api: { totalRequests: 3, totalErrors: 0, totalLatencyMs: 15000 }, + tokens: { + prompt: 1000, + candidates: 2000, + total: 43234, + cached: 500, + thoughts: 100, + tool: 50, + }, + }, + 'gemini-2.5-flash': { + api: { totalRequests: 5, totalErrors: 1, totalLatencyMs: 4500 }, + tokens: { + prompt: 25000, + candidates: 15000, + total: 150000000, + cached: 10000, + thoughts: 2000, + tool: 1000, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }; - it('renders correctly with given stats and duration', () => { - const { lastFrame } = render( - , - ); + const { lastFrame } = renderWithMockedStats(metrics); + const output = lastFrame(); - expect(lastFrame()).toMatchSnapshot(); + expect(output).toContain('gemini-2.5-pro'); + expect(output).toContain('gemini-2.5-flash'); + expect(output).toContain('1,000'); + expect(output).toContain('25,000'); + expect(output).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, + it('renders all sections when all data is present', () => { + const metrics: SessionMetrics = { + models: { + 'gemini-2.5-pro': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 100, + candidates: 100, + total: 250, + cached: 50, + thoughts: 0, + tool: 0, + }, + }, + }, + tools: { + totalCalls: 2, + totalSuccess: 1, + totalFail: 1, + totalDurationMs: 123, + totalDecisions: { accept: 1, reject: 0, modify: 0 }, + byName: { + 'test-tool': { + count: 2, + success: 1, + fail: 1, + durationMs: 123, + decisions: { accept: 1, reject: 0, modify: 0 }, + }, + }, + }, }; - const { lastFrame } = render( - , - ); + const { lastFrame } = renderWithMockedStats(metrics); + const output = lastFrame(); + + expect(output).toContain('Performance'); + expect(output).toContain('Interaction Summary'); + expect(output).toContain('User Agreement'); + expect(output).toContain('Savings Highlight'); + expect(output).toContain('gemini-2.5-pro'); + expect(output).toMatchSnapshot(); + }); + + describe('Conditional Rendering Tests', () => { + it('hides User Agreement when no decisions are made', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 2, + totalSuccess: 1, + totalFail: 1, + totalDurationMs: 123, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, // No decisions + byName: { + 'test-tool': { + count: 2, + success: 1, + fail: 1, + durationMs: 123, + decisions: { accept: 0, reject: 0, modify: 0 }, + }, + }, + }, + }; + + const { lastFrame } = renderWithMockedStats(metrics); + const output = lastFrame(); + + expect(output).toContain('Interaction Summary'); + expect(output).toContain('Success Rate'); + expect(output).not.toContain('User Agreement'); + expect(output).toMatchSnapshot(); + }); + + it('hides Efficiency section when cache is not used', () => { + const metrics: SessionMetrics = { + models: { + 'gemini-2.5-pro': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 100, + candidates: 100, + total: 200, + cached: 0, + thoughts: 0, + tool: 0, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }; + + const { lastFrame } = renderWithMockedStats(metrics); + const output = lastFrame(); + + expect(output).not.toContain('Efficiency & Optimizations'); + expect(output).toMatchSnapshot(); + }); + }); + + describe('Conditional Color Tests', () => { + it('renders success rate in green for high values', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 10, + totalSuccess: 10, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }; + const { lastFrame } = renderWithMockedStats(metrics); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders success rate in yellow for medium values', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 10, + totalSuccess: 9, + totalFail: 1, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }; + const { lastFrame } = renderWithMockedStats(metrics); + expect(lastFrame()).toMatchSnapshot(); + }); - expect(lastFrame()).toMatchSnapshot(); + it('renders success rate in red for low values', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 10, + totalSuccess: 5, + totalFail: 5, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }; + const { lastFrame } = renderWithMockedStats(metrics); + expect(lastFrame()).toMatchSnapshot(); + }); }); }); diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index 76d48821..249fc106 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -8,90 +8,230 @@ 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'; -import { FormattedStats, StatRow, StatsColumn } from './Stats.js'; +import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js'; +import { + getStatusColor, + TOOL_SUCCESS_RATE_HIGH, + TOOL_SUCCESS_RATE_MEDIUM, + USER_AGREEMENT_RATE_HIGH, + USER_AGREEMENT_RATE_MEDIUM, +} from '../utils/displayUtils.js'; +import { computeSessionStats } from '../utils/computeStats.js'; -// --- Constants --- +// A more flexible and powerful StatRow component +interface StatRowProps { + title: string; + children: React.ReactNode; // Use children to allow for complex, colored values +} + +const StatRow: React.FC = ({ title, children }) => ( + + {/* Fixed width for the label creates a clean "gutter" for alignment */} + + {title} + + {children} + +); + +// A SubStatRow for indented, secondary information +interface SubStatRowProps { + title: string; + children: React.ReactNode; +} + +const SubStatRow: React.FC = ({ title, children }) => ( + + {/* Adjust width for the "» " prefix */} + + » {title} + + {children} + +); + +// A Section component to group related stats +interface SectionProps { + title: string; + children: React.ReactNode; +} + +const Section: React.FC = ({ title, children }) => ( + + {title} + {children} + +); -const COLUMN_WIDTH = '48%'; +const ModelUsageTable: React.FC<{ + models: Record; + totalCachedTokens: number; + cacheEfficiency: number; +}> = ({ models, totalCachedTokens, cacheEfficiency }) => { + const nameWidth = 25; + const requestsWidth = 8; + const inputTokensWidth = 15; + const outputTokensWidth = 15; + + return ( + + {/* Header */} + + + Model Usage + + + Reqs + + + Input Tokens + + + Output Tokens + + + {/* Divider */} + -// --- Prop and Data Structures --- + {/* Rows */} + {Object.entries(models).map(([name, modelMetrics]) => ( + + + {name.replace('-001', '')} + + + {modelMetrics.api.totalRequests} + + + + {modelMetrics.tokens.prompt.toLocaleString()} + + + + + {modelMetrics.tokens.candidates.toLocaleString()} + + + + ))} + {cacheEfficiency > 0 && ( + + + Savings Highlight:{' '} + {totalCachedTokens.toLocaleString()} ({cacheEfficiency.toFixed(1)} + %) of input tokens were served from the cache, reducing costs. + + + + » Tip: For a full token breakdown, run `/stats model`. + + + )} + + ); +}; interface StatsDisplayProps { - stats: CumulativeStats; - lastTurnStats: CumulativeStats; duration: string; } -// --- 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, - }; +export const StatsDisplay: React.FC = ({ duration }) => { + const { stats } = useSessionStats(); + const { metrics } = stats; + const { models, tools } = metrics; + const computed = computeSessionStats(metrics); - const cumulativeFormatted: FormattedStats = { - inputTokens: stats.promptTokenCount, - outputTokens: stats.candidatesTokenCount, - toolUseTokens: stats.toolUsePromptTokenCount, - thoughtsTokens: stats.thoughtsTokenCount, - cachedTokens: stats.cachedContentTokenCount, - totalTokens: stats.totalTokenCount, + const successThresholds = { + green: TOOL_SUCCESS_RATE_HIGH, + yellow: TOOL_SUCCESS_RATE_MEDIUM, + }; + const agreementThresholds = { + green: USER_AGREEMENT_RATE_HIGH, + yellow: USER_AGREEMENT_RATE_MEDIUM, }; + const successColor = getStatusColor(computed.successRate, successThresholds); + const agreementColor = getStatusColor( + computed.agreementRate, + agreementThresholds, + ); return ( - Stats + Session Stats + - - - - + {tools.totalCalls > 0 && ( +
+ + + {tools.totalCalls} ({' '} + ✔ {tools.totalSuccess}{' '} + ✖ {tools.totalFail} ) + + + + {computed.successRate.toFixed(1)}% + + {computed.totalDecisions > 0 && ( + + + {computed.agreementRate.toFixed(1)}%{' '} + + ({computed.totalDecisions} reviewed) + + + + )} +
+ )} - - {/* Left column for "Last Turn" duration */} - - - +
+ + {duration} + + + {formatDuration(computed.agentActiveTime)} + + + + {formatDuration(computed.totalApiTime)}{' '} + + ({computed.apiTimePercent.toFixed(1)}%) + + + + + + {formatDuration(computed.totalToolTime)}{' '} + + ({computed.toolTimePercent.toFixed(1)}%) + + + +
- {/* Right column for "Cumulative" durations */} - - - - -
+ {Object.keys(models).length > 0 && ( + + )}
); }; diff --git a/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx b/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx new file mode 100644 index 00000000..54902788 --- /dev/null +++ b/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx @@ -0,0 +1,176 @@ +/** + * @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 { ToolStatsDisplay } from './ToolStatsDisplay.js'; +import * as SessionContext from '../contexts/SessionContext.js'; +import { SessionMetrics } from '../contexts/SessionContext.js'; + +// Mock the context to provide controlled data for testing +vi.mock('../contexts/SessionContext.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSessionStats: vi.fn(), + }; +}); + +const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); + +const renderWithMockedStats = (metrics: SessionMetrics) => { + useSessionStatsMock.mockReturnValue({ + stats: { + sessionStartTime: new Date(), + metrics, + lastPromptTokenCount: 0, + }, + }); + + return render(); +}; + +describe('', () => { + it('should render "no tool calls" message when there are no active tools', () => { + const { lastFrame } = renderWithMockedStats({ + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + }); + + expect(lastFrame()).toContain( + 'No tool calls have been made in this session.', + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should display stats for a single tool correctly', () => { + const { lastFrame } = renderWithMockedStats({ + models: {}, + tools: { + totalCalls: 1, + totalSuccess: 1, + totalFail: 0, + totalDurationMs: 100, + totalDecisions: { accept: 1, reject: 0, modify: 0 }, + byName: { + 'test-tool': { + count: 1, + success: 1, + fail: 0, + durationMs: 100, + decisions: { accept: 1, reject: 0, modify: 0 }, + }, + }, + }, + }); + + const output = lastFrame(); + expect(output).toContain('test-tool'); + expect(output).toMatchSnapshot(); + }); + + it('should display stats for multiple tools correctly', () => { + const { lastFrame } = renderWithMockedStats({ + models: {}, + tools: { + totalCalls: 3, + totalSuccess: 2, + totalFail: 1, + totalDurationMs: 300, + totalDecisions: { accept: 1, reject: 1, modify: 1 }, + byName: { + 'tool-a': { + count: 2, + success: 1, + fail: 1, + durationMs: 200, + decisions: { accept: 1, reject: 1, modify: 0 }, + }, + 'tool-b': { + count: 1, + success: 1, + fail: 0, + durationMs: 100, + decisions: { accept: 0, reject: 0, modify: 1 }, + }, + }, + }, + }); + + const output = lastFrame(); + expect(output).toContain('tool-a'); + expect(output).toContain('tool-b'); + expect(output).toMatchSnapshot(); + }); + + it('should handle large values without wrapping or overlapping', () => { + const { lastFrame } = renderWithMockedStats({ + models: {}, + tools: { + totalCalls: 999999999, + totalSuccess: 888888888, + totalFail: 111111111, + totalDurationMs: 987654321, + totalDecisions: { + accept: 123456789, + reject: 98765432, + modify: 12345, + }, + byName: { + 'long-named-tool-for-testing-wrapping-and-such': { + count: 999999999, + success: 888888888, + fail: 111111111, + durationMs: 987654321, + decisions: { + accept: 123456789, + reject: 98765432, + modify: 12345, + }, + }, + }, + }, + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should handle zero decisions gracefully', () => { + const { lastFrame } = renderWithMockedStats({ + models: {}, + tools: { + totalCalls: 1, + totalSuccess: 1, + totalFail: 0, + totalDurationMs: 100, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: { + 'test-tool': { + count: 1, + success: 1, + fail: 0, + durationMs: 100, + decisions: { accept: 0, reject: 0, modify: 0 }, + }, + }, + }, + }); + + const output = lastFrame(); + expect(output).toContain('Total Reviewed Suggestions:'); + expect(output).toContain('0'); + expect(output).toContain('Overall Agreement Rate:'); + expect(output).toContain('--'); + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/ToolStatsDisplay.tsx b/packages/cli/src/ui/components/ToolStatsDisplay.tsx new file mode 100644 index 00000000..f2335d9e --- /dev/null +++ b/packages/cli/src/ui/components/ToolStatsDisplay.tsx @@ -0,0 +1,208 @@ +/** + * @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 { + getStatusColor, + TOOL_SUCCESS_RATE_HIGH, + TOOL_SUCCESS_RATE_MEDIUM, + USER_AGREEMENT_RATE_HIGH, + USER_AGREEMENT_RATE_MEDIUM, +} from '../utils/displayUtils.js'; +import { useSessionStats } from '../contexts/SessionContext.js'; +import { ToolCallStats } from '@google/gemini-cli-core'; + +const TOOL_NAME_COL_WIDTH = 25; +const CALLS_COL_WIDTH = 8; +const SUCCESS_RATE_COL_WIDTH = 15; +const AVG_DURATION_COL_WIDTH = 15; + +const StatRow: React.FC<{ + name: string; + stats: ToolCallStats; +}> = ({ name, stats }) => { + const successRate = stats.count > 0 ? (stats.success / stats.count) * 100 : 0; + const avgDuration = stats.count > 0 ? stats.durationMs / stats.count : 0; + const successColor = getStatusColor(successRate, { + green: TOOL_SUCCESS_RATE_HIGH, + yellow: TOOL_SUCCESS_RATE_MEDIUM, + }); + + return ( + + + {name} + + + {stats.count} + + + {successRate.toFixed(1)}% + + + {formatDuration(avgDuration)} + + + ); +}; + +export const ToolStatsDisplay: React.FC = () => { + const { stats } = useSessionStats(); + const { tools } = stats.metrics; + const activeTools = Object.entries(tools.byName).filter( + ([, metrics]) => metrics.count > 0, + ); + + if (activeTools.length === 0) { + return ( + + No tool calls have been made in this session. + + ); + } + + const totalDecisions = Object.values(tools.byName).reduce( + (acc, tool) => { + acc.accept += tool.decisions.accept; + acc.reject += tool.decisions.reject; + acc.modify += tool.decisions.modify; + return acc; + }, + { accept: 0, reject: 0, modify: 0 }, + ); + + const totalReviewed = + totalDecisions.accept + totalDecisions.reject + totalDecisions.modify; + const agreementRate = + totalReviewed > 0 ? (totalDecisions.accept / totalReviewed) * 100 : 0; + const agreementColor = getStatusColor(agreementRate, { + green: USER_AGREEMENT_RATE_HIGH, + yellow: USER_AGREEMENT_RATE_MEDIUM, + }); + + return ( + + + Tool Stats For Nerds + + + + {/* Header */} + + + Tool Name + + + Calls + + + Success Rate + + + Avg Duration + + + + {/* Divider */} + + + {/* Tool Rows */} + {activeTools.map(([name, stats]) => ( + + ))} + + + + {/* User Decision Summary */} + User Decision Summary + + + Total Reviewed Suggestions: + + + {totalReviewed} + + + + + » Accepted: + + + {totalDecisions.accept} + + + + + » Rejected: + + + {totalDecisions.reject} + + + + + » Modified: + + + {totalDecisions.modify} + + + + {/* Divider */} + + + + + Overall Agreement Rate: + + + 0 ? agreementColor : undefined}> + {totalReviewed > 0 ? `${agreementRate.toFixed(1)}%` : '--'} + + + + + ); +}; diff --git a/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap new file mode 100644 index 00000000..efc0862b --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap @@ -0,0 +1,121 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > should display a single model correctly 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Model Stats For Nerds │ +│ │ +│ Metric gemini-2.5-pro │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ +│ API │ +│ Requests 1 │ +│ Errors 0 (0.0%) │ +│ Avg Latency 100ms │ +│ │ +│ Tokens │ +│ Total 30 │ +│ ↳ Prompt 10 │ +│ ↳ Cached 5 (50.0%) │ +│ ↳ Thoughts 2 │ +│ ↳ Tool 1 │ +│ ↳ Output 20 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > should display conditional rows if at least one model has data 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Model Stats For Nerds │ +│ │ +│ Metric gemini-2.5-pro gemini-2.5-flash │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ +│ API │ +│ Requests 1 1 │ +│ Errors 0 (0.0%) 0 (0.0%) │ +│ Avg Latency 100ms 50ms │ +│ │ +│ Tokens │ +│ Total 30 15 │ +│ ↳ Prompt 10 5 │ +│ ↳ Cached 5 (50.0%) 0 (0.0%) │ +│ ↳ Thoughts 2 0 │ +│ ↳ Tool 0 3 │ +│ ↳ Output 20 10 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > should display stats for multiple models correctly 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Model Stats For Nerds │ +│ │ +│ Metric gemini-2.5-pro gemini-2.5-flash │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ +│ API │ +│ Requests 10 20 │ +│ Errors 1 (10.0%) 2 (10.0%) │ +│ Avg Latency 100ms 25ms │ +│ │ +│ Tokens │ +│ Total 300 600 │ +│ ↳ Prompt 100 200 │ +│ ↳ Cached 50 (50.0%) 100 (50.0%) │ +│ ↳ Thoughts 10 20 │ +│ ↳ Tool 5 10 │ +│ ↳ Output 200 400 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > should handle large values without wrapping or overlapping 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Model Stats For Nerds │ +│ │ +│ Metric gemini-2.5-pro │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ +│ API │ +│ Requests 999,999,999 │ +│ Errors 123,456,789 (12.3%) │ +│ Avg Latency 0ms │ +│ │ +│ Tokens │ +│ Total 999,999,999 │ +│ ↳ Prompt 987,654,321 │ +│ ↳ Cached 123,456,789 (12.5%) │ +│ ↳ Thoughts 111,111,111 │ +│ ↳ Tool 222,222,222 │ +│ ↳ Output 123,456,789 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > should not display conditional rows if no model has data for them 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Model Stats For Nerds │ +│ │ +│ Metric gemini-2.5-pro │ +│ ────────────────────────────────────────────────────────────────────────────────────────────── │ +│ API │ +│ Requests 1 │ +│ Errors 0 (0.0%) │ +│ Avg Latency 100ms │ +│ │ +│ Tokens │ +│ Total 30 │ +│ ↳ Prompt 10 │ +│ ↳ Output 20 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > should render "no API calls" message when there are no active models 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No API calls have been made in this session. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap index 3d2c373c..06dc2116 100644 --- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap @@ -1,43 +1,45 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > renders correctly with given stats and duration 1`] = ` +exports[` > correctly sums and displays stats from multiple models 1`] = ` "╭─────────────────────────────────────╮ │ │ │ Agent powering down. Goodbye! │ │ │ │ │ -│ Cumulative Stats (10 Turns) │ +│ Cumulative Stats (15 API calls) │ │ │ -│ Input Tokens 1,000 │ -│ Output Tokens 2,000 │ -│ Tool Use Tokens 200 │ -│ Thoughts Tokens 300 │ -│ Cached Tokens 500 (14.3%) │ +│ Input Tokens 1,500 │ +│ Output Tokens 3,000 │ +│ Tool Use Tokens 220 │ +│ Thoughts Tokens 350 │ +│ Cached Tokens 600 (12.0%) │ │ ───────────────────────────────── │ -│ Total Tokens 3,500 │ +│ Total Tokens 5,000 │ │ │ -│ Total duration (API) 50.2s │ +│ Total duration (API) 1m 2s │ +│ Total duration (Tools) 0s │ │ Total duration (wall) 1h 23m 45s │ │ │ ╰─────────────────────────────────────╯" `; exports[` > renders zero state correctly 1`] = ` -"╭─────────────────────────────────╮ -│ │ -│ Agent powering down. Goodbye! │ -│ │ -│ │ -│ Cumulative Stats (0 Turns) │ -│ │ -│ Input Tokens 0 │ -│ Output Tokens 0 │ -│ Thoughts Tokens 0 │ -│ ────────────────────────── │ -│ Total Tokens 0 │ -│ │ -│ Total duration (API) 0s │ -│ Total duration (wall) 0s │ -│ │ -╰─────────────────────────────────╯" +"╭─────────────────────────────────────╮ +│ │ +│ Agent powering down. Goodbye! │ +│ │ +│ │ +│ Cumulative Stats (0 API calls) │ +│ │ +│ Input Tokens 0 │ +│ Output Tokens 0 │ +│ Thoughts Tokens 0 │ +│ ───────────────────────────────── │ +│ Total Tokens 0 │ +│ │ +│ Total duration (API) 0s │ +│ Total duration (Tools) 0s │ +│ Total duration (wall) 1h 23m 45s │ +│ │ +╰─────────────────────────────────────╯" `; 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 b8a070a3..6fc2565e 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap @@ -1,41 +1,163 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > renders correctly with given stats and duration 1`] = ` +exports[` > Conditional Color Tests > renders success rate in green for high values 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ -│ Stats │ +│ Session Stats │ │ │ -│ Last Turn Cumulative (10 Turns) │ +│ Interaction Summary │ +│ Tool Calls: 10 ( ✔ 10 ✖ 0 ) │ +│ Success Rate: 100.0% │ │ │ -│ 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 │ +│ Performance │ +│ Wall Time: 1s │ +│ Agent Active: 0s │ +│ » API Time: 0s (0.0%) │ +│ » Tool Time: 0s (0.0%) │ │ │ -│ Turn Duration (API) 1.2s Total duration (API) 50.2s │ -│ Total duration (wall) 1h 23m 45s │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; -exports[` > renders zero state correctly 1`] = ` +exports[` > Conditional Color Tests > renders success rate in red for low values 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ -│ Stats │ +│ Session Stats │ │ │ -│ Last Turn Cumulative (0 Turns) │ +│ Interaction Summary │ +│ Tool Calls: 10 ( ✔ 5 ✖ 5 ) │ +│ Success Rate: 50.0% │ │ │ -│ Input Tokens 0 Input Tokens 0 │ -│ Output Tokens 0 Output Tokens 0 │ -│ Thoughts Tokens 0 Thoughts Tokens 0 │ -│ ───────────────────────────────────────────── ───────────────────────────────────────────── │ -│ Total Tokens 0 Total Tokens 0 │ +│ Performance │ +│ Wall Time: 1s │ +│ Agent Active: 0s │ +│ » API Time: 0s (0.0%) │ +│ » Tool Time: 0s (0.0%) │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > Conditional Color Tests > renders success rate in yellow for medium values 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Session Stats │ +│ │ +│ Interaction Summary │ +│ Tool Calls: 10 ( ✔ 9 ✖ 1 ) │ +│ Success Rate: 90.0% │ +│ │ +│ Performance │ +│ Wall Time: 1s │ +│ Agent Active: 0s │ +│ » API Time: 0s (0.0%) │ +│ » Tool Time: 0s (0.0%) │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > Conditional Rendering Tests > hides Efficiency section when cache is not used 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Session Stats │ +│ │ +│ Performance │ +│ Wall Time: 1s │ +│ Agent Active: 100ms │ +│ » API Time: 100ms (100.0%) │ +│ » Tool Time: 0s (0.0%) │ +│ │ +│ │ +│ Model Usage Reqs Input Tokens Output Tokens │ +│ ─────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 1 100 100 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > Conditional Rendering Tests > hides User Agreement when no decisions are made 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Session Stats │ +│ │ +│ Interaction Summary │ +│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │ +│ Success Rate: 50.0% │ +│ │ +│ Performance │ +│ Wall Time: 1s │ +│ Agent Active: 123ms │ +│ » API Time: 0s (0.0%) │ +│ » Tool Time: 123ms (100.0%) │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > renders a table with two models correctly 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Session Stats │ +│ │ +│ Performance │ +│ Wall Time: 1s │ +│ Agent Active: 19.5s │ +│ » API Time: 19.5s (100.0%) │ +│ » Tool Time: 0s (0.0%) │ +│ │ +│ │ +│ Model Usage Reqs Input Tokens Output Tokens │ +│ ─────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 3 1,000 2,000 │ +│ gemini-2.5-flash 5 25,000 15,000 │ +│ │ +│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │ +│ │ +│ » Tip: For a full token breakdown, run \`/stats model\`. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > renders all sections when all data is present 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Session Stats │ +│ │ +│ Interaction Summary │ +│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │ +│ Success Rate: 50.0% │ +│ User Agreement: 100.0% (1 reviewed) │ +│ │ +│ Performance │ +│ Wall Time: 1s │ +│ Agent Active: 223ms │ +│ » API Time: 100ms (44.8%) │ +│ » Tool Time: 123ms (55.2%) │ +│ │ +│ │ +│ Model Usage Reqs Input Tokens Output Tokens │ +│ ─────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 1 100 100 │ +│ │ +│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │ +│ │ +│ » Tip: For a full token breakdown, run \`/stats model\`. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > renders only the Performance section in its zero state 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Session Stats │ +│ │ +│ Performance │ +│ Wall Time: 1s │ +│ Agent Active: 0s │ +│ » API Time: 0s (0.0%) │ +│ » Tool Time: 0s (0.0%) │ │ │ -│ Turn Duration (API) 0s Total duration (API) 0s │ -│ Total duration (wall) 0s │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/components/__snapshots__/ToolStatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolStatsDisplay.test.tsx.snap new file mode 100644 index 00000000..61fb3efc --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ToolStatsDisplay.test.tsx.snap @@ -0,0 +1,91 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > should display stats for a single tool correctly 1`] = ` +"╭────────────────────────────────────────────────────────────────────╮ +│ │ +│ Tool Stats For Nerds │ +│ │ +│ Tool Name Calls Success Rate Avg Duration │ +│ ──────────────────────────────────────────────────────────────── │ +│ test-tool 1 100.0% 100ms │ +│ │ +│ User Decision Summary │ +│ Total Reviewed Suggestions: 1 │ +│ » Accepted: 1 │ +│ » Rejected: 0 │ +│ » Modified: 0 │ +│ ──────────────────────────────────────────────────────────────── │ +│ Overall Agreement Rate: 100.0% │ +│ │ +╰────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > should display stats for multiple tools correctly 1`] = ` +"╭────────────────────────────────────────────────────────────────────╮ +│ │ +│ Tool Stats For Nerds │ +│ │ +│ Tool Name Calls Success Rate Avg Duration │ +│ ──────────────────────────────────────────────────────────────── │ +│ tool-a 2 50.0% 100ms │ +│ tool-b 1 100.0% 100ms │ +│ │ +│ User Decision Summary │ +│ Total Reviewed Suggestions: 3 │ +│ » Accepted: 1 │ +│ » Rejected: 1 │ +│ » Modified: 1 │ +│ ──────────────────────────────────────────────────────────────── │ +│ Overall Agreement Rate: 33.3% │ +│ │ +╰────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > should handle large values without wrapping or overlapping 1`] = ` +"╭────────────────────────────────────────────────────────────────────╮ +│ │ +│ Tool Stats For Nerds │ +│ │ +│ Tool Name Calls Success Rate Avg Duration │ +│ ──────────────────────────────────────────────────────────────── │ +│ long-named-tool-for-testi99999999 88.9% 1ms │ +│ ng-wrapping-and-such 9 │ +│ │ +│ User Decision Summary │ +│ Total Reviewed Suggestions: 222234566 │ +│ » Accepted: 123456789 │ +│ » Rejected: 98765432 │ +│ » Modified: 12345 │ +│ ──────────────────────────────────────────────────────────────── │ +│ Overall Agreement Rate: 55.6% │ +│ │ +╰────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > should handle zero decisions gracefully 1`] = ` +"╭────────────────────────────────────────────────────────────────────╮ +│ │ +│ Tool Stats For Nerds │ +│ │ +│ Tool Name Calls Success Rate Avg Duration │ +│ ──────────────────────────────────────────────────────────────── │ +│ test-tool 1 100.0% 100ms │ +│ │ +│ User Decision Summary │ +│ Total Reviewed Suggestions: 0 │ +│ » Accepted: 0 │ +│ » Rejected: 0 │ +│ » Modified: 0 │ +│ ──────────────────────────────────────────────────────────────── │ +│ Overall Agreement Rate: -- │ +│ │ +╰────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > should render "no tool calls" message when there are no active tools 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No tool calls have been made in this session. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; -- cgit v1.2.3