diff --git a/src/cli/autocorrect.rs b/src/cli/autocorrect.rs index c6e4ccd..24f883e 100644 --- a/src/cli/autocorrect.rs +++ b/src/cli/autocorrect.rs @@ -44,6 +44,7 @@ const GLOBAL_FLAGS: &[&str] = &[ "--robot", "--json", "--color", + "--icons", "--quiet", "--no-quiet", "--verbose", diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 75de489..32d1286 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -44,6 +44,10 @@ pub struct Cli { #[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")] pub color: String, + /// Icon set: nerd (Nerd Fonts), unicode, or ascii + #[arg(long, global = true, value_parser = ["nerd", "unicode", "ascii"], help = "Icon set: nerd (Nerd Fonts), unicode, or ascii")] + pub icons: Option, + /// Suppress non-essential output #[arg( short = 'q', diff --git a/src/cli/progress.rs b/src/cli/progress.rs index ed002b5..4a7f7e5 100644 --- a/src/cli/progress.rs +++ b/src/cli/progress.rs @@ -1,8 +1,11 @@ use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use std::io::Write; use std::sync::LazyLock; +use std::time::Duration; use tracing_subscriber::fmt::MakeWriter; +use crate::cli::render::Icons; + static MULTI: LazyLock = LazyLock::new(MultiProgress::new); pub fn multi() -> &'static MultiProgress { @@ -29,6 +32,71 @@ pub fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> Progr pb } +/// Stage spinner with icon prefix and elapsed time on the right. +/// +/// Template: `{spinner:.cyan} {prefix} {wide_msg} {elapsed_style:.dim}` +pub fn stage_spinner_v2(icon: &str, label: &str, msg: &str, robot_mode: bool) -> ProgressBar { + if robot_mode { + return ProgressBar::hidden(); + } + let pb = multi().add(ProgressBar::new_spinner()); + pb.set_style( + ProgressStyle::default_spinner() + .template(" {spinner:.cyan} {prefix} {wide_msg}") + .expect("valid template"), + ); + pb.enable_steady_tick(Duration::from_millis(60)); + pb.set_prefix(format!("{icon} {label}")); + pb.set_message(msg.to_string()); + pb +} + +/// Nested progress bar with count, throughput, and ETA. +/// +/// Template: ` {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}` +pub fn nested_progress(msg: &str, len: u64, robot_mode: bool) -> ProgressBar { + if robot_mode { + return ProgressBar::hidden(); + } + let pb = multi().add(ProgressBar::new(len)); + pb.set_style( + ProgressStyle::default_bar() + .template( + " {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}", + ) + .expect("valid template") + .progress_chars(Icons::progress_chars()), + ); + pb.enable_steady_tick(Duration::from_millis(60)); + pb.set_message(msg.to_string()); + pb +} + +/// Replace a spinner with a static completion line showing icon, label, summary, and elapsed. +/// +/// Output: ` ✓ Label summary elapsed` +pub fn finish_stage(pb: &ProgressBar, icon: &str, label: &str, summary: &str, elapsed: Duration) { + let elapsed_str = format_elapsed(elapsed); + let line = format!(" {icon} {label:<12}{summary:>40} {elapsed_str:>8}",); + pb.set_style(ProgressStyle::with_template("{msg}").expect("valid template")); + pb.finish_with_message(line); +} + +/// Format a Duration as a compact human string (e.g. "1.2s", "42ms", "1m 5s"). +fn format_elapsed(d: Duration) -> String { + let ms = d.as_millis(); + if ms < 1000 { + format!("{ms}ms") + } else if ms < 60_000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else { + let secs = d.as_secs(); + let m = secs / 60; + let s = secs % 60; + format!("{m}m {s}s") + } +} + #[derive(Clone)] pub struct SuspendingWriter; @@ -116,9 +184,6 @@ mod tests { #[test] fn stage_spinner_human_mode_sets_properties() { - // In non-TTY test environments, MultiProgress may report bars as - // hidden. Verify the human-mode code path by checking that prefix - // and message are configured (robot-mode returns a bare hidden bar). let pb = stage_spinner(1, 3, "Testing...", false); assert_eq!(pb.prefix(), "[1/3]"); assert_eq!(pb.message(), "Testing..."); @@ -138,4 +203,52 @@ mod tests { assert_eq!(pb.message(), "Seeding timeline..."); pb.finish_and_clear(); } + + // ── New progress API tests ── + + #[test] + fn stage_spinner_v2_robot_mode_returns_hidden() { + let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", true); + assert!(pb.is_hidden()); + } + + #[test] + fn stage_spinner_v2_human_mode_sets_properties() { + let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", false); + assert!(pb.prefix().contains("Issues")); + assert_eq!(pb.message(), "fetching..."); + pb.finish_and_clear(); + } + + #[test] + fn nested_progress_robot_mode_returns_hidden() { + let pb = nested_progress("Embedding...", 100, true); + assert!(pb.is_hidden()); + } + + #[test] + fn nested_progress_human_mode_sets_length() { + let pb = nested_progress("Embedding...", 100, false); + assert_eq!(pb.length(), Some(100)); + assert_eq!(pb.message(), "Embedding..."); + pb.finish_and_clear(); + } + + #[test] + fn format_elapsed_sub_second() { + assert_eq!(format_elapsed(Duration::from_millis(42)), "42ms"); + assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms"); + } + + #[test] + fn format_elapsed_seconds() { + assert_eq!(format_elapsed(Duration::from_millis(1200)), "1.2s"); + assert_eq!(format_elapsed(Duration::from_millis(5000)), "5.0s"); + } + + #[test] + fn format_elapsed_minutes() { + assert_eq!(format_elapsed(Duration::from_secs(65)), "1m 5s"); + assert_eq!(format_elapsed(Duration::from_secs(120)), "2m 0s"); + } } diff --git a/src/cli/render.rs b/src/cli/render.rs index 2eca421..99540ac 100644 --- a/src/cli/render.rs +++ b/src/cli/render.rs @@ -16,17 +16,234 @@ pub enum ColorMode { Never, } +// ─── Glyph Mode ───────────────────────────────────────────────────────────── + +/// Icon tier: Nerd Font glyphs, Unicode symbols, or plain ASCII. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GlyphMode { + Nerd, + Unicode, + Ascii, +} + +impl GlyphMode { + /// Detect glyph mode from CLI flag, env, or terminal heuristics. + /// + /// Precedence: + /// 1. Explicit `--icons` CLI value (passed as `cli_flag`) + /// 2. `LORE_ICONS` environment variable + /// 3. Auto-detect: Nerd if `$TERM_PROGRAM` matches known Nerd-capable terminals + /// or `$NERD_FONTS=1`; otherwise Unicode + /// 4. Force ASCII if `force_ascii` is true (robot mode or `--color never`) + pub fn detect(cli_flag: Option<&str>, force_ascii: bool) -> Self { + if force_ascii { + return Self::Ascii; + } + + // 1. CLI flag + if let Some(flag) = cli_flag { + return Self::from_str_lossy(flag); + } + + // 2. Env var + if let Ok(val) = std::env::var("LORE_ICONS") { + return Self::from_str_lossy(&val); + } + + // 3. Auto-detect + if Self::detect_nerd_capable() { + Self::Nerd + } else { + Self::Unicode + } + } + + fn from_str_lossy(s: &str) -> Self { + match s.to_ascii_lowercase().as_str() { + "nerd" => Self::Nerd, + "unicode" => Self::Unicode, + "ascii" => Self::Ascii, + _ => Self::Unicode, + } + } + + fn detect_nerd_capable() -> bool { + if std::env::var("NERD_FONTS") + .ok() + .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) + { + return true; + } + + std::env::var("TERM_PROGRAM").ok().is_some_and(|tp| { + matches!( + tp.as_str(), + "WezTerm" | "kitty" | "Alacritty" | "iTerm2.app" | "iTerm.app" + ) + }) + } +} + +// ─── Icons ────────────────────────────────────────────────────────────────── + +/// Glyph catalog returning the right icon for the active `GlyphMode`. +pub struct Icons; + +impl Icons { + fn mode() -> GlyphMode { + RENDERER.get().map_or(GlyphMode::Unicode, |r| r.glyphs) + } + + // ── Status indicators ── + + pub fn success() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f058}", // nf-fa-check_circle + GlyphMode::Unicode => "\u{2714}", // heavy check mark + GlyphMode::Ascii => "[ok]", + } + } + + pub fn warning() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f421}", // nf-oct-alert + GlyphMode::Unicode => "\u{26a0}", // warning sign + GlyphMode::Ascii => "[!]", + } + } + + pub fn error() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f467}", // nf-oct-x_circle + GlyphMode::Unicode => "\u{2716}", // heavy multiplication x + GlyphMode::Ascii => "[X]", + } + } + + pub fn info() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f449}", // nf-oct-info + GlyphMode::Unicode => "\u{2139}", // information source + GlyphMode::Ascii => "[i]", + } + } + + // ── Entity state ── + + pub fn issue_opened() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f41b}", // nf-oct-issue_opened + GlyphMode::Unicode => "\u{25cb}", // white circle + GlyphMode::Ascii => "( )", + } + } + + pub fn issue_closed() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f41d}", // nf-oct-issue_closed + GlyphMode::Unicode => "\u{25cf}", // black circle + GlyphMode::Ascii => "(x)", + } + } + + pub fn mr_opened() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f407}", // nf-oct-git_pull_request + GlyphMode::Unicode => "\u{21c4}", // rightwards arrow over leftwards + GlyphMode::Ascii => "<->", + } + } + + pub fn mr_merged() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f402}", // nf-oct-git_merge + GlyphMode::Unicode => "\u{2714}", // heavy check mark + GlyphMode::Ascii => "[M]", + } + } + + pub fn mr_closed() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f430}", // nf-oct-git_pull_request_closed + GlyphMode::Unicode => "\u{2716}", // heavy multiplication x + GlyphMode::Ascii => "[X]", + } + } + + pub fn mr_draft() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f040}", // nf-fa-pencil + GlyphMode::Unicode => "\u{270e}", // lower right pencil + GlyphMode::Ascii => "[D]", + } + } + + // ── Miscellaneous ── + + pub fn note() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f3ed}", // nf-oct-comment + GlyphMode::Unicode => "\u{25b8}", // black right-pointing small triangle + GlyphMode::Ascii => ">", + } + } + + pub fn search() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f422}", // nf-oct-search + GlyphMode::Unicode => "\u{1f50d}", // left-pointing magnifying glass + GlyphMode::Ascii => "?", + } + } + + pub fn user() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f415}", // nf-oct-person + GlyphMode::Unicode => "@", + GlyphMode::Ascii => "@", + } + } + + pub fn sync() -> &'static str { + match Self::mode() { + GlyphMode::Nerd => "\u{f46a}", // nf-oct-sync + GlyphMode::Unicode => "\u{21bb}", // clockwise open circle arrow + GlyphMode::Ascii => "<>", + } + } + + /// Waiting stage indicator (dimmed dot). + pub fn waiting() -> &'static str { + match Self::mode() { + GlyphMode::Nerd | GlyphMode::Unicode => "\u{00b7}", // middle dot + GlyphMode::Ascii => ".", + } + } + + /// Progress bar characters: (filled, head, empty). + pub fn progress_chars() -> &'static str { + match Self::mode() { + GlyphMode::Nerd | GlyphMode::Unicode => "\u{2501}\u{2578} ", + GlyphMode::Ascii => "=> ", + } + } +} + +// ─── Renderer ─────────────────────────────────────────────────────────────── + /// Global renderer singleton, initialized once in `main.rs`. static RENDERER: OnceLock = OnceLock::new(); pub struct LoreRenderer { /// Resolved at init time so we don't re-check TTY + NO_COLOR on every call. colors: bool, + /// Icon tier for the session. + glyphs: GlyphMode, } impl LoreRenderer { /// Initialize the global renderer. Call once at startup. - pub fn init(mode: ColorMode) { + pub fn init(mode: ColorMode, glyphs: GlyphMode) { let colors = match mode { ColorMode::Always => true, ColorMode::Never => false, @@ -35,7 +252,7 @@ impl LoreRenderer { && std::env::var("NO_COLOR").map_or(true, |v| v.is_empty()) } }; - let _ = RENDERER.set(LoreRenderer { colors }); + let _ = RENDERER.set(LoreRenderer { colors, glyphs }); } /// Get the global renderer. Panics if `init` hasn't been called. @@ -49,6 +266,11 @@ impl LoreRenderer { pub fn colors_enabled(&self) -> bool { self.colors } + + /// The active glyph mode. + pub fn glyph_mode(&self) -> GlyphMode { + self.glyphs + } } /// Check if colors are enabled. Returns false if `LoreRenderer` hasn't been @@ -176,6 +398,39 @@ impl Theme { } } + pub fn state_draft() -> Style { + if colors_on() { + Style::new().foreground("#6b7280") + } else { + Style::new() + } + } + + // Semantic additions + pub fn muted() -> Style { + if colors_on() { + Style::new().foreground("#6b7280") + } else { + Style::new() + } + } + + pub fn highlight() -> Style { + if colors_on() { + Style::new().foreground("#fbbf24").bold() + } else { + Style::new() + } + } + + pub fn timing() -> Style { + if colors_on() { + Style::new().foreground("#94a3b8") + } else { + Style::new() + } + } + // Structure pub fn section_title() -> Style { if colors_on() { @@ -369,6 +624,24 @@ pub fn format_labels(labels: &[String], max_shown: usize) -> String { } } +/// Format a slice of labels without brackets. +/// e.g. `["bug", "urgent"]` with max 2 -> `"bug, urgent"` +/// e.g. `["a", "b", "c", "d"]` with max 2 -> `"a, b +2"` +pub fn format_labels_bare(labels: &[String], max_shown: usize) -> String { + if labels.is_empty() { + return String::new(); + } + + let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect(); + let overflow = labels.len().saturating_sub(max_shown); + + if overflow > 0 { + format!("{} +{}", shown.join(", "), overflow) + } else { + shown.join(", ") + } +} + /// Format a duration in milliseconds as a human-friendly string. pub fn format_duration_ms(ms: u64) -> String { if ms < 1000 { @@ -378,6 +651,26 @@ pub fn format_duration_ms(ms: u64) -> String { } } +/// Format an epoch-ms timestamp as a compact relative time string. +/// Returns short forms like `3h`, `2d`, `1w`, `3mo` suitable for tight table columns. +pub fn format_relative_time_compact(ms_epoch: i64) -> String { + let now = now_ms(); + let diff = now - ms_epoch; + + if diff < 0 { + return "future".to_string(); + } + + match diff { + d if d < 60_000 => "now".to_string(), + d if d < 3_600_000 => format!("{}m", d / 60_000), + d if d < 86_400_000 => format!("{}h", d / 3_600_000), + d if d < 604_800_000 => format!("{}d", d / 86_400_000), + d if d < 2_592_000_000 => format!("{}w", d / 604_800_000), + _ => format!("{}mo", diff / 2_592_000_000), + } +} + // ─── Table Renderer ────────────────────────────────────────────────────────── /// Column alignment for the table renderer. @@ -946,6 +1239,121 @@ mod tests { assert!(plain.contains("1"), "got: {plain}"); } + // ── GlyphMode ── + + #[test] + fn glyph_mode_cli_flag_overrides_all() { + assert_eq!(GlyphMode::detect(Some("ascii"), false), GlyphMode::Ascii); + assert_eq!(GlyphMode::detect(Some("nerd"), false), GlyphMode::Nerd); + assert_eq!( + GlyphMode::detect(Some("unicode"), false), + GlyphMode::Unicode + ); + } + + #[test] + fn glyph_mode_force_ascii_overrides_cli_flag() { + assert_eq!(GlyphMode::detect(Some("nerd"), true), GlyphMode::Ascii); + } + + #[test] + fn glyph_mode_unknown_falls_back_to_unicode() { + assert_eq!(GlyphMode::detect(Some("bogus"), false), GlyphMode::Unicode); + } + + // ── Icons ── + + #[test] + fn icons_return_nonempty_strings() { + // Without RENDERER initialized, Icons falls back to Unicode mode + assert!(!Icons::success().is_empty()); + assert!(!Icons::warning().is_empty()); + assert!(!Icons::error().is_empty()); + assert!(!Icons::info().is_empty()); + assert!(!Icons::issue_opened().is_empty()); + assert!(!Icons::issue_closed().is_empty()); + assert!(!Icons::mr_opened().is_empty()); + assert!(!Icons::mr_merged().is_empty()); + assert!(!Icons::mr_closed().is_empty()); + assert!(!Icons::mr_draft().is_empty()); + assert!(!Icons::note().is_empty()); + assert!(!Icons::search().is_empty()); + assert!(!Icons::user().is_empty()); + assert!(!Icons::sync().is_empty()); + assert!(!Icons::waiting().is_empty()); + assert!(!Icons::progress_chars().is_empty()); + } + + // ── format_labels_bare ── + + #[test] + fn format_labels_bare_empty() { + assert_eq!(format_labels_bare(&[], 2), ""); + } + + #[test] + fn format_labels_bare_no_brackets() { + let labels = vec!["bug".to_string(), "urgent".to_string()]; + assert_eq!(format_labels_bare(&labels, 2), "bug, urgent"); + } + + #[test] + fn format_labels_bare_overflow() { + let labels = vec![ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string(), + ]; + assert_eq!(format_labels_bare(&labels, 2), "a, b +2"); + } + + // ── format_relative_time_compact ── + + #[test] + fn format_relative_time_compact_now() { + let recent = now_ms() - 5_000; + assert_eq!(format_relative_time_compact(recent), "now"); + } + + #[test] + fn format_relative_time_compact_minutes() { + let mins_ago = now_ms() - 300_000; // 5 minutes + assert_eq!(format_relative_time_compact(mins_ago), "5m"); + } + + #[test] + fn format_relative_time_compact_hours() { + let hours_ago = now_ms() - 7_200_000; // 2 hours + assert_eq!(format_relative_time_compact(hours_ago), "2h"); + } + + #[test] + fn format_relative_time_compact_days() { + let days_ago = now_ms() - 172_800_000; // 2 days + assert_eq!(format_relative_time_compact(days_ago), "2d"); + } + + #[test] + fn format_relative_time_compact_weeks() { + let weeks_ago = now_ms() - 1_209_600_000; // 2 weeks + assert_eq!(format_relative_time_compact(weeks_ago), "2w"); + } + + #[test] + fn format_relative_time_compact_months() { + let months_ago = now_ms() - 5_184_000_000; // ~2 months + assert_eq!(format_relative_time_compact(months_ago), "2mo"); + } + + #[test] + fn format_relative_time_compact_future() { + let future = now_ms() + 60_000; + assert_eq!(format_relative_time_compact(future), "future"); + } + + // ── helpers ── + /// Strip ANSI escape codes (SGR sequences) for content assertions. fn strip_ansi(s: &str) -> String { let mut out = String::with_capacity(s.len());