From 9b4bd545dd08806a7074ea9a959e957be10425e9 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Tue, 12 Aug 2025 08:28:48 +0200 Subject: [PATCH] [feat] add `--no-progress` CLI option to disable progress bars and spinners --- src/lib.rs | 12 +++++++++++- src/main.rs | 5 +++++ src/models.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 690ac01..153c5d3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; static QUIET: AtomicBool = AtomicBool::new(false); static NO_INTERACTION: AtomicBool = AtomicBool::new(false); static VERBOSE: AtomicU8 = AtomicU8::new(0); +static NO_PROGRESS: AtomicBool = AtomicBool::new(false); /// Set quiet mode: when true, non-interactive logs should be suppressed. pub fn set_quiet(q: bool) { @@ -47,6 +48,15 @@ pub fn verbose_level() -> u8 { VERBOSE.load(Ordering::Relaxed) } +/// Disable interactive progress indicators (bars/spinners) +pub fn set_no_progress(b: bool) { + NO_PROGRESS.store(b, Ordering::Relaxed); +} +/// Return current no-progress state +pub fn is_no_progress() -> bool { + NO_PROGRESS.load(Ordering::Relaxed) +} + /// Check whether stdin is connected to a TTY. Used to avoid blocking prompts when not interactive. pub fn stdin_is_tty() -> bool { #[cfg(unix)] @@ -246,7 +256,7 @@ pub mod ui { /// Create a manager that enables bars when `n > 1`, stderr is a TTY, and not quiet. pub fn default_for_files(n: usize) -> Self { - let enabled = n > 1 && atty::is(Stream::Stderr) && !crate::is_quiet(); + let enabled = n > 1 && atty::is(Stream::Stderr) && !crate::is_quiet() && !crate::is_no_progress(); Self::new(enabled) } diff --git a/src/main.rs b/src/main.rs index 883275e..da0f480 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,6 +55,10 @@ struct Args { #[arg(long = "no-interaction", global = true)] no_interaction: bool, + /// Disable interactive progress indicators (bars/spinners) + #[arg(long = "no-progress", global = true)] + no_progress: bool, + /// Optional auxiliary subcommands (completions, man) #[command(subcommand)] aux: Option, @@ -218,6 +222,7 @@ fn run() -> Result<()> { polyscribe::set_verbose(args.verbose); polyscribe::set_quiet(args.quiet); polyscribe::set_no_interaction(args.no_interaction); + polyscribe::set_no_progress(args.no_progress); // Startup banner via UI (TTY-aware through cliclack), suppressed when quiet polyscribe::ui::intro(format!("PolyScribe v{}", env!("CARGO_PKG_VERSION"))); diff --git a/src/models.rs b/src/models.rs index 54fbbb6..593c554 100644 --- a/src/models.rs +++ b/src/models.rs @@ -14,6 +14,8 @@ use reqwest::blocking::Client; use reqwest::redirect::Policy; use serde::Deserialize; use sha2::{Digest, Sha256}; +use indicatif::{ProgressBar, ProgressStyle}; +use atty::Stream; // --- Model downloader: list & download ggml models from Hugging Face --- @@ -693,21 +695,47 @@ fn download_one_model(client: &Client, models_dir: &Path, entry: &ModelEntry) -> .with_context(|| format!("Failed to create {}", tmp_path.display()))?, ); + // Set up progress bar if interactive and we know size + let show_progress = !crate::is_quiet() && !crate::is_no_progress() && atty::is(Stream::Stderr) && entry.size > 0; + let pb_opt = if show_progress { + let pb = ProgressBar::new(entry.size); + let style = ProgressStyle::with_template("Downloading {prefix} ({total_bytes}) [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({percent}%)") + .unwrap() + .progress_chars("=>-"); + pb.set_style(style); + pb.set_prefix(format!("{}", entry.name)); + Some(pb) + } else { None }; + let mut hasher = Sha256::new(); + let mut downloaded: u64 = 0; let mut buf = [0u8; 1024 * 128]; + let mut read_err: Option = None; loop { - let n = resp.read(&mut buf).context("Network read error")?; - if n == 0 { - break; + let nres = resp.read(&mut buf); + match nres { + Ok(n) => { + if n == 0 { break; } + hasher.update(&buf[..n]); + if let Err(e) = file.write_all(&buf[..n]) { read_err = Some(anyhow!(e)); break; } + downloaded += n as u64; + if let Some(pb) = &pb_opt { pb.set_position(downloaded.min(entry.size)); } + } + Err(e) => { read_err = Some(anyhow!("Network read error: {}", e)); break; } } - hasher.update(&buf[..n]); - file.write_all(&buf[..n]).context("Write error")?; } file.flush().ok(); + if let Some(err) = read_err { + if let Some(pb) = &pb_opt { pb.abandon_with_message("download failed"); } + let _ = std::fs::remove_file(&tmp_path); + return Err(err); + } + let got = to_hex_lower(&hasher.finalize()); if let Some(expected) = &entry.sha256 { if got != expected.to_lowercase() { + if let Some(pb) = &pb_opt { pb.abandon_with_message("hash mismatch"); } let _ = std::fs::remove_file(&tmp_path); return Err(anyhow!( "SHA-256 mismatch for {}: expected {}, got {}", @@ -728,6 +756,7 @@ fn download_one_model(client: &Client, models_dir: &Path, entry: &ModelEntry) -> } std::fs::rename(&tmp_path, &final_path) .with_context(|| format!("Failed to move into place: {}", final_path.display()))?; + if let Some(pb) = &pb_opt { pb.finish_with_message("saved"); } qlog!("Saved: {}", final_path.display()); Ok(()) } @@ -805,7 +834,17 @@ pub fn update_local_models() -> Result<()> { if let Some(remote) = map.get(&model_name) { // If SHA256 available, verify and update if mismatch if let Some(expected) = &remote.sha256 { - match compute_file_sha256_hex(&path) { + // Show a small spinner while verifying hash (TTY, not quiet, not no-progress) + let show_spin = !crate::is_quiet() && !crate::is_no_progress() && atty::is(Stream::Stderr); + let spinner = if show_spin { + let pb = ProgressBar::new_spinner(); + pb.enable_steady_tick(std::time::Duration::from_millis(100)); + pb.set_message(format!("Verifying {}", fname)); + Some(pb) + } else { None }; + let verify_res = compute_file_sha256_hex(&path); + if let Some(pb) = &spinner { pb.finish_and_clear(); } + match verify_res { Ok(local_hash) => { if local_hash.eq_ignore_ascii_case(expected) { qlog!("{} is up-to-date.", fname);