import { feature } from 'bun:bundle'; import { plot as asciichart } from 'asciichart'; import chalk from 'chalk'; import figures from 'figures'; import React, { Suspense, use, useCallback, useEffect, useMemo, useState } from 'react'; import stripAnsi from 'strip-ansi'; import type { CommandResultDisplay } from '../commands.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow stats navigation import { Ansi, applyColor, Box, Text, useInput, stringWidth as getStringWidth, type Color, Pane, Tab, Tabs, useTabHeaderFocus, } from '@anthropic/ink'; import { useKeybinding } from '../keybindings/useKeybinding.js'; import { getGlobalConfig } from '../utils/config.js'; import { formatDuration, formatNumber } from '../utils/format.js'; import { generateHeatmap } from '../utils/heatmap.js'; import { renderModelName } from '../utils/model/model.js'; import { copyAnsiToClipboard } from '../utils/screenshotClipboard.js'; import { aggregateClaudeCodeStatsForRange, type ClaudeCodeStats, type DailyModelTokens, type StatsDateRange, } from '../utils/stats.js'; import { resolveThemeSetting } from '../utils/systemTheme.js'; import { getTheme, themeColorToAnsi } from '../utils/theme.js'; import { Spinner } from './Spinner.js'; function formatPeakDay(dateStr: string): string { const date = new Date(dateStr); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', }); } type Props = { onClose: (result?: string, options?: { display?: CommandResultDisplay }) => void; }; type StatsResult = { type: 'success'; data: ClaudeCodeStats } | { type: 'error'; message: string } | { type: 'empty' }; const DATE_RANGE_LABELS: Record = { '7d': 'Last 7 days', '30d': 'Last 30 days', all: 'All time', }; const DATE_RANGE_ORDER: StatsDateRange[] = ['all', '7d', '30d']; function getNextDateRange(current: StatsDateRange): StatsDateRange { const currentIndex = DATE_RANGE_ORDER.indexOf(current); return DATE_RANGE_ORDER[(currentIndex + 1) % DATE_RANGE_ORDER.length]!; } /** * Creates a stats loading promise that never rejects. * Always loads all-time stats for the heatmap. */ function createAllTimeStatsPromise(): Promise { return aggregateClaudeCodeStatsForRange('all') .then((data): StatsResult => { if (!data || data.totalSessions === 0) { return { type: 'empty' }; } return { type: 'success', data }; }) .catch((err): StatsResult => { const message = err instanceof Error ? err.message : 'Failed to load stats'; return { type: 'error', message }; }); } export function Stats({ onClose }: Props): React.ReactNode { // Always load all-time stats first (for heatmap) const allTimePromise = useMemo(() => createAllTimeStatsPromise(), []); return ( Loading your Claude Code stats… } > ); } type StatsContentProps = { allTimePromise: Promise; onClose: Props['onClose']; }; /** * Inner component that uses React 19's use() to read the stats promise. * Suspends while loading all-time stats, then handles date range changes without suspending. */ function StatsContent({ allTimePromise, onClose }: StatsContentProps): React.ReactNode { const allTimeResult = use(allTimePromise); const [dateRange, setDateRange] = useState('all'); const [statsCache, setStatsCache] = useState>>({}); const [isLoadingFiltered, setIsLoadingFiltered] = useState(false); const [activeTab, setActiveTab] = useState<'Overview' | 'Models'>('Overview'); const [copyStatus, setCopyStatus] = useState(null); // Load filtered stats when date range changes (with caching) useEffect(() => { if (dateRange === 'all') { return; } // Already cached if (statsCache[dateRange]) { return; } let cancelled = false; setIsLoadingFiltered(true); aggregateClaudeCodeStatsForRange(dateRange) .then(data => { if (!cancelled) { setStatsCache(prev => ({ ...prev, [dateRange]: data })); setIsLoadingFiltered(false); } }) .catch(() => { if (!cancelled) { setIsLoadingFiltered(false); } }); return () => { cancelled = true; }; }, [dateRange, statsCache]); // Use cached stats for current range const displayStats = dateRange === 'all' ? allTimeResult.type === 'success' ? allTimeResult.data : null : (statsCache[dateRange] ?? (allTimeResult.type === 'success' ? allTimeResult.data : null)); // All-time stats for the heatmap (always use all-time) const allTimeStats = allTimeResult.type === 'success' ? allTimeResult.data : null; const handleClose = useCallback(() => { onClose('Stats dialog dismissed', { display: 'system' }); }, [onClose]); useKeybinding('confirm:no', handleClose, { context: 'Confirmation' }); useInput((input, key) => { // Handle ctrl+c and ctrl+d for closing if (key.ctrl && (input === 'c' || input === 'd')) { onClose('Stats dialog dismissed', { display: 'system' }); } // Track tab changes if (key.tab) { setActiveTab(prev => (prev === 'Overview' ? 'Models' : 'Overview')); } // r to cycle date range if (input === 'r' && !key.ctrl && !key.meta) { setDateRange(getNextDateRange(dateRange)); } // Ctrl+S to copy screenshot to clipboard if (key.ctrl && input === 's' && displayStats) { void handleScreenshot(displayStats, activeTab, setCopyStatus); } }); if (allTimeResult.type === 'error') { return ( Failed to load stats: {allTimeResult.message} ); } if (allTimeResult.type === 'empty') { return ( No stats available yet. Start using Claude Code! ); } if (!displayStats || !allTimeStats) { return ( Loading stats… ); } return ( Esc to cancel · r to cycle dates · ctrl+s to copy {copyStatus ? ` · ${copyStatus}` : ''} ); } function DateRangeSelector({ dateRange, isLoading, }: { dateRange: StatsDateRange; isLoading: boolean; }): React.ReactNode { return ( {DATE_RANGE_ORDER.map((range, i) => ( {i > 0 && · } {range === dateRange ? ( {DATE_RANGE_LABELS[range]} ) : ( {DATE_RANGE_LABELS[range]} )} ))} {isLoading && } ); } function OverviewTab({ stats, allTimeStats, dateRange, isLoading, }: { stats: ClaudeCodeStats; allTimeStats: ClaudeCodeStats; dateRange: StatsDateRange; isLoading: boolean; }): React.ReactNode { const { columns: terminalWidth } = useTerminalSize(); // Calculate favorite model and total tokens const modelEntries = Object.entries(stats.modelUsage).sort( ([, a], [, b]) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), ); const favoriteModel = modelEntries[0]; const totalTokens = modelEntries.reduce((sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, 0); // Memoize the factoid so it doesn't change when switching tabs const factoid = useMemo(() => generateFunFactoid(stats, totalTokens), [stats, totalTokens]); // Calculate range days based on selected date range const rangeDays = dateRange === '7d' ? 7 : dateRange === '30d' ? 30 : stats.totalDays; // Compute shot stats data (ant-only, gated by feature flag) let shotStatsData: { avgShots: string; buckets: { label: string; count: number; pct: number }[]; } | null = null; if (feature('SHOT_STATS') && stats.shotDistribution) { const dist = stats.shotDistribution; const total = Object.values(dist).reduce((s, n) => s + n, 0); if (total > 0) { const totalShots = Object.entries(dist).reduce((s, [count, sessions]) => s + parseInt(count, 10) * sessions, 0); const bucket = (min: number, max?: number) => Object.entries(dist) .filter(([k]) => { const n = parseInt(k, 10); return n >= min && (max === undefined || n <= max); }) .reduce((s, [, v]) => s + v, 0); const pct = (n: number) => Math.round((n / total) * 100); const b1 = bucket(1, 1); const b2_5 = bucket(2, 5); const b6_10 = bucket(6, 10); const b11 = bucket(11); shotStatsData = { avgShots: (totalShots / total).toFixed(1), buckets: [ { label: '1-shot', count: b1, pct: pct(b1) }, { label: '2\u20135 shot', count: b2_5, pct: pct(b2_5) }, { label: '6\u201310 shot', count: b6_10, pct: pct(b6_10) }, { label: '11+ shot', count: b11, pct: pct(b11) }, ], }; } } return ( {/* Activity Heatmap - always shows all-time data */} {allTimeStats.dailyActivity.length > 0 && ( {generateHeatmap(allTimeStats.dailyActivity, { terminalWidth })} )} {/* Date range selector */} {/* Section 1: Usage */} {favoriteModel && ( Favorite model:{' '} {renderModelName(favoriteModel[0])} )} Total tokens: {formatNumber(totalTokens)} {/* Section 2: Activity - Row 1: Sessions | Longest session */} Sessions: {formatNumber(stats.totalSessions)} {stats.longestSession && ( Longest session: {formatDuration(stats.longestSession.duration)} )} {/* Row 2: Active days | Longest streak */} Active days: {stats.activeDays} /{rangeDays} Longest streak:{' '} {stats.streaks.longestStreak} {' '} {stats.streaks.longestStreak === 1 ? 'day' : 'days'} {/* Row 3: Most active day | Current streak */} {stats.peakActivityDay && ( Most active day: {formatPeakDay(stats.peakActivityDay)} )} Current streak:{' '} {allTimeStats.streaks.currentStreak} {' '} {allTimeStats.streaks.currentStreak === 1 ? 'day' : 'days'} {/* Speculation time saved (ant-only) */} {process.env.USER_TYPE === 'ant' && stats.totalSpeculationTimeSavedMs > 0 && ( Speculation saved: {formatDuration(stats.totalSpeculationTimeSavedMs)} )} {/* Shot stats (ant-only) */} {shotStatsData && ( <> Shot distribution {shotStatsData.buckets[0]!.label}: {shotStatsData.buckets[0]!.count} ({shotStatsData.buckets[0]!.pct}%) {shotStatsData.buckets[1]!.label}: {shotStatsData.buckets[1]!.count} ({shotStatsData.buckets[1]!.pct}%) {shotStatsData.buckets[2]!.label}: {shotStatsData.buckets[2]!.count} ({shotStatsData.buckets[2]!.pct}%) {shotStatsData.buckets[3]!.label}: {shotStatsData.buckets[3]!.count} ({shotStatsData.buckets[3]!.pct}%) Avg/session: {shotStatsData.avgShots} )} {/* Fun factoid */} {factoid && ( {factoid} )} ); } // Famous books and their approximate token counts (words * ~1.3) // Sorted by tokens ascending for comparison logic const BOOK_COMPARISONS = [ { name: 'The Little Prince', tokens: 22000 }, { name: 'The Old Man and the Sea', tokens: 35000 }, { name: 'A Christmas Carol', tokens: 37000 }, { name: 'Animal Farm', tokens: 39000 }, { name: 'Fahrenheit 451', tokens: 60000 }, { name: 'The Great Gatsby', tokens: 62000 }, { name: 'Slaughterhouse-Five', tokens: 64000 }, { name: 'Brave New World', tokens: 83000 }, { name: 'The Catcher in the Rye', tokens: 95000 }, { name: "Harry Potter and the Philosopher's Stone", tokens: 103000 }, { name: 'The Hobbit', tokens: 123000 }, { name: '1984', tokens: 123000 }, { name: 'To Kill a Mockingbird', tokens: 130000 }, { name: 'Pride and Prejudice', tokens: 156000 }, { name: 'Dune', tokens: 244000 }, { name: 'Moby-Dick', tokens: 268000 }, { name: 'Crime and Punishment', tokens: 274000 }, { name: 'A Game of Thrones', tokens: 381000 }, { name: 'Anna Karenina', tokens: 468000 }, { name: 'Don Quixote', tokens: 520000 }, { name: 'The Lord of the Rings', tokens: 576000 }, { name: 'The Count of Monte Cristo', tokens: 603000 }, { name: 'Les Misérables', tokens: 689000 }, { name: 'War and Peace', tokens: 730000 }, ]; // Time equivalents for session durations const TIME_COMPARISONS = [ { name: 'a TED talk', minutes: 18 }, { name: 'an episode of The Office', minutes: 22 }, { name: 'listening to Abbey Road', minutes: 47 }, { name: 'a yoga class', minutes: 60 }, { name: 'a World Cup soccer match', minutes: 90 }, { name: 'a half marathon (average time)', minutes: 120 }, { name: 'the movie Inception', minutes: 148 }, { name: 'watching Titanic', minutes: 195 }, { name: 'a transatlantic flight', minutes: 420 }, { name: 'a full night of sleep', minutes: 480 }, ]; function generateFunFactoid(stats: ClaudeCodeStats, totalTokens: number): string { const factoids: string[] = []; if (totalTokens > 0) { const matchingBooks = BOOK_COMPARISONS.filter(book => totalTokens >= book.tokens); for (const book of matchingBooks) { const times = totalTokens / book.tokens; if (times >= 2) { factoids.push(`You've used ~${Math.floor(times)}x more tokens than ${book.name}`); } else { factoids.push(`You've used the same number of tokens as ${book.name}`); } } } if (stats.longestSession) { const sessionMinutes = stats.longestSession.duration / (1000 * 60); for (const comparison of TIME_COMPARISONS) { const ratio = sessionMinutes / comparison.minutes; if (ratio >= 2) { factoids.push(`Your longest session is ~${Math.floor(ratio)}x longer than ${comparison.name}`); } } } if (factoids.length === 0) { return ''; } const randomIndex = Math.floor(Math.random() * factoids.length); return factoids[randomIndex]!; } function ModelsTab({ stats, dateRange, isLoading, }: { stats: ClaudeCodeStats; dateRange: StatsDateRange; isLoading: boolean; }): React.ReactNode { const { headerFocused, focusHeader } = useTabHeaderFocus(); const [scrollOffset, setScrollOffset] = useState(0); const { columns: terminalWidth } = useTerminalSize(); const VISIBLE_MODELS = 4; // Show 4 models at a time (2 per column) const modelEntries = Object.entries(stats.modelUsage).sort( ([, a], [, b]) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), ); // Handle scrolling with arrow keys useInput( (_input, key) => { if (key.downArrow && scrollOffset < modelEntries.length - VISIBLE_MODELS) { setScrollOffset(prev => Math.min(prev + 2, modelEntries.length - VISIBLE_MODELS)); } if (key.upArrow) { if (scrollOffset > 0) { setScrollOffset(prev => Math.max(prev - 2, 0)); } else { focusHeader(); } } }, { isActive: !headerFocused }, ); if (modelEntries.length === 0) { return ( No model usage data available ); } const totalTokens = modelEntries.reduce((sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, 0); // Generate token usage chart - use terminal width for responsive sizing const chartOutput = generateTokenChart( stats.dailyModelTokens, modelEntries.map(([model]) => model), terminalWidth, ); // Get visible models and split into two columns const visibleModels = modelEntries.slice(scrollOffset, scrollOffset + VISIBLE_MODELS); const midpoint = Math.ceil(visibleModels.length / 2); const leftModels = visibleModels.slice(0, midpoint); const rightModels = visibleModels.slice(midpoint); const canScrollUp = scrollOffset > 0; const canScrollDown = scrollOffset < modelEntries.length - VISIBLE_MODELS; const showScrollHint = modelEntries.length > VISIBLE_MODELS; return ( {/* Token usage chart */} {chartOutput && ( Tokens per Day {chartOutput.chart} {chartOutput.xAxisLabels} {chartOutput.legend.map((item, i) => ( {i > 0 ? ' · ' : ''} {item.coloredBullet} {item.model} ))} )} {/* Date range selector */} {/* Model breakdown - two columns with fixed width */} {leftModels.map(([model, usage]) => ( ))} {rightModels.map(([model, usage]) => ( ))} {/* Scroll hint */} {showScrollHint && ( {canScrollUp ? figures.arrowUp : ' '} {canScrollDown ? figures.arrowDown : ' '} {scrollOffset + 1}- {Math.min(scrollOffset + VISIBLE_MODELS, modelEntries.length)} of {modelEntries.length} models (↑↓ to scroll) )} ); } type ModelEntryProps = { model: string; usage: { inputTokens: number; outputTokens: number; cacheReadInputTokens: number; }; totalTokens: number; }; function ModelEntry({ model, usage, totalTokens }: ModelEntryProps): React.ReactNode { const modelTokens = usage.inputTokens + usage.outputTokens; const percentage = ((modelTokens / totalTokens) * 100).toFixed(1); return ( {figures.bullet} {renderModelName(model)} ({percentage}%) {' '}In: {formatNumber(usage.inputTokens)} · Out: {formatNumber(usage.outputTokens)} ); } type ChartLegend = { model: string; coloredBullet: string; // Pre-colored bullet using chalk }; type ChartOutput = { chart: string; legend: ChartLegend[]; xAxisLabels: string; }; function generateTokenChart( dailyTokens: DailyModelTokens[], models: string[], terminalWidth: number, ): ChartOutput | null { if (dailyTokens.length < 2 || models.length === 0) { return null; } // Y-axis labels take about 6 characters, plus some padding // Cap at ~52 to align with heatmap width (1 year of data) const yAxisWidth = 7; const availableWidth = terminalWidth - yAxisWidth; const chartWidth = Math.min(52, Math.max(20, availableWidth)); // Distribute data across the available chart width let recentData: DailyModelTokens[]; if (dailyTokens.length >= chartWidth) { // More data than space: take most recent N days recentData = dailyTokens.slice(-chartWidth); } else { // Less data than space: expand by repeating each point const repeatCount = Math.floor(chartWidth / dailyTokens.length); recentData = []; for (const day of dailyTokens) { for (let i = 0; i < repeatCount; i++) { recentData.push(day); } } } // Color palette for different models - use theme colors const theme = getTheme(resolveThemeSetting(getGlobalConfig().theme)); const colors = [themeColorToAnsi(theme.suggestion), themeColorToAnsi(theme.success), themeColorToAnsi(theme.warning)]; // Prepare series data for each model const series: number[][] = []; const legend: ChartLegend[] = []; // Only show top 3 models to keep chart readable const topModels = models.slice(0, 3); for (let i = 0; i < topModels.length; i++) { const model = topModels[i]!; const data = recentData.map(day => day.tokensByModel[model] || 0); // Only include if there's actual data if (data.some(v => v > 0)) { series.push(data); // Use theme colors that match the chart const bulletColors = [theme.suggestion, theme.success, theme.warning]; legend.push({ model: renderModelName(model), coloredBullet: applyColor(figures.bullet, bulletColors[i % bulletColors.length] as Color), }); } } if (series.length === 0) { return null; } const chart = asciichart(series, { height: 8, colors: colors.slice(0, series.length), format: (x: number) => { let label: string; if (x >= 1_000_000) { label = (x / 1_000_000).toFixed(1) + 'M'; } else if (x >= 1_000) { label = (x / 1_000).toFixed(0) + 'k'; } else { label = x.toFixed(0); } return label.padStart(6); }, }); // Generate x-axis labels with dates const xAxisLabels = generateXAxisLabels(recentData, recentData.length, yAxisWidth); return { chart, legend, xAxisLabels }; } function generateXAxisLabels(data: DailyModelTokens[], _chartWidth: number, yAxisOffset: number): string { if (data.length === 0) return ''; // Show 3-4 date labels evenly spaced, but leave room for last label const numLabels = Math.min(4, Math.max(2, Math.floor(data.length / 8))); // Don't use the very last position - leave room for the label text const usableLength = data.length - 6; // Reserve ~6 chars for last label (e.g., "Dec 7") const step = Math.floor(usableLength / (numLabels - 1)) || 1; const labelPositions: { pos: number; label: string }[] = []; for (let i = 0; i < numLabels; i++) { const idx = Math.min(i * step, data.length - 1); const date = new Date(data[idx]!.date); const label = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', }); labelPositions.push({ pos: idx, label }); } // Build the label string with proper spacing let result = ' '.repeat(yAxisOffset); let currentPos = 0; for (const { pos, label } of labelPositions) { const spaces = Math.max(1, pos - currentPos); result += ' '.repeat(spaces) + label; currentPos = pos + label.length; } return result; } // Screenshot functionality async function handleScreenshot( stats: ClaudeCodeStats, activeTab: 'Overview' | 'Models', setStatus: (status: string | null) => void, ): Promise { setStatus('copying…'); const ansiText = renderStatsToAnsi(stats, activeTab); const result = await copyAnsiToClipboard(ansiText); setStatus(result.success ? 'copied!' : 'copy failed'); // Clear status after 2 seconds setTimeout(setStatus, 2000, null); } function renderStatsToAnsi(stats: ClaudeCodeStats, activeTab: 'Overview' | 'Models'): string { const lines: string[] = []; if (activeTab === 'Overview') { lines.push(...renderOverviewToAnsi(stats)); } else { lines.push(...renderModelsToAnsi(stats)); } // Trim trailing empty lines while (lines.length > 0 && stripAnsi(lines[lines.length - 1]!).trim() === '') { lines.pop(); } // Add "/stats" right-aligned on the last line if (lines.length > 0) { const lastLine = lines[lines.length - 1]!; const lastLineLen = getStringWidth(lastLine); // Use known content widths based on layout: // Overview: two-column stats = COL2_START(40) + COL2_LABEL_WIDTH(18) + max_value(~12) = 70 // Models: chart width = 80 const contentWidth = activeTab === 'Overview' ? 70 : 80; const statsLabel = '/stats'; const padding = Math.max(2, contentWidth - lastLineLen - statsLabel.length); lines[lines.length - 1] = lastLine + ' '.repeat(padding) + chalk.gray(statsLabel); } return lines.join('\n'); } function renderOverviewToAnsi(stats: ClaudeCodeStats): string[] { const lines: string[] = []; const theme = getTheme(resolveThemeSetting(getGlobalConfig().theme)); const h = (text: string) => applyColor(text, theme.claude as Color); // Two-column helper with fixed spacing // Column 1: label (18 chars) + value + padding to reach col 2 // Column 2 starts at character position 40 const COL1_LABEL_WIDTH = 18; const COL2_START = 40; const COL2_LABEL_WIDTH = 18; const row = (l1: string, v1: string, l2: string, v2: string): string => { // Build column 1: label + value const label1 = (l1 + ':').padEnd(COL1_LABEL_WIDTH); const col1PlainLen = label1.length + v1.length; // Calculate spaces needed between col1 value and col2 label const spaceBetween = Math.max(2, COL2_START - col1PlainLen); // Build column 2: label + value const label2 = (l2 + ':').padEnd(COL2_LABEL_WIDTH); // Assemble with colors applied to values only return label1 + h(v1) + ' '.repeat(spaceBetween) + label2 + h(v2); }; // Heatmap - use fixed width for screenshot (56 = 52 weeks + 4 for day labels) if (stats.dailyActivity.length > 0) { lines.push(generateHeatmap(stats.dailyActivity, { terminalWidth: 56 })); lines.push(''); } // Calculate values const modelEntries = Object.entries(stats.modelUsage).sort( ([, a], [, b]) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), ); const favoriteModel = modelEntries[0]; const totalTokens = modelEntries.reduce((sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, 0); // Row 1: Favorite model | Total tokens if (favoriteModel) { lines.push(row('Favorite model', renderModelName(favoriteModel[0]), 'Total tokens', formatNumber(totalTokens))); } lines.push(''); // Row 2: Sessions | Longest session lines.push( row( 'Sessions', formatNumber(stats.totalSessions), 'Longest session', stats.longestSession ? formatDuration(stats.longestSession.duration) : 'N/A', ), ); // Row 3: Current streak | Longest streak const currentStreakVal = `${stats.streaks.currentStreak} ${stats.streaks.currentStreak === 1 ? 'day' : 'days'}`; const longestStreakVal = `${stats.streaks.longestStreak} ${stats.streaks.longestStreak === 1 ? 'day' : 'days'}`; lines.push(row('Current streak', currentStreakVal, 'Longest streak', longestStreakVal)); // Row 4: Active days | Peak hour const activeDaysVal = `${stats.activeDays}/${stats.totalDays}`; const peakHourVal = stats.peakActivityHour !== null ? `${stats.peakActivityHour}:00-${stats.peakActivityHour + 1}:00` : 'N/A'; lines.push(row('Active days', activeDaysVal, 'Peak hour', peakHourVal)); // Speculation time saved (ant-only) if (process.env.USER_TYPE === 'ant' && stats.totalSpeculationTimeSavedMs > 0) { const label = 'Speculation saved:'.padEnd(COL1_LABEL_WIDTH); lines.push(label + h(formatDuration(stats.totalSpeculationTimeSavedMs))); } // Shot stats (ant-only) if (feature('SHOT_STATS') && stats.shotDistribution) { const dist = stats.shotDistribution; const totalWithShots = Object.values(dist).reduce((s, n) => s + n, 0); if (totalWithShots > 0) { const totalShots = Object.entries(dist).reduce((s, [count, sessions]) => s + parseInt(count, 10) * sessions, 0); const avgShots = (totalShots / totalWithShots).toFixed(1); const bucket = (min: number, max?: number) => Object.entries(dist) .filter(([k]) => { const n = parseInt(k, 10); return n >= min && (max === undefined || n <= max); }) .reduce((s, [, v]) => s + v, 0); const pct = (n: number) => Math.round((n / totalWithShots) * 100); const fmtBucket = (count: number, p: number) => `${count} (${p}%)`; const b1 = bucket(1, 1); const b2_5 = bucket(2, 5); const b6_10 = bucket(6, 10); const b11 = bucket(11); lines.push(''); lines.push('Shot distribution'); lines.push(row('1-shot', fmtBucket(b1, pct(b1)), '2\u20135 shot', fmtBucket(b2_5, pct(b2_5)))); lines.push(row('6\u201310 shot', fmtBucket(b6_10, pct(b6_10)), '11+ shot', fmtBucket(b11, pct(b11)))); lines.push(`${'Avg/session:'.padEnd(COL1_LABEL_WIDTH)}${h(avgShots)}`); } } lines.push(''); // Fun factoid const factoid = generateFunFactoid(stats, totalTokens); lines.push(h(factoid)); lines.push(chalk.gray(`Stats from the last ${stats.totalDays} days`)); return lines; } function renderModelsToAnsi(stats: ClaudeCodeStats): string[] { const lines: string[] = []; const modelEntries = Object.entries(stats.modelUsage).sort( ([, a], [, b]) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), ); if (modelEntries.length === 0) { lines.push(chalk.gray('No model usage data available')); return lines; } const favoriteModel = modelEntries[0]; const totalTokens = modelEntries.reduce((sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, 0); // Generate chart if we have data - use fixed width for screenshot const chartOutput = generateTokenChart( stats.dailyModelTokens, modelEntries.map(([model]) => model), 80, // Fixed width for screenshot ); if (chartOutput) { lines.push(chalk.bold('Tokens per Day')); lines.push(chartOutput.chart); lines.push(chalk.gray(chartOutput.xAxisLabels)); // Legend - use pre-colored bullets from chart output const legendLine = chartOutput.legend.map(item => `${item.coloredBullet} ${item.model}`).join(' · '); lines.push(legendLine); lines.push(''); } // Summary lines.push( `${figures.star} Favorite: ${chalk.magenta.bold(renderModelName(favoriteModel?.[0] || ''))} · ${figures.circle} Total: ${chalk.magenta(formatNumber(totalTokens))} tokens`, ); lines.push(''); // Model breakdown - only show top 3 for screenshot const topModels = modelEntries.slice(0, 3); for (const [model, usage] of topModels) { const modelTokens = usage.inputTokens + usage.outputTokens; const percentage = ((modelTokens / totalTokens) * 100).toFixed(1); lines.push(`${figures.bullet} ${chalk.bold(renderModelName(model))} ${chalk.gray(`(${percentage}%)`)}`); lines.push(chalk.dim(` In: ${formatNumber(usage.inputTokens)} · Out: ${formatNumber(usage.outputTokens)}`)); } return lines; }