feat(cli): add GlyphMode icon system, Theme extensions, and progress API

Phase 1 of UX skin overhaul: foundation layer that all subsequent
phases build upon.

Icons: 3-tier glyph system (Nerd Font / Unicode / ASCII) with
auto-detection from TERM_PROGRAM, LORE_ICONS env, or --icons flag.
16 semantic icon methods on Icons struct (success, warning, error,
issue states, MR states, note, search, user, sync, waiting).

Theme: 4 new semantic styles — muted (#6b7280), highlight (#fbbf24),
timing (#94a3b8), state_draft (#6b7280).

Progress: stage_spinner_v2 with icon prefix, nested_progress with
bounded bar/throughput/ETA, finish_stage for static completion lines,
format_elapsed for compact duration strings.

Utilities: format_relative_time_compact (3h, 2d, 1w, 3mo),
format_labels_bare (comma-separated without brackets).

CLI: --icons global flag, GLOBAL_FLAGS registry updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-14 10:05:02 -05:00
parent ebf64816c9
commit d710403567
4 changed files with 531 additions and 5 deletions

View File

@@ -44,6 +44,7 @@ const GLOBAL_FLAGS: &[&str] = &[
"--robot", "--robot",
"--json", "--json",
"--color", "--color",
"--icons",
"--quiet", "--quiet",
"--no-quiet", "--no-quiet",
"--verbose", "--verbose",

View File

@@ -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")] #[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")]
pub color: String, 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<String>,
/// Suppress non-essential output /// Suppress non-essential output
#[arg( #[arg(
short = 'q', short = 'q',

View File

@@ -1,8 +1,11 @@
use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::io::Write; use std::io::Write;
use std::sync::LazyLock; use std::sync::LazyLock;
use std::time::Duration;
use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::fmt::MakeWriter;
use crate::cli::render::Icons;
static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new); static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new);
pub fn multi() -> &'static MultiProgress { pub fn multi() -> &'static MultiProgress {
@@ -29,6 +32,71 @@ pub fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> Progr
pb 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)] #[derive(Clone)]
pub struct SuspendingWriter; pub struct SuspendingWriter;
@@ -116,9 +184,6 @@ mod tests {
#[test] #[test]
fn stage_spinner_human_mode_sets_properties() { 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); let pb = stage_spinner(1, 3, "Testing...", false);
assert_eq!(pb.prefix(), "[1/3]"); assert_eq!(pb.prefix(), "[1/3]");
assert_eq!(pb.message(), "Testing..."); assert_eq!(pb.message(), "Testing...");
@@ -138,4 +203,52 @@ mod tests {
assert_eq!(pb.message(), "Seeding timeline..."); assert_eq!(pb.message(), "Seeding timeline...");
pb.finish_and_clear(); 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");
}
} }

View File

@@ -16,17 +16,234 @@ pub enum ColorMode {
Never, 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`. /// Global renderer singleton, initialized once in `main.rs`.
static RENDERER: OnceLock<LoreRenderer> = OnceLock::new(); static RENDERER: OnceLock<LoreRenderer> = OnceLock::new();
pub struct LoreRenderer { pub struct LoreRenderer {
/// Resolved at init time so we don't re-check TTY + NO_COLOR on every call. /// Resolved at init time so we don't re-check TTY + NO_COLOR on every call.
colors: bool, colors: bool,
/// Icon tier for the session.
glyphs: GlyphMode,
} }
impl LoreRenderer { impl LoreRenderer {
/// Initialize the global renderer. Call once at startup. /// Initialize the global renderer. Call once at startup.
pub fn init(mode: ColorMode) { pub fn init(mode: ColorMode, glyphs: GlyphMode) {
let colors = match mode { let colors = match mode {
ColorMode::Always => true, ColorMode::Always => true,
ColorMode::Never => false, ColorMode::Never => false,
@@ -35,7 +252,7 @@ impl LoreRenderer {
&& std::env::var("NO_COLOR").map_or(true, |v| v.is_empty()) && 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. /// Get the global renderer. Panics if `init` hasn't been called.
@@ -49,6 +266,11 @@ impl LoreRenderer {
pub fn colors_enabled(&self) -> bool { pub fn colors_enabled(&self) -> bool {
self.colors 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 /// 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 // Structure
pub fn section_title() -> Style { pub fn section_title() -> Style {
if colors_on() { 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. /// Format a duration in milliseconds as a human-friendly string.
pub fn format_duration_ms(ms: u64) -> String { pub fn format_duration_ms(ms: u64) -> String {
if ms < 1000 { 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 ────────────────────────────────────────────────────────── // ─── Table Renderer ──────────────────────────────────────────────────────────
/// Column alignment for the table renderer. /// Column alignment for the table renderer.
@@ -946,6 +1239,121 @@ mod tests {
assert!(plain.contains("1"), "got: {plain}"); 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. /// Strip ANSI escape codes (SGR sequences) for content assertions.
fn strip_ansi(s: &str) -> String { fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len()); let mut out = String::with_capacity(s.len());