claude-code-wrapped 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.python-version +1 -0
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/bin/cli.js +62 -0
- package/claude_code_wrapped/__init__.py +3 -0
- package/claude_code_wrapped/main.py +102 -0
- package/claude_code_wrapped/pricing.py +179 -0
- package/claude_code_wrapped/reader.py +267 -0
- package/claude_code_wrapped/stats.py +339 -0
- package/claude_code_wrapped/ui.py +604 -0
- package/package.json +33 -0
- package/pyproject.toml +35 -0
- package/uv.lock +57 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""Rich-based terminal UI for Claude Code Wrapped."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from rich.align import Align
|
|
8
|
+
from rich.console import Console, Group
|
|
9
|
+
from rich.live import Live
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
|
12
|
+
from rich.style import Style
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from .stats import WrappedStats, format_tokens
|
|
17
|
+
|
|
18
|
+
# Minimal color palette
|
|
19
|
+
COLORS = {
|
|
20
|
+
"orange": "#E67E22",
|
|
21
|
+
"purple": "#9B59B6",
|
|
22
|
+
"blue": "#3498DB",
|
|
23
|
+
"green": "#27AE60",
|
|
24
|
+
"white": "#ECF0F1",
|
|
25
|
+
"gray": "#7F8C8D",
|
|
26
|
+
"dark": "#2C3E50",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# GitHub-style contribution colors (light to dark green)
|
|
30
|
+
CONTRIB_COLORS = ["#161B22", "#0E4429", "#006D32", "#26A641", "#39D353"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def wait_for_keypress():
|
|
34
|
+
"""Wait for user to press Enter or Space."""
|
|
35
|
+
try:
|
|
36
|
+
import termios
|
|
37
|
+
import tty
|
|
38
|
+
fd = sys.stdin.fileno()
|
|
39
|
+
old_settings = termios.tcgetattr(fd)
|
|
40
|
+
try:
|
|
41
|
+
tty.setraw(fd)
|
|
42
|
+
ch = sys.stdin.read(1)
|
|
43
|
+
# Also handle escape sequences for special keys
|
|
44
|
+
if ch == '\x1b':
|
|
45
|
+
sys.stdin.read(2) # consume rest of escape sequence
|
|
46
|
+
return ch
|
|
47
|
+
finally:
|
|
48
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
49
|
+
except (ImportError, AttributeError, OSError):
|
|
50
|
+
# Fallback for non-Unix systems (Windows) or piped input
|
|
51
|
+
input()
|
|
52
|
+
return '\n'
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def create_dramatic_stat(value: str, label: str, subtitle: str = "", color: str = COLORS["orange"]) -> Text:
|
|
56
|
+
"""Create a dramatic full-screen stat reveal."""
|
|
57
|
+
text = Text()
|
|
58
|
+
text.append("\n\n\n\n\n")
|
|
59
|
+
text.append(f"{value}\n", style=Style(color=color, bold=True))
|
|
60
|
+
text.append(f"{label}\n\n", style=Style(color=COLORS["white"], bold=True))
|
|
61
|
+
if subtitle:
|
|
62
|
+
text.append(subtitle, style=Style(color=COLORS["gray"]))
|
|
63
|
+
text.append("\n\n\n\n")
|
|
64
|
+
text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"]))
|
|
65
|
+
return text
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def create_title_slide(year: int) -> Text:
|
|
69
|
+
"""Create the opening title."""
|
|
70
|
+
title = Text()
|
|
71
|
+
title.append("\n\n\n")
|
|
72
|
+
title.append(" ░█████╗░██╗░░░░░░█████╗░██╗░░░██╗██████╗░███████╗\n", style=COLORS["orange"])
|
|
73
|
+
title.append(" ██╔══██╗██║░░░░░██╔══██╗██║░░░██║██╔══██╗██╔════╝\n", style=COLORS["orange"])
|
|
74
|
+
title.append(" ██║░░╚═╝██║░░░░░███████║██║░░░██║██║░░██║█████╗░░\n", style=COLORS["orange"])
|
|
75
|
+
title.append(" ██║░░██╗██║░░░░░██╔══██║██║░░░██║██║░░██║██╔══╝░░\n", style=COLORS["orange"])
|
|
76
|
+
title.append(" ╚█████╔╝███████╗██║░░██║╚██████╔╝██████╔╝███████╗\n", style=COLORS["orange"])
|
|
77
|
+
title.append(" ░╚════╝░╚══════╝╚═╝░░╚═╝░╚═════╝░╚═════╝░╚══════╝\n", style=COLORS["orange"])
|
|
78
|
+
title.append("\n")
|
|
79
|
+
title.append(" C O D E W R A P P E D\n", style=Style(color=COLORS["white"], bold=True))
|
|
80
|
+
title.append(f" {year}\n\n", style=Style(color=COLORS["purple"], bold=True))
|
|
81
|
+
title.append(" by ", style=Style(color=COLORS["gray"]))
|
|
82
|
+
title.append("Banker.so", style=Style(color=COLORS["blue"], bold=True, link="https://banker.so"))
|
|
83
|
+
title.append("\n\n\n")
|
|
84
|
+
title.append(" press [ENTER] to begin", style=Style(color=COLORS["dark"]))
|
|
85
|
+
title.append("\n\n")
|
|
86
|
+
return title
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def create_big_stat(value: str, label: str, color: str = COLORS["orange"]) -> Text:
|
|
90
|
+
"""Create a big statistic display."""
|
|
91
|
+
text = Text()
|
|
92
|
+
text.append(f"{value}\n", style=Style(color=color, bold=True))
|
|
93
|
+
text.append(label, style=Style(color=COLORS["gray"]))
|
|
94
|
+
return text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_contribution_graph(daily_stats: dict, year: int) -> Panel:
|
|
98
|
+
"""Create a GitHub-style contribution graph."""
|
|
99
|
+
if not daily_stats:
|
|
100
|
+
return Panel("No activity data", title="Activity", border_style=COLORS["gray"])
|
|
101
|
+
|
|
102
|
+
dates = sorted(daily_stats.keys())
|
|
103
|
+
start_date = datetime.strptime(dates[0], "%Y-%m-%d")
|
|
104
|
+
end_date = datetime.strptime(dates[-1], "%Y-%m-%d")
|
|
105
|
+
|
|
106
|
+
max_count = max(s.message_count for s in daily_stats.values()) if daily_stats else 1
|
|
107
|
+
|
|
108
|
+
weeks = []
|
|
109
|
+
current = start_date - timedelta(days=start_date.weekday())
|
|
110
|
+
|
|
111
|
+
while current <= end_date + timedelta(days=7):
|
|
112
|
+
week = []
|
|
113
|
+
for day in range(7):
|
|
114
|
+
date = current + timedelta(days=day)
|
|
115
|
+
date_str = date.strftime("%Y-%m-%d")
|
|
116
|
+
if date_str in daily_stats:
|
|
117
|
+
count = daily_stats[date_str].message_count
|
|
118
|
+
level = min(4, 1 + int((count / max_count) * 3)) if count > 0 else 0
|
|
119
|
+
else:
|
|
120
|
+
level = 0
|
|
121
|
+
week.append(level)
|
|
122
|
+
weeks.append(week)
|
|
123
|
+
current += timedelta(days=7)
|
|
124
|
+
|
|
125
|
+
graph = Text()
|
|
126
|
+
days_labels = ["Mon", " ", "Wed", " ", "Fri", " ", " "]
|
|
127
|
+
|
|
128
|
+
for row in range(7):
|
|
129
|
+
graph.append(f"{days_labels[row]} ", style=Style(color=COLORS["gray"]))
|
|
130
|
+
for week in weeks:
|
|
131
|
+
color = CONTRIB_COLORS[week[row]]
|
|
132
|
+
graph.append("■ ", style=Style(color=color))
|
|
133
|
+
graph.append("\n")
|
|
134
|
+
|
|
135
|
+
legend = Text()
|
|
136
|
+
legend.append("\n Less ", style=Style(color=COLORS["gray"]))
|
|
137
|
+
for color in CONTRIB_COLORS:
|
|
138
|
+
legend.append("■ ", style=Style(color=color))
|
|
139
|
+
legend.append("More", style=Style(color=COLORS["gray"]))
|
|
140
|
+
|
|
141
|
+
content = Group(graph, Align.center(legend))
|
|
142
|
+
|
|
143
|
+
return Panel(
|
|
144
|
+
Align.center(content),
|
|
145
|
+
title=f"Activity · {len([d for d in daily_stats.values() if d.message_count > 0])} active days",
|
|
146
|
+
border_style=Style(color=COLORS["green"]),
|
|
147
|
+
padding=(0, 2),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def create_hour_chart(distribution: list[int]) -> Panel:
|
|
152
|
+
"""Create a clean hourly distribution chart."""
|
|
153
|
+
max_val = max(distribution) if any(distribution) else 1
|
|
154
|
+
chars = "▁▂▃▄▅▆▇█"
|
|
155
|
+
|
|
156
|
+
content = Text()
|
|
157
|
+
for i, val in enumerate(distribution):
|
|
158
|
+
idx = int((val / max_val) * (len(chars) - 1)) if max_val > 0 else 0
|
|
159
|
+
if 6 <= i < 12:
|
|
160
|
+
color = COLORS["orange"]
|
|
161
|
+
elif 12 <= i < 18:
|
|
162
|
+
color = COLORS["blue"]
|
|
163
|
+
elif 18 <= i < 22:
|
|
164
|
+
color = COLORS["purple"]
|
|
165
|
+
else:
|
|
166
|
+
color = COLORS["gray"]
|
|
167
|
+
content.append(chars[idx], style=Style(color=color))
|
|
168
|
+
|
|
169
|
+
content.append("\n")
|
|
170
|
+
content.append("0 6 12 18 24", style=Style(color=COLORS["gray"]))
|
|
171
|
+
|
|
172
|
+
return Panel(
|
|
173
|
+
Align.center(content),
|
|
174
|
+
title="Hours",
|
|
175
|
+
border_style=Style(color=COLORS["purple"]),
|
|
176
|
+
padding=(0, 1),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def create_weekday_chart(distribution: list[int]) -> Panel:
|
|
181
|
+
"""Create a clean weekday distribution chart."""
|
|
182
|
+
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
183
|
+
max_val = max(distribution) if any(distribution) else 1
|
|
184
|
+
|
|
185
|
+
content = Text()
|
|
186
|
+
for i, (day, count) in enumerate(zip(days, distribution)):
|
|
187
|
+
bar_len = int((count / max_val) * 12) if max_val > 0 else 0
|
|
188
|
+
bar = "█" * bar_len + "░" * (12 - bar_len)
|
|
189
|
+
content.append(f"{day} ", style=Style(color=COLORS["gray"]))
|
|
190
|
+
content.append(bar, style=Style(color=COLORS["blue"]))
|
|
191
|
+
content.append(f" {count:,}\n", style=Style(color=COLORS["gray"]))
|
|
192
|
+
|
|
193
|
+
return Panel(
|
|
194
|
+
content,
|
|
195
|
+
title="Days",
|
|
196
|
+
border_style=Style(color=COLORS["blue"]),
|
|
197
|
+
padding=(0, 1),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def create_top_list(items: list[tuple[str, int]], title: str, color: str) -> Panel:
|
|
202
|
+
"""Create a clean top items list."""
|
|
203
|
+
content = Text()
|
|
204
|
+
max_val = max(v for _, v in items) if items else 1
|
|
205
|
+
|
|
206
|
+
for i, (name, count) in enumerate(items[:5], 1):
|
|
207
|
+
content.append(f"{i}. ", style=Style(color=COLORS["gray"]))
|
|
208
|
+
content.append(f"{name[:12]:<12} ", style=Style(color=COLORS["white"]))
|
|
209
|
+
bar_len = int((count / max_val) * 8)
|
|
210
|
+
content.append("▓" * bar_len, style=Style(color=color))
|
|
211
|
+
content.append("░" * (8 - bar_len), style=Style(color=COLORS["dark"]))
|
|
212
|
+
content.append(f" {count:,}\n", style=Style(color=COLORS["gray"]))
|
|
213
|
+
|
|
214
|
+
return Panel(
|
|
215
|
+
content,
|
|
216
|
+
title=title,
|
|
217
|
+
border_style=Style(color=color),
|
|
218
|
+
padding=(0, 1),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def create_personality_card(stats: WrappedStats) -> Panel:
|
|
223
|
+
"""Create the personality card."""
|
|
224
|
+
personality = determine_personality(stats)
|
|
225
|
+
|
|
226
|
+
content = Text()
|
|
227
|
+
content.append(f"\n {personality['emoji']} ", style=Style(bold=True))
|
|
228
|
+
content.append(f"{personality['title']}\n\n", style=Style(color=COLORS["purple"], bold=True))
|
|
229
|
+
content.append(f" {personality['description']}\n", style=Style(color=COLORS["gray"]))
|
|
230
|
+
|
|
231
|
+
return Panel(
|
|
232
|
+
content,
|
|
233
|
+
title="Your Type",
|
|
234
|
+
border_style=Style(color=COLORS["purple"]),
|
|
235
|
+
padding=(0, 1),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def determine_personality(stats: WrappedStats) -> dict:
|
|
240
|
+
"""Determine user's coding personality based on stats."""
|
|
241
|
+
night_hours = sum(stats.hourly_distribution[22:]) + sum(stats.hourly_distribution[:6])
|
|
242
|
+
day_hours = sum(stats.hourly_distribution[6:22])
|
|
243
|
+
top_tool = stats.top_tools[0][0] if stats.top_tools else None
|
|
244
|
+
weekend_msgs = stats.weekday_distribution[5] + stats.weekday_distribution[6]
|
|
245
|
+
weekday_msgs = sum(stats.weekday_distribution[:5])
|
|
246
|
+
|
|
247
|
+
if night_hours > day_hours * 0.4:
|
|
248
|
+
return {"emoji": "🦉", "title": "Night Owl", "description": "The quiet hours are your sanctuary."}
|
|
249
|
+
elif stats.streak_longest >= 14:
|
|
250
|
+
return {"emoji": "🔥", "title": "Streak Master", "description": f"{stats.streak_longest} days. Unstoppable."}
|
|
251
|
+
elif top_tool == "Edit":
|
|
252
|
+
return {"emoji": "🎨", "title": "The Refactorer", "description": "You see beauty in clean code."}
|
|
253
|
+
elif top_tool == "Bash":
|
|
254
|
+
return {"emoji": "⚡", "title": "Terminal Warrior", "description": "Command line is your domain."}
|
|
255
|
+
elif stats.total_projects >= 5:
|
|
256
|
+
return {"emoji": "🚀", "title": "Empire Builder", "description": f"{stats.total_projects} projects. Legend."}
|
|
257
|
+
elif weekend_msgs > weekday_msgs * 0.5:
|
|
258
|
+
return {"emoji": "🌙", "title": "Weekend Warrior", "description": "Passion fuels your weekends."}
|
|
259
|
+
elif stats.models_used.get("Opus", 0) > stats.models_used.get("Sonnet", 0):
|
|
260
|
+
return {"emoji": "🎯", "title": "Perfectionist", "description": "Only the best will do."}
|
|
261
|
+
else:
|
|
262
|
+
return {"emoji": "💻", "title": "Dedicated Dev", "description": "Steady and reliable."}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def get_fun_facts(stats: WrappedStats) -> list[tuple[str, str]]:
|
|
266
|
+
"""Generate fun facts / bloopers based on stats."""
|
|
267
|
+
facts = []
|
|
268
|
+
|
|
269
|
+
# Late night coding
|
|
270
|
+
late_night = sum(stats.hourly_distribution[0:5])
|
|
271
|
+
if late_night > 100:
|
|
272
|
+
facts.append(("🌙", f"You coded after midnight {late_night:,} times. Sleep is overrated."))
|
|
273
|
+
|
|
274
|
+
# Most active day insight
|
|
275
|
+
if stats.most_active_day:
|
|
276
|
+
day_name = stats.most_active_day[0].strftime("%A")
|
|
277
|
+
facts.append(("📅", f"Your biggest day was a {day_name}. {stats.most_active_day[1]:,} messages. Epic."))
|
|
278
|
+
|
|
279
|
+
# Tool obsession
|
|
280
|
+
if stats.top_tools:
|
|
281
|
+
top_tool, count = stats.top_tools[0]
|
|
282
|
+
facts.append(("🔧", f"You used {top_tool} {count:,} times. It's basically muscle memory now."))
|
|
283
|
+
|
|
284
|
+
# If they use Opus a lot
|
|
285
|
+
opus_count = stats.models_used.get("Opus", 0)
|
|
286
|
+
if opus_count > 1000:
|
|
287
|
+
facts.append(("🎭", f"You summoned Opus {opus_count:,} times. Only the best for you."))
|
|
288
|
+
|
|
289
|
+
# Streak fact
|
|
290
|
+
if stats.streak_longest >= 7:
|
|
291
|
+
facts.append(("🔥", f"Your {stats.streak_longest}-day streak was legendary. Consistency wins."))
|
|
292
|
+
|
|
293
|
+
# Multi-project
|
|
294
|
+
if stats.total_projects >= 3:
|
|
295
|
+
facts.append(("🏗️", f"You juggled {stats.total_projects} projects. Multitasking champion."))
|
|
296
|
+
|
|
297
|
+
# Token usage perspective
|
|
298
|
+
if stats.total_tokens > 1_000_000_000:
|
|
299
|
+
books = stats.total_tokens // 100_000 # ~100k tokens per book
|
|
300
|
+
facts.append(("📚", f"You processed enough tokens for ~{books:,} books. Wow."))
|
|
301
|
+
|
|
302
|
+
# Weekend warrior
|
|
303
|
+
weekend = stats.weekday_distribution[5] + stats.weekday_distribution[6]
|
|
304
|
+
if weekend > 1000:
|
|
305
|
+
facts.append(("🏖️", f"Even weekends weren't safe. {weekend:,} weekend messages."))
|
|
306
|
+
|
|
307
|
+
return facts[:5] # Limit to 5 facts
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def create_fun_facts_slide(facts: list[tuple[str, str]]) -> Text:
|
|
311
|
+
"""Create a fun facts slide."""
|
|
312
|
+
text = Text()
|
|
313
|
+
text.append("\n\n")
|
|
314
|
+
text.append(" B L O O P E R S & F U N F A C T S\n\n", style=Style(color=COLORS["purple"], bold=True))
|
|
315
|
+
|
|
316
|
+
for emoji, fact in facts:
|
|
317
|
+
text.append(f" {emoji} ", style=Style(bold=True))
|
|
318
|
+
text.append(f"{fact}\n\n", style=Style(color=COLORS["white"]))
|
|
319
|
+
|
|
320
|
+
text.append("\n")
|
|
321
|
+
text.append(" press [ENTER] for credits", style=Style(color=COLORS["dark"]))
|
|
322
|
+
text.append("\n")
|
|
323
|
+
return text
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def simplify_model_name(model: str) -> str:
|
|
327
|
+
"""Simplify a full model ID to a display name."""
|
|
328
|
+
model_lower = model.lower()
|
|
329
|
+
if 'opus-4-5' in model_lower or 'opus-4.5' in model_lower:
|
|
330
|
+
return 'Opus 4.5'
|
|
331
|
+
elif 'opus-4-1' in model_lower or 'opus-4.1' in model_lower:
|
|
332
|
+
return 'Opus 4.1'
|
|
333
|
+
elif 'opus' in model_lower:
|
|
334
|
+
return 'Opus'
|
|
335
|
+
elif 'sonnet-4-5' in model_lower or 'sonnet-4.5' in model_lower:
|
|
336
|
+
return 'Sonnet 4.5'
|
|
337
|
+
elif 'sonnet' in model_lower:
|
|
338
|
+
return 'Sonnet'
|
|
339
|
+
elif 'haiku-4-5' in model_lower or 'haiku-4.5' in model_lower:
|
|
340
|
+
return 'Haiku 4.5'
|
|
341
|
+
elif 'haiku' in model_lower:
|
|
342
|
+
return 'Haiku'
|
|
343
|
+
return model
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def create_credits_roll(stats: WrappedStats) -> list[Text]:
|
|
347
|
+
"""Create end credits content."""
|
|
348
|
+
from .pricing import format_cost
|
|
349
|
+
|
|
350
|
+
frames = []
|
|
351
|
+
|
|
352
|
+
# Aggregate costs by simplified model name for display
|
|
353
|
+
display_costs: dict[str, float] = {}
|
|
354
|
+
for model, cost in stats.cost_by_model.items():
|
|
355
|
+
display_name = simplify_model_name(model)
|
|
356
|
+
display_costs[display_name] = display_costs.get(display_name, 0) + cost
|
|
357
|
+
|
|
358
|
+
# Frame 1: The Numbers (cost + tokens)
|
|
359
|
+
numbers = Text()
|
|
360
|
+
numbers.append("\n\n\n")
|
|
361
|
+
numbers.append(" T H E N U M B E R S\n\n", style=Style(color=COLORS["green"], bold=True))
|
|
362
|
+
if stats.estimated_cost is not None:
|
|
363
|
+
numbers.append(f" Estimated Cost ", style=Style(color=COLORS["white"], bold=True))
|
|
364
|
+
numbers.append(f"{format_cost(stats.estimated_cost)}\n", style=Style(color=COLORS["green"], bold=True))
|
|
365
|
+
for model, cost in sorted(display_costs.items(), key=lambda x: -x[1]):
|
|
366
|
+
numbers.append(f" {model}: {format_cost(cost)}\n", style=Style(color=COLORS["gray"]))
|
|
367
|
+
numbers.append(f"\n Tokens ", style=Style(color=COLORS["white"], bold=True))
|
|
368
|
+
numbers.append(f"{format_tokens(stats.total_tokens)}\n", style=Style(color=COLORS["orange"], bold=True))
|
|
369
|
+
numbers.append(f" Input: {format_tokens(stats.total_input_tokens)}\n", style=Style(color=COLORS["gray"]))
|
|
370
|
+
numbers.append(f" Output: {format_tokens(stats.total_output_tokens)}\n", style=Style(color=COLORS["gray"]))
|
|
371
|
+
numbers.append("\n\n")
|
|
372
|
+
numbers.append(" [ENTER]", style=Style(color=COLORS["dark"]))
|
|
373
|
+
frames.append(numbers)
|
|
374
|
+
|
|
375
|
+
# Frame 2: Timeline
|
|
376
|
+
timeline = Text()
|
|
377
|
+
timeline.append("\n\n\n")
|
|
378
|
+
timeline.append(" T I M E L I N E\n\n", style=Style(color=COLORS["orange"], bold=True))
|
|
379
|
+
if stats.first_message_date:
|
|
380
|
+
timeline.append(" First message ", style=Style(color=COLORS["white"], bold=True))
|
|
381
|
+
timeline.append(f"{stats.first_message_date.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"]))
|
|
382
|
+
if stats.last_message_date:
|
|
383
|
+
timeline.append(" Last message ", style=Style(color=COLORS["white"], bold=True))
|
|
384
|
+
timeline.append(f"{stats.last_message_date.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"]))
|
|
385
|
+
timeline.append(f"\n Active days ", style=Style(color=COLORS["white"], bold=True))
|
|
386
|
+
timeline.append(f"{stats.active_days}\n", style=Style(color=COLORS["orange"], bold=True))
|
|
387
|
+
if stats.most_active_hour is not None:
|
|
388
|
+
hour_label = "AM" if stats.most_active_hour < 12 else "PM"
|
|
389
|
+
hour_12 = stats.most_active_hour % 12 or 12
|
|
390
|
+
timeline.append(f" Peak hour ", style=Style(color=COLORS["white"], bold=True))
|
|
391
|
+
timeline.append(f"{hour_12}:00 {hour_label}\n", style=Style(color=COLORS["purple"], bold=True))
|
|
392
|
+
timeline.append("\n\n")
|
|
393
|
+
timeline.append(" [ENTER]", style=Style(color=COLORS["dark"]))
|
|
394
|
+
frames.append(timeline)
|
|
395
|
+
|
|
396
|
+
# Frame 3: Cast (models)
|
|
397
|
+
cast = Text()
|
|
398
|
+
cast.append("\n\n\n")
|
|
399
|
+
cast.append(" S T A R R I N G\n\n", style=Style(color=COLORS["purple"], bold=True))
|
|
400
|
+
for model, count in stats.models_used.most_common(3):
|
|
401
|
+
cast.append(f" Claude {model}", style=Style(color=COLORS["white"], bold=True))
|
|
402
|
+
cast.append(f" ({count:,} messages)\n", style=Style(color=COLORS["gray"]))
|
|
403
|
+
cast.append("\n\n\n")
|
|
404
|
+
cast.append(" [ENTER]", style=Style(color=COLORS["dark"]))
|
|
405
|
+
frames.append(cast)
|
|
406
|
+
|
|
407
|
+
# Frame 4: Projects
|
|
408
|
+
if stats.top_projects:
|
|
409
|
+
projects = Text()
|
|
410
|
+
projects.append("\n\n\n")
|
|
411
|
+
projects.append(" P R O J E C T S\n\n", style=Style(color=COLORS["blue"], bold=True))
|
|
412
|
+
for proj, count in stats.top_projects[:5]:
|
|
413
|
+
projects.append(f" {proj}", style=Style(color=COLORS["white"], bold=True))
|
|
414
|
+
projects.append(f" ({count:,} messages)\n", style=Style(color=COLORS["gray"]))
|
|
415
|
+
projects.append("\n\n\n")
|
|
416
|
+
projects.append(" [ENTER]", style=Style(color=COLORS["dark"]))
|
|
417
|
+
frames.append(projects)
|
|
418
|
+
|
|
419
|
+
# Frame 5: Final card
|
|
420
|
+
final = Text()
|
|
421
|
+
final.append("\n\n\n\n")
|
|
422
|
+
final.append(" See you in ", style=Style(color=COLORS["gray"]))
|
|
423
|
+
final.append(f"{stats.year + 1}", style=Style(color=COLORS["orange"], bold=True))
|
|
424
|
+
final.append("\n\n\n\n", style=Style(color=COLORS["gray"]))
|
|
425
|
+
final.append(" ", style=Style())
|
|
426
|
+
final.append("Banker.so", style=Style(color=COLORS["blue"], bold=True, link="https://banker.so"))
|
|
427
|
+
final.append(" presents\n\n", style=Style(color=COLORS["gray"]))
|
|
428
|
+
final.append(" [ENTER] to exit", style=Style(color=COLORS["dark"]))
|
|
429
|
+
frames.append(final)
|
|
430
|
+
|
|
431
|
+
return frames
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def render_wrapped(stats: WrappedStats, console: Console | None = None, animate: bool = True):
|
|
435
|
+
"""Render the complete wrapped experience."""
|
|
436
|
+
if console is None:
|
|
437
|
+
console = Console()
|
|
438
|
+
|
|
439
|
+
# === CINEMATIC MODE ===
|
|
440
|
+
if animate:
|
|
441
|
+
# Loading
|
|
442
|
+
console.clear()
|
|
443
|
+
with Progress(
|
|
444
|
+
SpinnerColumn(style=COLORS["orange"]),
|
|
445
|
+
TextColumn("[bold]Unwrapping your year...[/bold]"),
|
|
446
|
+
BarColumn(complete_style=COLORS["orange"], finished_style=COLORS["green"]),
|
|
447
|
+
console=console,
|
|
448
|
+
transient=True,
|
|
449
|
+
) as progress:
|
|
450
|
+
task = progress.add_task("", total=100)
|
|
451
|
+
for _ in range(100):
|
|
452
|
+
time.sleep(0.012)
|
|
453
|
+
progress.update(task, advance=1)
|
|
454
|
+
|
|
455
|
+
console.clear()
|
|
456
|
+
|
|
457
|
+
# Title slide - wait for keypress
|
|
458
|
+
console.print(Align.center(create_title_slide(stats.year)))
|
|
459
|
+
wait_for_keypress()
|
|
460
|
+
console.clear()
|
|
461
|
+
|
|
462
|
+
# Dramatic stat reveals
|
|
463
|
+
slides = [
|
|
464
|
+
(f"{stats.total_messages:,}", "MESSAGES", "conversations with Claude", COLORS["orange"]),
|
|
465
|
+
(str(stats.total_sessions), "SESSIONS", "coding adventures", COLORS["purple"]),
|
|
466
|
+
(format_tokens(stats.total_tokens), "TOKENS", "processed through the AI", COLORS["green"]),
|
|
467
|
+
(f"{stats.streak_longest}", "DAY STREAK", "your longest run", COLORS["blue"]),
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
for value, label, subtitle, color in slides:
|
|
471
|
+
console.print(Align.center(create_dramatic_stat(value, label, subtitle, color)))
|
|
472
|
+
wait_for_keypress()
|
|
473
|
+
console.clear()
|
|
474
|
+
|
|
475
|
+
# Personality reveal
|
|
476
|
+
personality = determine_personality(stats)
|
|
477
|
+
personality_text = Text()
|
|
478
|
+
personality_text.append("\n\n\n\n")
|
|
479
|
+
personality_text.append(f" {personality['emoji']}\n\n", style=Style(bold=True))
|
|
480
|
+
personality_text.append(f" You are\n", style=Style(color=COLORS["gray"]))
|
|
481
|
+
personality_text.append(f" {personality['title']}\n\n", style=Style(color=COLORS["purple"], bold=True))
|
|
482
|
+
personality_text.append(f" {personality['description']}\n\n\n", style=Style(color=COLORS["white"]))
|
|
483
|
+
personality_text.append(" [ENTER]", style=Style(color=COLORS["dark"]))
|
|
484
|
+
console.print(Align.center(personality_text))
|
|
485
|
+
wait_for_keypress()
|
|
486
|
+
console.clear()
|
|
487
|
+
|
|
488
|
+
# === DASHBOARD VIEW ===
|
|
489
|
+
console.print()
|
|
490
|
+
|
|
491
|
+
# Header
|
|
492
|
+
header = Text()
|
|
493
|
+
header.append("═" * 60 + "\n", style=Style(color=COLORS["orange"]))
|
|
494
|
+
header.append(" CLAUDE CODE WRAPPED ", style=Style(color=COLORS["white"], bold=True))
|
|
495
|
+
header.append(str(stats.year), style=Style(color=COLORS["orange"], bold=True))
|
|
496
|
+
header.append("\n" + "═" * 60, style=Style(color=COLORS["orange"]))
|
|
497
|
+
console.print(Align.center(header))
|
|
498
|
+
console.print()
|
|
499
|
+
|
|
500
|
+
# Big stats row
|
|
501
|
+
stats_table = Table(show_header=False, box=None, padding=(0, 3), expand=True)
|
|
502
|
+
stats_table.add_column(justify="center")
|
|
503
|
+
stats_table.add_column(justify="center")
|
|
504
|
+
stats_table.add_column(justify="center")
|
|
505
|
+
stats_table.add_column(justify="center")
|
|
506
|
+
|
|
507
|
+
stats_table.add_row(
|
|
508
|
+
create_big_stat(f"{stats.total_messages:,}", "messages", COLORS["orange"]),
|
|
509
|
+
create_big_stat(str(stats.total_sessions), "sessions", COLORS["purple"]),
|
|
510
|
+
create_big_stat(format_tokens(stats.total_tokens), "tokens", COLORS["green"]),
|
|
511
|
+
create_big_stat(f"{stats.streak_longest}d", "best streak", COLORS["blue"]),
|
|
512
|
+
)
|
|
513
|
+
console.print(Align.center(stats_table))
|
|
514
|
+
console.print()
|
|
515
|
+
|
|
516
|
+
# Contribution graph
|
|
517
|
+
console.print(create_contribution_graph(stats.daily_stats, stats.year))
|
|
518
|
+
|
|
519
|
+
# Charts row
|
|
520
|
+
charts = Table(show_header=False, box=None, padding=(0, 1), expand=True)
|
|
521
|
+
charts.add_column(ratio=1)
|
|
522
|
+
charts.add_column(ratio=2)
|
|
523
|
+
charts.add_row(
|
|
524
|
+
create_personality_card(stats),
|
|
525
|
+
create_weekday_chart(stats.weekday_distribution),
|
|
526
|
+
)
|
|
527
|
+
console.print(charts)
|
|
528
|
+
|
|
529
|
+
# Hour chart
|
|
530
|
+
console.print(create_hour_chart(stats.hourly_distribution))
|
|
531
|
+
|
|
532
|
+
# Top lists
|
|
533
|
+
lists = Table(show_header=False, box=None, padding=(0, 1), expand=True)
|
|
534
|
+
lists.add_column(ratio=1)
|
|
535
|
+
lists.add_column(ratio=1)
|
|
536
|
+
lists.add_row(
|
|
537
|
+
create_top_list(stats.top_tools[:5], "Top Tools", COLORS["orange"]),
|
|
538
|
+
create_top_list(stats.top_projects, "Top Projects", COLORS["green"]),
|
|
539
|
+
)
|
|
540
|
+
console.print(lists)
|
|
541
|
+
|
|
542
|
+
# Insights
|
|
543
|
+
insights = Text()
|
|
544
|
+
if stats.most_active_day:
|
|
545
|
+
insights.append(" Peak day: ", style=Style(color=COLORS["gray"]))
|
|
546
|
+
insights.append(f"{stats.most_active_day[0].strftime('%b %d')}", style=Style(color=COLORS["orange"], bold=True))
|
|
547
|
+
insights.append(f" ({stats.most_active_day[1]:,} msgs)", style=Style(color=COLORS["gray"]))
|
|
548
|
+
if stats.most_active_hour is not None:
|
|
549
|
+
insights.append(" • Peak hour: ", style=Style(color=COLORS["gray"]))
|
|
550
|
+
insights.append(f"{stats.most_active_hour}:00", style=Style(color=COLORS["purple"], bold=True))
|
|
551
|
+
if stats.primary_model:
|
|
552
|
+
insights.append(" • Favorite: ", style=Style(color=COLORS["gray"]))
|
|
553
|
+
insights.append(f"Claude {stats.primary_model}", style=Style(color=COLORS["blue"], bold=True))
|
|
554
|
+
|
|
555
|
+
console.print()
|
|
556
|
+
console.print(Align.center(insights))
|
|
557
|
+
|
|
558
|
+
# === CREDITS SEQUENCE ===
|
|
559
|
+
if animate:
|
|
560
|
+
console.print()
|
|
561
|
+
continue_text = Text()
|
|
562
|
+
continue_text.append("\n press [ENTER] for fun facts & credits", style=Style(color=COLORS["dark"]))
|
|
563
|
+
console.print(Align.center(continue_text))
|
|
564
|
+
wait_for_keypress()
|
|
565
|
+
console.clear()
|
|
566
|
+
|
|
567
|
+
# Fun facts
|
|
568
|
+
facts = get_fun_facts(stats)
|
|
569
|
+
if facts:
|
|
570
|
+
console.print(Align.center(create_fun_facts_slide(facts)))
|
|
571
|
+
wait_for_keypress()
|
|
572
|
+
console.clear()
|
|
573
|
+
|
|
574
|
+
# Credits roll
|
|
575
|
+
for frame in create_credits_roll(stats):
|
|
576
|
+
console.print(Align.center(frame))
|
|
577
|
+
wait_for_keypress()
|
|
578
|
+
console.clear()
|
|
579
|
+
|
|
580
|
+
# Final footer
|
|
581
|
+
console.print()
|
|
582
|
+
footer = Text()
|
|
583
|
+
footer.append("─" * 60 + "\n\n", style=Style(color=COLORS["dark"]))
|
|
584
|
+
footer.append("Thanks for building with Claude ", style=Style(color=COLORS["gray"]))
|
|
585
|
+
footer.append("✨\n\n", style=Style(color=COLORS["orange"]))
|
|
586
|
+
footer.append("Built by ", style=Style(color=COLORS["gray"]))
|
|
587
|
+
footer.append("Mert Deveci", style=Style(color=COLORS["white"], bold=True, link="https://x.com/gm_mertd"))
|
|
588
|
+
footer.append(" · ", style=Style(color=COLORS["dark"]))
|
|
589
|
+
footer.append("@gm_mertd", style=Style(color=COLORS["blue"], link="https://x.com/gm_mertd"))
|
|
590
|
+
footer.append(" · ", style=Style(color=COLORS["dark"]))
|
|
591
|
+
footer.append("Banker.so", style=Style(color=COLORS["blue"], bold=True, link="https://banker.so"))
|
|
592
|
+
footer.append("\n")
|
|
593
|
+
console.print(Align.center(footer))
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
if __name__ == "__main__":
|
|
597
|
+
from .reader import load_all_messages
|
|
598
|
+
from .stats import aggregate_stats
|
|
599
|
+
|
|
600
|
+
print("Loading data...")
|
|
601
|
+
messages = load_all_messages(year=2025)
|
|
602
|
+
stats = aggregate_stats(messages, 2025)
|
|
603
|
+
|
|
604
|
+
render_wrapped(stats)
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-code-wrapped",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Your year with Claude Code - Spotify Wrapped style terminal experience",
|
|
5
|
+
"bin": {
|
|
6
|
+
"claude-code-wrapped": "./bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node bin/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/The-Money-Company-Limited/claudecodewrapped.git"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude",
|
|
17
|
+
"anthropic",
|
|
18
|
+
"cli",
|
|
19
|
+
"wrapped",
|
|
20
|
+
"stats",
|
|
21
|
+
"terminal",
|
|
22
|
+
"claude-code"
|
|
23
|
+
],
|
|
24
|
+
"author": "Mert Deveci",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/The-Money-Company-Limited/claudecodewrapped/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/The-Money-Company-Limited/claudecodewrapped#readme",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=16.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "claude-code-wrapped"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
description = "Your year with Claude Code - Spotify Wrapped style terminal experience"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Mert Deveci" }
|
|
10
|
+
]
|
|
11
|
+
keywords = ["claude", "anthropic", "cli", "wrapped", "stats", "terminal"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"rich>=13.0.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/The-Money-Company-Limited/claudecodewrapped"
|
|
28
|
+
Repository = "https://github.com/The-Money-Company-Limited/claudecodewrapped"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
claude-code-wrapped = "claude_code_wrapped.main:main"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|