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, Tabs } from '@anthropic/ink' import { Tab, useTabHeaderFocus } from './design-system/Tabs.js' 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< Partial> >({}) 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 }