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:
@@ -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<MultiProgress> = 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user