From bc168bbae4f4907f0518ba69964fffab670d84ec Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Wed, 17 Dec 2025 18:01:37 -0800 Subject: [PATCH] Change detailed model stats to use a new shared Table class to resolve robustness issues. (#15208) --- .../ui/components/ModelStatsDisplay.test.tsx | 60 +++- .../src/ui/components/ModelStatsDisplay.tsx | 310 +++++++++--------- packages/cli/src/ui/components/Table.test.tsx | 61 ++++ packages/cli/src/ui/components/Table.tsx | 87 +++++ .../ModelStatsDisplay.test.tsx.snap | 67 ++-- .../__snapshots__/Table.test.tsx.snap | 14 + 6 files changed, 415 insertions(+), 184 deletions(-) create mode 100644 packages/cli/src/ui/components/Table.test.tsx create mode 100644 packages/cli/src/ui/components/Table.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx index 0646466b33..d233d3b385 100644 --- a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx @@ -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(); + return render(, width); }; describe('', () => { @@ -312,4 +312,60 @@ describe('', () => { 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(); + }); }); diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.tsx index 065bc99184..f765bcede3 100644 --- a/packages/cli/src/ui/components/ModelStatsDisplay.tsx +++ b/packages/cli/src/ui/components/ModelStatsDisplay.tsx @@ -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; - 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 = ({ - title, - values, - isSubtle = false, - isSection = false, -}) => ( - - - - {isSubtle ? ` ↳ ${title}` : title} - - - {values.map((value, index) => ( - - {value} - - ))} - -); - export const ModelStatsDisplay: React.FC = () => { const { stats } = useSessionStats(); const { models } = stats.metrics; @@ -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 ( + 0 ? theme.status.error : theme.text.primary + } + > + {m.api.totalErrors.toLocaleString()} ({errorRate.toFixed(1)}%) + + ); + }), + createRow('Avg Latency', (m) => formatDuration(calculateAverageLatency(m))), + + // Spacer + { metric: '' }, + + // Tokens Section + { metric: 'Tokens', isSection: true }, + createRow('Total', (m) => ( + + {m.tokens.total.toLocaleString()} + + )), + createRow( + 'Input', + (m) => ( + + {m.tokens.input.toLocaleString()} + + ), + { isSubtle: true }, + ), + ]; + + if (hasCached) { + rows.push( + createRow( + 'Cache Reads', + (m) => { + const cacheHitRate = calculateCacheHitRate(m); + return ( + + {m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%) + + ); + }, + { isSubtle: true }, + ), + ); + } + + if (hasThoughts) { + rows.push( + createRow( + 'Thoughts', + (m) => ( + + {m.tokens.thoughts.toLocaleString()} + + ), + { isSubtle: true }, + ), + ); + } + + if (hasTool) { + rows.push( + createRow( + 'Tool', + (m) => ( + + {m.tokens.tool.toLocaleString()} + + ), + { isSubtle: true }, + ), + ); + } + + rows.push( + createRow( + 'Output', + (m) => ( + + {m.tokens.candidates.toLocaleString()} + + ), + { isSubtle: true }, + ), + ); + + const columns: Array> = [ + { + key: 'metric', + header: 'Metric', + width: 28, + renderCell: (row) => ( + + {row.isSubtle ? ` ↳ ${row.metric}` : row.metric} + + ), + }, + ...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 {val}; + } + return val as React.ReactNode; + }, + })), + ]; + return ( { Model Stats For Nerds - - {/* Header */} - - - - Metric - - - {modelNames.map((name) => ( - - - {name} - - - ))} - - - {/* Divider */} - - - {/* API Section */} - - m.api.totalRequests.toLocaleString())} - /> - { - const errorRate = calculateErrorRate(m); - return ( - 0 ? theme.status.error : theme.text.primary - } - > - {m.api.totalErrors.toLocaleString()} ({errorRate.toFixed(1)}%) - - ); - })} - /> - { - const avgLatency = calculateAverageLatency(m); - return formatDuration(avgLatency); - })} - /> - - - - {/* Tokens Section */} - - ( - - {m.tokens.total.toLocaleString()} - - ))} - /> - ( - - {m.tokens.input.toLocaleString()} - - ))} - /> - {hasCached && ( - { - const cacheHitRate = calculateCacheHitRate(m); - return ( - - {m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%) - - ); - })} - /> - )} - {hasThoughts && ( - ( - - {m.tokens.thoughts.toLocaleString()} - - ))} - /> - )} - {hasTool && ( - ( - - {m.tokens.tool.toLocaleString()} - - ))} - /> - )} - ( - - {m.tokens.candidates.toLocaleString()} - - ))} - /> + ); }; diff --git a/packages/cli/src/ui/components/Table.test.tsx b/packages/cli/src/ui/components/Table.test.tsx new file mode 100644 index 0000000000..ab2e9b2a23 --- /dev/null +++ b/packages/cli/src/ui/components/Table.test.tsx @@ -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(
, 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 }) => ( + {item.value * 2} + ), + }, + ]; + const data = [{ value: 10 }]; + + const { lastFrame } = render(
, 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(
, 100); + const output = lastFrame(); + expect(output).toContain('undefined'); + }); +}); diff --git a/packages/cli/src/ui/components/Table.tsx b/packages/cli/src/ui/components/Table.tsx new file mode 100644 index 0000000000..e06e5d38f2 --- /dev/null +++ b/packages/cli/src/ui/components/Table.tsx @@ -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 { + key: string; + header: React.ReactNode; + width?: number; + flexGrow?: number; + flexShrink?: number; + flexBasis?: number | string; + renderCell?: (item: T) => React.ReactNode; +} + +interface TableProps { + data: T[]; + columns: Array>; +} + +export function Table({ data, columns }: TableProps) { + return ( + + {/* Header */} + + {columns.map((col, index) => ( + + {typeof col.header === 'string' ? ( + + {col.header} + + ) : ( + col.header + )} + + ))} + + + {/* Divider */} + + + {/* Rows */} + {data.map((item, rowIndex) => ( + + {columns.map((col, colIndex) => ( + + {col.renderCell ? ( + col.renderCell(item) + ) : ( + + {String((item as Record)[col.key])} + + )} + + ))} + + ))} + + ); +} diff --git a/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap index 6199b9b7de..f06cc3fe5a 100644 --- a/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap @@ -11,7 +11,6 @@ exports[` > 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[` > 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[` > 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[` > 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[` > should handle large values without wrapping or ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; +exports[` > 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[` > should not display conditional rows if no model has data for them 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -103,7 +121,6 @@ exports[` > should not display conditional rows if no model │ Requests 1 │ │ Errors 0 (0.0%) │ │ Avg Latency 100ms │ -│ │ │ Tokens │ │ Total 30 │ │ ↳ Input 10 │ diff --git a/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap new file mode 100644 index 0000000000..231784c6e1 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap @@ -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" +`;