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 indicatif::{ProgressBar, ProgressStyle};
use crate::cli::render::{self, Icons, Theme, format_number};
use serde::Serialize;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use tracing::Instrument;
use tracing::{info, warn};
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::metrics::{MetricsLayer, StageTiming};
use crate::core::shutdown::ShutdownSignal;
@@ -76,23 +76,10 @@ pub async fn run_sync(
IngestDisplay::progress_only()
};
let total_stages: u8 = if options.no_docs && options.no_embed {
2
} else if options.no_docs || options.no_embed {
3
} 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");
// ── Stage: Issues ──
let stage_start = Instant::now();
let spinner = stage_spinner_v2(Icons::sync(), "Issues", "fetching...", options.robot_mode);
info!("Sync: ingesting issues");
let issues_result = run_ingest(
config,
"issues",
@@ -110,21 +97,23 @@ pub async fn run_sync(
result.resource_events_fetched += issues_result.resource_events_fetched;
result.resource_events_failed += issues_result.resource_events_failed;
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() {
info!("Shutdown requested after issues stage, returning partial sync results");
return Ok(result);
}
current_stage += 1;
let spinner = stage_spinner(
current_stage,
total_stages,
"Fetching merge requests from GitLab...",
options.robot_mode,
);
info!("Sync stage {current_stage}/{total_stages}: ingesting merge requests");
// ── Stage: MRs ──
let stage_start = Instant::now();
let spinner = stage_spinner_v2(Icons::sync(), "MRs", "fetching...", options.robot_mode);
info!("Sync: ingesting merge requests");
let mrs_result = run_ingest(
config,
"mrs",
@@ -143,37 +132,26 @@ pub async fn run_sync(
result.resource_events_failed += mrs_result.resource_events_failed;
result.mr_diffs_fetched += mrs_result.mr_diffs_fetched;
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() {
info!("Shutdown requested after MRs stage, returning partial sync results");
return Ok(result);
}
// ── Stage: Docs ──
if !options.no_docs {
current_stage += 1;
let spinner = stage_spinner(
current_stage,
total_stages,
"Processing documents...",
options.robot_mode,
);
info!("Sync stage {current_stage}/{total_stages}: generating documents");
let stage_start = Instant::now();
let spinner = stage_spinner_v2(Icons::sync(), "Docs", "generating...", options.robot_mode);
info!("Sync: generating documents");
let docs_bar = if 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 = nested_progress("Docs", 0, options.robot_mode);
let docs_bar_clone = docs_bar.clone();
let tick_started = Arc::new(AtomicBool::new(false));
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))?;
result.documents_regenerated = docs_result.regenerated;
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 {
info!("Sync: skipping document generation (--no-docs)");
}
// ── Stage: Embed ──
if !options.no_embed {
current_stage += 1;
let spinner = stage_spinner(
current_stage,
total_stages,
"Generating embeddings...",
options.robot_mode,
);
info!("Sync stage {current_stage}/{total_stages}: embedding documents");
let stage_start = Instant::now();
let spinner = stage_spinner_v2(Icons::sync(), "Embed", "preparing...", options.robot_mode);
info!("Sync: embedding documents");
let embed_bar = if 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 = nested_progress("Embed", 0, options.robot_mode);
let embed_bar_clone = embed_bar.clone();
let tick_started = Arc::new(AtomicBool::new(false));
let tick_started_clone = Arc::clone(&tick_started);
@@ -234,14 +199,16 @@ pub async fn run_sync(
Ok(embed_result) => {
result.documents_embedded = embed_result.docs_embedded;
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) => {
embed_bar.finish_and_clear();
spinner.finish_and_clear();
if !options.robot_mode {
eprintln!(" {} Embedding skipped ({})", Theme::warning().render("warn"), e);
}
let warn_summary = format!("skipped ({})", e);
finish_stage(&spinner, Icons::warning(), "Embed", &warn_summary, stage_start.elapsed());
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_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::{
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
@@ -145,24 +145,29 @@ async fn main() {
}
// 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()) {
LoreRenderer::init(ColorMode::Never);
LoreRenderer::init(ColorMode::Never, glyphs);
console::set_colors_enabled(false);
} else {
match cli.color.as_str() {
"never" => {
LoreRenderer::init(ColorMode::Never);
LoreRenderer::init(ColorMode::Never, glyphs);
console::set_colors_enabled(false);
}
"always" => {
LoreRenderer::init(ColorMode::Always);
LoreRenderer::init(ColorMode::Always, glyphs);
console::set_colors_enabled(true);
}
"auto" => {
LoreRenderer::init(ColorMode::Auto);
LoreRenderer::init(ColorMode::Auto, glyphs);
}
other => {
LoreRenderer::init(ColorMode::Auto);
LoreRenderer::init(ColorMode::Auto, glyphs);
eprintln!("Warning: unknown color mode '{}', using auto", other);
}
}
@@ -409,21 +414,28 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
);
std::process::exit(gi_error.exit_code());
} 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() {
eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion);
eprintln!();
eprintln!(" {suggestion}");
}
let actions = gi_error.actions();
if !actions.is_empty() {
eprintln!();
for action in &actions {
eprintln!(
" {} {}",
Theme::dim().render("$"),
" {} {}",
Theme::dim().render("\u{2192}"),
Theme::bold().render(action)
);
}
}
eprintln!();
std::process::exit(gi_error.exit_code());
}
}
@@ -443,7 +455,13 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
})
);
} 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);
}
@@ -1966,7 +1984,6 @@ async fn handle_embed(
args: EmbedArgs,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
use indicatif::{ProgressBar, ProgressStyle};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -1985,18 +2002,7 @@ async fn handle_embed(
std::process::exit(130);
});
let embed_bar = if 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 embed_bar = lore::cli::progress::nested_progress("Embedding", 0, robot_mode);
let bar_clone = embed_bar.clone();
let tick_started = Arc::new(AtomicBool::new(false));
let tick_clone = Arc::clone(&tick_started);