diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index 38400caf..816948f2 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -60,6 +60,10 @@ describe('', () => { totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, + files: { + totalLinesAdded: 42, + totalLinesRemoved: 15, + }, }; const { lastFrame } = renderWithMockedStats(metrics); diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx index eed105e3..6d6fa809 100644 --- a/packages/cli/src/ui/components/StatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx @@ -50,6 +50,10 @@ describe('', () => { totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const { lastFrame } = renderWithMockedStats(zeroMetrics); @@ -96,6 +100,10 @@ describe('', () => { totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const { lastFrame } = renderWithMockedStats(metrics); @@ -139,6 +147,10 @@ describe('', () => { }, }, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const { lastFrame } = renderWithMockedStats(metrics); @@ -172,6 +184,10 @@ describe('', () => { }, }, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const { lastFrame } = renderWithMockedStats(metrics); @@ -206,6 +222,10 @@ describe('', () => { totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const { lastFrame } = renderWithMockedStats(metrics); @@ -228,6 +248,10 @@ describe('', () => { totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const { lastFrame } = renderWithMockedStats(metrics); expect(lastFrame()).toMatchSnapshot(); @@ -244,6 +268,10 @@ describe('', () => { totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const { lastFrame } = renderWithMockedStats(metrics); expect(lastFrame()).toMatchSnapshot(); @@ -260,12 +288,68 @@ describe('', () => { totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const { lastFrame } = renderWithMockedStats(metrics); expect(lastFrame()).toMatchSnapshot(); }); }); + describe('Code Changes Display', () => { + it('displays Code Changes when line counts are present', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 1, + totalSuccess: 1, + totalFail: 0, + totalDurationMs: 100, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + files: { + totalLinesAdded: 42, + totalLinesRemoved: 18, + }, + }; + + const { lastFrame } = renderWithMockedStats(metrics); + const output = lastFrame(); + + expect(output).toContain('Code Changes:'); + expect(output).toContain('+42'); + expect(output).toContain('-18'); + expect(output).toMatchSnapshot(); + }); + + it('hides Code Changes when no lines are added or removed', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 1, + totalSuccess: 1, + totalFail: 0, + totalDurationMs: 100, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + + const { lastFrame } = renderWithMockedStats(metrics); + const output = lastFrame(); + + expect(output).not.toContain('Code Changes:'); + expect(output).toMatchSnapshot(); + }); + }); + describe('Title Rendering', () => { const zeroMetrics: SessionMetrics = { models: {}, @@ -277,6 +361,10 @@ describe('', () => { totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; it('renders the default title when no title prop is provided', () => { diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index 71c88aef..8dd00efd 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import Gradient from 'ink-gradient'; -import { Colors } from '../colors.js'; +import { theme } from '../semantic-colors.js'; import { formatDuration } from '../utils/formatters.js'; import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js'; import { @@ -29,7 +29,7 @@ const StatRow: React.FC = ({ title, children }) => ( {/* Fixed width for the label creates a clean "gutter" for alignment */} - {title} + {title} {children} @@ -111,12 +111,12 @@ const ModelUsageTable: React.FC<{ {modelMetrics.api.totalRequests} - + {modelMetrics.tokens.prompt.toLocaleString()} - + {modelMetrics.tokens.candidates.toLocaleString()} @@ -125,12 +125,12 @@ const ModelUsageTable: React.FC<{ {cacheEfficiency > 0 && ( - Savings Highlight:{' '} + Savings Highlight:{' '} {totalCachedTokens.toLocaleString()} ({cacheEfficiency.toFixed(1)} %) of input tokens were served from the cache, reducing costs. - + » Tip: For a full token breakdown, run `/stats model`. @@ -150,7 +150,7 @@ export const StatsDisplay: React.FC = ({ }) => { const { stats } = useSessionStats(); const { metrics } = stats; - const { models, tools } = metrics; + const { models, tools, files } = metrics; const computed = computeSessionStats(metrics); const successThresholds = { @@ -169,18 +169,18 @@ export const StatsDisplay: React.FC = ({ const renderTitle = () => { if (title) { - return Colors.GradientColors && Colors.GradientColors.length > 0 ? ( - + return theme.ui.gradient && theme.ui.gradient.length > 0 ? ( + {title} ) : ( - + {title} ); } return ( - + Session Stats ); @@ -189,7 +189,7 @@ export const StatsDisplay: React.FC = ({ return ( = ({ {tools.totalCalls} ({' '} - ✔ {tools.totalSuccess}{' '} - ✖ {tools.totalFail} ) + ✔ {tools.totalSuccess}{' '} + ✖ {tools.totalFail} ) @@ -215,12 +215,25 @@ export const StatsDisplay: React.FC = ({ {computed.agreementRate.toFixed(1)}%{' '} - + ({computed.totalDecisions} reviewed) )} + {files && + (files.totalLinesAdded > 0 || files.totalLinesRemoved > 0) && ( + + + + +{files.totalLinesAdded} + {' '} + + -{files.totalLinesRemoved} + + + + )}
@@ -233,7 +246,7 @@ export const StatsDisplay: React.FC = ({ {formatDuration(computed.totalApiTime)}{' '} - + ({computed.apiTimePercent.toFixed(1)}%) @@ -241,7 +254,7 @@ export const StatsDisplay: React.FC = ({ {formatDuration(computed.totalToolTime)}{' '} - + ({computed.toolTimePercent.toFixed(1)}%) diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap index 98e7722e..97a0b525 100644 --- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap @@ -9,6 +9,7 @@ exports[` > renders the summary display with a title 1` │ Session ID: │ │ Tool Calls: 0 ( ✔ 0 ✖ 0 ) │ │ Success Rate: 0.0% │ +│ Code Changes: +42 -15 │ │ │ │ Performance │ │ Wall Time: 1h 23m 45s │ diff --git a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap index 09202599..d6842188 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap @@ -1,5 +1,46 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[` > Code Changes Display > displays Code Changes when line counts are present 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Session Stats │ +│ │ +│ Interaction Summary │ +│ Session ID: test-session-id │ +│ Tool Calls: 1 ( ✔ 1 ✖ 0 ) │ +│ Success Rate: 100.0% │ +│ Code Changes: +42 -18 │ +│ │ +│ Performance │ +│ Wall Time: 1s │ +│ Agent Active: 100ms │ +│ » API Time: 0s (0.0%) │ +│ » Tool Time: 100ms (100.0%) │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > Code Changes Display > hides Code Changes when no lines are added or removed 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Session Stats │ +│ │ +│ Interaction Summary │ +│ Session ID: test-session-id │ +│ Tool Calls: 1 ( ✔ 1 ✖ 0 ) │ +│ Success Rate: 100.0% │ +│ │ +│ Performance │ +│ Wall Time: 1s │ +│ Agent Active: 100ms │ +│ » API Time: 0s (0.0%) │ +│ » Tool Time: 100ms (100.0%) │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + exports[` > Conditional Color Tests > renders success rate in green for high values 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ diff --git a/packages/cli/src/ui/contexts/SessionContext.tsx b/packages/cli/src/ui/contexts/SessionContext.tsx index 80c2454c..6c48c60b 100644 --- a/packages/cli/src/ui/contexts/SessionContext.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.tsx @@ -44,6 +44,8 @@ export interface ComputedSessionStats { agreementRate: number; totalCachedTokens: number; totalPromptTokens: number; + totalLinesAdded: number; + totalLinesRemoved: number; } // Defines the final "value" of our context, including the state diff --git a/packages/cli/src/ui/utils/computeStats.test.ts b/packages/cli/src/ui/utils/computeStats.test.ts index 0e32ffe2..e9085fb3 100644 --- a/packages/cli/src/ui/utils/computeStats.test.ts +++ b/packages/cli/src/ui/utils/computeStats.test.ts @@ -121,6 +121,10 @@ describe('computeSessionStats', () => { totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const result = computeSessionStats(metrics); @@ -137,6 +141,8 @@ describe('computeSessionStats', () => { agreementRate: 0, totalPromptTokens: 0, totalCachedTokens: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, }); }); @@ -163,6 +169,10 @@ describe('computeSessionStats', () => { totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const result = computeSessionStats(metrics); @@ -197,6 +207,10 @@ describe('computeSessionStats', () => { totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const result = computeSessionStats(metrics); @@ -215,6 +229,10 @@ describe('computeSessionStats', () => { totalDecisions: { accept: 6, reject: 2, modify: 2 }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const result = computeSessionStats(metrics); @@ -234,6 +252,10 @@ describe('computeSessionStats', () => { totalDecisions: { accept: 0, reject: 0, modify: 0 }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }; const result = computeSessionStats(metrics); @@ -244,4 +266,27 @@ describe('computeSessionStats', () => { expect(result.successRate).toBe(0); expect(result.agreementRate).toBe(0); }); + + it('should correctly include line counts', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + files: { + totalLinesAdded: 42, + totalLinesRemoved: 18, + }, + }; + + const result = computeSessionStats(metrics); + + expect(result.totalLinesAdded).toBe(42); + expect(result.totalLinesRemoved).toBe(18); + }); }); diff --git a/packages/cli/src/ui/utils/computeStats.ts b/packages/cli/src/ui/utils/computeStats.ts index e0483c3b..ec7c49fa 100644 --- a/packages/cli/src/ui/utils/computeStats.ts +++ b/packages/cli/src/ui/utils/computeStats.ts @@ -34,7 +34,7 @@ export function calculateCacheHitRate(metrics: ModelMetrics): number { export const computeSessionStats = ( metrics: SessionMetrics, ): ComputedSessionStats => { - const { models, tools } = metrics; + const { models, tools, files } = metrics; const totalApiTime = Object.values(models).reduce( (acc, model) => acc + model.api.totalLatencyMs, 0, @@ -80,5 +80,7 @@ export const computeSessionStats = ( agreementRate, totalCachedTokens, totalPromptTokens, + totalLinesAdded: files.totalLinesAdded, + totalLinesRemoved: files.totalLinesRemoved, }; }; diff --git a/packages/core/src/telemetry/uiTelemetry.test.ts b/packages/core/src/telemetry/uiTelemetry.test.ts index cd509a8e..a64f839e 100644 --- a/packages/core/src/telemetry/uiTelemetry.test.ts +++ b/packages/core/src/telemetry/uiTelemetry.test.ts @@ -108,6 +108,10 @@ describe('UiTelemetryService', () => { }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }); expect(service.getLastPromptTokenCount()).toBe(0); }); @@ -342,9 +346,9 @@ describe('UiTelemetryService', () => { ToolConfirmationOutcome.ProceedOnce, ); service.addEvent({ - ...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall))), + ...structuredClone(new ToolCallEvent(toolCall)), 'event.name': EVENT_TOOL_CALL, - }); + } as ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); const metrics = service.getMetrics(); const { tools } = metrics; @@ -376,9 +380,9 @@ describe('UiTelemetryService', () => { ToolConfirmationOutcome.Cancel, ); service.addEvent({ - ...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall))), + ...structuredClone(new ToolCallEvent(toolCall)), 'event.name': EVENT_TOOL_CALL, - }); + } as ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); const metrics = service.getMetrics(); const { tools } = metrics; @@ -410,9 +414,9 @@ describe('UiTelemetryService', () => { ToolConfirmationOutcome.ModifyWithEditor, ); service.addEvent({ - ...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall))), + ...structuredClone(new ToolCallEvent(toolCall)), 'event.name': EVENT_TOOL_CALL, - }); + } as ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); const metrics = service.getMetrics(); const { tools } = metrics; @@ -426,9 +430,9 @@ describe('UiTelemetryService', () => { it('should process a ToolCallEvent without a decision', () => { const toolCall = createFakeCompletedToolCall('test_tool', true, 100); service.addEvent({ - ...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall))), + ...structuredClone(new ToolCallEvent(toolCall)), 'event.name': EVENT_TOOL_CALL, - }); + } as ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); const metrics = service.getMetrics(); const { tools } = metrics; @@ -462,13 +466,13 @@ describe('UiTelemetryService', () => { ); service.addEvent({ - ...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall1))), + ...structuredClone(new ToolCallEvent(toolCall1)), 'event.name': EVENT_TOOL_CALL, - }); + } as ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); service.addEvent({ - ...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall2))), + ...structuredClone(new ToolCallEvent(toolCall2)), 'event.name': EVENT_TOOL_CALL, - }); + } as ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); const metrics = service.getMetrics(); const { tools } = metrics; @@ -497,13 +501,13 @@ describe('UiTelemetryService', () => { const toolCall1 = createFakeCompletedToolCall('tool_A', true, 100); const toolCall2 = createFakeCompletedToolCall('tool_B', false, 200); service.addEvent({ - ...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall1))), + ...structuredClone(new ToolCallEvent(toolCall1)), 'event.name': EVENT_TOOL_CALL, - }); + } as ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); service.addEvent({ - ...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall2))), + ...structuredClone(new ToolCallEvent(toolCall2)), 'event.name': EVENT_TOOL_CALL, - }); + } as ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); const metrics = service.getMetrics(); const { tools } = metrics; @@ -629,4 +633,42 @@ describe('UiTelemetryService', () => { expect(spy).toHaveBeenCalledOnce(); }); }); + + describe('Tool Call Event with Line Count Metadata', () => { + it('should aggregate valid line count metadata', () => { + const toolCall = createFakeCompletedToolCall('test_tool', true, 100); + const event = { + ...structuredClone(new ToolCallEvent(toolCall)), + 'event.name': EVENT_TOOL_CALL, + metadata: { + ai_added_lines: 10, + ai_removed_lines: 5, + }, + } as ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }; + + service.addEvent(event); + + const metrics = service.getMetrics(); + expect(metrics.files.totalLinesAdded).toBe(10); + expect(metrics.files.totalLinesRemoved).toBe(5); + }); + + it('should ignore null/undefined values in line count metadata', () => { + const toolCall = createFakeCompletedToolCall('test_tool', true, 100); + const event = { + ...structuredClone(new ToolCallEvent(toolCall)), + 'event.name': EVENT_TOOL_CALL, + metadata: { + ai_added_lines: null, + ai_removed_lines: undefined, + }, + } as ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }; + + service.addEvent(event); + + const metrics = service.getMetrics(); + expect(metrics.files.totalLinesAdded).toBe(0); + expect(metrics.files.totalLinesRemoved).toBe(0); + }); + }); }); diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index 8d1b044f..318478aa 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -63,6 +63,10 @@ export interface SessionMetrics { }; byName: Record; }; + files: { + totalLinesAdded: number; + totalLinesRemoved: number; + }; } const createInitialModelMetrics = (): ModelMetrics => ({ @@ -96,6 +100,10 @@ const createInitialMetrics = (): SessionMetrics => ({ }, byName: {}, }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, }); export class UiTelemetryService extends EventEmitter { @@ -171,7 +179,7 @@ export class UiTelemetryService extends EventEmitter { } private processToolCall(event: ToolCallEvent) { - const { tools } = this.#metrics; + const { tools, files } = this.#metrics; tools.totalCalls++; tools.totalDurationMs += event.duration_ms; @@ -209,6 +217,16 @@ export class UiTelemetryService extends EventEmitter { tools.totalDecisions[event.decision]++; toolStats.decisions[event.decision]++; } + + // Aggregate line count data from metadata + if (event.metadata) { + if (event.metadata['ai_added_lines'] !== undefined) { + files.totalLinesAdded += event.metadata['ai_added_lines']; + } + if (event.metadata['ai_removed_lines'] !== undefined) { + files.totalLinesRemoved += event.metadata['ai_removed_lines']; + } + } } }