refactor(sync): overhaul progress display with stage spinners and summaries

Phase 2 of the UX overhaul. Replaces the old numbered-stage progress
system (1/4, 2/4...) and manual indicatif ProgressBar/ProgressStyle
setup with the new centralized progress helpers.

Sync command changes (src/cli/commands/sync.rs):
- Replace stage_spinner(n, total, msg) with stage_spinner_v2(icon, label, status)
  removing the rigid numbered-stage counter in favor of named stages
- Replace manual ProgressBar::new + ProgressStyle::default_bar for docs
  and embed sub-progress with nested_progress(label, len, robot_mode)
- Add finish_stage() calls that display a completion summary with
  elapsed time, e.g. "Issues  42 issues from 3 projects  1.2s"
- Each stage (Issues, MRs, Docs, Embed) now reports what it did on
  completion rather than just clearing the spinner silently
- Embed failure path uses Icons::warning() instead of inline Theme
  formatting, keeping error display consistent with success path
- Remove indicatif direct dependency from sync.rs (now handled by
  progress module)

Main entry point changes (src/main.rs):
- Add GlyphMode detection: auto-detect Unicode/Nerd Font support or
  fall back to ASCII based on --icons flag, --color=never, NO_COLOR,
  or robot mode
- Update all LoreRenderer::init() calls to pass GlyphMode alongside
  ColorMode for icon-aware rendering throughout the CLI
- Overhaul handle_error() formatting: use Icons::error() glyph,
  bold error text, arrow prefixed action suggestions, and breathing
  room with blank lines for scannability
- Migrate handle_embed() progress bar from manual ProgressBar +
  ProgressStyle to nested_progress() helper, matching sync command

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-14 10:00:22 -05:00
committed by teernisse
parent 96b288ccdd
commit af8fc4af76
2 changed files with 77 additions and 104 deletions

View File

@@ -1,13 +1,13 @@
use crate::cli::render::{self, Theme}; use crate::cli::render::{self, Icons, Theme, format_number};
use indicatif::{ProgressBar, ProgressStyle};
use serde::Serialize; use serde::Serialize;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use tracing::Instrument; use tracing::Instrument;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::Config; use crate::Config;
use crate::cli::progress::stage_spinner; use crate::cli::progress::{finish_stage, nested_progress, stage_spinner_v2};
use crate::core::error::Result; use crate::core::error::Result;
use crate::core::metrics::{MetricsLayer, StageTiming}; use crate::core::metrics::{MetricsLayer, StageTiming};
use crate::core::shutdown::ShutdownSignal; use crate::core::shutdown::ShutdownSignal;
@@ -76,23 +76,10 @@ pub async fn run_sync(
IngestDisplay::progress_only() IngestDisplay::progress_only()
}; };
let total_stages: u8 = if options.no_docs && options.no_embed { // ── Stage: Issues ──
2 let stage_start = Instant::now();
} else if options.no_docs || options.no_embed { let spinner = stage_spinner_v2(Icons::sync(), "Issues", "fetching...", options.robot_mode);
3 info!("Sync: ingesting issues");
} else {
4
};
let mut current_stage: u8 = 0;
current_stage += 1;
let spinner = stage_spinner(
current_stage,
total_stages,
"Fetching issues from GitLab...",
options.robot_mode,
);
info!("Sync stage {current_stage}/{total_stages}: ingesting issues");
let issues_result = run_ingest( let issues_result = run_ingest(
config, config,
"issues", "issues",
@@ -110,21 +97,23 @@ pub async fn run_sync(
result.resource_events_fetched += issues_result.resource_events_fetched; result.resource_events_fetched += issues_result.resource_events_fetched;
result.resource_events_failed += issues_result.resource_events_failed; result.resource_events_failed += issues_result.resource_events_failed;
result.status_enrichment_errors += issues_result.status_enrichment_errors; result.status_enrichment_errors += issues_result.status_enrichment_errors;
spinner.finish_and_clear(); let issues_summary = format!(
"{} issues from {} {}",
format_number(result.issues_updated as i64),
issues_result.projects_synced,
if issues_result.projects_synced == 1 { "project" } else { "projects" }
);
finish_stage(&spinner, Icons::success(), "Issues", &issues_summary, stage_start.elapsed());
if signal.is_cancelled() { if signal.is_cancelled() {
info!("Shutdown requested after issues stage, returning partial sync results"); info!("Shutdown requested after issues stage, returning partial sync results");
return Ok(result); return Ok(result);
} }
current_stage += 1; // ── Stage: MRs ──
let spinner = stage_spinner( let stage_start = Instant::now();
current_stage, let spinner = stage_spinner_v2(Icons::sync(), "MRs", "fetching...", options.robot_mode);
total_stages, info!("Sync: ingesting merge requests");
"Fetching merge requests from GitLab...",
options.robot_mode,
);
info!("Sync stage {current_stage}/{total_stages}: ingesting merge requests");
let mrs_result = run_ingest( let mrs_result = run_ingest(
config, config,
"mrs", "mrs",
@@ -143,37 +132,26 @@ pub async fn run_sync(
result.resource_events_failed += mrs_result.resource_events_failed; result.resource_events_failed += mrs_result.resource_events_failed;
result.mr_diffs_fetched += mrs_result.mr_diffs_fetched; result.mr_diffs_fetched += mrs_result.mr_diffs_fetched;
result.mr_diffs_failed += mrs_result.mr_diffs_failed; result.mr_diffs_failed += mrs_result.mr_diffs_failed;
spinner.finish_and_clear(); let mrs_summary = format!(
"{} merge requests from {} {}",
format_number(result.mrs_updated as i64),
mrs_result.projects_synced,
if mrs_result.projects_synced == 1 { "project" } else { "projects" }
);
finish_stage(&spinner, Icons::success(), "MRs", &mrs_summary, stage_start.elapsed());
if signal.is_cancelled() { if signal.is_cancelled() {
info!("Shutdown requested after MRs stage, returning partial sync results"); info!("Shutdown requested after MRs stage, returning partial sync results");
return Ok(result); return Ok(result);
} }
// ── Stage: Docs ──
if !options.no_docs { if !options.no_docs {
current_stage += 1; let stage_start = Instant::now();
let spinner = stage_spinner( let spinner = stage_spinner_v2(Icons::sync(), "Docs", "generating...", options.robot_mode);
current_stage, info!("Sync: generating documents");
total_stages,
"Processing documents...",
options.robot_mode,
);
info!("Sync stage {current_stage}/{total_stages}: generating documents");
let docs_bar = if options.robot_mode { let docs_bar = nested_progress("Docs", 0, options.robot_mode);
ProgressBar::hidden()
} else {
let b = crate::cli::progress::multi().add(ProgressBar::new(0));
b.set_style(
ProgressStyle::default_bar()
.template(
" {spinner:.blue} Processing documents [{bar:30.cyan/dim}] {pos}/{len}",
)
.unwrap()
.progress_chars("=> "),
);
b
};
let docs_bar_clone = docs_bar.clone(); let docs_bar_clone = docs_bar.clone();
let tick_started = Arc::new(AtomicBool::new(false)); let tick_started = Arc::new(AtomicBool::new(false));
let tick_started_clone = Arc::clone(&tick_started); let tick_started_clone = Arc::clone(&tick_started);
@@ -189,35 +167,22 @@ pub async fn run_sync(
let docs_result = run_generate_docs(config, options.full, None, Some(docs_cb))?; let docs_result = run_generate_docs(config, options.full, None, Some(docs_cb))?;
result.documents_regenerated = docs_result.regenerated; result.documents_regenerated = docs_result.regenerated;
docs_bar.finish_and_clear(); docs_bar.finish_and_clear();
spinner.finish_and_clear(); let docs_summary = format!(
"{} documents generated",
format_number(result.documents_regenerated as i64),
);
finish_stage(&spinner, Icons::success(), "Docs", &docs_summary, stage_start.elapsed());
} else { } else {
info!("Sync: skipping document generation (--no-docs)"); info!("Sync: skipping document generation (--no-docs)");
} }
// ── Stage: Embed ──
if !options.no_embed { if !options.no_embed {
current_stage += 1; let stage_start = Instant::now();
let spinner = stage_spinner( let spinner = stage_spinner_v2(Icons::sync(), "Embed", "preparing...", options.robot_mode);
current_stage, info!("Sync: embedding documents");
total_stages,
"Generating embeddings...",
options.robot_mode,
);
info!("Sync stage {current_stage}/{total_stages}: embedding documents");
let embed_bar = if options.robot_mode { let embed_bar = nested_progress("Embed", 0, options.robot_mode);
ProgressBar::hidden()
} else {
let b = crate::cli::progress::multi().add(ProgressBar::new(0));
b.set_style(
ProgressStyle::default_bar()
.template(
" {spinner:.blue} Generating embeddings [{bar:30.cyan/dim}] {pos}/{len}",
)
.unwrap()
.progress_chars("=> "),
);
b
};
let embed_bar_clone = embed_bar.clone(); let embed_bar_clone = embed_bar.clone();
let tick_started = Arc::new(AtomicBool::new(false)); let tick_started = Arc::new(AtomicBool::new(false));
let tick_started_clone = Arc::clone(&tick_started); let tick_started_clone = Arc::clone(&tick_started);
@@ -234,14 +199,16 @@ pub async fn run_sync(
Ok(embed_result) => { Ok(embed_result) => {
result.documents_embedded = embed_result.docs_embedded; result.documents_embedded = embed_result.docs_embedded;
embed_bar.finish_and_clear(); embed_bar.finish_and_clear();
spinner.finish_and_clear(); let embed_summary = format!(
"{} chunks embedded",
format_number(embed_result.chunks_embedded as i64),
);
finish_stage(&spinner, Icons::success(), "Embed", &embed_summary, stage_start.elapsed());
} }
Err(e) => { Err(e) => {
embed_bar.finish_and_clear(); embed_bar.finish_and_clear();
spinner.finish_and_clear(); let warn_summary = format!("skipped ({})", e);
if !options.robot_mode { finish_stage(&spinner, Icons::warning(), "Embed", &warn_summary, stage_start.elapsed());
eprintln!(" {} Embedding skipped ({})", Theme::warning().render("warn"), e);
}
warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing"); warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing");
} }
} }

View File

@@ -25,7 +25,7 @@ use lore::cli::commands::{
run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
run_sync_status, run_timeline, run_who, run_sync_status, run_timeline, run_who,
}; };
use lore::cli::render::{ColorMode, LoreRenderer, Theme}; use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
use lore::cli::robot::{RobotMeta, strip_schemas}; use lore::cli::robot::{RobotMeta, strip_schemas};
use lore::cli::{ use lore::cli::{
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs, Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
@@ -145,24 +145,29 @@ async fn main() {
} }
// I1: Respect NO_COLOR convention (https://no-color.org/) // I1: Respect NO_COLOR convention (https://no-color.org/)
let force_ascii = robot_mode
|| cli.color == "never"
|| std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty());
let glyphs = GlyphMode::detect(cli.icons.as_deref(), force_ascii);
if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) { if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) {
LoreRenderer::init(ColorMode::Never); LoreRenderer::init(ColorMode::Never, glyphs);
console::set_colors_enabled(false); console::set_colors_enabled(false);
} else { } else {
match cli.color.as_str() { match cli.color.as_str() {
"never" => { "never" => {
LoreRenderer::init(ColorMode::Never); LoreRenderer::init(ColorMode::Never, glyphs);
console::set_colors_enabled(false); console::set_colors_enabled(false);
} }
"always" => { "always" => {
LoreRenderer::init(ColorMode::Always); LoreRenderer::init(ColorMode::Always, glyphs);
console::set_colors_enabled(true); console::set_colors_enabled(true);
} }
"auto" => { "auto" => {
LoreRenderer::init(ColorMode::Auto); LoreRenderer::init(ColorMode::Auto, glyphs);
} }
other => { other => {
LoreRenderer::init(ColorMode::Auto); LoreRenderer::init(ColorMode::Auto, glyphs);
eprintln!("Warning: unknown color mode '{}', using auto", other); eprintln!("Warning: unknown color mode '{}', using auto", other);
} }
} }
@@ -409,9 +414,15 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
); );
std::process::exit(gi_error.exit_code()); std::process::exit(gi_error.exit_code());
} else { } else {
eprintln!("{} {}", Theme::error().render("Error:"), gi_error); eprintln!();
eprintln!(
" {} {}",
Theme::error().render(Icons::error()),
Theme::error().bold().render(&gi_error.to_string())
);
if let Some(suggestion) = gi_error.suggestion() { if let Some(suggestion) = gi_error.suggestion() {
eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion); eprintln!();
eprintln!(" {suggestion}");
} }
let actions = gi_error.actions(); let actions = gi_error.actions();
if !actions.is_empty() { if !actions.is_empty() {
@@ -419,11 +430,12 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
for action in &actions { for action in &actions {
eprintln!( eprintln!(
" {} {}", " {} {}",
Theme::dim().render("$"), Theme::dim().render("\u{2192}"),
Theme::bold().render(action) Theme::bold().render(action)
); );
} }
} }
eprintln!();
std::process::exit(gi_error.exit_code()); std::process::exit(gi_error.exit_code());
} }
} }
@@ -443,7 +455,13 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
}) })
); );
} else { } else {
eprintln!("{} {}", Theme::error().render("Error:"), e); eprintln!();
eprintln!(
" {} {}",
Theme::error().render(Icons::error()),
Theme::error().bold().render(&e.to_string())
);
eprintln!();
} }
std::process::exit(1); std::process::exit(1);
} }
@@ -1966,7 +1984,6 @@ async fn handle_embed(
args: EmbedArgs, args: EmbedArgs,
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
use indicatif::{ProgressBar, ProgressStyle};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -1985,18 +2002,7 @@ async fn handle_embed(
std::process::exit(130); std::process::exit(130);
}); });
let embed_bar = if robot_mode { let embed_bar = lore::cli::progress::nested_progress("Embedding", 0, robot_mode);
ProgressBar::hidden()
} else {
let b = lore::cli::progress::multi().add(ProgressBar::new(0));
b.set_style(
ProgressStyle::default_bar()
.template(" {spinner:.blue} Generating embeddings [{bar:30.cyan/dim}] {pos}/{len}")
.unwrap()
.progress_chars("=> "),
);
b
};
let bar_clone = embed_bar.clone(); let bar_clone = embed_bar.clone();
let tick_started = Arc::new(AtomicBool::new(false)); let tick_started = Arc::new(AtomicBool::new(false));
let tick_clone = Arc::clone(&tick_started); let tick_clone = Arc::clone(&tick_started);