diff --git a/.github/workflows/codex-api-review.yml b/.github/workflows/codex-api-review.yml new file mode 100644 index 0000000..16c50e3 --- /dev/null +++ b/.github/workflows/codex-api-review.yml @@ -0,0 +1,138 @@ +name: Codex Code Review (API) + +on: + pull_request: + types: [opened, reopened, ready_for_review] + issue_comment: + types: [created] + +# Cancel in-progress reviews if a new one is triggered +concurrency: + group: codex-review-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +jobs: + review: + name: Code Review + runs-on: ubuntu-latest + # Run on PR events OR when someone comments "@codex review" on a PR + if: > + github.event_name == 'pull_request' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + contains(github.event.comment.body, '@codex review')) + permissions: + contents: read + pull-requests: write + issues: write + outputs: + review_output: ${{ steps.run_codex.outputs.final-message }} + + steps: + - name: Get PR number + id: pr_number + run: | + if [ "${{ github.event_name }}" == "pull_request" ]; then + echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + else + echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT + fi + + - name: Get PR details + id: pr_details + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: ${{ steps.pr_number.outputs.number }} + }); + core.setOutput('base_sha', pr.data.base.sha); + core.setOutput('head_sha', pr.data.head.sha); + core.setOutput('base_ref', pr.data.base.ref); + + - name: Checkout PR + uses: actions/checkout@v4 + with: + ref: refs/pull/${{ steps.pr_number.outputs.number }}/merge + fetch-depth: 0 + + - name: Fetch base and head refs + run: | + git fetch --no-tags origin \ + ${{ steps.pr_details.outputs.base_ref }} \ + +refs/pull/${{ steps.pr_number.outputs.number }}/head + + - name: Run Codex Review + id: run_codex + uses: openai/codex-action@v1 + with: + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + sandbox: read-only + model: codex-mini-latest + prompt: | + You are reviewing PR #${{ steps.pr_number.outputs.number }} in the ${{ github.repository }} repository. + + Review ONLY the changes introduced by this PR between commits: + - Base: ${{ steps.pr_details.outputs.base_sha }} + - Head: ${{ steps.pr_details.outputs.head_sha }} + + Use `git diff ${{ steps.pr_details.outputs.base_sha }}...${{ steps.pr_details.outputs.head_sha }}` to see the changes. + + Focus your review on: + 1. **Bugs & Logic Errors**: Identify potential bugs, edge cases, or incorrect logic + 2. **Security Issues**: Flag any security vulnerabilities (XSS, injection, auth issues, etc.) + 3. **Performance**: Note any obvious performance concerns + 4. **Best Practices**: Suggest improvements for code quality and maintainability + + Guidelines: + - Only report issues with HIGH confidence (P0/P1 severity) + - Be specific: reference file names and line numbers + - Be concise: no fluff, just actionable feedback + - If the code looks good, say so briefly + - Format your response in markdown + + Do NOT: + - Nitpick style or formatting (we have linters for that) + - Suggest changes unrelated to the PR's purpose + - Report low-confidence or speculative issues + + post_review: + name: Post Review Comment + runs-on: ubuntu-latest + needs: review + if: needs.review.outputs.review_output != '' + permissions: + pull-requests: write + issues: write + + steps: + - name: Get PR number + id: pr_number + run: | + if [ "${{ github.event_name }}" == "pull_request" ]; then + echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + else + echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT + fi + + - name: Post review comment + uses: actions/github-script@v7 + env: + REVIEW_OUTPUT: ${{ needs.review.outputs.review_output }} + with: + script: | + const reviewBody = `## ๐Ÿค– Codex Code Review + + ${process.env.REVIEW_OUTPUT} + + --- + Powered by [OpenAI Codex](https://openai.com/codex/) via API`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.pr_number.outputs.number }}, + body: reviewBody + }); diff --git a/claude_code_wrapped/exporters/html_exporter.py b/claude_code_wrapped/exporters/html_exporter.py index e36a832..ba374d0 100644 --- a/claude_code_wrapped/exporters/html_exporter.py +++ b/claude_code_wrapped/exporters/html_exporter.py @@ -1,6 +1,6 @@ """HTML export for Claude Code Wrapped.""" -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from pathlib import Path from ..stats import WrappedStats, format_tokens @@ -265,15 +265,25 @@ def _get_css() -> str: }} .cost-table th {{ - padding: 12px; - text-align: left; + padding: 12px 20px; + text-align: right; border-bottom: 2px solid #30363D; font-weight: bold; }} + .cost-table th:first-child {{ + text-align: left; + width: 30%; + }} + .cost-table td {{ - padding: 12px; + padding: 12px 20px; border-bottom: 1px solid #30363D; + text-align: right; + }} + + .cost-table td:first-child {{ + text-align: left; }} .cost-table tr:last-child td {{ @@ -425,14 +435,17 @@ def _build_dramatic_reveals(stats: WrappedStats, start_date: datetime, end_date:
{}
- Input: {} ยท Output: {} + Input: {} ยท Output: {}
+ Cache write: {} ยท Cache read: {}
""".format( stats.total_tokens, format_tokens(stats.total_tokens), format_tokens(stats.total_input_tokens), - format_tokens(stats.total_output_tokens) + format_tokens(stats.total_output_tokens), + format_tokens(stats.total_cache_creation_tokens), + format_tokens(stats.total_cache_read_tokens) ) return reveals @@ -844,6 +857,8 @@ def _build_credits(stats: WrappedStats, year: int | None) -> str:
Input: {format_tokens(stats.total_input_tokens)}
Output: {format_tokens(stats.total_output_tokens)}
+
Cache write: {format_tokens(stats.total_cache_creation_tokens)}
+
Cache read: {format_tokens(stats.total_cache_read_tokens)}
''' # Frame 2: Timeline @@ -851,13 +866,33 @@ def _build_credits(stats: WrappedStats, year: int | None) -> str: if year is None: # All-time: calculate days from first to last message if stats.first_message_date and stats.last_message_date: - total_days = (stats.last_message_date - stats.first_message_date).days + 1 + total_days_year = (stats.last_message_date - stats.first_message_date).days + 1 else: - total_days = stats.active_days + total_days_year = stats.active_days elif year == today.year: - total_days = (today - datetime(year, 1, 1)).days + 1 + total_days_year = (today - datetime(year, 1, 1)).days + 1 else: - total_days = 366 if year % 4 == 0 else 365 + total_days_year = 366 if year % 4 == 0 else 365 + + # Calculate days since journey start + if stats.first_message_date: + if year is None: + # All-time: days from first to last message + if stats.last_message_date: + days_since_journey = (stats.last_message_date - stats.first_message_date).days + 1 + else: + days_since_journey = stats.active_days + elif year == today.year: + # Current year: days from first message to today + first_msg_date = stats.first_message_date.date() if hasattr(stats.first_message_date, 'date') else stats.first_message_date + days_since_journey = (today.date() - first_msg_date).days + 1 + else: + # Past year: days from first message to end of year + year_end = datetime(year, 12, 31).date() + first_msg_date = stats.first_message_date.date() if hasattr(stats.first_message_date, 'date') else stats.first_message_date + days_since_journey = (year_end - first_msg_date).days + 1 + else: + days_since_journey = stats.active_days year_display = format_year_display(year) # Use sentence case for "All time" in Period field @@ -877,11 +912,20 @@ def _build_credits(stats: WrappedStats, year: int | None) -> str: {stats.first_message_date.strftime('%B %d')} ''' + year_pct = (stats.active_days / total_days_year * 100) if total_days_year > 0 else 0 + journey_pct = (stats.active_days / days_since_journey * 100) if days_since_journey > 0 else 0 html += f'''
Active days {stats.active_days} - of {total_days} +
+
+ Active days of year + {year_pct:.1f}% +
+
+ Active days on journey + {journey_pct:.1f}%
''' if stats.most_active_hour is not None: diff --git a/claude_code_wrapped/exporters/markdown_exporter.py b/claude_code_wrapped/exporters/markdown_exporter.py index 7df06ce..1fa1c5d 100644 --- a/claude_code_wrapped/exporters/markdown_exporter.py +++ b/claude_code_wrapped/exporters/markdown_exporter.py @@ -1,6 +1,6 @@ """Markdown export for Claude Code Wrapped.""" -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from pathlib import Path from ..stats import WrappedStats, format_tokens @@ -103,7 +103,9 @@ def _build_dramatic_reveals(stats: WrappedStats, start_date: datetime, end_date: **{stats.total_tokens:,}** tokens ({format_tokens(stats.total_tokens)}) - Input: {format_tokens(stats.total_input_tokens)} -- Output: {format_tokens(stats.total_output_tokens)}""") +- Output: {format_tokens(stats.total_output_tokens)} +- Cache write: {format_tokens(stats.total_cache_creation_tokens)} +- Cache read: {format_tokens(stats.total_cache_read_tokens)}""") return "\n\n".join(sections) @@ -290,9 +292,9 @@ def _build_monthly_costs(stats: WrappedStats) -> str: if not stats.monthly_costs: return "" - # Build table - table = "| Month | Input | Output | Cache | Cost |\n" - table += "|-------|-------|--------|-------|------|\n" + # Build table with wider columns for better spacing + table = "| Month | Input | Output | Cache | Cost |\n" + table += "|:--------------|--------:|--------:|--------:|---------:|\n" total_cost = 0 total_input = 0 @@ -318,9 +320,9 @@ def _build_monthly_costs(stats: WrappedStats) -> str: month_date = datetime.strptime(month_str, "%Y-%m") month_label = month_date.strftime("%b %Y") - table += f"| {month_label} | {format_tokens(input_tokens)} | {format_tokens(output_tokens)} | {format_tokens(cache_tokens)} | {format_cost(cost)} |\n" + table += f"| {month_label:<13} | {format_tokens(input_tokens):>7} | {format_tokens(output_tokens):>7} | {format_tokens(cache_tokens):>7} | {format_cost(cost):>8} |\n" - table += f"| **Total** | **{format_tokens(total_input)}** | **{format_tokens(total_output)}** | **{format_tokens(total_cache)}** | **{format_cost(total_cost)}** |\n" + table += f"| **Total** | **{format_tokens(total_input)}** | **{format_tokens(total_output)}** | **{format_tokens(total_cache)}** | **{format_cost(total_cost)}** |\n" return f"""--- @@ -365,6 +367,8 @@ def _build_credits(stats: WrappedStats, year: int | None) -> str: numbers += f"**Tokens:** {format_tokens(stats.total_tokens)}\n\n" numbers += f"- Input: {format_tokens(stats.total_input_tokens)}\n" numbers += f"- Output: {format_tokens(stats.total_output_tokens)}\n" + numbers += f"- Cache write: {format_tokens(stats.total_cache_creation_tokens)}\n" + numbers += f"- Cache read: {format_tokens(stats.total_cache_read_tokens)}\n" sections.append(numbers) # Timeline @@ -372,13 +376,33 @@ def _build_credits(stats: WrappedStats, year: int | None) -> str: if year is None: # All-time: calculate days from first to last message if stats.first_message_date and stats.last_message_date: - total_days = (stats.last_message_date - stats.first_message_date).days + 1 + total_days_year = (stats.last_message_date - stats.first_message_date).days + 1 else: - total_days = stats.active_days + total_days_year = stats.active_days elif year == today.year: - total_days = (today - datetime(year, 1, 1)).days + 1 + total_days_year = (today - datetime(year, 1, 1)).days + 1 else: - total_days = 366 if year % 4 == 0 else 365 + total_days_year = 366 if year % 4 == 0 else 365 + + # Calculate days since journey start + if stats.first_message_date: + if year is None: + # All-time: days from first to last message + if stats.last_message_date: + days_since_journey = (stats.last_message_date - stats.first_message_date).days + 1 + else: + days_since_journey = stats.active_days + elif year == today.year: + # Current year: days from first message to today + first_msg_date = stats.first_message_date.date() if hasattr(stats.first_message_date, 'date') else stats.first_message_date + days_since_journey = (today.date() - first_msg_date).days + 1 + else: + # Past year: days from first message to end of year + year_end = datetime(year, 12, 31).date() + first_msg_date = stats.first_message_date.date() if hasattr(stats.first_message_date, 'date') else stats.first_message_date + days_since_journey = (year_end - first_msg_date).days + 1 + else: + days_since_journey = stats.active_days year_display = format_year_display(year) # Use sentence case for "All time" in Period field @@ -388,7 +412,11 @@ def _build_credits(stats: WrappedStats, year: int | None) -> str: if stats.first_message_date: date_str = stats.first_message_date.strftime('%B %d, %Y') if year is None else stats.first_message_date.strftime('%B %d') timeline += f"- **Journey started:** {date_str}\n" - timeline += f"- **Active days:** {stats.active_days} of {total_days}\n" + year_pct = (stats.active_days / total_days_year * 100) if total_days_year > 0 else 0 + journey_pct = (stats.active_days / days_since_journey * 100) if days_since_journey > 0 else 0 + timeline += f"- **Active days:** {stats.active_days}\n" + timeline += f"- **Active days of year:** {year_pct:.1f}%\n" + timeline += f"- **Active days on journey:** {journey_pct:.1f}%\n" if stats.most_active_hour is not None: hour_label = "AM" if stats.most_active_hour < 12 else "PM" hour_12 = stats.most_active_hour % 12 or 12 diff --git a/claude_code_wrapped/main.py b/claude_code_wrapped/main.py index e67e6b6..1560386 100644 --- a/claude_code_wrapped/main.py +++ b/claude_code_wrapped/main.py @@ -16,6 +16,16 @@ def main(): """Main entry point for Claude Code Wrapped.""" + try: + _run() + except KeyboardInterrupt: + console = Console() + console.print("\n\n[#C96442]You pulled the plug. No hard feelings.[/]") + sys.exit(0) + + +def _run(): + """Internal run function.""" # Check if we should use interactive mode if should_use_interactive_mode(): # Get user selections through interactive prompts diff --git a/claude_code_wrapped/ui.py b/claude_code_wrapped/ui.py index 26f1a39..8bfdc23 100644 --- a/claude_code_wrapped/ui.py +++ b/claude_code_wrapped/ui.py @@ -21,13 +21,14 @@ "purple": "#9B59B6", "blue": "#3498DB", "green": "#27AE60", + "yellow": "#F1C40F", "white": "#ECF0F1", "gray": "#7F8C8D", "dark": "#2C3E50", } -# GitHub-style contribution colors (light to dark green) -CONTRIB_COLORS = ["#161B22", "#0E4429", "#006D32", "#26A641", "#39D353"] +# GitHub-style contribution colors (no activity = visible gray, then greens) +CONTRIB_COLORS = ["#3a3a3a", "#0E4429", "#006D32", "#26A641", "#39D353"] def wait_for_keypress(): @@ -36,18 +37,133 @@ def wait_for_keypress(): return '\n' +# === ANIMATION UTILITIES === + +def animate_count_up(console: Console, target: int, duration: float = 1.2, suffix: str = "", + color: str = COLORS["orange"], bold: bool = True, centered: bool = True): + """Animate a number counting up from 0 to target value.""" + steps = min(30, target) if target > 0 else 1 + step_duration = duration / steps + + with Live(console=console, refresh_per_second=60, transient=True) as live: + for i in range(steps + 1): + current = int((i / steps) * target) + text = Text() + text.append(f"{current:,}{suffix}", style=Style(color=color, bold=bold)) + + if centered: + live.update(Align.center(text)) + else: + live.update(text) + time.sleep(step_duration) + + # Final value (permanent) + final = Text() + final.append(f"{target:,}{suffix}", style=Style(color=color, bold=bold)) + if centered: + console.print(Align.center(final)) + else: + console.print(final) + + +def animate_typing(console: Console, text: str, color: str = COLORS["white"], + delay: float = 0.03, bold: bool = False, centered: bool = True): + """Display text with a brief pause (simpler, no character-by-character for regular text).""" + # Just display the text with a small dramatic pause + dramatic_pause(delay * len(text) * 0.3) # Shorter total time + styled = Text(text, style=Style(color=color, bold=bold)) + if centered: + console.print(Align.center(styled)) + else: + console.print(styled) + + +def animate_lines(console: Console, lines: list[tuple[str, str]], delay: float = 0.08): + """Reveal lines one at a time with a fade-in effect. + + Args: + lines: List of (text, style) tuples + delay: Delay between lines + """ + for line_text, style in lines: + console.print(line_text, style=style) + time.sleep(delay) + + +def animate_ascii_art(console: Console, art_lines: list[str], color: str = COLORS["orange"], + delay: float = 0.05, centered: bool = True): + """Animate ASCII art appearing line by line.""" + for line in art_lines: + styled = Text(line, style=Style(color=color)) + if centered: + console.print(Align.center(styled)) + else: + console.print(styled) + time.sleep(delay) + + +def dramatic_pause(duration: float = 0.5): + """Add a dramatic pause before a reveal.""" + time.sleep(duration) + + +def animate_stat_reveal(console: Console, value: int | float, label: str, subtitle: str = "", + color: str = COLORS["orange"], count_duration: float = 1.0): + """Animate a dramatic stat reveal with count-up effect.""" + console.print() + console.print() + console.print() + + # Animate the number counting up + if isinstance(value, float): + # For floats (like cost), format with 2 decimal places + steps = 30 + step_duration = count_duration / steps + for i in range(steps + 1): + current = (i / steps) * value + text = Text() + text.append(f"${current:.2f}" if value < 1000 else f"${current:,.0f}", + style=Style(color=color, bold=True)) + console.print("\r" + " " * 50, end="\r") + console.print(Align.center(text), end="\r") + time.sleep(step_duration) + final_text = Text() + final_text.append(f"${value:.2f}" if value < 1000 else f"${value:,.0f}", + style=Style(color=color, bold=True)) + console.print("\r" + " " * 50, end="\r") + console.print(Align.center(final_text)) + else: + animate_count_up(console, value, duration=count_duration, color=color) + + # Label appears after number + dramatic_pause(0.2) + label_text = Text(label, style=Style(color=COLORS["white"], bold=True)) + console.print(Align.center(label_text)) + + # Subtitle types out + if subtitle: + console.print() + animate_typing(console, subtitle, color=COLORS["gray"], delay=0.02) + + def format_year_display(year: int | None) -> str: """Format year for display - returns 'All time' if year is None.""" return "All time" if year is None else str(year) -def create_dashboard_header(year: int | None) -> Text: - """Create the dashboard header bar.""" +def create_dashboard_header(year: int | None, width: int = 80) -> Text: + """Create the dashboard header bar using specified width.""" + # Build the title text + title = f"CLAUDE CODE WRAPPED {format_year_display(year)}" + # Center the title within the width + padding = (width - len(title)) // 2 + header = Text() - header.append("โ•" * 60 + "\n", style=Style(color=COLORS["orange"])) - header.append(" CLAUDE CODE WRAPPED ", style=Style(color=COLORS["white"], bold=True)) + header.append("โ•" * width + "\n", style=Style(color=COLORS["orange"])) + header.append(" " * padding, style=Style(color=COLORS["white"])) + header.append("CLAUDE CODE WRAPPED ", style=Style(color=COLORS["white"], bold=True)) header.append(format_year_display(year), style=Style(color=COLORS["orange"], bold=True)) - header.append("\n" + "โ•" * 60, style=Style(color=COLORS["orange"])) + header.append("\n" + "โ•" * width, style=Style(color=COLORS["orange"])) return header @@ -69,7 +185,7 @@ def create_dramatic_stat(value: str, label: str, subtitle: str = "", color: str def create_title_slide(year: int | None) -> Text: - """Create the opening title.""" + """Create the opening title (static version for non-animated mode).""" title = Text() title.append("\n\n\n") title.append(" โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–‘โ–ˆโ–ˆโ•—โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–‘โ–ˆโ–ˆโ•—โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—\n", style="#C96442") @@ -90,6 +206,90 @@ def create_title_slide(year: int | None) -> Text: return title +# ASCII art lines for animated display +CLAUDE_ASCII_LINES = [ + " โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–‘โ–ˆโ–ˆโ•—โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–‘โ–ˆโ–ˆโ•—โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—", + " โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•", + " โ–ˆโ–ˆโ•‘โ–‘โ–‘โ•šโ•โ•โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–‘โ–‘", + " โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ•โ–‘โ–‘", + " โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘โ–‘โ–‘โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—", + " โ–‘โ•šโ•โ•โ•โ•โ•โ–‘โ•šโ•โ•โ•โ•โ•โ•โ•โ•šโ•โ•โ–‘โ–‘โ•šโ•โ•โ–‘โ•šโ•โ•โ•โ•โ•โ•โ–‘โ•šโ•โ•โ•โ•โ•โ•โ–‘โ•šโ•โ•โ•โ•โ•โ•โ•", +] + + +def render_animated_title(console: Console, year: int | None): + """Render the title slide with animations.""" + console.print("\n\n\n") + + # Fade in ASCII art line by line (CLAUDE logo) + animate_ascii_art(console, CLAUDE_ASCII_LINES, color="#C96442", delay=0.08) + + console.print() + dramatic_pause(0.3) + + # Build tree by typing out "CODE WRAPPED" - grows like a Christmas tree + # First C = yellow (star), D in CODE & A,E in WRAPPED = white (tinsel), rest = green + # Note: Empty print first to work around Rich centering bug on first line + console.print(Align.center(Text(""))) + tree_text = "C O D E W R A P P E D" + final_width = len(tree_text) # 23 characters + # Character indices: C=0, O=2, D=4, E=6, W=10, R=12, A=14, P=16, P=18, E=20, D=22 + tinsel_indices = {4, 14, 20} # D in CODE, A in WRAPPED, E in WRAPPED + + is_first_line = True + for end_idx in range(1, len(tree_text) + 1): + # Get the visible portion + visible = tree_text[:end_idx] + # Pad to full width so centering is consistent + padded = visible.center(final_width) + + # Build styled text + styled = Text() + # Python's center() uses ceiling division for left padding + left_pad = (final_width - len(visible) + 1) // 2 + for j, c in enumerate(padded): + # Map padded index back to original index + orig_idx = j - left_pad + if orig_idx < 0 or orig_idx >= len(visible): + styled.append(c) # Padding space + elif orig_idx == 0 and is_first_line: # First C = yellow star + styled.append(c, style=Style(color=COLORS["yellow"], bold=True)) + elif orig_idx in tinsel_indices and c != " ": # Tinsel = white + styled.append(c, style=Style(color=COLORS["white"], bold=True)) + elif c == " ": + styled.append(c) + else: # Rest = green + styled.append(c, style=Style(color=COLORS["green"], bold=True)) + + console.print(Align.center(styled)) + + if tree_text[end_idx - 1] not in " ": + time.sleep(0.04) + is_first_line = False + + dramatic_pause(0.2) + + # Tree stump - 3 years stacked + year_display = format_year_display(year) + for _ in range(3): + year_text = Text(year_display, style=Style(color=COLORS["purple"], bold=True)) + console.print(Align.center(year_text)) + time.sleep(0.15) + + console.print() + dramatic_pause(0.3) + + # Credits + credits = Text() + credits.append("by ", style=Style(color=COLORS["gray"])) + credits.append("Trollefsen", style=Style(color=COLORS["blue"], bold=True, link="https://github.com/da-troll")) + console.print(Align.center(credits)) + + console.print("\n\n") + prompt = Text("press [ENTER] to begin", style=Style(color=COLORS["dark"])) + console.print(Align.center(prompt)) + + def create_big_stat(value: str, label: str, color: str = COLORS["orange"]) -> Text: """Create a big statistic display.""" text = Text() @@ -98,82 +298,188 @@ def create_big_stat(value: str, label: str, color: str = COLORS["orange"]) -> Te return text -def create_contribution_graph(daily_stats: dict, year: int | None) -> Panel: - """Create a GitHub-style contribution graph for the full year or all-time.""" +def get_contribution_data(daily_stats: dict, year: int | None) -> tuple: + """Calculate contribution graph data. Returns (weeks_data, max_count, active_count, date_range, start_date, end_date).""" if not daily_stats: - return Panel("No activity data", title="Activity", border_style=COLORS["gray"]) + return [], 0, 0, "", None, None # Calculate date range if year is None: - # All-time: use actual date range from daily_stats dates = [datetime.strptime(d, "%Y-%m-%d") for d in daily_stats.keys()] start_date = min(dates) if dates else datetime.now() end_date = max(dates) if dates else datetime.now() else: - # Always show full year: Jan 1 to Dec 31 (or today if current year) start_date = datetime(year, 1, 1) today = datetime.now() - if year == today.year: - end_date = today - else: - end_date = datetime(year, 12, 31) + end_date = today if year == today.year else datetime(year, 12, 31) max_count = max(s.message_count for s in daily_stats.values()) if daily_stats else 1 - weeks = [] + # Build weeks with their start dates + weeks_data = [] current = start_date - timedelta(days=start_date.weekday()) - while current <= end_date + timedelta(days=7): + while current <= end_date: week = [] for day in range(7): date = current + timedelta(days=day) date_str = date.strftime("%Y-%m-%d") - if date_str in daily_stats: + if year is not None and date.year != year: + level = 0 + elif date_str in daily_stats: count = daily_stats[date_str].message_count level = min(4, 1 + int((count / max_count) * 3)) if count > 0 else 0 else: level = 0 week.append(level) - weeks.append(week) + weeks_data.append((current, week)) current += timedelta(days=7) + # Trim leading empty weeks + first_active_idx = 0 + for i, (_, week) in enumerate(weeks_data): + if any(level > 0 for level in week): + first_active_date = weeks_data[i][0] + month_start = first_active_date.replace(day=1) + for j, (week_start, _) in enumerate(weeks_data): + if week_start <= month_start < week_start + timedelta(days=7): + first_active_idx = j + break + else: + first_active_idx = max(0, i - 1) + break + + weeks_data = weeks_data[first_active_idx:] + + # Calculate date range for title + if weeks_data: + display_start = weeks_data[0][0] + display_end = min(weeks_data[-1][0] + timedelta(days=6), end_date) + date_range = f"{display_start.strftime('%b')} - {display_end.strftime('%b %Y')}" + else: + date_range = "" + + active_count = len([d for d in daily_stats.values() if d.message_count > 0]) + + return weeks_data, max_count, active_count, date_range, start_date, end_date + + +def build_month_row(weeks_data: list) -> Text: + """Build the month labels row for contribution graph.""" + month_row = Text() + month_row.append(" ", style=Style(color=COLORS["gray"])) + last_month = None + skip_count = 0 + for i, (week_start, _) in enumerate(weeks_data): + if skip_count > 0: + skip_count -= 1 + continue + month_changed = week_start.month != last_month + if month_changed: + month_abbr = week_start.strftime("%b") + month_row.append(f"{month_abbr} ", style=Style(color=COLORS["gray"])) + last_month = week_start.month + skip_count = 1 + else: + month_row.append(" ", style=Style(color=COLORS["gray"])) + month_row.append("\n") + return month_row + + +def create_contribution_graph(daily_stats: dict, year: int | None) -> Panel: + """Create a GitHub-style contribution graph for the full year or all-time.""" + weeks_data, _, active_count, date_range, _, _ = get_contribution_data(daily_stats, year) + + if not weeks_data: + return Panel("No activity data", title="Activity", border_style=COLORS["gray"]) + + month_row = build_month_row(weeks_data) + + # Build the graph graph = Text() - days_labels = ["Mon", " ", "Wed", " ", "Fri", " ", " "] + graph.append("\n") + graph.append(month_row) + days_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] for row in range(7): graph.append(f"{days_labels[row]} ", style=Style(color=COLORS["gray"])) - for week in weeks: + for _, week in weeks_data: color = CONTRIB_COLORS[week[row]] graph.append("โ–  ", style=Style(color=color)) graph.append("\n") legend = Text() - legend.append("\n Less ", style=Style(color=COLORS["gray"])) + legend.append("Less ", style=Style(color=COLORS["gray"])) for color in CONTRIB_COLORS: legend.append("โ–  ", style=Style(color=color)) legend.append("More", style=Style(color=COLORS["gray"])) - content = Group(graph, Align.center(legend)) - - # Calculate total days for context - today = datetime.now() - if year is None: - # All-time: calculate total days from date range - total_days = (end_date - start_date).days + 1 - elif year == today.year: - total_days = (today - datetime(year, 1, 1)).days + 1 - else: - total_days = 366 if year % 4 == 0 else 365 - active_count = len([d for d in daily_stats.values() if d.message_count > 0]) + content = Group(Align.center(graph), Align.center(legend)) return Panel( Align.center(content), - title=f"Activity ยท {active_count} of {total_days} days", + title=f"Activity ยท {active_count} days ยท {date_range}", border_style=Style(color=COLORS["green"]), padding=(0, 2), ) +def animate_contribution_graph(console: Console, daily_stats: dict, year: int | None, delay: float = 0.015): + """Animate the contribution graph squares appearing row by row.""" + weeks_data, _, active_count, date_range, _, _ = get_contribution_data(daily_stats, year) + + if not weeks_data: + console.print(Panel("No activity data", title="Activity", border_style=COLORS["gray"])) + return + + month_row = build_month_row(weeks_data) + days_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + num_weeks = len(weeks_data) + + def build_graph_frame(revealed_squares: int) -> Panel: + """Build graph with only revealed_squares visible.""" + graph = Text() + graph.append("\n") + graph.append(month_row) + + squares_shown = 0 + for row in range(7): + graph.append(f"{days_labels[row]} ", style=Style(color=COLORS["gray"])) + for _, week in weeks_data: + if squares_shown < revealed_squares: + color = CONTRIB_COLORS[week[row]] + graph.append("โ–  ", style=Style(color=color)) + else: + # Empty placeholder (dark gray) + graph.append("โ–  ", style=Style(color=COLORS["dark"])) + squares_shown += 1 + graph.append("\n") + + legend = Text() + legend.append("Less ", style=Style(color=COLORS["gray"])) + for color in CONTRIB_COLORS: + legend.append("โ–  ", style=Style(color=color)) + legend.append("More", style=Style(color=COLORS["gray"])) + + content = Group(Align.center(graph), Align.center(legend)) + return Panel( + Align.center(content), + title=f"Activity ยท {active_count} days ยท {date_range}", + border_style=Style(color=COLORS["green"]), + padding=(0, 2), + ) + + total_squares = 7 * num_weeks + + with Live(build_graph_frame(0), console=console, refresh_per_second=60, transient=True) as live: + for i in range(total_squares + 1): + live.update(build_graph_frame(i)) + time.sleep(delay) + + # Print final state + console.print(build_graph_frame(total_squares)) + + def create_hour_chart(distribution: list[int]) -> Panel: """Create a clean hourly distribution chart.""" max_val = max(distribution) if any(distribution) else 1 @@ -187,7 +493,7 @@ def create_hour_chart(distribution: list[int]) -> Panel: elif 12 <= i < 18: color = COLORS["blue"] elif 18 <= i < 24: - color = COLORS["purple"] + color = COLORS["yellow"] else: color = COLORS["gray"] content.append(chars[idx], style=Style(color=color)) @@ -200,20 +506,25 @@ def create_hour_chart(distribution: list[int]) -> Panel: return Panel( Align.center(content), title="Hours", - border_style=Style(color=COLORS["purple"]), + border_style=Style(color=COLORS["yellow"]), padding=(0, 1), + expand=True, ) -def create_weekday_chart(distribution: list[int]) -> Panel: - """Create a clean weekday distribution chart.""" +def create_weekday_chart(distribution: list[int], width: int = 80) -> Panel: + """Create a clean weekday distribution chart that fills available width.""" days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] max_val = max(distribution) if any(distribution) else 1 + # Calculate bar width: width - day label (4) - count (~8) - padding/borders (~6) + bar_width = max(10, width - 18) + content = Text() + content.append("\n") # Add spacing above Monday for i, (day, count) in enumerate(zip(days, distribution)): - bar_len = int((count / max_val) * 12) if max_val > 0 else 0 - bar = "โ–ˆ" * bar_len + "โ–‘" * (12 - bar_len) + bar_len = int((count / max_val) * bar_width) if max_val > 0 else 0 + bar = "โ–ˆ" * bar_len + "โ–‘" * (bar_width - bar_len) content.append(f"{day} ", style=Style(color=COLORS["gray"])) content.append(bar, style=Style(color=COLORS["blue"])) content.append(f" {count:,}\n", style=Style(color=COLORS["gray"])) @@ -223,20 +534,28 @@ def create_weekday_chart(distribution: list[int]) -> Panel: title="Days", border_style=Style(color=COLORS["blue"]), padding=(0, 1), + expand=True, ) -def create_top_list(items: list[tuple[str, int]], title: str, color: str) -> Panel: - """Create a clean top items list.""" +def create_top_list(items: list[tuple[str, int]], title: str, color: str, width: int = 30) -> Panel: + """Create a clean top items list with dynamic bar width.""" content = Text() + content.append("\n") # Add spacing above first item max_val = max(v for _, v in items) if items else 1 + # Calculate bar width for the bar line + # Overhead: panel border (2), padding (2), count " XX,XXX" (~8) = 12 chars + bar_width = max(8, width - 12) + for i, (name, count) in enumerate(items[:5], 1): + # Line 1: rank + name content.append(f"{i}. ", style=Style(color=COLORS["gray"])) - content.append(f"{name[:12]:<12} ", style=Style(color=COLORS["white"])) - bar_len = int((count / max_val) * 8) + content.append(f"{name[:12]}\n", style=Style(color=COLORS["white"])) + # Line 2: bar + count + bar_len = int((count / max_val) * bar_width) content.append("โ–“" * bar_len, style=Style(color=color)) - content.append("โ–‘" * (8 - bar_len), style=Style(color=COLORS["dark"])) + content.append("โ–‘" * (bar_width - bar_len), style=Style(color=COLORS["dark"])) content.append(f" {count:,}\n", style=Style(color=COLORS["gray"])) return Panel( @@ -310,19 +629,23 @@ def get_fun_facts(stats: WrappedStats) -> list[tuple[str, str]]: return facts -def create_fun_facts_slide(facts: list[tuple[str, str]]) -> Text: - """Create a fun facts slide.""" +def create_fun_facts_slide(facts: list[tuple[str, str]], console_width: int = 80, console_height: int = 24) -> Text: + """Create a fun facts slide (without prompt text).""" + # Left quarter position + pad = " " * (console_width // 4) + + # Calculate content height: title (1) + blank lines (2) + facts (2 lines each) + prompt (2) + content_height = 1 + 2 + len(facts) * 2 + 2 + vertical_pad = max(0, (console_height - content_height) // 2) + text = Text() - text.append("\n\n") - 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)) + text.append("\n" * vertical_pad) + text.append(f"{pad}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)) for emoji, fact in facts: - text.append(f" {emoji} ", style=Style(bold=True)) + text.append(f"{pad}{emoji} ", style=Style(bold=True)) text.append(f"{fact}\n\n", style=Style(color=COLORS["white"])) - text.append("\n") - text.append("press [ENTER] for the credits", style=Style(color=COLORS["dark"])) - text.append("\n") return text @@ -355,14 +678,16 @@ def create_monthly_cost_table(stats: WrappedStats) -> Panel: header_style=Style(color=COLORS["white"], bold=True), border_style=Style(color=COLORS["dark"]), box=None, - padding=(0, 1), + padding=(0, 2), + expand=True, ) - table.add_column("Month", style=Style(color=COLORS["gray"])) - table.add_column("Input", justify="right", style=Style(color=COLORS["blue"])) - table.add_column("Output", justify="right", style=Style(color=COLORS["orange"])) - table.add_column("Cache", justify="right", style=Style(color=COLORS["purple"])) - table.add_column("Cost", justify="right", style=Style(color=COLORS["green"], bold=True)) + # Month column gets larger ratio to create gap before data columns + table.add_column("Month", style=Style(color=COLORS["gray"]), ratio=3) + table.add_column("Input", justify="right", style=Style(color=COLORS["blue"]), ratio=2) + table.add_column("Output", justify="right", style=Style(color=COLORS["orange"]), ratio=2) + table.add_column("Cache", justify="right", style=Style(color=COLORS["purple"]), ratio=2) + table.add_column("Cost", justify="right", style=Style(color=COLORS["green"], bold=True), ratio=2) # Sort months chronologically sorted_months = sorted(stats.monthly_costs.keys()) @@ -410,10 +735,18 @@ def create_monthly_cost_table(stats: WrappedStats) -> Panel: ) -def create_credits_roll(stats: WrappedStats) -> list[Text]: +def create_credits_roll(stats: WrappedStats, console_width: int = 80, console_height: int = 24) -> list[Text]: """Create end credits content.""" from .pricing import format_cost + # Dynamic positioning based on console width - all sections at left quarter + pad = " " * (console_width // 4) + + def vertical_center(content_lines: int) -> str: + """Return newlines needed to vertically center content.""" + padding = max(0, (console_height - content_lines) // 2) + return "\n" * padding + # Build frames with labels for post-processing [ENTER] prompts labeled_frames: list[tuple[Text, str]] = [] @@ -423,150 +756,195 @@ def create_credits_roll(stats: WrappedStats) -> list[Text]: display_name = simplify_model_name(model) display_costs[display_name] = display_costs.get(display_name, 0) + cost - # Frame 1: The Numbers (cost + tokens) + # Frame 1: The Numbers (cost + tokens) - ~15 content lines numbers = Text() - numbers.append("\n\n\n") - numbers.append(" T H E N U M B E R S\n\n", style=Style(color=COLORS["green"], bold=True)) + numbers.append(vertical_center(15)) + numbers.append(f"{pad}T H E N U M B E R S\n\n", style=Style(color=COLORS["green"], bold=True)) if stats.estimated_cost is not None: - numbers.append(f" Estimated Cost ", style=Style(color=COLORS["white"], bold=True)) - numbers.append(f"{format_cost(stats.estimated_cost)}\n", style=Style(color=COLORS["green"], bold=True)) - # Calculate max model name length for alignment - max_model_len = max(len(model) for model in display_costs.keys()) if display_costs else 0 + numbers.append(f"{pad}Estimated Cost ", style=Style(color=COLORS["white"], bold=True)) + numbers.append(f"{format_cost(stats.estimated_cost):>20}\n", style=Style(color=COLORS["green"], bold=True)) for model, cost in sorted(display_costs.items(), key=lambda x: -x[1]): - # Right-align cost values with main cost cost_str = format_cost(cost) - main_cost_str = format_cost(stats.estimated_cost) - padding = len("Estimated Cost " + main_cost_str) - len(model + ": " + cost_str) - numbers.append(f" {model}: {' ' * padding}{cost_str}\n", style=Style(color=COLORS["gray"])) - numbers.append(f"\n Tokens ", style=Style(color=COLORS["white"], bold=True)) - numbers.append(f"{format_tokens(stats.total_tokens)}\n", style=Style(color=COLORS["orange"], bold=True)) - # Right-align token breakdown values + numbers.append(f"{pad}{model}{'':>{27 - len(model)}}", style=Style(color=COLORS["gray"])) + numbers.append(f"{cost_str:>20}\n", style=Style(color=COLORS["gray"])) + numbers.append(f"\n{pad}Tokens ", style=Style(color=COLORS["white"], bold=True)) tokens_str = format_tokens(stats.total_tokens) + numbers.append(f"{tokens_str:>20}\n", style=Style(color=COLORS["orange"], bold=True)) + # Right-align token breakdown values input_str = format_tokens(stats.total_input_tokens) output_str = format_tokens(stats.total_output_tokens) - input_padding = len("Tokens " + tokens_str) - len("Input: " + input_str) - output_padding = len("Tokens " + tokens_str) - len("Output: " + output_str) - numbers.append(f" Input: {' ' * input_padding}{input_str}\n", style=Style(color=COLORS["gray"])) - numbers.append(f" Output: {' ' * output_padding}{output_str}\n", style=Style(color=COLORS["gray"])) + cache_create_str = format_tokens(stats.total_cache_creation_tokens) + cache_read_str = format_tokens(stats.total_cache_read_tokens) + numbers.append(f"{pad}Input ", style=Style(color=COLORS["gray"])) + numbers.append(f"{input_str:>20}\n", style=Style(color=COLORS["gray"])) + numbers.append(f"{pad}Output ", style=Style(color=COLORS["gray"])) + numbers.append(f"{output_str:>20}\n", style=Style(color=COLORS["gray"])) + numbers.append(f"{pad}Cache write ", style=Style(color=COLORS["gray"])) + numbers.append(f"{cache_create_str:>20}\n", style=Style(color=COLORS["gray"])) + numbers.append(f"{pad}Cache read ", style=Style(color=COLORS["gray"])) + numbers.append(f"{cache_read_str:>20}\n", style=Style(color=COLORS["gray"])) numbers.append("\n\n") labeled_frames.append((numbers, "numbers")) - # Frame 2: Timeline (full year context) + # Frame 2: Timeline (full year context) - ~12 content lines timeline = Text() - timeline.append("\n\n\n") - timeline.append(" T I M E L I N E\n\n", style=Style(color=COLORS["orange"], bold=True)) - timeline.append(" Period ", style=Style(color=COLORS["white"], bold=True)) + timeline.append(vertical_center(12)) + timeline.append(f"{pad}T I M E L I N E\n\n", style=Style(color=COLORS["orange"], bold=True)) + timeline.append(f"{pad}Period ", style=Style(color=COLORS["white"], bold=True)) # Use sentence case for "All time" in timeline period_text = "All time" if stats.year is None else str(stats.year) - timeline.append(f"{period_text}\n", style=Style(color=COLORS["orange"], bold=True)) + timeline.append(f"{period_text:>20}\n", style=Style(color=COLORS["orange"], bold=True)) if stats.first_message_date: - timeline.append(" Journey started ", style=Style(color=COLORS["white"], bold=True)) - timeline.append(f"{stats.first_message_date.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"])) - # Calculate total days + timeline.append(f"{pad}Journey started ", style=Style(color=COLORS["white"], bold=True)) + timeline.append(f"{stats.first_message_date.strftime('%B %d, %Y'):>20}\n", style=Style(color=COLORS["gray"])) + # Calculate total days in year today = datetime.now() if stats.year is None: # All-time: calculate days from first to last message if stats.first_message_date and stats.last_message_date: - total_days = (stats.last_message_date - stats.first_message_date).days + 1 + total_days_year = (stats.last_message_date - stats.first_message_date).days + 1 else: - total_days = stats.active_days + total_days_year = stats.active_days elif stats.year == today.year: - total_days = (today - datetime(stats.year, 1, 1)).days + 1 + total_days_year = (today - datetime(stats.year, 1, 1)).days + 1 else: - total_days = 366 if stats.year % 4 == 0 else 365 - timeline.append(f"\n Active days ", style=Style(color=COLORS["white"], bold=True)) - timeline.append(f"{stats.active_days}", style=Style(color=COLORS["orange"], bold=True)) - timeline.append(f" of {total_days}\n", style=Style(color=COLORS["gray"])) + total_days_year = 366 if stats.year % 4 == 0 else 365 + + # Calculate days since journey start + if stats.first_message_date: + if stats.year is None: + # All-time: days from first to last message + if stats.last_message_date: + days_since_journey = (stats.last_message_date - stats.first_message_date).days + 1 + else: + days_since_journey = stats.active_days + elif stats.year == today.year: + # Current year: days from first message to today + first_msg_date = stats.first_message_date.date() if hasattr(stats.first_message_date, 'date') else stats.first_message_date + days_since_journey = (today.date() - first_msg_date).days + 1 + else: + # Past year: days from first message to end of year + year_end = datetime(stats.year, 12, 31).date() + first_msg_date = stats.first_message_date.date() if hasattr(stats.first_message_date, 'date') else stats.first_message_date + days_since_journey = (year_end - first_msg_date).days + 1 + else: + days_since_journey = stats.active_days + + timeline.append(f"\n{pad}Active days ", style=Style(color=COLORS["white"], bold=True)) + timeline.append(f"{stats.active_days:>20}\n", style=Style(color=COLORS["orange"], bold=True)) + year_pct = (stats.active_days / total_days_year * 100) if total_days_year > 0 else 0 + journey_pct = (stats.active_days / days_since_journey * 100) if days_since_journey > 0 else 0 + year_pct_str = f"{year_pct:.1f}%" + journey_pct_str = f"{journey_pct:.1f}%" + timeline.append(f"{pad}Active days of year ", style=Style(color=COLORS["white"], bold=True)) + timeline.append(f"{year_pct_str:>20}\n", style=Style(color=COLORS["gray"])) + timeline.append(f"{pad}Active days on journey ", style=Style(color=COLORS["white"], bold=True)) + timeline.append(f"{journey_pct_str:>20}\n", style=Style(color=COLORS["purple"], bold=True)) if stats.most_active_hour is not None: hour_label = "AM" if stats.most_active_hour < 12 else "PM" hour_12 = stats.most_active_hour % 12 or 12 - timeline.append(f" Peak hour ", style=Style(color=COLORS["white"], bold=True)) - timeline.append(f"{hour_12}:00 {hour_label}\n", style=Style(color=COLORS["purple"], bold=True)) + hour_str = f"{hour_12}:00 {hour_label}" + timeline.append(f"{pad}Peak hour ", style=Style(color=COLORS["white"], bold=True)) + timeline.append(f"{hour_str:>20}\n", style=Style(color=COLORS["purple"], bold=True)) timeline.append("\n\n") labeled_frames.append((timeline, "timeline")) - # Frame 3: Averages + # Frame 3: Averages - ~12 content lines from .pricing import format_cost averages = Text() - averages.append("\n\n\n") - averages.append(" A V E R A G E S\n\n", style=Style(color=COLORS["blue"], bold=True)) - averages.append(" Messages\n", style=Style(color=COLORS["white"], bold=True)) - averages.append(f" Per day: {stats.avg_messages_per_day:.1f}\n", style=Style(color=COLORS["gray"])) - averages.append(f" Per week: {stats.avg_messages_per_week:.1f}\n", style=Style(color=COLORS["gray"])) - averages.append(f" Per month: {stats.avg_messages_per_month:.1f}\n", style=Style(color=COLORS["gray"])) + averages.append(vertical_center(12)) + averages.append(f"{pad}A V E R A G E S\n\n", style=Style(color=COLORS["blue"], bold=True)) + averages.append(f"{pad}Messages ", style=Style(color=COLORS["white"], bold=True)) + averages.append("\n", style=Style(color=COLORS["white"])) + averages.append(f"{pad}Per day ", style=Style(color=COLORS["gray"])) + averages.append(f"{stats.avg_messages_per_day:>20.1f}\n", style=Style(color=COLORS["gray"])) + averages.append(f"{pad}Per week ", style=Style(color=COLORS["gray"])) + averages.append(f"{stats.avg_messages_per_week:>20.1f}\n", style=Style(color=COLORS["gray"])) + averages.append(f"{pad}Per month ", style=Style(color=COLORS["gray"])) + averages.append(f"{stats.avg_messages_per_month:>20.1f}\n", style=Style(color=COLORS["gray"])) if stats.estimated_cost is not None: - averages.append("\n Cost\n", style=Style(color=COLORS["white"], bold=True)) - averages.append(f" Per day: {format_cost(stats.avg_cost_per_day)}\n", style=Style(color=COLORS["gray"])) - averages.append(f" Per week: {format_cost(stats.avg_cost_per_week)}\n", style=Style(color=COLORS["gray"])) - averages.append(f" Per month: {format_cost(stats.avg_cost_per_month)}\n", style=Style(color=COLORS["gray"])) + averages.append(f"\n{pad}Cost ", style=Style(color=COLORS["white"], bold=True)) + averages.append("\n", style=Style(color=COLORS["white"])) + averages.append(f"{pad}Per day ", style=Style(color=COLORS["gray"])) + averages.append(f"{format_cost(stats.avg_cost_per_day):>20}\n", style=Style(color=COLORS["gray"])) + averages.append(f"{pad}Per week ", style=Style(color=COLORS["gray"])) + averages.append(f"{format_cost(stats.avg_cost_per_week):>20}\n", style=Style(color=COLORS["gray"])) + averages.append(f"{pad}Per month ", style=Style(color=COLORS["gray"])) + averages.append(f"{format_cost(stats.avg_cost_per_month):>20}\n", style=Style(color=COLORS["gray"])) averages.append("\n\n") labeled_frames.append((averages, "averages")) - # Frame 4: Longest Streak (if significant) + # Frame 4: Longest Streak (if significant) - ~10 content lines if stats.streak_longest >= 3 and stats.streak_longest_start and stats.streak_longest_end: streak = Text() - streak.append("\n\n\n") - streak.append(" L O N G E S T S T R E A K\n\n", style=Style(color=COLORS["blue"], bold=True)) - streak.append(f" {stats.streak_longest}", style=Style(color=COLORS["blue"], bold=True)) + streak.append(vertical_center(10)) + streak.append(f"{pad}L O N G E S T S T R E A K\n\n", style=Style(color=COLORS["blue"], bold=True)) + streak.append(f"{pad}{stats.streak_longest}", style=Style(color=COLORS["blue"], bold=True)) streak.append(" days of consistent coding\n\n", style=Style(color=COLORS["white"], bold=True)) - streak.append(f" From ", style=Style(color=COLORS["white"], bold=True)) - streak.append(f"{stats.streak_longest_start.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"])) - streak.append(f" To ", style=Style(color=COLORS["white"], bold=True)) - streak.append(f"{stats.streak_longest_end.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"])) - streak.append("\n Consistency is the key to mastery.\n", style=Style(color=COLORS["gray"])) + streak.append(f"{pad}From ", style=Style(color=COLORS["white"], bold=True)) + streak.append(f"{stats.streak_longest_start.strftime('%B %d, %Y'):>29}\n", style=Style(color=COLORS["gray"])) + streak.append(f"{pad}To ", style=Style(color=COLORS["white"], bold=True)) + streak.append(f"{stats.streak_longest_end.strftime('%B %d, %Y'):>29}\n", style=Style(color=COLORS["gray"])) + streak.append(f"\n{pad}Consistency is the key to mastery.\n", style=Style(color=COLORS["gray"])) if stats.streak_current > 0: - streak.append(f"\n Current streak: {stats.streak_current} days\n", style=Style(color=COLORS["gray"])) + streak.append(f"\n{pad}Current streak: {stats.streak_current} days\n", style=Style(color=COLORS["gray"])) streak.append("\n\n") labeled_frames.append((streak, "streak")) - # Frame 5: Longest Conversation + # Frame 5: Longest Conversation - ~10 content lines if stats.longest_conversation_messages > 0: longest = Text() - longest.append("\n\n\n") - longest.append(" L O N G E S T C O N V E R S A T I O N\n\n", style=Style(color=COLORS["purple"], bold=True)) - longest.append(f" Messages ", style=Style(color=COLORS["white"], bold=True)) + longest.append(vertical_center(10)) + longest.append(f"{pad}L O N G E S T C O N V E R S A T I O N\n\n", style=Style(color=COLORS["purple"], bold=True)) + longest.append(f"{pad}Messages ", style=Style(color=COLORS["white"], bold=True)) longest.append(f"{stats.longest_conversation_messages:,}\n", style=Style(color=COLORS["purple"], bold=True)) if stats.longest_conversation_tokens > 0: - longest.append(f" Tokens ", style=Style(color=COLORS["white"], bold=True)) + longest.append(f"{pad}Tokens ", style=Style(color=COLORS["white"], bold=True)) longest.append(f"{format_tokens(stats.longest_conversation_tokens)}\n", style=Style(color=COLORS["orange"], bold=True)) if stats.longest_conversation_date: - longest.append(f" Date ", style=Style(color=COLORS["white"], bold=True)) + longest.append(f"{pad}Date ", style=Style(color=COLORS["white"], bold=True)) longest.append(f"{stats.longest_conversation_date.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"])) - longest.append("\n That's one epic coding session!\n", style=Style(color=COLORS["gray"])) + longest.append(f"\n{pad}That's one epic coding session!\n", style=Style(color=COLORS["gray"])) longest.append("\n\n") labeled_frames.append((longest, "conversation")) - # Frame 6: Cast (models) + # Frame 6: Cast (models) - ~8 content lines cast = Text() - cast.append("\n\n\n") - cast.append(" S T A R R I N G\n\n", style=Style(color=COLORS["purple"], bold=True)) + cast.append(vertical_center(8)) + cast.append(f"{pad}S T A R R I N G\n\n", style=Style(color=COLORS["purple"], bold=True)) for model, count in stats.models_used.most_common(3): - cast.append(f" Claude {model}", style=Style(color=COLORS["white"], bold=True)) + cast.append(f"{pad}Claude {model}", style=Style(color=COLORS["white"], bold=True)) cast.append(f" ({count:,} messages)\n", style=Style(color=COLORS["gray"])) cast.append("\n\n\n") labeled_frames.append((cast, "starring")) - # Frame 7: Projects + # Frame 7: Projects - ~10 content lines if stats.top_projects: projects = Text() - projects.append("\n\n\n") - projects.append(" P R O J E C T S\n\n", style=Style(color=COLORS["blue"], bold=True)) + projects.append(vertical_center(10)) + projects.append(f"{pad}P R O J E C T S\n\n", style=Style(color=COLORS["blue"], bold=True)) for proj, count in stats.top_projects[:5]: - projects.append(f" {proj}", style=Style(color=COLORS["white"], bold=True)) + projects.append(f"{pad}{proj}", style=Style(color=COLORS["white"], bold=True)) projects.append(f" ({count:,} messages)\n", style=Style(color=COLORS["gray"])) projects.append("\n\n\n") labeled_frames.append((projects, "projects")) - # Frame 8: Final card + # Frame 8: Final card - ~4 content lines, CENTER aligned final = Text() - final.append("\n\n\n\n") + final.append(vertical_center(4)) if stats.year is not None: - final.append(" See you in ", style=Style(color=COLORS["gray"])) + see_you_text = f"See you in {stats.year + 1}" + center_pad = " " * ((console_width - len(see_you_text)) // 2) + final.append(f"{center_pad}See you in ", style=Style(color=COLORS["gray"])) final.append(f"{stats.year + 1}", style=Style(color=COLORS["orange"], bold=True)) else: - final.append(" Nothing exploded. That's a win.", style=Style(color=COLORS["orange"], bold=True)) + alt_text = "Nothing exploded. That's a win." + center_pad = " " * ((console_width - len(alt_text)) // 2) + final.append(f"{center_pad}{alt_text}", style=Style(color=COLORS["orange"], bold=True)) final.append("\n\n\n\n\n\n", style=Style(color=COLORS["gray"])) - final.append(" [ENTER] to exit", style=Style(color=COLORS["dark"])) + exit_text = "[ENTER] to exit" + exit_pad = " " * ((console_width - len(exit_text)) // 2) + final.append(f"{exit_pad}{exit_text}", style=Style(color=COLORS["dark"])) labeled_frames.append((final, "final")) # Map labels to friendly names for [ENTER] prompts @@ -589,11 +967,11 @@ def create_credits_roll(stats: WrappedStats) -> list[Text]: next_label = labeled_frames[i + 1][1] next_name = label_to_name.get(next_label, "continue") if next_name: - frame.append(f" press [ENTER] for {next_name}", style=Style(color=COLORS["dark"])) + frame.append(f"{pad}press [ENTER] for {next_name}", style=Style(color=COLORS["dark"])) else: - frame.append(" press [ENTER] to continue", style=Style(color=COLORS["dark"])) + frame.append(f"{pad}press [ENTER] to continue", style=Style(color=COLORS["dark"])) else: - frame.append(" press [ENTER] to continue", style=Style(color=COLORS["dark"])) + frame.append(f"{pad}press [ENTER] to continue", style=Style(color=COLORS["dark"])) frames.append(frame) return frames @@ -606,13 +984,24 @@ def render_wrapped(stats: WrappedStats, console: Console | None = None, animate: # === CINEMATIC MODE === if animate: - # Loading + # Loading - centered vertically and full width console.clear() + + # Calculate vertical centering + terminal_height = console.height + vertical_padding = (terminal_height // 2) - 1 + for _ in range(vertical_padding): + console.print() + + # Calculate bar width (full width minus text and spinner) loading_text = "Unwrapping your history..." if stats.year is None else "Unwrapping your year..." + text_width = len(loading_text) + 4 # spinner + spacing + bar_width = max(20, console.width - text_width - 4) + with Progress( SpinnerColumn(style=COLORS["orange"]), TextColumn(f"[bold]{loading_text}[/bold]"), - BarColumn(complete_style=COLORS["orange"], finished_style=COLORS["green"]), + BarColumn(complete_style=COLORS["orange"], finished_style=COLORS["green"], bar_width=bar_width), console=console, transient=True, ) as progress: @@ -623,44 +1012,76 @@ def render_wrapped(stats: WrappedStats, console: Console | None = None, animate: console.clear() - # Title slide - wait for keypress - console.print(Align.center(create_title_slide(stats.year))) + # Title slide - ANIMATED + render_animated_title(console, stats.year) wait_for_keypress() console.clear() - # Slide 1: Messages with date range + # Slide 1: Messages with count-up animation first_date = stats.first_message_date.strftime("%d %B") if stats.first_message_date else "the beginning" last_date = stats.last_message_date.strftime("%d %B %Y") if stats.last_message_date else "today" messages_subtitle = f"From {first_date} to {last_date}" - console.print(Align.center(create_dramatic_stat( - f"{stats.total_messages:,}", "MESSAGES", messages_subtitle, COLORS["orange"] - ))) + + # Center vertically (~8 content lines) + vertical_pad = max(0, (console.height - 8) // 2) + console.print("\n" * vertical_pad) + animate_typing(console, "you exchanged", color=COLORS["gray"], delay=0.03) + console.print() + animate_count_up(console, stats.total_messages, duration=1.5, color=COLORS["orange"]) + dramatic_pause(0.2) + label = Text("MESSAGES", style=Style(color=COLORS["white"], bold=True)) + console.print(Align.center(label)) + console.print() + animate_typing(console, messages_subtitle, color=COLORS["gray"], delay=0.02) + console.print("\n\n") + prompt = Text("press [ENTER] to continue", style=Style(color=COLORS["dark"])) + console.print(Align.center(prompt)) wait_for_keypress() console.clear() - # Slide 2: Averages + # Slide 2: Averages with animated reveals (~10 content lines) from .pricing import format_cost - averages_text = Text() - averages_text.append("\n\n\n\n") - averages_text.append("On average, you sent\n\n", style=Style(color=COLORS["gray"])) - averages_text.append(f"{stats.avg_messages_per_day:.0f}", style=Style(color=COLORS["orange"], bold=True)) - averages_text.append(" messages per day\n", style=Style(color=COLORS["white"])) - averages_text.append(f"{stats.avg_messages_per_week:.0f}", style=Style(color=COLORS["blue"], bold=True)) - averages_text.append(" messages per week\n", style=Style(color=COLORS["white"])) - averages_text.append(f"{stats.avg_messages_per_month:.0f}", style=Style(color=COLORS["purple"], bold=True)) - averages_text.append(" messages per month\n\n", style=Style(color=COLORS["white"])) + vertical_pad = max(0, (console.height - 10) // 2) + console.print("\n" * vertical_pad) + animate_typing(console, "On average, you sent", color=COLORS["gray"], delay=0.03) + console.print() + + # Animate each average + dramatic_pause(0.3) + avg_day = Text() + avg_day.append(f"{stats.avg_messages_per_day:.0f}", style=Style(color=COLORS["orange"], bold=True)) + avg_day.append(" messages per day", style=Style(color=COLORS["white"])) + console.print(Align.center(avg_day)) + time.sleep(0.4) + + avg_week = Text() + avg_week.append(f"{stats.avg_messages_per_week:.0f}", style=Style(color=COLORS["blue"], bold=True)) + avg_week.append(" messages per week", style=Style(color=COLORS["white"])) + console.print(Align.center(avg_week)) + time.sleep(0.4) + + avg_month = Text() + avg_month.append(f"{stats.avg_messages_per_month:.0f}", style=Style(color=COLORS["purple"], bold=True)) + avg_month.append(" messages per month", style=Style(color=COLORS["white"])) + console.print(Align.center(avg_month)) + if stats.estimated_cost is not None: - averages_text.append("Costing about ", style=Style(color=COLORS["gray"])) - averages_text.append(f"{format_cost(stats.avg_cost_per_day)}/day", style=Style(color=COLORS["green"], bold=True)) - averages_text.append(f" ยท {format_cost(stats.avg_cost_per_week)}/week", style=Style(color=COLORS["green"])) - averages_text.append(f" ยท {format_cost(stats.avg_cost_per_month)}/month\n", style=Style(color=COLORS["green"])) - averages_text.append("\n\n\n") - averages_text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"])) - console.print(Align.center(averages_text)) + console.print() + dramatic_pause(0.5) + cost_text = Text() + cost_text.append("Costing about ", style=Style(color=COLORS["gray"])) + cost_text.append(f"{format_cost(stats.avg_cost_per_day)}/day", style=Style(color=COLORS["green"], bold=True)) + cost_text.append(f" ยท {format_cost(stats.avg_cost_per_week)}/week", style=Style(color=COLORS["green"])) + cost_text.append(f" ยท {format_cost(stats.avg_cost_per_month)}/month", style=Style(color=COLORS["green"])) + console.print(Align.center(cost_text)) + + console.print("\n\n") + prompt = Text("press [ENTER] to continue", style=Style(color=COLORS["dark"])) + console.print(Align.center(prompt)) wait_for_keypress() console.clear() - # Slide 3: Tokens + # Slide 3: Tokens with dramatic reveal def format_tokens_dramatic(tokens: int) -> str: if tokens >= 1_000_000_000: return f"{tokens / 1_000_000_000:.1f} Bn" @@ -670,30 +1091,45 @@ def format_tokens_dramatic(tokens: int) -> str: return f"{tokens / 1_000:.0f} K" return str(tokens) - tokens_text = Text() - tokens_text.append("\n\n\n\n\n") - tokens_text.append("That's\n\n", style=Style(color=COLORS["gray"])) - tokens_text.append(f"{format_tokens_dramatic(stats.total_tokens)}\n", style=Style(color=COLORS["green"], bold=True)) - tokens_text.append("TOKENS\n\n", style=Style(color=COLORS["white"], bold=True)) - tokens_text.append("processed through the AI", style=Style(color=COLORS["gray"])) - tokens_text.append("\n\n\n\n") - tokens_text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"])) + # Center vertically (~8 content lines) + vertical_pad = max(0, (console.height - 8) // 2) + console.print("\n" * vertical_pad) + animate_typing(console, "Together, you processed", color=COLORS["gray"], delay=0.03) + console.print() + dramatic_pause(0.3) + + # Big token reveal + tokens_display = format_tokens_dramatic(stats.total_tokens) + tokens_text = Text(tokens_display, style=Style(color=COLORS["green"], bold=True)) console.print(Align.center(tokens_text)) + dramatic_pause(0.2) + + tokens_label = Text("TOKENS", style=Style(color=COLORS["white"], bold=True)) + console.print(Align.center(tokens_label)) + console.print() + animate_typing(console, "processed through the AI", color=COLORS["gray"], delay=0.02) + console.print("\n\n") + prompt = Text("press [ENTER] to continue", style=Style(color=COLORS["dark"])) + console.print(Align.center(prompt)) wait_for_keypress() console.clear() - # Slide 4: Streak + Personality (merged) - personality = determine_personality(stats) - streak_text = Text() - streak_text.append("\n\n\n\n") - streak_text.append(f"{stats.streak_longest}\n", style=Style(color=COLORS["blue"], bold=True)) - streak_text.append("DAY STREAK\n\n", style=Style(color=COLORS["white"], bold=True)) - streak_text.append(f"{personality['emoji']} ", style=Style(bold=True)) - streak_text.append(f"{personality['title']}\n", style=Style(color=COLORS["purple"], bold=True)) - streak_text.append(f"{personality['description']}\n", style=Style(color=COLORS["gray"])) - streak_text.append("\n\n\n") - streak_text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"])) - console.print(Align.center(streak_text)) + # Slide 4: Streak reveal (~6 content lines) + vertical_pad = max(0, (console.height - 6) // 2) + console.print("\n" * vertical_pad) + animate_typing(console, "Your longest streak was", color=COLORS["gray"], delay=0.03) + console.print() + dramatic_pause(0.3) + + # Animate streak count + animate_count_up(console, stats.streak_longest, duration=1.0, color=COLORS["blue"]) + dramatic_pause(0.2) + streak_label = Text("DAYS IN A ROW", style=Style(color=COLORS["white"], bold=True)) + console.print(Align.center(streak_label)) + + console.print("\n\n") + prompt = Text("press [ENTER] to continue", style=Style(color=COLORS["dark"])) + console.print(Align.center(prompt)) wait_for_keypress() console.clear() @@ -703,7 +1139,7 @@ def format_tokens_dramatic(tokens: int) -> str: if animate: console.clear() console.print() - console.print(Align.center(create_dashboard_header(stats.year))) + console.print(create_dashboard_header(stats.year, console.width)) console.print() # Big stats row @@ -723,29 +1159,33 @@ def format_tokens_dramatic(tokens: int) -> str: console.print() # Contribution graph - console.print(create_contribution_graph(stats.daily_stats, stats.year)) + if animate: + animate_contribution_graph(console, stats.daily_stats, stats.year) + else: + console.print(create_contribution_graph(stats.daily_stats, stats.year)) if animate: console.print() prompt_text = Text() prompt_text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"])) - console.print(prompt_text) + console.print(Align.center(prompt_text)) wait_for_keypress() # PANEL 2: Personality + Days + Hours if animate: console.clear() console.print() - console.print(Align.center(create_dashboard_header(stats.year))) + console.print(create_dashboard_header(stats.year, console.width)) console.print() - # Charts row + # Charts row - Days panel gets 2/3 of width charts = Table(show_header=False, box=None, padding=(0, 1), expand=True) charts.add_column(ratio=1) charts.add_column(ratio=2) + days_width = (console.width * 2) // 3 # 2/3 of terminal width for Days panel charts.add_row( create_personality_card(stats), - create_weekday_chart(stats.weekday_distribution), + create_weekday_chart(stats.weekday_distribution, days_width), ) console.print(charts) @@ -756,42 +1196,56 @@ def format_tokens_dramatic(tokens: int) -> str: console.print() prompt_text = Text() prompt_text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"])) - console.print(prompt_text) + console.print(Align.center(prompt_text)) + wait_for_keypress() + + # PANEL 3: Top Projects (full width) + if animate: + console.clear() + console.print() + console.print(create_dashboard_header(stats.year, console.width)) + console.print() + + console.print(create_top_list(stats.top_projects, "Top Projects", COLORS["green"], console.width)) + + if animate: + console.print() + prompt_text = Text() + prompt_text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"])) + console.print(Align.center(prompt_text)) wait_for_keypress() - # PANEL 3: Top Tools + Projects + MCPs + # PANEL 4: Top Tools + MCPs if animate: console.clear() console.print() - console.print(Align.center(create_dashboard_header(stats.year))) + console.print(create_dashboard_header(stats.year, console.width)) console.print() - # Top lists + # Top lists - Tools and MCPs side by side lists = Table(show_header=False, box=None, padding=(0, 1), expand=True) lists.add_column(ratio=1) lists.add_column(ratio=1) + # Each column gets roughly 1/2 of terminal width + col_width = console.width // 2 lists.add_row( - create_top_list(stats.top_tools[:5], "Top Tools", COLORS["orange"]), - create_top_list(stats.top_projects, "Top Projects", COLORS["green"]), + create_top_list(stats.top_tools[:5], "Top Tools", COLORS["orange"], col_width), + create_top_list(stats.top_mcps, "Top MCP Servers", COLORS["purple"], col_width) if stats.top_mcps else Text(""), ) console.print(lists) - # MCPs (if any) - if stats.top_mcps: - console.print(create_top_list(stats.top_mcps, "MCP Servers", COLORS["purple"])) - if animate: console.print() prompt_text = Text() prompt_text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"])) - console.print(prompt_text) + console.print(Align.center(prompt_text)) wait_for_keypress() - # PANEL 4: Monthly Costs + Insights + # PANEL 5: Monthly Costs + Insights if animate: console.clear() console.print() - console.print(Align.center(create_dashboard_header(stats.year))) + console.print(create_dashboard_header(stats.year, console.width)) console.print() # Monthly cost table @@ -814,13 +1268,6 @@ def format_tokens_dramatic(tokens: int) -> str: console.print() console.print(Align.center(insights)) - if animate: - console.print() - prompt_text = Text() - prompt_text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"])) - console.print(prompt_text) - wait_for_keypress() - # === CREDITS SEQUENCE === if animate: console.print() @@ -832,14 +1279,18 @@ def format_tokens_dramatic(tokens: int) -> str: # Fun facts facts = get_fun_facts(stats) + pad = " " * (console.width // 4) if facts: - console.print(Align.center(create_fun_facts_slide(facts))) + console.print(create_fun_facts_slide(facts, console.width, console.height)) + prompt_text = Text() + prompt_text.append(f"{pad}press [ENTER] for the credits", style=Style(color=COLORS["dark"])) + console.print(prompt_text) wait_for_keypress() console.clear() # Credits roll - for frame in create_credits_roll(stats): - console.print(Align.center(frame)) + for frame in create_credits_roll(stats, console.width, console.height): + console.print(frame) wait_for_keypress() console.clear()