diff options
Diffstat (limited to 'packages/cli/src/ui/components')
15 files changed, 1803 insertions, 240 deletions
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<FooterProps> = ({ @@ -37,10 +35,10 @@ export const Footer: React.FC<FooterProps> = ({ errorCount, showErrorDetails, showMemoryUsage, - totalTokenCount, + promptTokenCount, }) => { const limit = tokenLimit(model); - const percentage = totalTokenCount / limit; + const percentage = promptTokenCount / limit; return ( <Box marginTop={1} justifyContent="space-between" width="100%"> 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('<HistoryItemDisplay />', () => { }); 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( - <HistoryItemDisplay {...baseItem} item={item} />, + <SessionStatsProvider> + <HistoryItemDisplay {...baseItem} item={item} /> + </SessionStatsProvider>, ); expect(lastFrame()).toContain('Stats'); }); @@ -76,25 +66,46 @@ describe('<HistoryItemDisplay />', () => { 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( + <SessionStatsProvider> + <HistoryItemDisplay {...baseItem} item={item} /> + </SessionStatsProvider>, + ); + 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( + <SessionStatsProvider> + <HistoryItemDisplay {...baseItem} item={item} /> + </SessionStatsProvider>, + ); + 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( - <HistoryItemDisplay {...baseItem} item={item} />, + <SessionStatsProvider> + <HistoryItemDisplay {...baseItem} item={item} /> + </SessionStatsProvider>, ); 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<HistoryItemDisplayProps> = ({ gcpProject={item.gcpProject} /> )} - {item.type === 'stats' && ( - <StatsDisplay - stats={item.stats} - lastTurnStats={item.lastTurnStats} - duration={item.duration} - /> - )} - {item.type === 'quit' && ( - <SessionSummaryDisplay stats={item.stats} duration={item.duration} /> - )} + {item.type === 'stats' && <StatsDisplay duration={item.duration} />} + {item.type === 'model_stats' && <ModelStatsDisplay />} + {item.type === 'tool_stats' && <ToolStatsDisplay />} + {item.type === 'quit' && <SessionSummaryDisplay duration={item.duration} />} {item.type === 'tool_group' && ( <ToolGroupMessage toolCalls={item.tools} diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx new file mode 100644 index 00000000..6c41b775 --- /dev/null +++ b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx @@ -0,0 +1,235 @@ +/** + * @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 { ModelStatsDisplay } from './ModelStatsDisplay.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<typeof SessionContext>(); + 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(<ModelStatsDisplay />); +}; + +describe('<ModelStatsDisplay />', () => { + 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<string | React.ReactElement>; + isSubtle?: boolean; + isSection?: boolean; +} + +const StatRow: React.FC<StatRowProps> = ({ + title, + values, + isSubtle = false, + isSection = false, +}) => ( + <Box> + <Box width={METRIC_COL_WIDTH}> + <Text bold={isSection} color={isSection ? undefined : Colors.LightBlue}> + {isSubtle ? ` ↳ ${title}` : title} + </Text> + </Box> + {values.map((value, index) => ( + <Box width={MODEL_COL_WIDTH} key={index}> + <Text>{value}</Text> + </Box> + ))} + </Box> +); + +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 ( + <Box + borderStyle="round" + borderColor={Colors.Gray} + paddingY={1} + paddingX={2} + > + <Text>No API calls have been made in this session.</Text> + </Box> + ); + } + + 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 ( + <Box + borderStyle="round" + borderColor={Colors.Gray} + flexDirection="column" + paddingY={1} + paddingX={2} + > + <Text bold color={Colors.AccentPurple}> + Model Stats For Nerds + </Text> + <Box height={1} /> + + {/* Header */} + <Box> + <Box width={METRIC_COL_WIDTH}> + <Text bold>Metric</Text> + </Box> + {modelNames.map((name) => ( + <Box width={MODEL_COL_WIDTH} key={name}> + <Text bold>{name}</Text> + </Box> + ))} + </Box> + + {/* Divider */} + <Box + borderStyle="single" + borderBottom={true} + borderTop={false} + borderLeft={false} + borderRight={false} + /> + + {/* API Section */} + <StatRow title="API" values={[]} isSection /> + <StatRow + title="Requests" + values={getModelValues((m) => m.api.totalRequests.toLocaleString())} + /> + <StatRow + title="Errors" + values={getModelValues((m) => { + const errorRate = calculateErrorRate(m); + return ( + <Text + color={ + m.api.totalErrors > 0 ? Colors.AccentRed : Colors.Foreground + } + > + {m.api.totalErrors.toLocaleString()} ({errorRate.toFixed(1)}%) + </Text> + ); + })} + /> + <StatRow + title="Avg Latency" + values={getModelValues((m) => { + const avgLatency = calculateAverageLatency(m); + return formatDuration(avgLatency); + })} + /> + + <Box height={1} /> + + {/* Tokens Section */} + <StatRow title="Tokens" values={[]} isSection /> + <StatRow + title="Total" + values={getModelValues((m) => ( + <Text color={Colors.AccentYellow}> + {m.tokens.total.toLocaleString()} + </Text> + ))} + /> + <StatRow + title="Prompt" + isSubtle + values={getModelValues((m) => m.tokens.prompt.toLocaleString())} + /> + {hasCached && ( + <StatRow + title="Cached" + isSubtle + values={getModelValues((m) => { + const cacheHitRate = calculateCacheHitRate(m); + return ( + <Text color={Colors.AccentGreen}> + {m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%) + </Text> + ); + })} + /> + )} + {hasThoughts && ( + <StatRow + title="Thoughts" + isSubtle + values={getModelValues((m) => m.tokens.thoughts.toLocaleString())} + /> + )} + {hasTool && ( + <StatRow + title="Tool" + isSubtle + values={getModelValues((m) => m.tokens.tool.toLocaleString())} + /> + )} + <StatRow + title="Output" + isSubtle + values={getModelValues((m) => m.tokens.candidates.toLocaleString())} + /> + </Box> + ); +}; 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('<SessionSummaryDisplay />', () => { - 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<typeof SessionContext>(); + 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( - <SessionSummaryDisplay stats={mockStats} duration={mockDuration} />, - ); +const renderWithMockedStats = (metrics: SessionMetrics) => { + useSessionStatsMock.mockReturnValue({ + stats: { + sessionStartTime: new Date(), + metrics, + lastPromptTokenCount: 0, + }, + }); - expect(lastFrame()).toMatchSnapshot(); + return render(<SessionSummaryDisplay duration="1h 23m 45s" />); +}; + +describe('<SessionSummaryDisplay />', () => { + 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( - <SessionSummaryDisplay stats={zeroStats} duration="0s" />, - ); - + 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<SessionSummaryDisplayProps> = ({ - 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<SessionSummaryDisplayProps> = ({ <Box marginTop={1}> <StatsColumn - title={`Cumulative Stats (${stats.turnCount} Turns)`} + title={`Cumulative Stats (${totalRequests} API calls)`} stats={cumulativeFormatted} isCumulative={true} > <Box marginTop={1} flexDirection="column"> <StatRow label="Total duration (API)" - value={formatDuration(stats.apiTimeMs)} + value={formatDuration(computed.totalApiTime)} + /> + <StatRow + label="Total duration (Tools)" + value={formatDuration(computed.totalToolTime)} /> <StatRow label="Total duration (wall)" value={duration} /> </Box> 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('<StatsDisplay />', () => { - 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<typeof SessionContext>(); + 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(<StatsDisplay duration="1s" />); +}; + +describe('<StatsDisplay />', () => { + 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( - <StatsDisplay - stats={mockStats} - lastTurnStats={mockLastTurnStats} - duration={mockDuration} - />, - ); + 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( - <StatsDisplay - stats={zeroStats} - lastTurnStats={zeroStats} - duration="0s" - />, - ); + 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<StatRowProps> = ({ title, children }) => ( + <Box> + {/* Fixed width for the label creates a clean "gutter" for alignment */} + <Box width={28}> + <Text color={Colors.LightBlue}>{title}</Text> + </Box> + {children} + </Box> +); + +// A SubStatRow for indented, secondary information +interface SubStatRowProps { + title: string; + children: React.ReactNode; +} + +const SubStatRow: React.FC<SubStatRowProps> = ({ title, children }) => ( + <Box paddingLeft={2}> + {/* Adjust width for the "» " prefix */} + <Box width={26}> + <Text>» {title}</Text> + </Box> + {children} + </Box> +); + +// A Section component to group related stats +interface SectionProps { + title: string; + children: React.ReactNode; +} + +const Section: React.FC<SectionProps> = ({ title, children }) => ( + <Box flexDirection="column" width="100%" marginBottom={1}> + <Text bold>{title}</Text> + {children} + </Box> +); -const COLUMN_WIDTH = '48%'; +const ModelUsageTable: React.FC<{ + models: Record<string, ModelMetrics>; + totalCachedTokens: number; + cacheEfficiency: number; +}> = ({ models, totalCachedTokens, cacheEfficiency }) => { + const nameWidth = 25; + const requestsWidth = 8; + const inputTokensWidth = 15; + const outputTokensWidth = 15; -// --- Prop and Data Structures --- + return ( + <Box flexDirection="column" marginTop={1}> + {/* Header */} + <Box> + <Box width={nameWidth}> + <Text bold>Model Usage</Text> + </Box> + <Box width={requestsWidth} justifyContent="flex-end"> + <Text bold>Reqs</Text> + </Box> + <Box width={inputTokensWidth} justifyContent="flex-end"> + <Text bold>Input Tokens</Text> + </Box> + <Box width={outputTokensWidth} justifyContent="flex-end"> + <Text bold>Output Tokens</Text> + </Box> + </Box> + {/* Divider */} + <Box + borderStyle="round" + borderBottom={true} + borderTop={false} + borderLeft={false} + borderRight={false} + width={nameWidth + requestsWidth + inputTokensWidth + outputTokensWidth} + ></Box> + + {/* Rows */} + {Object.entries(models).map(([name, modelMetrics]) => ( + <Box key={name}> + <Box width={nameWidth}> + <Text>{name.replace('-001', '')}</Text> + </Box> + <Box width={requestsWidth} justifyContent="flex-end"> + <Text>{modelMetrics.api.totalRequests}</Text> + </Box> + <Box width={inputTokensWidth} justifyContent="flex-end"> + <Text color={Colors.AccentYellow}> + {modelMetrics.tokens.prompt.toLocaleString()} + </Text> + </Box> + <Box width={outputTokensWidth} justifyContent="flex-end"> + <Text color={Colors.AccentYellow}> + {modelMetrics.tokens.candidates.toLocaleString()} + </Text> + </Box> + </Box> + ))} + {cacheEfficiency > 0 && ( + <Box flexDirection="column" marginTop={1}> + <Text> + <Text color={Colors.AccentGreen}>Savings Highlight:</Text>{' '} + {totalCachedTokens.toLocaleString()} ({cacheEfficiency.toFixed(1)} + %) of input tokens were served from the cache, reducing costs. + </Text> + <Box height={1} /> + <Text color={Colors.Gray}> + » Tip: For a full token breakdown, run `/stats model`. + </Text> + </Box> + )} + </Box> + ); +}; interface StatsDisplayProps { - stats: CumulativeStats; - lastTurnStats: CumulativeStats; duration: string; } -// --- Main Component --- +export const StatsDisplay: React.FC<StatsDisplayProps> = ({ duration }) => { + const { stats } = useSessionStats(); + const { metrics } = stats; + const { models, tools } = metrics; + const computed = computeSessionStats(metrics); -export const StatsDisplay: React.FC<StatsDisplayProps> = ({ - 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 successThresholds = { + green: TOOL_SUCCESS_RATE_HIGH, + yellow: TOOL_SUCCESS_RATE_MEDIUM, }; - - const cumulativeFormatted: FormattedStats = { - inputTokens: stats.promptTokenCount, - outputTokens: stats.candidatesTokenCount, - toolUseTokens: stats.toolUsePromptTokenCount, - thoughtsTokens: stats.thoughtsTokenCount, - cachedTokens: stats.cachedContentTokenCount, - totalTokens: stats.totalTokenCount, + 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 ( <Box borderStyle="round" - borderColor="gray" + borderColor={Colors.Gray} flexDirection="column" paddingY={1} paddingX={2} > <Text bold color={Colors.AccentPurple}> - Stats + Session Stats </Text> + <Box height={1} /> - <Box flexDirection="row" justifyContent="space-between" marginTop={1}> - <StatsColumn - title="Last Turn" - stats={lastTurnFormatted} - width={COLUMN_WIDTH} - /> - <StatsColumn - title={`Cumulative (${stats.turnCount} Turns)`} - stats={cumulativeFormatted} - isCumulative={true} - width={COLUMN_WIDTH} - /> - </Box> + {tools.totalCalls > 0 && ( + <Section title="Interaction Summary"> + <StatRow title="Tool Calls:"> + <Text> + {tools.totalCalls} ({' '} + <Text color={Colors.AccentGreen}>✔ {tools.totalSuccess}</Text>{' '} + <Text color={Colors.AccentRed}>✖ {tools.totalFail}</Text> ) + </Text> + </StatRow> + <StatRow title="Success Rate:"> + <Text color={successColor}>{computed.successRate.toFixed(1)}%</Text> + </StatRow> + {computed.totalDecisions > 0 && ( + <StatRow title="User Agreement:"> + <Text color={agreementColor}> + {computed.agreementRate.toFixed(1)}%{' '} + <Text color={Colors.Gray}> + ({computed.totalDecisions} reviewed) + </Text> + </Text> + </StatRow> + )} + </Section> + )} - <Box flexDirection="row" justifyContent="space-between" marginTop={1}> - {/* Left column for "Last Turn" duration */} - <Box width={COLUMN_WIDTH} flexDirection="column"> - <StatRow - label="Turn Duration (API)" - value={formatDuration(lastTurnStats.apiTimeMs)} - /> - </Box> + <Section title="Performance"> + <StatRow title="Wall Time:"> + <Text>{duration}</Text> + </StatRow> + <StatRow title="Agent Active:"> + <Text>{formatDuration(computed.agentActiveTime)}</Text> + </StatRow> + <SubStatRow title="API Time:"> + <Text> + {formatDuration(computed.totalApiTime)}{' '} + <Text color={Colors.Gray}> + ({computed.apiTimePercent.toFixed(1)}%) + </Text> + </Text> + </SubStatRow> + <SubStatRow title="Tool Time:"> + <Text> + {formatDuration(computed.totalToolTime)}{' '} + <Text color={Colors.Gray}> + ({computed.toolTimePercent.toFixed(1)}%) + </Text> + </Text> + </SubStatRow> + </Section> - {/* Right column for "Cumulative" durations */} - <Box width={COLUMN_WIDTH} flexDirection="column"> - <StatRow - label="Total duration (API)" - value={formatDuration(stats.apiTimeMs)} - /> - <StatRow label="Total duration (wall)" value={duration} /> - </Box> - </Box> + {Object.keys(models).length > 0 && ( + <ModelUsageTable + models={models} + totalCachedTokens={computed.totalCachedTokens} + cacheEfficiency={computed.cacheEfficiency} + /> + )} </Box> ); }; 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<typeof SessionContext>(); + 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(<ToolStatsDisplay />); +}; + +describe('<ToolStatsDisplay />', () => { + 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 ( + <Box> + <Box width={TOOL_NAME_COL_WIDTH}> + <Text color={Colors.LightBlue}>{name}</Text> + </Box> + <Box width={CALLS_COL_WIDTH} justifyContent="flex-end"> + <Text>{stats.count}</Text> + </Box> + <Box width={SUCCESS_RATE_COL_WIDTH} justifyContent="flex-end"> + <Text color={successColor}>{successRate.toFixed(1)}%</Text> + </Box> + <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end"> + <Text>{formatDuration(avgDuration)}</Text> + </Box> + </Box> + ); +}; + +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 ( + <Box + borderStyle="round" + borderColor={Colors.Gray} + paddingY={1} + paddingX={2} + > + <Text>No tool calls have been made in this session.</Text> + </Box> + ); + } + + 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 ( + <Box + borderStyle="round" + borderColor={Colors.Gray} + flexDirection="column" + paddingY={1} + paddingX={2} + width={70} + > + <Text bold color={Colors.AccentPurple}> + Tool Stats For Nerds + </Text> + <Box height={1} /> + + {/* Header */} + <Box> + <Box width={TOOL_NAME_COL_WIDTH}> + <Text bold>Tool Name</Text> + </Box> + <Box width={CALLS_COL_WIDTH} justifyContent="flex-end"> + <Text bold>Calls</Text> + </Box> + <Box width={SUCCESS_RATE_COL_WIDTH} justifyContent="flex-end"> + <Text bold>Success Rate</Text> + </Box> + <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end"> + <Text bold>Avg Duration</Text> + </Box> + </Box> + + {/* Divider */} + <Box + borderStyle="single" + borderBottom={true} + borderTop={false} + borderLeft={false} + borderRight={false} + width="100%" + /> + + {/* Tool Rows */} + {activeTools.map(([name, stats]) => ( + <StatRow key={name} name={name} stats={stats as ToolCallStats} /> + ))} + + <Box height={1} /> + + {/* User Decision Summary */} + <Text bold>User Decision Summary</Text> + <Box> + <Box + width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH} + > + <Text color={Colors.LightBlue}>Total Reviewed Suggestions:</Text> + </Box> + <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end"> + <Text>{totalReviewed}</Text> + </Box> + </Box> + <Box> + <Box + width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH} + > + <Text> » Accepted:</Text> + </Box> + <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end"> + <Text color={Colors.AccentGreen}>{totalDecisions.accept}</Text> + </Box> + </Box> + <Box> + <Box + width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH} + > + <Text> » Rejected:</Text> + </Box> + <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end"> + <Text color={Colors.AccentRed}>{totalDecisions.reject}</Text> + </Box> + </Box> + <Box> + <Box + width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH} + > + <Text> » Modified:</Text> + </Box> + <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end"> + <Text color={Colors.AccentYellow}>{totalDecisions.modify}</Text> + </Box> + </Box> + + {/* Divider */} + <Box + borderStyle="single" + borderBottom={true} + borderTop={false} + borderLeft={false} + borderRight={false} + width="100%" + /> + + <Box> + <Box + width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH} + > + <Text> Overall Agreement Rate:</Text> + </Box> + <Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end"> + <Text bold color={totalReviewed > 0 ? agreementColor : undefined}> + {totalReviewed > 0 ? `${agreementRate.toFixed(1)}%` : '--'} + </Text> + </Box> + </Box> + </Box> + ); +}; 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[`<ModelStatsDisplay /> > 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[`<ModelStatsDisplay /> > 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[`<ModelStatsDisplay /> > 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[`<ModelStatsDisplay /> > 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[`<ModelStatsDisplay /> > 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[`<ModelStatsDisplay /> > 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[`<SessionSummaryDisplay /> > renders correctly with given stats and duration 1`] = ` +exports[`<SessionSummaryDisplay /> > 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[`<SessionSummaryDisplay /> > 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[`<StatsDisplay /> > renders correctly with given stats and duration 1`] = ` +exports[`<StatsDisplay /> > 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[`<StatsDisplay /> > renders zero state correctly 1`] = ` +exports[`<StatsDisplay /> > 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[`<StatsDisplay /> > 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[`<StatsDisplay /> > 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[`<StatsDisplay /> > 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[`<StatsDisplay /> > 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[`<StatsDisplay /> > 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[`<StatsDisplay /> > 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[`<ToolStatsDisplay /> > 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[`<ToolStatsDisplay /> > 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[`<ToolStatsDisplay /> > 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[`<ToolStatsDisplay /> > 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[`<ToolStatsDisplay /> > should render "no tool calls" message when there are no active tools 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No tool calls have been made in this session. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; |
