From 25bd29b7883518182af9dc3e254f996977fc4df9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:54:57 +0000 Subject: [PATCH] feat: enhance CLI output with modern progress bars and tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change introduces two micro-UX improvements to the CLI experience: 1. **Cleaner Progress Bars**: Replaced the "light shade" character (`░`) with a cleaner "middle dot" (`·`) for less visual noise during long operations. 2. **Card-Style Summary Table**: Replaced the plain ASCII summary table with a Unicode box-drawing table (with robust ASCII fallback) for a more polished report at the end of execution. 3. **Success Delight**: Added a random success message on completion to add a touch of personality. These changes respect the existing `USE_COLORS` logic (checking TTY) to ensure CI logs remain readable. Verification: - Ran `pytest tests/` (all passed). - Visually verified both Unicode (forced) and ASCII output modes. Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- .Jules/palette.md | 4 ++ main.py | 119 +++++++++++++++++++++++++++++++++++----------- tests/test_ux.py | 3 +- 3 files changed, 96 insertions(+), 30 deletions(-) diff --git a/.Jules/palette.md b/.Jules/palette.md index 5d11198..c11e524 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -1,3 +1,7 @@ ## 2025-01-26 - [Silent Waits in CI] **Learning:** Long silent waits in CLI tools (especially in CI/non-interactive mode) cause user anxiety about hung processes. **Action:** Always provide periodic heartbeat logs (e.g. every 10s) for long operations in non-interactive environments. + +## 2025-02-14 - [ASCII Fallback for Tables] +**Learning:** Using Unicode box drawing characters enhances the CLI experience, but a robust ASCII fallback is crucial for CI environments and piped outputs. +**Action:** Always implement a fallback mechanism (like checking `sys.stderr.isatty()`) when using rich text or Unicode symbols. diff --git a/main.py b/main.py index 1f4f047..3e3bd00 100644 --- a/main.py +++ b/main.py @@ -21,6 +21,7 @@ import logging import os import platform +import random import re import shutil import socket @@ -71,6 +72,15 @@ class Colors: UNDERLINE = "" +class Box: + """Box drawing characters for pretty tables.""" + + if USE_COLORS: + H, V, TL, TR, BL, BR, T, B, L, R, X = "─", "│", "┌", "┐", "└", "┘", "┬", "┴", "├", "┤", "┼" + else: + H, V, TL, TR, BL, BR, T, B, L, R, X = "-", "|", "+", "+", "+", "+", "+", "+", "+", "+", "+" + + class ColoredFormatter(logging.Formatter): """Custom formatter to add colors to log levels.""" @@ -332,7 +342,7 @@ def countdown_timer(seconds: int, message: str = "Waiting") -> None: for remaining in range(seconds, 0, -1): progress = (seconds - remaining + 1) / seconds filled = int(width * progress) - bar = "█" * filled + "░" * (width - filled) + bar = "█" * filled + "·" * (width - filled) sys.stderr.write( f"\r{Colors.CYAN}⏳ {message}: [{bar}] {remaining}s...{Colors.ENDC}" ) @@ -354,7 +364,7 @@ def render_progress_bar( progress = min(1.0, current / total) filled = int(width * progress) - bar = "█" * filled + "░" * (width - filled) + bar = "█" * filled + "·" * (width - filled) percent = int(progress * 100) # Use \033[K to clear line residue @@ -2051,25 +2061,56 @@ def validate_profile_input(value: str) -> bool: max_profile_len = max((len(r["profile"]) for r in sync_results), default=25) profile_col_width = max(25, max_profile_len) - # Calculate total width for the table - # Profile ID + " | " + Folders + " | " + Rules + " | " + Duration + " | " + Status - # Widths: profile_col_width + 3 + 10 + 3 + 10 + 3 + 10 + 3 + 15 = profile_col_width + 57 - table_width = profile_col_width + 57 - - title_text = "DRY RUN SUMMARY" if args.dry_run else "SYNC SUMMARY" + # Column widths + w_profile = profile_col_width + w_folders = 10 + w_rules = 12 + w_duration = 10 + w_status = 15 + + def make_col_separator(left, mid, right, horiz): + parts = [ + horiz * (w_profile + 2), + horiz * (w_folders + 2), + horiz * (w_rules + 2), + horiz * (w_duration + 2), + horiz * (w_status + 2), + ] + return left + mid.join(parts) + right + + # Calculate table width using a dummy separator + dummy_sep = make_col_separator(Box.TL, Box.T, Box.TR, Box.H) + table_width = len(dummy_sep) + + title_text = " DRY RUN SUMMARY " if args.dry_run else " SYNC SUMMARY " title_color = Colors.CYAN if args.dry_run else Colors.HEADER - print("\n" + "=" * table_width) - print(f"{title_color}{title_text:^{table_width}}{Colors.ENDC}") - print("=" * table_width) + # Top Border (Single Cell for Title) + print("\n" + Box.TL + Box.H * (table_width - 2) + Box.TR) - # Header + # Title Row + visible_title = title_text.strip() + inner_width = table_width - 2 + pad_left = (inner_width - len(visible_title)) // 2 + pad_right = inner_width - len(visible_title) - pad_left print( - f"{Colors.BOLD}" - f"{'Profile ID':<{profile_col_width}} | {'Folders':>10} | {'Rules':>10} | {'Duration':>10} | {'Status':<15}" - f"{Colors.ENDC}" + f"{Box.V}{' ' * pad_left}{title_color}{visible_title}{Colors.ENDC}{' ' * pad_right}{Box.V}" ) - print("-" * table_width) + + # Separator between Title and Headers (introduces columns) + print(make_col_separator(Box.L, Box.T, Box.R, Box.H)) + + # Header Row + print( + f"{Box.V} {Colors.BOLD}{'Profile ID':<{w_profile}}{Colors.ENDC} " + f"{Box.V} {Colors.BOLD}{'Folders':>{w_folders}}{Colors.ENDC} " + f"{Box.V} {Colors.BOLD}{'Rules':>{w_rules}}{Colors.ENDC} " + f"{Box.V} {Colors.BOLD}{'Duration':>{w_duration}}{Colors.ENDC} " + f"{Box.V} {Colors.BOLD}{'Status':<{w_status}}{Colors.ENDC} {Box.V}" + ) + + # Separator between Header and Body + print(make_col_separator(Box.L, Box.X, Box.R, Box.H)) # Rows total_folders = 0 @@ -2080,18 +2121,23 @@ def validate_profile_input(value: str) -> bool: # Use boolean success field for color logic status_color = Colors.GREEN if res["success"] else Colors.FAIL + s_folders = f"{res['folders']:,}" + s_rules = f"{res['rules']:,}" + s_duration = f"{res['duration']:.1f}s" + print( - f"{res['profile']:<{profile_col_width}} | " - f"{res['folders']:>10} | " - f"{res['rules']:>10,} | " - f"{res['duration']:>9.1f}s | " - f"{status_color}{res['status_label']:<15}{Colors.ENDC}" + f"{Box.V} {res['profile']:<{w_profile}} " + f"{Box.V} {s_folders:>{w_folders}} " + f"{Box.V} {s_rules:>{w_rules}} " + f"{Box.V} {s_duration:>{w_duration}} " + f"{Box.V} {status_color}{res['status_label']:<{w_status}}{Colors.ENDC} {Box.V}" ) total_folders += res["folders"] total_rules += res["rules"] total_duration += res["duration"] - print("-" * table_width) + # Separator between Body and Total + print(make_col_separator(Box.L, Box.X, Box.R, Box.H)) # Total Row total = len(profile_ids or ["dry-run-placeholder"]) @@ -2110,15 +2156,30 @@ def validate_profile_input(value: str) -> bool: total_status_color = Colors.GREEN if all_success else Colors.FAIL + s_total_folders = f"{total_folders:,}" + s_total_rules = f"{total_rules:,}" + s_total_duration = f"{total_duration:.1f}s" + print( - f"{Colors.BOLD}" - f"{'TOTAL':<{profile_col_width}} | " - f"{total_folders:>10} | " - f"{total_rules:>10,} | " - f"{total_duration:>9.1f}s | " - f"{total_status_color}{total_status_text:<15}{Colors.ENDC}" + f"{Box.V} {Colors.BOLD}{'TOTAL':<{w_profile}}{Colors.ENDC} " + f"{Box.V} {s_total_folders:>{w_folders}} " + f"{Box.V} {s_total_rules:>{w_rules}} " + f"{Box.V} {s_total_duration:>{w_duration}} " + f"{Box.V} {total_status_color}{total_status_text:<{w_status}}{Colors.ENDC} {Box.V}" ) - print("=" * table_width + "\n") + # Bottom Border + print(make_col_separator(Box.BL, Box.B, Box.BR, Box.H)) + + # Success Delight + if all_success and USE_COLORS and not args.dry_run: + success_msgs = [ + "✨ All synced!", + "🚀 Ready for liftoff!", + "🎨 Beautifully done!", + "💎 Smooth operation!", + "🌈 Perfect harmony!", + ] + print(f"\n{Colors.GREEN}{random.choice(success_msgs)}{Colors.ENDC}") # Display cache statistics if any cache activity occurred if _cache_stats["hits"] + _cache_stats["misses"] + _cache_stats["validations"] > 0: diff --git a/tests/test_ux.py b/tests/test_ux.py index ad593ee..bc1385e 100644 --- a/tests/test_ux.py +++ b/tests/test_ux.py @@ -22,7 +22,8 @@ def test_countdown_timer_visuals(monkeypatch): combined_output = "".join(writes) # Check for progress bar chars - assert "░" in combined_output + # We changed the empty character from '░' to '·' in the progress bar + assert "·" in combined_output assert "█" in combined_output assert "Test" in combined_output assert "Done!" in combined_output