From 4bba75f7655dc9826465cde65e28846dc1e1df6d Mon Sep 17 00:00:00 2001 From: JerryLee <223425819+Jerry2003826@users.noreply.github.com> Date: Mon, 11 May 2026 22:35:55 +1000 Subject: [PATCH] fix(cli): keep long model stats header on one line (#4032) * fix(cli): keep long model stats header on one line * test(cli): cover fixed model stats columns --- .../ui/components/ModelStatsDisplay.test.tsx | 85 ++++++++++++++++++- .../src/ui/components/ModelStatsDisplay.tsx | 47 ++++++++-- 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx index 51dd755f2..73f7c1a15 100644 --- a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx @@ -39,6 +39,7 @@ const renderWithMockedStats = ( string, { inputPerMillionTokens?: number; outputPerMillionTokens?: number } >, + width?: number, ) => { useSessionStatsMock.mockReturnValue({ stats: { @@ -60,7 +61,7 @@ const renderWithMockedStats = ( return render( - + , ); }; @@ -275,6 +276,88 @@ describe('', () => { expect(output).toMatchSnapshot(); }); + it('keeps a long single model name on the metric header line when space is available', () => { + const modelName = 'ggml-org/gemma-4-E4B-it-GGUF'; + const { lastFrame } = renderWithMockedStats( + { + models: { + [modelName]: mainOnly({ + api: { totalRequests: 2, totalErrors: 1, totalLatencyMs: 220000 }, + tokens: { + prompt: 17953, + candidates: 225, + total: 18178, + cached: 0, + thoughts: 0, + }, + }), + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 }, + byName: {}, + }, + files: { totalLinesAdded: 0, totalLinesRemoved: 0 }, + }, + undefined, + 100, + ); + + expect(lastFrame()).toContain(`Metric ${modelName}`); + }); + + it('keeps fixed model column widths for multiple models even when space is available', () => { + const { lastFrame } = renderWithMockedStats( + { + models: { + 'model-a': mainOnly({ + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 10, + candidates: 20, + total: 30, + cached: 0, + thoughts: 0, + }, + }), + 'model-b': mainOnly({ + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 10, + candidates: 20, + total: 30, + cached: 0, + thoughts: 0, + }, + }), + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 }, + byName: {}, + }, + files: { totalLinesAdded: 0, totalLinesRemoved: 0 }, + }, + undefined, + 120, + ); + + const headerLine = lastFrame() + ?.split('\n') + .find((line) => line.includes('Metric') && line.includes('model-a')); + + expect(headerLine).toBeDefined(); + expect( + headerLine!.indexOf('model-b') - headerLine!.indexOf('model-a'), + ).toBe(24); + }); + describe('Subagent source attribution', () => { const baseTools: SessionMetrics['tools'] = { totalCalls: 0, diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.tsx index 563630fe5..953f91df9 100644 --- a/packages/cli/src/ui/components/ModelStatsDisplay.tsx +++ b/packages/cli/src/ui/components/ModelStatsDisplay.tsx @@ -26,10 +26,15 @@ const METRIC_COL_WIDTH = 28; // Sessions with three or more sources will exceed the panel — acceptable per // the design doc, which accepts the crowded layout for many-subagent cases. const MODEL_COL_WIDTH = 24; +// Keep this in sync with the surrounding Box borderStyle and paddingX: +// Ink's round border consumes 2 border columns plus 2 columns of horizontal +// padding on each side. +const PANEL_HORIZONTAL_CHROME_WIDTH = 6; interface StatRowProps { title: string; values: Array; + modelColWidth: number; isSubtle?: boolean; isSection?: boolean; } @@ -37,6 +42,7 @@ interface StatRowProps { const StatRow: React.FC = ({ title, values, + modelColWidth, isSubtle = false, isSection = false, }) => ( @@ -50,7 +56,7 @@ const StatRow: React.FC = ({ {values.map((value, index) => ( - + {value} ))} @@ -94,6 +100,13 @@ export const ModelStatsDisplay: React.FC = ({ ({ metrics }) => metrics.tokens.thoughts > 0, ); const hasCached = entries.some(({ metrics }) => metrics.tokens.cached > 0); + const modelColWidth = + entries.length === 1 && width + ? Math.max( + MODEL_COL_WIDTH, + width - PANEL_HORIZONTAL_CHROME_WIDTH - METRIC_COL_WIDTH, + ) + : MODEL_COL_WIDTH; const getModelName = (key: string): string => key.split('::')[0]; @@ -128,7 +141,7 @@ export const ModelStatsDisplay: React.FC = ({ {entries.map(({ key, label }) => ( - + {label} @@ -147,10 +160,16 @@ export const ModelStatsDisplay: React.FC = ({ /> {/* API Section */} - + m.api.totalRequests.toLocaleString())} + modelColWidth={modelColWidth} /> = ({ ); })} + modelColWidth={modelColWidth} /> = ({ const avgLatency = calculateAverageLatency(m); return formatDuration(avgLatency); })} + modelColWidth={modelColWidth} /> {/* Tokens Section */} - + ( @@ -186,11 +212,13 @@ export const ModelStatsDisplay: React.FC = ({ {m.tokens.total.toLocaleString()} ))} + modelColWidth={modelColWidth} /> m.tokens.prompt.toLocaleString())} + modelColWidth={modelColWidth} /> {hasCached && ( = ({ ); })} + modelColWidth={modelColWidth} /> )} {hasThoughts && ( @@ -211,17 +240,24 @@ export const ModelStatsDisplay: React.FC = ({ title={t('Thoughts')} isSubtle values={getModelValues((m) => m.tokens.thoughts.toLocaleString())} + modelColWidth={modelColWidth} /> )} m.tokens.candidates.toLocaleString())} + modelColWidth={modelColWidth} /> {hasPricing && ( <> - + { @@ -233,6 +269,7 @@ export const ModelStatsDisplay: React.FC = ({ }); return cost != null ? `$${cost.toFixed(4)}` : 'N/A'; })} + modelColWidth={modelColWidth} /> )}