Change detailed model stats to use a new shared Table class to resolve robustness issues. (#15208)

This commit is contained in:
Jacob Richman
2025-12-17 18:01:37 -08:00
committed by GitHub
parent 124a6da743
commit bc168bbae4
6 changed files with 415 additions and 184 deletions

View File

@@ -22,7 +22,7 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
const renderWithMockedStats = (metrics: SessionMetrics, width?: number) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session',
@@ -36,7 +36,7 @@ const renderWithMockedStats = (metrics: SessionMetrics) => {
startNewPrompt: vi.fn(),
});
return render(<ModelStatsDisplay />);
return render(<ModelStatsDisplay />, width);
};
describe('<ModelStatsDisplay />', () => {
@@ -312,4 +312,60 @@ describe('<ModelStatsDisplay />', () => {
expect(output).not.toContain('gemini-2.5-flash');
expect(output).toMatchSnapshot();
});
it('should handle models with long names (gemini-3-*-preview) without layout breaking', () => {
const { lastFrame } = renderWithMockedStats(
{
models: {
'gemini-3-pro-preview': {
api: { totalRequests: 10, totalErrors: 0, totalLatencyMs: 2000 },
tokens: {
input: 1000,
prompt: 2000,
candidates: 4000,
total: 6000,
cached: 500,
thoughts: 100,
tool: 50,
},
},
'gemini-3-flash-preview': {
api: { totalRequests: 20, totalErrors: 0, totalLatencyMs: 1000 },
tokens: {
input: 2000,
prompt: 4000,
candidates: 8000,
total: 12000,
cached: 1000,
thoughts: 200,
tool: 100,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
},
80,
);
const output = lastFrame();
expect(output).toContain('gemini-3-pro-');
expect(output).toContain('gemini-3-flash-');
expect(output).toMatchSnapshot();
});
});

View File

@@ -13,42 +13,17 @@ import {
calculateCacheHitRate,
calculateErrorRate,
} from '../utils/computeStats.js';
import type { ModelMetrics } from '../contexts/SessionContext.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { Table, type Column } from './Table.js';
const METRIC_COL_WIDTH = 28;
const MODEL_COL_WIDTH = 22;
interface StatRowProps {
title: string;
values: Array<string | React.ReactElement>;
isSubtle?: boolean;
interface StatRowData {
metric: string;
isSection?: boolean;
isSubtle?: boolean;
// Dynamic keys for model values
[key: string]: string | React.ReactNode | boolean | undefined;
}
const StatRow: React.FC<StatRowProps> = ({
title,
values,
isSubtle = false,
isSection = false,
}) => (
<Box>
<Box width={METRIC_COL_WIDTH}>
<Text
bold={isSection}
color={isSection ? theme.text.primary : theme.text.link}
>
{isSubtle ? `${title}` : title}
</Text>
</Box>
{values.map((value, index) => (
<Box width={MODEL_COL_WIDTH} key={index}>
<Text color={theme.text.primary}>{value}</Text>
</Box>
))}
</Box>
);
export const ModelStatsDisplay: React.FC = () => {
const { stats } = useSessionStats();
const { models } = stats.metrics;
@@ -73,10 +48,6 @@ export const ModelStatsDisplay: React.FC = () => {
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,
);
@@ -85,6 +56,152 @@ export const ModelStatsDisplay: React.FC = () => {
([, metrics]) => metrics.tokens.cached > 0,
);
// Helper to create a row with values for each model
const createRow = (
metric: string,
getValue: (
metrics: (typeof activeModels)[0][1],
) => string | React.ReactNode,
options: { isSection?: boolean; isSubtle?: boolean } = {},
): StatRowData => {
const row: StatRowData = {
metric,
isSection: options.isSection,
isSubtle: options.isSubtle,
};
activeModels.forEach(([name, metrics]) => {
row[name] = getValue(metrics);
});
return row;
};
const rows: StatRowData[] = [
// API Section
{ metric: 'API', isSection: true },
createRow('Requests', (m) => m.api.totalRequests.toLocaleString()),
createRow('Errors', (m) => {
const errorRate = calculateErrorRate(m);
return (
<Text
color={
m.api.totalErrors > 0 ? theme.status.error : theme.text.primary
}
>
{m.api.totalErrors.toLocaleString()} ({errorRate.toFixed(1)}%)
</Text>
);
}),
createRow('Avg Latency', (m) => formatDuration(calculateAverageLatency(m))),
// Spacer
{ metric: '' },
// Tokens Section
{ metric: 'Tokens', isSection: true },
createRow('Total', (m) => (
<Text color={theme.text.secondary}>
{m.tokens.total.toLocaleString()}
</Text>
)),
createRow(
'Input',
(m) => (
<Text color={theme.text.primary}>
{m.tokens.input.toLocaleString()}
</Text>
),
{ isSubtle: true },
),
];
if (hasCached) {
rows.push(
createRow(
'Cache Reads',
(m) => {
const cacheHitRate = calculateCacheHitRate(m);
return (
<Text color={theme.text.secondary}>
{m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%)
</Text>
);
},
{ isSubtle: true },
),
);
}
if (hasThoughts) {
rows.push(
createRow(
'Thoughts',
(m) => (
<Text color={theme.text.primary}>
{m.tokens.thoughts.toLocaleString()}
</Text>
),
{ isSubtle: true },
),
);
}
if (hasTool) {
rows.push(
createRow(
'Tool',
(m) => (
<Text color={theme.text.primary}>
{m.tokens.tool.toLocaleString()}
</Text>
),
{ isSubtle: true },
),
);
}
rows.push(
createRow(
'Output',
(m) => (
<Text color={theme.text.primary}>
{m.tokens.candidates.toLocaleString()}
</Text>
),
{ isSubtle: true },
),
);
const columns: Array<Column<StatRowData>> = [
{
key: 'metric',
header: 'Metric',
width: 28,
renderCell: (row) => (
<Text
bold={row.isSection}
color={row.isSection ? theme.text.primary : theme.text.link}
>
{row.isSubtle ? `${row.metric}` : row.metric}
</Text>
),
},
...modelNames.map((name) => ({
key: name,
header: name,
flexGrow: 1,
renderCell: (row: StatRowData) => {
// Don't render anything for section headers in model columns
if (row.isSection) return null;
const val = row[name];
if (val === undefined || val === null) return null;
if (typeof val === 'string' || typeof val === 'number') {
return <Text color={theme.text.primary}>{val}</Text>;
}
return val as React.ReactNode;
},
})),
];
return (
<Box
borderStyle="round"
@@ -97,128 +214,7 @@ export const ModelStatsDisplay: React.FC = () => {
Model Stats For Nerds
</Text>
<Box height={1} />
{/* Header */}
<Box>
<Box width={METRIC_COL_WIDTH}>
<Text bold color={theme.text.primary}>
Metric
</Text>
</Box>
{modelNames.map((name) => (
<Box width={MODEL_COL_WIDTH} key={name}>
<Text bold color={theme.text.primary}>
{name}
</Text>
</Box>
))}
</Box>
{/* Divider */}
<Box
borderStyle="single"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
/>
{/* 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 ? theme.status.error : theme.text.primary
}
>
{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={theme.text.secondary}>
{m.tokens.total.toLocaleString()}
</Text>
))}
/>
<StatRow
title="Input"
isSubtle
values={getModelValues((m) => (
<Text color={theme.text.primary}>
{m.tokens.input.toLocaleString()}
</Text>
))}
/>
{hasCached && (
<StatRow
title="Cache Reads"
isSubtle
values={getModelValues((m) => {
const cacheHitRate = calculateCacheHitRate(m);
return (
<Text color={theme.text.secondary}>
{m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%)
</Text>
);
})}
/>
)}
{hasThoughts && (
<StatRow
title="Thoughts"
isSubtle
values={getModelValues((m) => (
<Text color={theme.text.primary}>
{m.tokens.thoughts.toLocaleString()}
</Text>
))}
/>
)}
{hasTool && (
<StatRow
title="Tool"
isSubtle
values={getModelValues((m) => (
<Text color={theme.text.primary}>
{m.tokens.tool.toLocaleString()}
</Text>
))}
/>
)}
<StatRow
title="Output"
isSubtle
values={getModelValues((m) => (
<Text color={theme.text.primary}>
{m.tokens.candidates.toLocaleString()}
</Text>
))}
/>
<Table data={rows} columns={columns} />
</Box>
);
};

View File

@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { Table } from './Table.js';
import { Text } from 'ink';
describe('Table', () => {
it('should render headers and data correctly', () => {
const columns = [
{ key: 'id', header: 'ID', width: 5 },
{ key: 'name', header: 'Name', flexGrow: 1 },
];
const data = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
const { lastFrame } = render(<Table columns={columns} data={data} />, 100);
const output = lastFrame();
expect(output).toContain('ID');
expect(output).toContain('Name');
expect(output).toContain('1');
expect(output).toContain('Alice');
expect(output).toContain('2');
expect(output).toContain('Bob');
expect(lastFrame()).toMatchSnapshot();
});
it('should support custom cell rendering', () => {
const columns = [
{
key: 'value',
header: 'Value',
flexGrow: 1,
renderCell: (item: { value: number }) => (
<Text color="green">{item.value * 2}</Text>
),
},
];
const data = [{ value: 10 }];
const { lastFrame } = render(<Table columns={columns} data={data} />, 100);
const output = lastFrame();
expect(output).toContain('20');
expect(lastFrame()).toMatchSnapshot();
});
it('should handle undefined values gracefully', () => {
const columns = [{ key: 'name', header: 'Name', flexGrow: 1 }];
const data: Array<{ name: string | undefined }> = [{ name: undefined }];
const { lastFrame } = render(<Table columns={columns} data={data} />, 100);
const output = lastFrame();
expect(output).toContain('undefined');
});
});

View File

@@ -0,0 +1,87 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
export interface Column<T> {
key: string;
header: React.ReactNode;
width?: number;
flexGrow?: number;
flexShrink?: number;
flexBasis?: number | string;
renderCell?: (item: T) => React.ReactNode;
}
interface TableProps<T> {
data: T[];
columns: Array<Column<T>>;
}
export function Table<T>({ data, columns }: TableProps<T>) {
return (
<Box flexDirection="column">
{/* Header */}
<Box flexDirection="row">
{columns.map((col, index) => (
<Box
key={`header-${index}`}
width={col.width}
flexGrow={col.flexGrow}
flexShrink={col.flexShrink}
flexBasis={col.flexBasis ?? (col.width ? undefined : 0)}
paddingRight={1}
>
{typeof col.header === 'string' ? (
<Text bold color={theme.text.primary}>
{col.header}
</Text>
) : (
col.header
)}
</Box>
))}
</Box>
{/* Divider */}
<Box
borderStyle="single"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
borderColor={theme.border.default}
marginBottom={0}
/>
{/* Rows */}
{data.map((item, rowIndex) => (
<Box key={`row-${rowIndex}`} flexDirection="row">
{columns.map((col, colIndex) => (
<Box
key={`cell-${rowIndex}-${colIndex}`}
width={col.width}
flexGrow={col.flexGrow}
flexShrink={col.flexShrink}
flexBasis={col.flexBasis ?? (col.width ? undefined : 0)}
paddingRight={1}
>
{col.renderCell ? (
col.renderCell(item)
) : (
<Text color={theme.text.primary}>
{String((item as Record<string, unknown>)[col.key])}
</Text>
)}
</Box>
))}
</Box>
))}
</Box>
);
}

View File

@@ -11,7 +11,6 @@ exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ │
│ Tokens │
│ Total 30 │
│ ↳ Input 5 │
@@ -28,20 +27,19 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 1
│ Errors 0 (0.0%) 0 (0.0%)
│ Avg Latency 100ms 50ms
│ │
│ Requests 1 1
│ Errors 0 (0.0%) 0 (0.0%)
│ Avg Latency 100ms 50ms
│ Tokens │
│ Total 30 15
│ ↳ Input 5 5
│ ↳ Cache Reads 5 (50.0%) 0 (0.0%)
│ ↳ Thoughts 2 0
│ ↳ Tool 0 3
│ ↳ Output 20 10
│ Total 30 15
│ ↳ Input 5 5
│ ↳ Cache Reads 5 (50.0%) 0 (0.0%)
│ ↳ Thoughts 2 0
│ ↳ Tool 0 3
│ ↳ Output 20 10
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -51,20 +49,19 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 10 20
│ Errors 1 (10.0%) 2 (10.0%)
│ Avg Latency 100ms 25ms
│ │
│ Requests 10 20
│ Errors 1 (10.0%) 2 (10.0%)
│ Avg Latency 100ms 25ms
│ Tokens │
│ Total 300 600
│ ↳ Input 50 100
│ ↳ Cache Reads 50 (50.0%) 100 (50.0%)
│ ↳ Thoughts 10 20
│ ↳ Tool 5 10
│ ↳ Output 200 400
│ Total 300 600
│ ↳ Input 50 100
│ ↳ Cache Reads 50 (50.0%) 100 (50.0%)
│ ↳ Thoughts 10 20
│ ↳ Tool 5 10
│ ↳ Output 200 400
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -80,7 +77,6 @@ exports[`<ModelStatsDisplay /> > should handle large values without wrapping or
│ Requests 999,999,999 │
│ Errors 123,456,789 (12.3%) │
│ Avg Latency 0ms │
│ │
│ Tokens │
│ Total 999,999,999 │
│ ↳ Input 864,197,532 │
@@ -92,6 +88,28 @@ exports[`<ModelStatsDisplay /> > should handle large values without wrapping or
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-3-pro-preview gemini-3-flash-preview │
│ ────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 10 20 │
│ Errors 0 (0.0%) 0 (0.0%) │
│ Avg Latency 200ms 50ms │
│ Tokens │
│ Total 6,000 12,000 │
│ ↳ Input 1,000 2,000 │
│ ↳ Cache Reads 500 (25.0%) 1,000 (25.0%) │
│ ↳ Thoughts 100 200 │
│ ↳ Tool 50 100 │
│ ↳ Output 4,000 8,000 │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should not display conditional rows if no model has data for them 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -103,7 +121,6 @@ exports[`<ModelStatsDisplay /> > should not display conditional rows if no model
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ │
│ Tokens │
│ Total 30 │
│ ↳ Input 10 │

View File

@@ -0,0 +1,14 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Table > should render headers and data correctly 1`] = `
"ID Name
────────────────────────────────────────────────────────────────────────────────────────────────────
1 Alice
2 Bob"
`;
exports[`Table > should support custom cell rendering 1`] = `
"Value
────────────────────────────────────────────────────────────────────────────────────────────────────
20"
`;