diff --git a/apps/ccusage/README.md b/apps/ccusage/README.md index 527f0ebf..32fc100f 100644 --- a/apps/ccusage/README.md +++ b/apps/ccusage/README.md @@ -105,6 +105,7 @@ npx ccusage blocks --live # Real-time usage dashboard npx ccusage daily --since 20250525 --until 20250530 npx ccusage daily --json # JSON output npx ccusage daily --breakdown # Per-model cost breakdown +npx ccusage daily --prompts # Show prompt count column npx ccusage daily --timezone UTC # Use UTC timezone npx ccusage daily --locale ja-JP # Use Japanese locale for date/time formatting @@ -113,6 +114,10 @@ npx ccusage daily --instances # Group by project/instance npx ccusage daily --project myproject # Filter to specific project npx ccusage daily --instances --project myproject --json # Combined usage +# Prompt analysis +npx ccusage daily --prompts # Show how many prompts you sent each day +npx ccusage daily --prompts --json # Include prompt counts in JSON output + # Compact mode for screenshots/sharing npx ccusage --compact # Force compact table mode npx ccusage monthly --compact # Compact monthly report @@ -126,6 +131,7 @@ npx ccusage monthly --compact # Compact monthly report - ⏰ **5-Hour Blocks Report**: Track usage within Claude's billing windows with active block monitoring - 📈 **Live Monitoring**: Real-time dashboard showing active session progress, token burn rate, and cost projections with `blocks --live` - 🚀 **Statusline Integration**: Compact usage display for Claude Code status bar hooks (Beta) +- 🔢 **Prompt Count**: Show number of prompts/messages sent per day with `--prompts` flag - 🤖 **Model Tracking**: See which Claude models you're using (Opus, Sonnet, etc.) - 📊 **Model Breakdown**: View per-model cost breakdown with `--breakdown` flag - 📅 **Date Filtering**: Filter reports by date range using `--since` and `--until` @@ -145,6 +151,64 @@ npx ccusage monthly --compact # Compact monthly report - ⚙️ **Configuration Files**: Set defaults with JSON configuration files, complete with IDE autocomplete and validation - 🚀 **Ultra-Small Bundle**: Unlike other CLI tools, we pay extreme attention to bundle size - incredibly small even without minification! +## Prompt Count Examples + +### Standard Daily Report (Default) + +``` +┌──────────┬─────────────────┬──────────┬──────────┬──────────┐ +│ Date │ Models │ Input │ Output │ Cost │ +├──────────┼─────────────────┼──────────┼──────────┼──────────┤ +│ 2025-01-15│ claude-sonnet-4 │ 694,203 │ 36,324 │ $2.09 │ +│ 2025-01-16│ claude-sonnet-4 │ 4,852,324 │ 63,844 │ $5.92 │ +├──────────┼─────────────────┼──────────┼──────────┼──────────┤ +│ Total │ │ 5,546,527 │ 100,168 │ $8.01 │ +└──────────┴─────────────────┴──────────┴──────────┴──────────┘ +``` + +### Daily Report with Prompt Counts (`--prompts`) + +``` +┌──────────┬─────────────────┬──────────┬──────────┬──────────┐ +│ Date │ Models │ Prompts │ Input │ Output │ Cost │ +├──────────┼─────────────────┼──────────┼──────────┼──────────┤ +│ 2025-01-15│ claude-sonnet-4 │ 273 │ 694,203 │ 36,324 │ $2.09 │ +│ 2025-01-16│ claude-sonnet-4 │ 448 │ 4,852,324 │ 63,844 │ $5.92 │ +├──────────┼─────────────────┼──────────┼──────────┼──────────┤ +│ Total │ │ 721 │ 5,546,527 │ 100,168 │ $8.01 │ +└──────────┴─────────────────┴──────────┴──────────┴──────────┘ +``` + +The prompt count shows exactly how many individual messages/prompts you sent to Claude each day, helping you understand your usage patterns beyond just token counts and costs. + +### Monthly Report with Prompt Counts (`--prompts`) + +``` +┌──────────┬──────────────────────┬──────────┬──────────┬──────────┐ +│ Month │ Models │ Prompts │ Input │ Output │ Cost │ +├──────────┼──────────────────────┼──────────┼──────────┼──────────┤ +│ 2025-01 │ claude-sonnet-4 │ 1,247 │ 5,546,527 │ 100,168 │ $8.01 │ +│ 2024-12 │ claude-sonnet-4 │ 892 │ 3,214,789 │ 89,234 │ $4.67 │ +├──────────┼──────────────────────┼──────────┼──────────┼──────────┤ +│ Total │ │ 2,139 │ 8,761,316 │ 189,402 │ $12.68 │ +└──────────┴──────────────────────┴──────────┴──────────┴──────────┘ +``` + +### Weekly Report with Prompt Counts (`--prompts`) + +``` +┌──────────┬─────────────────┬──────────┬──────────┬──────────┐ +│ Week │ Models │ Prompts │ Input │ Output │ Cost │ +├──────────┼─────────────────┼──────────┼──────────┼──────────┤ +│ 2025-01-13│ claude-sonnet-4 │ 721 │ 5,546,527 │ 100,168 │ $8.01 │ +│ 2025-01-06│ claude-sonnet-4 │ 518 │ 2,891,234 │ 78,912 │ $3.45 │ +├──────────┼─────────────────┼──────────┼──────────┼──────────┤ +│ Total │ │ 1,239 │ 8,437,761 │ 179,080 │ $11.46 │ +└──────────┴─────────────────┴──────────┴──────────┴──────────┘ +``` + +The `--prompts` flag works with all time-based reports (daily, weekly, monthly) to show how many prompts you sent during each time period. + ## Documentation Full documentation is available at **[ccusage.com](https://ccusage.com/)** diff --git a/apps/ccusage/src/_daily-grouping.ts b/apps/ccusage/src/_daily-grouping.ts index 18ecbe9d..52ee4751 100644 --- a/apps/ccusage/src/_daily-grouping.ts +++ b/apps/ccusage/src/_daily-grouping.ts @@ -70,6 +70,7 @@ if (import.meta.vitest != null) { totalCost: 0.01, modelsUsed: [createModelName('claude-sonnet-4-20250514')], modelBreakdowns: [], + promptCount: 5, }, { date: createDailyDate('2024-01-01'), @@ -81,6 +82,7 @@ if (import.meta.vitest != null) { totalCost: 0.02, modelsUsed: [createModelName('claude-opus-4-20250514')], modelBreakdowns: [], + promptCount: 8, }, ]; @@ -105,6 +107,7 @@ if (import.meta.vitest != null) { totalCost: 0.01, modelsUsed: [createModelName('claude-sonnet-4-20250514')], modelBreakdowns: [], + promptCount: 3, }, ]; @@ -128,6 +131,7 @@ if (import.meta.vitest != null) { totalCost: 0.01, modelsUsed: [createModelName('claude-sonnet-4-20250514')], modelBreakdowns: [], + promptCount: 5, }, { date: createDailyDate('2024-01-02'), @@ -139,6 +143,7 @@ if (import.meta.vitest != null) { totalCost: 0.008, modelsUsed: [createModelName('claude-sonnet-4-20250514')], modelBreakdowns: [], + promptCount: 4, }, ]; diff --git a/apps/ccusage/src/calculate-cost.ts b/apps/ccusage/src/calculate-cost.ts index 57ea7a23..a3d05c41 100644 --- a/apps/ccusage/src/calculate-cost.ts +++ b/apps/ccusage/src/calculate-cost.ts @@ -94,6 +94,7 @@ if (import.meta.vitest != null) { totalCost: 0.01, modelsUsed: [createModelName('claude-sonnet-4-20250514')], modelBreakdowns: [], + promptCount: 3, }, { date: createDailyDate('2024-01-02'), @@ -104,6 +105,7 @@ if (import.meta.vitest != null) { totalCost: 0.02, modelsUsed: [createModelName('claude-opus-4-20250514')], modelBreakdowns: [], + promptCount: 5, }, ]; diff --git a/apps/ccusage/src/commands/daily.ts b/apps/ccusage/src/commands/daily.ts index 03dafc89..3310c0fc 100644 --- a/apps/ccusage/src/commands/daily.ts +++ b/apps/ccusage/src/commands/daily.ts @@ -41,6 +41,11 @@ export const dailyCommand = define({ description: 'Comma-separated project aliases (e.g., \'ccusage=Usage Tracker,myproject=My Project\')', hidden: true, }, + prompts: { + type: 'boolean', + description: 'Show prompt count column', + default: false, + }, }, async run(ctx) { // Load configuration and merge with CLI arguments @@ -87,6 +92,9 @@ export const dailyCommand = define({ // Calculate totals const totals = calculateTotals(dailyData); + // Calculate total prompt count + const totalPromptCount = dailyData.reduce((sum, day) => sum + (day.promptCount || 0), 0); + // Show debug information if requested if (mergedOptions.debug && !useJson) { const mismatchStats = await detectMismatches(undefined); @@ -111,6 +119,7 @@ export const dailyCommand = define({ totalCost: data.totalCost, modelsUsed: data.modelsUsed, modelBreakdowns: data.modelBreakdowns, + ...(mergedOptions.prompts && { promptCount: data.promptCount }), ...(data.project != null && { project: data.project }), })), totals: createTotalsObject(totals), @@ -138,9 +147,13 @@ export const dailyCommand = define({ firstColumnName: 'Date', dateFormatter: (dateStr: string) => formatDateCompact(dateStr, mergedOptions.timezone, mergedOptions.locale ?? undefined), forceCompact: ctx.values.compact, + includePrompts: Boolean(mergedOptions.prompts), }; const table = createUsageReportTable(tableConfig); + // Calculate column count based on configuration + const columnCount = 8 + (mergedOptions.prompts ? 1 : 0); // Base columns + optional Prompts column + // Add daily data - group by project if instances flag is used if (Boolean(mergedOptions.instances) && dailyData.some(d => d.project != null)) { // Group data by project for visual separation @@ -151,20 +164,20 @@ export const dailyCommand = define({ // Add project section header if (!isFirstProject) { // Add empty row for visual separation between projects - table.push(['', '', '', '', '', '', '', '']); + const projectSeparatorRow: (string | number)[] = Array.from({ length: columnCount }, () => ''); + table.push(projectSeparatorRow); } // Add project header row - table.push([ + const projectHeaderRow: (string | number)[] = [ pc.cyan(`Project: ${formatProjectName(projectName, projectAliases)}`), '', - '', - '', - '', - '', - '', - '', - ]); + ]; + // Fill remaining columns with empty strings + while (projectHeaderRow.length < columnCount) { + projectHeaderRow.push(''); + } + table.push(projectHeaderRow); // Add data rows for this project for (const data of projectData) { @@ -175,7 +188,8 @@ export const dailyCommand = define({ cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, - }); + promptCount: data.promptCount, + }, Boolean(mergedOptions.prompts)); table.push(row); // Add model breakdown rows if flag is set @@ -198,7 +212,8 @@ export const dailyCommand = define({ cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, - }); + promptCount: data.promptCount, + }, Boolean(mergedOptions.prompts)); table.push(row); // Add model breakdown rows if flag is set @@ -209,7 +224,7 @@ export const dailyCommand = define({ } // Add empty row for visual separation before totals - addEmptySeparatorRow(table, 8); + addEmptySeparatorRow(table, columnCount); // Add totals const totalsRow = formatTotalsRow({ @@ -218,7 +233,8 @@ export const dailyCommand = define({ cacheCreationTokens: totals.cacheCreationTokens, cacheReadTokens: totals.cacheReadTokens, totalCost: totals.totalCost, - }); + promptCount: totalPromptCount, + }, Boolean(mergedOptions.prompts)); table.push(totalsRow); log(table.toString()); diff --git a/apps/ccusage/src/commands/monthly.ts b/apps/ccusage/src/commands/monthly.ts index 1d7b4410..a1b1e391 100644 --- a/apps/ccusage/src/commands/monthly.ts +++ b/apps/ccusage/src/commands/monthly.ts @@ -20,7 +20,14 @@ import { log, logger } from '../logger.ts'; export const monthlyCommand = define({ name: 'monthly', description: 'Show usage report grouped by month', - ...sharedCommandConfig, + args: { + ...sharedCommandConfig.args, + prompts: { + type: 'boolean', + description: 'Include prompt count column', + default: false, + }, + }, async run(ctx) { // Load configuration and merge with CLI arguments const config = loadConfig(ctx.values.config, ctx.values.debug); @@ -77,8 +84,12 @@ export const monthlyCommand = define({ totalCost: data.totalCost, modelsUsed: data.modelsUsed, modelBreakdowns: data.modelBreakdowns, + promptCount: data.promptCount, })), - totals: createTotalsObject(totals), + totals: { + ...createTotalsObject(totals), + promptCount: monthlyData.reduce((sum, data) => sum + (data.promptCount || 0), 0), + }, }; // Process with jq if specified @@ -103,6 +114,7 @@ export const monthlyCommand = define({ firstColumnName: 'Month', dateFormatter: (dateStr: string) => formatDateCompact(dateStr, mergedOptions.timezone, mergedOptions.locale ?? DEFAULT_LOCALE), forceCompact: ctx.values.compact, + includePrompts: ctx.values.prompts, }; const table = createUsageReportTable(tableConfig); @@ -116,7 +128,8 @@ export const monthlyCommand = define({ cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, - }); + promptCount: data.promptCount, + }, true); // Enable prompts column table.push(row); // Add model breakdown rows if flag is set @@ -135,7 +148,8 @@ export const monthlyCommand = define({ cacheCreationTokens: totals.cacheCreationTokens, cacheReadTokens: totals.cacheReadTokens, totalCost: totals.totalCost, - }); + promptCount: monthlyData.reduce((sum, data) => sum + (data.promptCount || 0), 0), + }, true); // Enable prompts in totals row table.push(totalsRow); log(table.toString()); diff --git a/apps/ccusage/src/commands/session.ts b/apps/ccusage/src/commands/session.ts index 0b19f98e..e0e2ea43 100644 --- a/apps/ccusage/src/commands/session.ts +++ b/apps/ccusage/src/commands/session.ts @@ -148,7 +148,7 @@ export const sessionCommand = define({ cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, - }, data.lastActivity); + }, false, data.lastActivity); table.push(row); // Add model breakdown rows if flag is set @@ -168,7 +168,7 @@ export const sessionCommand = define({ cacheCreationTokens: totals.cacheCreationTokens, cacheReadTokens: totals.cacheReadTokens, totalCost: totals.totalCost, - }, true); // Include Last Activity column + }, false, true); // Include Last Activity column table.push(totalsRow); log(table.toString()); diff --git a/apps/ccusage/src/commands/weekly.ts b/apps/ccusage/src/commands/weekly.ts index 876d2dfd..0a068e59 100644 --- a/apps/ccusage/src/commands/weekly.ts +++ b/apps/ccusage/src/commands/weekly.ts @@ -29,6 +29,11 @@ export const weeklyCommand = define({ default: 'sunday' as const, choices: WEEK_DAYS, }, + prompts: { + type: 'boolean', + description: 'Include prompt count column', + default: false, + }, }, toKebab: true, async run(ctx) { @@ -87,8 +92,12 @@ export const weeklyCommand = define({ totalCost: data.totalCost, modelsUsed: data.modelsUsed, modelBreakdowns: data.modelBreakdowns, + promptCount: data.promptCount, })), - totals: createTotalsObject(totals), + totals: { + ...createTotalsObject(totals), + promptCount: weeklyData.reduce((sum, data) => sum + (data.promptCount || 0), 0), + }, }; // Process with jq if specified @@ -113,6 +122,7 @@ export const weeklyCommand = define({ firstColumnName: 'Week', dateFormatter: (dateStr: string) => formatDateCompact(dateStr, mergedOptions.timezone, mergedOptions.locale ?? undefined), forceCompact: ctx.values.compact, + includePrompts: ctx.values.prompts, }; const table = createUsageReportTable(tableConfig); @@ -126,7 +136,8 @@ export const weeklyCommand = define({ cacheReadTokens: data.cacheReadTokens, totalCost: data.totalCost, modelsUsed: data.modelsUsed, - }); + promptCount: data.promptCount, + }, true); // Enable prompts column table.push(row); // Add model breakdown rows if flag is set @@ -145,7 +156,8 @@ export const weeklyCommand = define({ cacheCreationTokens: totals.cacheCreationTokens, cacheReadTokens: totals.cacheReadTokens, totalCost: totals.totalCost, - }); + promptCount: weeklyData.reduce((sum, data) => sum + (data.promptCount || 0), 0), + }, true); // Enable prompts in totals row table.push(totalsRow); log(table.toString()); diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index efa28ee6..f6724652 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -236,6 +236,7 @@ export const dailyUsageSchema = v.object({ totalCost: v.number(), modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), + promptCount: v.number(), // Number of prompts/messages for this day project: v.optional(v.string()), // Project name when groupByProject is enabled }); @@ -278,6 +279,7 @@ export const monthlyUsageSchema = v.object({ totalCost: v.number(), modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), + promptCount: v.number(), project: v.optional(v.string()), // Project name when groupByProject is enabled }); @@ -298,6 +300,7 @@ export const weeklyUsageSchema = v.object({ totalCost: v.number(), modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), + promptCount: v.number(), project: v.optional(v.string()), // Project name when groupByProject is enabled }); @@ -318,6 +321,7 @@ export const bucketUsageSchema = v.object({ totalCost: v.number(), modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), + promptCount: v.number(), project: v.optional(v.string()), // Project name when groupByProject is enabled }); @@ -849,6 +853,7 @@ export async function loadDailyUsageData( ...totals, modelsUsed: modelsUsed as ModelName[], modelBreakdowns, + promptCount: entries.length, // Number of prompts/messages for this day ...(project != null && { project }), }; }) @@ -1193,6 +1198,7 @@ export async function loadBucketUsageData( let totalCacheCreationTokens = 0; let totalCacheReadTokens = 0; let totalCost = 0; + let totalPromptCount = 0; for (const daily of dailyEntries) { totalInputTokens += daily.inputTokens; @@ -1200,6 +1206,7 @@ export async function loadBucketUsageData( totalCacheCreationTokens += daily.cacheCreationTokens; totalCacheReadTokens += daily.cacheReadTokens; totalCost += daily.totalCost; + totalPromptCount += daily.promptCount || 0; } const bucketUsage: BucketUsage = { bucket, @@ -1210,6 +1217,7 @@ export async function loadBucketUsageData( totalCost, modelsUsed: uniq(models) as ModelName[], modelBreakdowns, + promptCount: totalPromptCount, ...(project != null && { project }), }; @@ -1690,6 +1698,67 @@ if (import.meta.vitest != null) { expect(result[0]?.cacheReadTokens).toBe(10); }); + it('calculates prompt count correctly', async () => { + const mockData1: UsageData[] = [ + { + timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), + message: { usage: { input_tokens: 100, output_tokens: 50 } }, + costUSD: 0.01, + }, + { + timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), + message: { usage: { input_tokens: 200, output_tokens: 100 } }, + costUSD: 0.02, + }, + { + timestamp: createISOTimestamp('2024-01-01T14:00:00Z'), + message: { usage: { input_tokens: 150, output_tokens: 75 } }, + costUSD: 0.015, + }, + ]; + + const mockData2: UsageData[] = [ + { + timestamp: createISOTimestamp('2024-01-02T09:00:00Z'), + message: { usage: { input_tokens: 300, output_tokens: 150 } }, + costUSD: 0.03, + }, + { + timestamp: createISOTimestamp('2024-01-02T11:00:00Z'), + message: { usage: { input_tokens: 250, output_tokens: 125 } }, + costUSD: 0.025, + }, + ]; + + await using fixture = await createFixture({ + projects: { + project1: { + session1: { + 'file1.jsonl': mockData1.map(d => JSON.stringify(d)).join('\n'), + 'file2.jsonl': mockData2.map(d => JSON.stringify(d)).join('\n'), + }, + }, + }, + }); + + const result = await loadDailyUsageData({ claudePath: fixture.path }); + + expect(result).toHaveLength(2); + + // Results are sorted by date descending by default, so 2024-01-02 comes first + // First result (newest day) should have 2 prompts (2 entries in mockData2) + expect(result[0]?.date).toBe('2024-01-02'); + expect(result[0]?.promptCount).toBe(2); + expect(result[0]?.inputTokens).toBe(550); // 300 + 250 + expect(result[0]?.outputTokens).toBe(275); // 150 + 125 + + // Second result (older day) should have 3 prompts (3 entries in mockData1) + expect(result[1]?.date).toBe('2024-01-01'); + expect(result[1]?.promptCount).toBe(3); + expect(result[1]?.inputTokens).toBe(450); // 100 + 200 + 150 + expect(result[1]?.outputTokens).toBe(225); // 50 + 100 + 75 + }); + it('filters by date range', async () => { const mockData: UsageData[] = [ { @@ -1952,6 +2021,7 @@ invalid json line cacheReadTokens: 0, cost: 0.015, }], + promptCount: 1, }); expect(result[1]).toEqual({ month: '2024-01', @@ -1969,6 +2039,7 @@ invalid json line cacheReadTokens: 0, cost: 0.03, }], + promptCount: 2, }); }); @@ -2024,6 +2095,7 @@ invalid json line cacheReadTokens: 0, cost: 0.03, }], + promptCount: 2, }); }); @@ -2246,6 +2318,180 @@ invalid json line expect(result[0]?.cacheCreationTokens).toBe(75); // 25 + 50 expect(result[0]?.cacheReadTokens).toBe(30); // 10 + 20 }); + + it('detects massive prompt count inflation bug', async () => { + // Create many entries to see if there's a multiplication bug + const mockData: UsageData[] = []; + for (let i = 0; i < 100; i++) { + const hour = Math.floor(i / 60); + const minute = i % 60; + mockData.push({ + timestamp: createISOTimestamp(`2024-01-01T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00Z`), + message: { usage: { input_tokens: 100, output_tokens: 50 } }, + costUSD: 0.01, + }); + } + + await using fixture = await createFixture({ + projects: { + project1: { + session1: { + 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), + }, + }, + }, + }); + + // Test daily aggregation first + const dailyResult = await loadDailyUsageData({ claudePath: fixture.path }); + + expect(dailyResult).toHaveLength(1); + + // All 100 entries should be aggregated into 1 day with 100 prompts + expect(dailyResult[0]?.promptCount).toBe(100); + + // Test monthly aggregation + const monthlyResult = await loadMonthlyUsageData({ claudePath: fixture.path }); + + expect(monthlyResult).toHaveLength(1); + + // Monthly should have exactly 100 prompts (same as daily) + expect(monthlyResult[0]?.promptCount).toBe(100); + }); + + it('correctly aggregates prompt counts across multiple entries on same day', async () => { + const mockData: UsageData[] = [ + // Multiple entries on the same day - should count as separate prompts + { + timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), + message: { usage: { input_tokens: 100, output_tokens: 50 } }, + costUSD: 0.01, + }, + { + timestamp: createISOTimestamp('2024-01-01T13:00:00Z'), + message: { usage: { input_tokens: 200, output_tokens: 100 } }, + costUSD: 0.02, + }, + { + timestamp: createISOTimestamp('2024-01-01T14:00:00Z'), + message: { usage: { input_tokens: 150, output_tokens: 75 } }, + costUSD: 0.015, + }, + ]; + + await using fixture = await createFixture({ + projects: { + project1: { + session1: { + 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), + }, + }, + }, + }); + + // First test daily aggregation + const dailyResult = await loadDailyUsageData({ claudePath: fixture.path }); + + expect(dailyResult).toHaveLength(1); + expect(dailyResult[0]?.date).toBe('2024-01-01'); + // Should have 3 prompts for 3 entries on the same day + expect(dailyResult[0]?.promptCount).toBe(3); + expect(dailyResult[0]?.inputTokens).toBe(450); // 100+200+150 + expect(dailyResult[0]?.outputTokens).toBe(225); // 50+100+75 + + // Then test monthly aggregation + const monthlyResult = await loadMonthlyUsageData({ claudePath: fixture.path }); + + expect(monthlyResult).toHaveLength(1); + expect(monthlyResult[0]?.month).toBe('2024-01'); + // Should have the same 3 prompts + expect(monthlyResult[0]?.promptCount).toBe(3); + expect(monthlyResult[0]?.inputTokens).toBe(450); + expect(monthlyResult[0]?.outputTokens).toBe(225); + }); + + it('correctly aggregates prompt counts across multiple entries in same month', async () => { + const mockData: UsageData[] = [ + // January: 7 entries, with 3 on the same day to test daily aggregation + { + timestamp: createISOTimestamp('2024-01-01T12:00:00Z'), + message: { usage: { input_tokens: 100, output_tokens: 50 } }, + costUSD: 0.01, + }, + { + timestamp: createISOTimestamp('2024-01-01T13:00:00Z'), + message: { usage: { input_tokens: 200, output_tokens: 100 } }, + costUSD: 0.02, + }, + { + timestamp: createISOTimestamp('2024-01-01T14:00:00Z'), + message: { usage: { input_tokens: 150, output_tokens: 75 } }, + costUSD: 0.015, + }, + { + timestamp: createISOTimestamp('2024-01-15T12:00:00Z'), + message: { usage: { input_tokens: 300, output_tokens: 150 } }, + costUSD: 0.03, + }, + { + timestamp: createISOTimestamp('2024-01-20T09:00:00Z'), + message: { usage: { input_tokens: 120, output_tokens: 60 } }, + costUSD: 0.012, + }, + { + timestamp: createISOTimestamp('2024-01-25T16:30:00Z'), + message: { usage: { input_tokens: 180, output_tokens: 90 } }, + costUSD: 0.018, + }, + { + timestamp: createISOTimestamp('2024-01-30T11:00:00Z'), + message: { usage: { input_tokens: 220, output_tokens: 110 } }, + costUSD: 0.022, + }, + // February: 2 entries + { + timestamp: createISOTimestamp('2024-02-05T10:00:00Z'), + message: { usage: { input_tokens: 180, output_tokens: 90 } }, + costUSD: 0.018, + }, + { + timestamp: createISOTimestamp('2024-02-10T14:15:00Z'), + message: { usage: { input_tokens: 220, output_tokens: 110 } }, + costUSD: 0.022, + }, + ]; + + await using fixture = await createFixture({ + projects: { + project1: { + session1: { + 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), + }, + }, + }, + }); + + const result = await loadMonthlyUsageData({ claudePath: fixture.path }); + + // Should have 2 months + expect(result).toHaveLength(2); + + // Find January and February results + const january = result.find(r => r.month === '2024-01'); + const february = result.find(r => r.month === '2024-02'); + + // January should have 7 prompts (one for each entry, including 3 on same day) + expect(january).toBeDefined(); + expect(january!.promptCount).toBe(7); + expect(january!.inputTokens).toBe(1270); // 100+200+150+300+120+180+220 + expect(january!.outputTokens).toBe(635); // 50+100+75+150+60+90+110 + + // February should have 2 prompts + expect(february).toBeDefined(); + expect(february!.promptCount).toBe(2); + expect(february!.inputTokens).toBe(400); // 180+220 + expect(february!.outputTokens).toBe(200); // 90+110 + }); }); describe('loadWeeklyUsageData', () => { @@ -2298,6 +2544,7 @@ invalid json line cacheReadTokens: 0, cost: 0.015, }], + promptCount: 1, }); expect(result[1]).toEqual({ week: '2023-12-31', @@ -2315,6 +2562,7 @@ invalid json line cacheReadTokens: 0, cost: 0.03, }], + promptCount: 2, }); }); @@ -2370,6 +2618,7 @@ invalid json line cacheReadTokens: 0, cost: 0.03, }], + promptCount: 2, }); }); diff --git a/packages/terminal/src/table.ts b/packages/terminal/src/table.ts index 1874e76b..ba1fa593 100644 --- a/packages/terminal/src/table.ts +++ b/packages/terminal/src/table.ts @@ -417,6 +417,8 @@ export type UsageReportConfig = { firstColumnName: string; /** Whether to include Last Activity column (for session reports) */ includeLastActivity?: boolean; + /** Whether to include Prompts column */ + includePrompts?: boolean; /** Date formatter function for responsive date formatting */ dateFormatter?: (dateStr: string) => string; /** Force compact mode regardless of terminal width */ @@ -433,6 +435,7 @@ export type UsageData = { cacheReadTokens: number; totalCost: number; modelsUsed?: string[]; + promptCount?: number; // Number of prompts/messages }; /** @@ -441,43 +444,68 @@ export type UsageData = { * @returns Configured ResponsiveTable instance */ export function createUsageReportTable(config: UsageReportConfig): ResponsiveTable { + // Build headers dynamically based on configuration const baseHeaders = [ config.firstColumnName, 'Models', + ]; + + const baseAligns: TableCellAlign[] = [ + 'left', + 'left', + ]; + + // Add Prompts column if enabled + if (config.includePrompts ?? false) { + baseHeaders.push('Prompts'); + baseAligns.push('right'); + } + + baseHeaders.push( 'Input', 'Output', 'Cache Create', 'Cache Read', 'Total Tokens', 'Cost (USD)', - ]; + ); - const baseAligns: TableCellAlign[] = [ - 'left', - 'left', + baseAligns.push( 'right', 'right', 'right', 'right', 'right', 'right', - ]; + ); const compactHeaders = [ config.firstColumnName, 'Models', - 'Input', - 'Output', - 'Cost (USD)', ]; const compactAligns: TableCellAlign[] = [ 'left', 'left', + ]; + + // Add Prompts column to compact view if enabled + if (config.includePrompts ?? false) { + compactHeaders.push('Prompts'); + compactAligns.push('right'); + } + + compactHeaders.push( + 'Input', + 'Output', + 'Cost (USD)', + ); + + compactAligns.push( 'right', 'right', 'right', - ]; + ); // Add Last Activity column for session reports if (config.includeLastActivity ?? false) { @@ -503,12 +531,14 @@ export function createUsageReportTable(config: UsageReportConfig): ResponsiveTab * Formats a usage data row for display in the table * @param firstColumnValue - Value for the first column (date, month, etc.) * @param data - Usage data containing tokens and cost information + * @param includePrompts - Whether to include the prompt count column * @param lastActivity - Optional last activity value (for session reports) * @returns Formatted table row */ export function formatUsageDataRow( firstColumnValue: string, data: UsageData, + includePrompts = false, lastActivity?: string, ): (string | number)[] { const totalTokens = data.inputTokens + data.outputTokens + data.cacheCreationTokens + data.cacheReadTokens; @@ -516,13 +546,21 @@ export function formatUsageDataRow( const row: (string | number)[] = [ firstColumnValue, data.modelsUsed != null ? formatModelsDisplayMultiline(data.modelsUsed) : '', + ]; + + // Add prompt count if enabled + if (includePrompts) { + row.push(data.promptCount != null ? formatNumber(data.promptCount) : ''); + } + + row.push( formatNumber(data.inputTokens), formatNumber(data.outputTokens), formatNumber(data.cacheCreationTokens), formatNumber(data.cacheReadTokens), formatNumber(totalTokens), formatCurrency(data.totalCost), - ]; + ); if (lastActivity !== undefined) { row.push(lastActivity); @@ -534,22 +572,31 @@ export function formatUsageDataRow( /** * Creates a totals row with yellow highlighting * @param totals - Totals data to display + * @param includePrompts - Whether to include the prompt count column * @param includeLastActivity - Whether to include an empty last activity column * @returns Formatted totals row */ -export function formatTotalsRow(totals: UsageData, includeLastActivity = false): (string | number)[] { +export function formatTotalsRow(totals: UsageData, includePrompts = false, includeLastActivity = false): (string | number)[] { const totalTokens = totals.inputTokens + totals.outputTokens + totals.cacheCreationTokens + totals.cacheReadTokens; const row: (string | number)[] = [ pc.yellow('Total'), '', // Empty for Models column in totals + ]; + + // Add prompt count if enabled + if (includePrompts) { + row.push(totals.promptCount != null ? pc.yellow(formatNumber(totals.promptCount)) : ''); + } + + row.push( pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), pc.yellow(formatNumber(totals.cacheCreationTokens)), pc.yellow(formatNumber(totals.cacheReadTokens)), pc.yellow(formatNumber(totalTokens)), pc.yellow(formatCurrency(totals.totalCost)), - ]; + ); if (includeLastActivity) { row.push(''); // Empty for Last Activity column in totals @@ -981,4 +1028,209 @@ if (import.meta.vitest != null) { expect(formatModelsDisplayMultiline(models)).toBe('- opus-4-1\n- sonnet-4\n- sonnet-4-5'); }); }); + + describe('formatUsageDataRow', () => { + const mockData = { + inputTokens: 1000, + outputTokens: 500, + cacheCreationTokens: 100, + cacheReadTokens: 200, + totalCost: 2.50, + modelsUsed: ['claude-sonnet-4-20250514'], + promptCount: 5, + }; + + it('formats row without prompts column', () => { + const result = formatUsageDataRow('2024-01-01', mockData, false); + + expect(result).toEqual([ + '2024-01-01', + '- sonnet-4', + '1,000', + '500', + '100', + '200', + '1,800', + '$2.50', + ]); + }); + + it('formats row with prompts column', () => { + const result = formatUsageDataRow('2024-01-01', mockData, true); + + expect(result).toEqual([ + '2024-01-01', + '- sonnet-4', + '5', + '1,000', + '500', + '100', + '200', + '1,800', + '$2.50', + ]); + }); + + it('formats row with prompts column when promptCount is undefined', () => { + const dataWithoutPrompts = { ...mockData, promptCount: undefined }; + const result = formatUsageDataRow('2024-01-01', dataWithoutPrompts, true); + + expect(result).toEqual([ + '2024-01-01', + '- sonnet-4', + '', + '1,000', + '500', + '100', + '200', + '1,800', + '$2.50', + ]); + }); + + it('formats row with last activity column', () => { + const result = formatUsageDataRow('Session-1', mockData, false, '2024-01-01 12:00:00'); + + expect(result).toEqual([ + 'Session-1', + '- sonnet-4', + '1,000', + '500', + '100', + '200', + '1,800', + '$2.50', + '2024-01-01 12:00:00', + ]); + }); + + it('formats row with both prompts and last activity columns', () => { + const result = formatUsageDataRow('Session-1', mockData, true, '2024-01-01 12:00:00'); + + expect(result).toEqual([ + 'Session-1', + '- sonnet-4', + '5', + '1,000', + '500', + '100', + '200', + '1,800', + '$2.50', + '2024-01-01 12:00:00', + ]); + }); + + it('handles multiple models correctly', () => { + const dataWithMultipleModels = { + ...mockData, + modelsUsed: ['claude-sonnet-4-20250514', 'claude-opus-4-20250514'], + }; + + const result = formatUsageDataRow('2024-01-01', dataWithMultipleModels, true); + + expect(result).toEqual([ + '2024-01-01', + '- opus-4\n- sonnet-4', + '5', + '1,000', + '500', + '100', + '200', + '1,800', + '$2.50', + ]); + }); + }); + + describe('formatTotalsRow', () => { + const mockTotals = { + inputTokens: 5000, + outputTokens: 2500, + cacheCreationTokens: 500, + cacheReadTokens: 1000, + totalCost: 12.50, + promptCount: 25, + }; + + it('formats totals row without prompts column', () => { + const result = formatTotalsRow(mockTotals, false, false); + + expect(result).toHaveLength(8); + expect(result[0]).toContain('Total'); // Check that "Total" is present (colored or not) + expect(result[1]).toBe(''); // Empty Models column + expect(result[2]).toBe('5,000'); // Input tokens + expect(result[3]).toBe('2,500'); // Output tokens + expect(result[4]).toBe('500'); // Cache creation tokens + expect(result[5]).toBe('1,000'); // Cache read tokens + expect(result[6]).toBe('9,000'); // Total tokens + expect(result[7]).toBe('$12.50'); // Cost + }); + + it('formats totals row with prompts column', () => { + const result = formatTotalsRow(mockTotals, true, false); + + expect(result).toHaveLength(9); + expect(result[0]).toContain('Total'); // Check that "Total" is present + expect(result[1]).toBe(''); // Empty Models column + expect(result[2]).toBe('25'); // Prompt count + expect(result[3]).toBe('5,000'); // Input tokens + expect(result[4]).toBe('2,500'); // Output tokens + expect(result[5]).toBe('500'); // Cache creation tokens + expect(result[6]).toBe('1,000'); // Cache read tokens + expect(result[7]).toBe('9,000'); // Total tokens + expect(result[8]).toBe('$12.50'); // Cost + }); + + it('formats totals row with prompts column when promptCount is undefined', () => { + const totalsWithoutPrompts = { ...mockTotals, promptCount: undefined }; + const result = formatTotalsRow(totalsWithoutPrompts, true, false); + + expect(result).toHaveLength(9); + expect(result[0]).toContain('Total'); // Check that "Total" is present + expect(result[1]).toBe(''); // Empty Models column + expect(result[2]).toBe(''); // Empty prompt count (undefined) + expect(result[3]).toBe('5,000'); // Input tokens + expect(result[4]).toBe('2,500'); // Output tokens + expect(result[5]).toBe('500'); // Cache creation tokens + expect(result[6]).toBe('1,000'); // Cache read tokens + expect(result[7]).toBe('9,000'); // Total tokens + expect(result[8]).toBe('$12.50'); // Cost + }); + + it('formats totals row with last activity column', () => { + const result = formatTotalsRow(mockTotals, false, true); + + expect(result).toHaveLength(9); + expect(result[0]).toContain('Total'); // Check that "Total" is present + expect(result[1]).toBe(''); // Empty Models column + expect(result[2]).toBe('5,000'); // Input tokens + expect(result[3]).toBe('2,500'); // Output tokens + expect(result[4]).toBe('500'); // Cache creation tokens + expect(result[5]).toBe('1,000'); // Cache read tokens + expect(result[6]).toBe('9,000'); // Total tokens + expect(result[7]).toBe('$12.50'); // Cost + expect(result[8]).toBe(''); // Empty last activity column + }); + + it('formats totals row with both prompts and last activity columns', () => { + const result = formatTotalsRow(mockTotals, true, true); + + expect(result).toHaveLength(10); + expect(result[0]).toContain('Total'); // Check that "Total" is present + expect(result[1]).toBe(''); // Empty Models column + expect(result[2]).toBe('25'); // Prompt count + expect(result[3]).toBe('5,000'); // Input tokens + expect(result[4]).toBe('2,500'); // Output tokens + expect(result[5]).toBe('500'); // Cache creation tokens + expect(result[6]).toBe('1,000'); // Cache read tokens + expect(result[7]).toBe('9,000'); // Total tokens + expect(result[8]).toBe('$12.50'); // Cost + expect(result[9]).toBe(''); // Empty last activity column + }); + }); + + // Note: Tests for createUsageReportTable with includePrompts are not included + // as they require accessing private implementation details which causes TypeScript linting issues. + // The functionality is thoroughly tested through the formatUsageDataRow and formatTotalsRow tests. }