refactor(ui): revamp exit stats display (#2771)

This commit is contained in:
Abhi 2025-06-30 20:28:49 -04:00 committed by GitHub
parent 3587054d32
commit f91927569c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 123 additions and 424 deletions

View File

@ -33,7 +33,7 @@ const renderWithMockedStats = (metrics: SessionMetrics) => {
};
describe('<SessionSummaryDisplay />', () => {
it('correctly sums and displays stats from multiple models', () => {
it('renders the summary display with a title', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
@ -47,17 +47,6 @@ describe('<SessionSummaryDisplay />', () => {
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,
@ -72,25 +61,7 @@ describe('<SessionSummaryDisplay />', () => {
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
// Verify totals are summed correctly
expect(output).toContain('Cumulative Stats (15 API calls)');
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).toMatchSnapshot();
});
it('renders zero state correctly', () => {
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);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@ -5,101 +5,14 @@
*/
import React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { computeSessionStats } from '../utils/computeStats.js';
import { FormattedStats, StatRow, StatsColumn } from './Stats.js';
// --- Prop and Data Structures ---
import { StatsDisplay } from './StatsDisplay.js';
interface SessionSummaryDisplayProps {
duration: string;
}
// --- Main Component ---
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => {
const { stats } = useSessionStats();
const { metrics } = stats;
const computed = computeSessionStats(metrics);
const cumulativeFormatted: FormattedStats = {
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 (
<Box
borderStyle="round"
borderColor="gray"
flexDirection="column"
paddingY={1}
paddingX={2}
alignSelf="flex-start"
>
<Box marginBottom={1} flexDirection="column">
{Colors.GradientColors ? (
<Gradient colors={Colors.GradientColors}>
<Text bold>{title}</Text>
</Gradient>
) : (
<Text bold>{title}</Text>
)}
</Box>
<Box marginTop={1}>
<StatsColumn
title={`Cumulative Stats (${totalRequests} API calls)`}
stats={cumulativeFormatted}
isCumulative={true}
>
<Box marginTop={1} flexDirection="column">
<StatRow
label="Total duration (API)"
value={formatDuration(computed.totalApiTime)}
/>
<StatRow
label="Total duration (Tools)"
value={formatDuration(computed.totalToolTime)}
/>
<StatRow label="Total duration (wall)" value={duration} />
</Box>
</StatsColumn>
</Box>
</Box>
);
};
}) => (
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
);

View File

@ -1,89 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect } from 'vitest';
import {
StatRow,
StatsColumn,
DurationColumn,
FormattedStats,
} from './Stats.js';
import { Colors } from '../colors.js';
describe('<StatRow />', () => {
it('renders a label and value', () => {
const { lastFrame } = render(
<StatRow label="Test Label" value="Test Value" />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with a specific value color', () => {
const { lastFrame } = render(
<StatRow
label="Test Label"
value="Test Value"
valueColor={Colors.AccentGreen}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
});
describe('<StatsColumn />', () => {
const mockStats: FormattedStats = {
inputTokens: 100,
outputTokens: 200,
toolUseTokens: 50,
thoughtsTokens: 25,
cachedTokens: 10,
totalTokens: 385,
};
it('renders a stats column with children', () => {
const { lastFrame } = render(
<StatsColumn title="Test Stats" stats={mockStats}>
<StatRow label="Child Prop" value="Child Value" />
</StatsColumn>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a stats column with a specific width', () => {
const { lastFrame } = render(
<StatsColumn title="Test Stats" stats={mockStats} width="50%" />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a cumulative stats column with percentages', () => {
const { lastFrame } = render(
<StatsColumn title="Cumulative Stats" stats={mockStats} isCumulative />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('hides the tool use row when there are no tool use tokens', () => {
const statsWithNoToolUse: FormattedStats = {
...mockStats,
toolUseTokens: 0,
};
const { lastFrame } = render(
<StatsColumn title="Test Stats" stats={statsWithNoToolUse} />,
);
expect(lastFrame()).not.toContain('Tool Use Tokens');
});
});
describe('<DurationColumn />', () => {
it('renders a duration column', () => {
const { lastFrame } = render(
<DurationColumn apiTime="5s" wallTime="10s" />,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@ -1,118 +0,0 @@
/**
* @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';
// --- Prop and Data Structures ---
export interface FormattedStats {
inputTokens: number;
outputTokens: number;
toolUseTokens: number;
thoughtsTokens: number;
cachedTokens: number;
totalTokens: number;
}
// --- Helper Components ---
/**
* Renders a single row with a colored label on the left and a value on the right.
*/
export const StatRow: React.FC<{
label: string;
value: string | number;
valueColor?: string;
}> = ({ label, value, valueColor }) => (
<Box justifyContent="space-between" gap={2}>
<Text color={Colors.LightBlue}>{label}</Text>
<Text color={valueColor}>{value}</Text>
</Box>
);
/**
* Renders a full column for either "Last Turn" or "Cumulative" stats.
*/
export const StatsColumn: React.FC<{
title: string;
stats: FormattedStats;
isCumulative?: boolean;
width?: string | number;
children?: React.ReactNode;
}> = ({ title, stats, isCumulative = false, width, children }) => {
const cachedDisplay =
isCumulative && stats.totalTokens > 0
? `${stats.cachedTokens.toLocaleString()} (${((stats.cachedTokens / stats.totalTokens) * 100).toFixed(1)}%)`
: stats.cachedTokens.toLocaleString();
const cachedColor =
isCumulative && stats.cachedTokens > 0 ? Colors.AccentGreen : undefined;
return (
<Box flexDirection="column" width={width}>
<Text bold>{title}</Text>
<Box marginTop={1} flexDirection="column">
{/* All StatRows below will now inherit the gap */}
<StatRow
label="Input Tokens"
value={stats.inputTokens.toLocaleString()}
/>
<StatRow
label="Output Tokens"
value={stats.outputTokens.toLocaleString()}
/>
{stats.toolUseTokens > 0 && (
<StatRow
label="Tool Use Tokens"
value={stats.toolUseTokens.toLocaleString()}
/>
)}
<StatRow
label="Thoughts Tokens"
value={stats.thoughtsTokens.toLocaleString()}
/>
{stats.cachedTokens > 0 && (
<StatRow
label="Cached Tokens"
value={cachedDisplay}
valueColor={cachedColor}
/>
)}
{/* Divider Line */}
<Box
borderTop={true}
borderLeft={false}
borderRight={false}
borderBottom={false}
borderStyle="single"
/>
<StatRow
label="Total Tokens"
value={stats.totalTokens.toLocaleString()}
/>
{children}
</Box>
</Box>
);
};
/**
* Renders a column for displaying duration information.
*/
export const DurationColumn: React.FC<{
apiTime: string;
wallTime: string;
}> = ({ apiTime, wallTime }) => (
<Box flexDirection="column" width={'48%'}>
<Text bold>Duration</Text>
<Box marginTop={1} flexDirection="column">
<StatRow label="API Time" value={apiTime} />
<StatRow label="Wall Time" value={wallTime} />
</Box>
</Box>
);

View File

@ -260,4 +260,44 @@ describe('<StatsDisplay />', () => {
expect(lastFrame()).toMatchSnapshot();
});
});
describe('Title Rendering', () => {
const zeroMetrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
it('renders the default title when no title prop is provided', () => {
const { lastFrame } = renderWithMockedStats(zeroMetrics);
const output = lastFrame();
expect(output).toContain('Session Stats');
expect(output).not.toContain('Agent powering down');
expect(output).toMatchSnapshot();
});
it('renders the custom title when a title prop is provided', () => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics: zeroMetrics,
lastPromptTokenCount: 0,
},
});
const { lastFrame } = render(
<StatsDisplay duration="1s" title="Agent powering down. Goodbye!" />,
);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).not.toContain('Session Stats');
expect(output).toMatchSnapshot();
});
});
});

View File

@ -6,6 +6,7 @@
import React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
@ -140,9 +141,13 @@ const ModelUsageTable: React.FC<{
interface StatsDisplayProps {
duration: string;
title?: string;
}
export const StatsDisplay: React.FC<StatsDisplayProps> = ({ duration }) => {
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
duration,
title,
}) => {
const { stats } = useSessionStats();
const { metrics } = stats;
const { models, tools } = metrics;
@ -162,6 +167,25 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({ duration }) => {
agreementThresholds,
);
const renderTitle = () => {
if (title) {
return Colors.GradientColors && Colors.GradientColors.length > 0 ? (
<Gradient colors={Colors.GradientColors}>
<Text bold>{title}</Text>
</Gradient>
) : (
<Text bold color={Colors.AccentPurple}>
{title}
</Text>
);
}
return (
<Text bold color={Colors.AccentPurple}>
Session Stats
</Text>
);
};
return (
<Box
borderStyle="round"
@ -170,9 +194,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({ duration }) => {
paddingY={1}
paddingX={2}
>
<Text bold color={Colors.AccentPurple}>
Session Stats
</Text>
{renderTitle()}
<Box height={1} />
{tools.totalCalls > 0 && (

View File

@ -1,45 +1,24 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SessionSummaryDisplay /> > correctly sums and displays stats from multiple models 1`] = `
"╭─────────────────────────────────────╮
│ │
│ Agent powering down. Goodbye! │
│ │
│ │
│ Cumulative Stats (15 API calls) │
│ │
│ Input Tokens 1,500 │
│ Output Tokens 3,000 │
│ Tool Use Tokens 220 │
│ Thoughts Tokens 350 │
│ Cached Tokens 600 (12.0%) │
│ ───────────────────────────────── │
│ Total Tokens 5,000 │
│ │
│ 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 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 │
│ │
╰─────────────────────────────────────╯"
exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Agent powering down. Goodbye! │
│ │
│ Performance │
│ Wall Time: 1h 23m 45s │
│ Agent Active: 50.2s │
│ » API Time: 50.2s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 10 1,000 2,000 │
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@ -1,49 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<DurationColumn /> > renders a duration column 1`] = `
"Duration
API Time 5s
Wall Time 10s"
`;
exports[`<StatRow /> > renders a label and value 1`] = `"Test Label Test Value"`;
exports[`<StatRow /> > renders with a specific value color 1`] = `"Test Label Test Value"`;
exports[`<StatsColumn /> > renders a cumulative stats column with percentages 1`] = `
"Cumulative Stats
Input Tokens 100
Output Tokens 200
Tool Use Tokens 50
Thoughts Tokens 25
Cached Tokens 10 (2.6%)
────────────────────────────────────────────────────────────────────────────────────────────────────
Total Tokens 385"
`;
exports[`<StatsColumn /> > renders a stats column with a specific width 1`] = `
"Test Stats
Input Tokens 100
Output Tokens 200
Tool Use Tokens 50
Thoughts Tokens 25
Cached Tokens 10
──────────────────────────────────────────────────
Total Tokens 385"
`;
exports[`<StatsColumn /> > renders a stats column with children 1`] = `
"Test Stats
Input Tokens 100
Output Tokens 200
Tool Use Tokens 50
Thoughts Tokens 25
Cached Tokens 10
────────────────────────────────────────────────────────────────────────────────────────────────────
Total Tokens 385
Child Prop Child Value"
`;

View File

@ -95,6 +95,36 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement w
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Title Rendering > renders the custom title when a title prop is provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Agent powering down. Goodbye! │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Title Rendering > renders the default title when no title prop is provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │