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"
+`;