Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
term: Switch to `indicatif` spinner
Merged did:key:z6MkgFq6...nBGz opened 9 months ago

This replaces the current spinner implementation with the one that is provided by indicatif.

Rationale

The current spinner breaks the output on narrow terminals for long spinner messages. This results in the spinner filling up the terminal with a new line for every progress update.

A few other spinner libraries e.g. spinners where taking into consideration, but indicatif turned out to be the best choice in terms of flexibilty and future maintenance. The drawback is obviously that indicatif is built on top of console, yet another terminal abstraction library that is added to our dependencies besides crossterm. But it can be argued, that crossterm is rather low-level, whereas console is more high-level, thus having both is a valid use case.

In order to be able to replace the current spinner with indicatif, the standard I/O stream abstraction was refactored and now provides a RenderTarget, which supports switching streams.

Additionally, it introduces output macros for warnings and errors.

6 files changed +245 -102 37903795 66adbffd
modified Cargo.lock
@@ -478,6 +478,19 @@ dependencies = [
]

[[package]]
+
name = "console"
+
version = "0.16.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d"
+
dependencies = [
+
 "encode_unicode",
+
 "libc",
+
 "once_cell",
+
 "unicode-width 0.2.1",
+
 "windows-sys 0.60.2",
+
]
+

+
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -848,6 +861,12 @@ dependencies = [
]

[[package]]
+
name = "encode_unicode"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+

+
[[package]]
name = "env_filter"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1823,6 +1842,19 @@ dependencies = [
]

[[package]]
+
name = "indicatif"
+
version = "0.18.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "70a646d946d06bedbbc4cac4c218acf4bbf2d87757a784857025f4d447e4e1cd"
+
dependencies = [
+
 "console",
+
 "portable-atomic",
+
 "unicode-width 0.2.1",
+
 "unit-prefix",
+
 "web-time",
+
]
+

+
[[package]]
name = "inout"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1846,7 +1878,7 @@ dependencies = [
 "once_cell",
 "tempfile",
 "unicode-segmentation",
-
 "unicode-width",
+
 "unicode-width 0.1.11",
]

[[package]]
@@ -3004,6 +3036,7 @@ dependencies = [
 "crossbeam-channel",
 "crossterm 0.29.0",
 "git2",
+
 "indicatif",
 "inquire",
 "libc",
 "pretty_assertions",
@@ -4125,6 +4158,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85"

[[package]]
+
name = "unicode-width"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
+

+
[[package]]
+
name = "unit-prefix"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817"
+

+
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4291,6 +4336,16 @@ dependencies = [
]

[[package]]
+
name = "web-time"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+
dependencies = [
+
 "js-sys",
+
 "wasm-bindgen",
+
]
+

+
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4422,6 +4477,15 @@ dependencies = [
]

[[package]]
+
name = "windows-sys"
+
version = "0.60.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+
dependencies = [
+
 "windows-targets 0.53.2",
+
]
+

+
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4445,7 +4509,7 @@ dependencies = [
 "windows_aarch64_gnullvm 0.52.6",
 "windows_aarch64_msvc 0.52.6",
 "windows_i686_gnu 0.52.6",
-
 "windows_i686_gnullvm",
+
 "windows_i686_gnullvm 0.52.6",
 "windows_i686_msvc 0.52.6",
 "windows_x86_64_gnu 0.52.6",
 "windows_x86_64_gnullvm 0.52.6",
@@ -4453,6 +4517,22 @@ dependencies = [
]

[[package]]
+
name = "windows-targets"
+
version = "0.53.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef"
+
dependencies = [
+
 "windows_aarch64_gnullvm 0.53.0",
+
 "windows_aarch64_msvc 0.53.0",
+
 "windows_i686_gnu 0.53.0",
+
 "windows_i686_gnullvm 0.53.0",
+
 "windows_i686_msvc 0.53.0",
+
 "windows_x86_64_gnu 0.53.0",
+
 "windows_x86_64_gnullvm 0.53.0",
+
 "windows_x86_64_msvc 0.53.0",
+
]
+

+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4465,6 +4545,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"

[[package]]
+
name = "windows_aarch64_gnullvm"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
+

+
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4477,6 +4563,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"

[[package]]
+
name = "windows_aarch64_msvc"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
+

+
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4489,12 +4581,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"

[[package]]
+
name = "windows_i686_gnu"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
+

+
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"

[[package]]
+
name = "windows_i686_gnullvm"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
+

+
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4507,6 +4611,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"

[[package]]
+
name = "windows_i686_msvc"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
+

+
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4519,6 +4629,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"

[[package]]
+
name = "windows_x86_64_gnu"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
+

+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4531,6 +4647,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"

[[package]]
+
name = "windows_x86_64_gnullvm"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
+

+
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4543,6 +4665,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

[[package]]
+
name = "windows_x86_64_msvc"
+
version = "0.53.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
+

+
[[package]]
name = "winnow"
version = "0.6.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified crates/radicle-cli/src/node.rs
@@ -1,6 +1,5 @@
use core::time;
use std::collections::BTreeSet;
-
use std::io;
use std::io::Write;

use radicle::node::sync;
@@ -93,51 +92,12 @@ impl SyncError {
    }
}

-
/// Writes sync output.
-
#[derive(Debug)]
-
pub enum SyncWriter {
-
    /// Write to standard out.
-
    Stdout(io::Stdout),
-
    /// Write to standard error.
-
    Stderr(io::Stderr),
-
    /// Discard output, like [`std::io::sink`].
-
    Sink,
-
}
-

-
impl Clone for SyncWriter {
-
    fn clone(&self) -> Self {
-
        match self {
-
            Self::Stdout(_) => Self::Stdout(io::stdout()),
-
            Self::Stderr(_) => Self::Stderr(io::stderr()),
-
            Self::Sink => Self::Sink,
-
        }
-
    }
-
}
-

-
impl io::Write for SyncWriter {
-
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
-
        match self {
-
            Self::Stdout(stdout) => stdout.write(buf),
-
            Self::Stderr(stderr) => stderr.write(buf),
-
            Self::Sink => Ok(buf.len()),
-
        }
-
    }
-

-
    fn flush(&mut self) -> io::Result<()> {
-
        match self {
-
            Self::Stdout(stdout) => stdout.flush(),
-
            Self::Stderr(stderr) => stderr.flush(),
-
            Self::Sink => Ok(()),
-
        }
-
    }
-
}
-

/// Configures how sync progress is reported.
pub struct SyncReporting {
    /// Progress messages or animations.
-
    pub progress: SyncWriter,
+
    pub progress: term::PaintTarget,
    /// Completion messages.
-
    pub completion: SyncWriter,
+
    pub completion: term::PaintTarget,
    /// Debug output.
    pub debug: bool,
}
@@ -145,8 +105,8 @@ pub struct SyncReporting {
impl Default for SyncReporting {
    fn default() -> Self {
        Self {
-
            progress: SyncWriter::Stderr(io::stderr()),
-
            completion: SyncWriter::Stdout(io::stdout()),
+
            progress: term::PaintTarget::Stderr,
+
            completion: term::PaintTarget::Stdout,
            debug: false,
        }
    }
@@ -173,7 +133,7 @@ pub fn announce<R: ReadRepository>(
fn announce_<R>(
    repo: &R,
    settings: SyncSettings,
-
    mut reporting: SyncReporting,
+
    reporting: SyncReporting,
    node: &mut Node,
    profile: &Profile,
) -> Result<Option<sync::AnnouncerResult>, SyncError>
@@ -214,7 +174,7 @@ where
        Err(err) => match err {
            sync::AnnouncerError::AlreadySynced(result) => {
                term::success!(
-
                    &mut reporting.completion;
+
                    &mut reporting.completion.writer();
                    "Nothing to announce, already in sync with {} seed(s) (see `rad sync status`)",
                    term::format::positive(result.synced()),
                );
@@ -222,7 +182,7 @@ where
            }
            sync::AnnouncerError::NoSeeds => {
                term::info!(
-
                    &mut reporting.completion;
+
                    &mut reporting.completion.writer();
                    "{}",
                    term::format::yellow(format!("No seeds found for {rid}."))
                );
@@ -235,8 +195,8 @@ where
    let min_replicas = target.replicas().lower_bound();
    let mut spinner = term::spinner_to(
        format!("Found {} seed(s)..", announcer.progress().unsynced()),
-
        reporting.completion.clone(),
        reporting.progress.clone(),
+
        reporting.completion.clone(),
    );

    match node.announce(rid, settings.timeout, announcer, |node, progress| {
modified crates/radicle-remote-helper/src/push.rs
@@ -898,16 +898,16 @@ fn sync(
    profile: &Profile,
) -> Result<(), cli::node::SyncError> {
    let progress = if io::stderr().is_terminal() {
-
        cli::node::SyncWriter::Stderr(io::stderr())
+
        term::PaintTarget::Stderr
    } else {
-
        cli::node::SyncWriter::Sink
+
        term::PaintTarget::Hidden
    };
    let result = cli::node::announce(
        repo,
        cli::node::SyncSettings::default().with_profile(profile),
        cli::node::SyncReporting {
            progress,
-
            completion: cli::node::SyncWriter::Stderr(io::stderr()),
+
            completion: term::PaintTarget::Stderr,
            debug: opts.sync_debug,
        },
        &mut node,
modified crates/radicle-term/Cargo.toml
@@ -15,7 +15,11 @@ default = ["git2"]
[dependencies]
anstyle-query = "1.0.0"
crossterm = "0.29.0"
-
inquire = { version = "0.7.4", default-features = false, features = ["crossterm", "editor"] }
+
indicatif = { version = "0.18.0" }
+
inquire = { version = "0.7.4", default-features = false, features = [
+
    "crossterm",
+
    "editor",
+
] }
thiserror = { workspace = true }
unicode-display-width = "0.3.0"
unicode-segmentation = "1.7.1"
modified crates/radicle-term/src/io.rs
@@ -46,6 +46,26 @@ pub static CONFIG: LazyLock<RenderConfig> = LazyLock::new(|| RenderConfig {
    ..RenderConfig::default_colored()
});

+
/// Target for paint operations.
+
///
+
/// This tells a [`Spinner`] object where to paint to.
+
#[derive(Clone)]
+
pub enum PaintTarget {
+
    Stdout,
+
    Stderr,
+
    Hidden,
+
}
+

+
impl PaintTarget {
+
    pub fn writer(&self) -> Box<dyn io::Write> {
+
        match self {
+
            PaintTarget::Stdout => Box::new(io::stdout()),
+
            PaintTarget::Stderr => Box::new(io::stderr()),
+
            PaintTarget::Hidden => Box::new(io::sink()),
+
        }
+
    }
+
}
+

#[macro_export]
macro_rules! info {
    ($writer:expr; $($arg:tt)*) => ({
modified crates/radicle-term/src/spinner.rs
@@ -1,13 +1,14 @@
use std::io::IsTerminal;
use std::mem::ManuallyDrop;
-
use std::sync::{Arc, Mutex};
+
use std::sync::{Arc, LazyLock, Mutex};
use std::{fmt, io, thread, time};

-
use crate::io::{PREFIX_ERROR, PREFIX_WARNING};
-
use crate::Paint;
+
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
+

+
use crate::{Paint, PaintTarget};

/// How much time to wait between spinner animation updates.
-
pub const DEFAULT_TICK: time::Duration = time::Duration::from_millis(99);
+
pub const DEFAULT_TICK: time::Duration = time::Duration::from_millis(120);
/// The spinner animation strings.
pub const DEFAULT_STYLE: [Paint<&'static str>; 4] = [
    Paint::magenta("◢"),
@@ -15,9 +16,26 @@ pub const DEFAULT_STYLE: [Paint<&'static str>; 4] = [
    Paint::magenta("◤"),
    Paint::blue("◥"),
];
+
static TEMPLATE: LazyLock<ProgressStyle> =
+
    LazyLock::new(|| ProgressStyle::with_template("{spinner:.blue} {msg}").unwrap());

-
const CLEAR_UNTIL_NEWLINE: crossterm::terminal::Clear =
-
    crossterm::terminal::Clear(crossterm::terminal::ClearType::UntilNewLine);
+
impl From<PaintTarget> for ProgressDrawTarget {
+
    fn from(value: PaintTarget) -> Self {
+
        match value {
+
            PaintTarget::Stdout => ProgressDrawTarget::stdout(),
+
            PaintTarget::Stderr => ProgressDrawTarget::stderr(),
+
            PaintTarget::Hidden => ProgressDrawTarget::hidden(),
+
        }
+
    }
+
}
+

+
enum State {
+
    Running,
+
    Canceled,
+
    Done,
+
    Warn,
+
    Error,
+
}

struct Progress {
    state: State,
@@ -27,20 +45,12 @@ struct Progress {
impl Progress {
    fn new(message: Paint<String>) -> Self {
        Self {
-
            state: State::Running { cursor: 0 },
+
            state: State::Running,
            message,
        }
    }
}

-
enum State {
-
    Running { cursor: usize },
-
    Canceled,
-
    Done,
-
    Warn,
-
    Error,
-
}
-

/// A progress spinner.
pub struct Spinner {
    progress: Arc<Mutex<Progress>>,
@@ -50,10 +60,11 @@ pub struct Spinner {
impl Drop for Spinner {
    fn drop(&mut self) {
        if let Ok(mut progress) = self.progress.lock() {
-
            if let State::Running { .. } = progress.state {
+
            if let State::Running = progress.state {
                progress.state = State::Canceled;
            }
        }
+

        unsafe { ManuallyDrop::take(&mut self.handle) }
            .join()
            .unwrap();
@@ -109,11 +120,10 @@ impl Spinner {
/// failure messages to `stdout`. This function handles signals, with there being only one
/// element handling signals at a time, and is a wrapper to [`spinner_to()`].
pub fn spinner(message: impl ToString) -> Spinner {
-
    let (stdout, stderr) = (io::stdout(), io::stderr());
-
    if stderr.is_terminal() {
-
        spinner_to(message, stdout, stderr)
+
    if io::stderr().is_terminal() {
+
        spinner_to(message, PaintTarget::Stderr, PaintTarget::Stdout)
    } else {
-
        spinner_to(message, stdout, io::sink())
+
        spinner_to(message, PaintTarget::Hidden, PaintTarget::Stdout)
    }
}

@@ -126,11 +136,11 @@ pub fn spinner(message: impl ToString) -> Spinner {
/// handlers, then it will not attempt to install handlers again, and continue running.
pub fn spinner_to(
    message: impl ToString,
-
    mut completion: impl io::Write + Send + 'static,
-
    mut animation: impl io::Write + Send + 'static,
+
    progress_target: PaintTarget,
+
    completion_target: PaintTarget,
) -> Spinner {
    let message = message.to_string();
-
    let progress = Arc::new(Mutex::new(Progress::new(Paint::new(message))));
+
    let progress = Arc::new(Mutex::new(Progress::new(Paint::new(message.clone()))));

    #[cfg(unix)]
    let (sig_tx, sig_rx) = crossbeam_channel::unbounded();
@@ -142,10 +152,18 @@ pub fn spinner_to(
        .name(String::from("spinner"))
        .spawn({
            let progress = progress.clone();
+
            let spinner = ProgressBar::new_spinner();

-
            move || {
-
                write!(animation, "{}", crossterm::cursor::Hide).ok();
+
            spinner.set_draw_target(progress_target.into());
+
            spinner.set_message(message.to_string());
+
            spinner.set_style(TEMPLATE.clone().tick_strings(&[
+
                DEFAULT_STYLE[0].to_string().as_str(),
+
                DEFAULT_STYLE[1].to_string().as_str(),
+
                DEFAULT_STYLE[2].to_string().as_str(),
+
                DEFAULT_STYLE[3].to_string().as_str(),
+
            ]));

+
            move || {
                loop {
                    let Ok(mut progress) = progress.lock() else {
                        break;
@@ -158,15 +176,14 @@ pub fn spinner_to(
                                if sig == radicle_signals::Signal::Interrupt
                                    || sig == radicle_signals::Signal::Terminate =>
                            {
-
                                write!(animation, "\r{CLEAR_UNTIL_NEWLINE}").ok();
+
                                spinner.finish_and_clear();
                                writeln!(
-
                                    completion,
-
                                    "{PREFIX_ERROR} {} {}",
-
                                    &progress.message,
+
                                    completion_target.writer(),
+
                                    "{} {message} {}",
+
                                    super::PREFIX_ERROR,
                                    Paint::red("<canceled>")
                                )
                                .ok();
-
                                drop(animation);
                                std::process::exit(-1);
                            }
                            Ok(_) => {}
@@ -175,51 +192,67 @@ pub fn spinner_to(
                    }
                    match &mut *progress {
                        Progress {
-
                            state: State::Running { cursor },
+
                            state: State::Running,
                            message,
                        } => {
-
                            let spinner = DEFAULT_STYLE[*cursor];
-

-
                            write!(animation, "\r{CLEAR_UNTIL_NEWLINE}{spinner} {message}",).ok();
-

-
                            *cursor += 1;
-
                            *cursor %= DEFAULT_STYLE.len();
+
                            spinner.set_message(message.to_string());
+
                            spinner.inc(1);
                        }
+

                        Progress {
                            state: State::Done,
                            message,
                        } => {
-
                            write!(animation, "\r{CLEAR_UNTIL_NEWLINE}").ok();
-
                            writeln!(completion, "{} {message}", super::PREFIX_SUCCESS).ok();
+
                            spinner.finish_and_clear();
+
                            writeln!(
+
                                completion_target.writer(),
+
                                "{} {message}",
+
                                super::PREFIX_SUCCESS
+
                            )
+
                            .ok();
                            break;
                        }
+

                        Progress {
                            state: State::Canceled,
                            message,
                        } => {
-
                            write!(animation, "\r{CLEAR_UNTIL_NEWLINE}").ok();
+
                            spinner.finish_and_clear();
                            writeln!(
-
                                completion,
-
                                "{PREFIX_ERROR} {message} {}",
+
                                completion_target.writer(),
+
                                "{} {message} {}",
+
                                super::PREFIX_ERROR,
                                Paint::red("<canceled>")
                            )
                            .ok();
                            break;
                        }
+

                        Progress {
                            state: State::Warn,
                            message,
                        } => {
-
                            write!(animation, "\r{CLEAR_UNTIL_NEWLINE}").ok();
-
                            writeln!(completion, "{PREFIX_WARNING} {message}").ok();
+
                            spinner.finish_and_clear();
+
                            writeln!(
+
                                completion_target.writer(),
+
                                "{} {message}",
+
                                super::PREFIX_WARNING
+
                            )
+
                            .ok();
                            break;
                        }
+

                        Progress {
                            state: State::Error,
                            message,
                        } => {
-
                            write!(animation, "\r{CLEAR_UNTIL_NEWLINE}").ok();
-
                            writeln!(completion, "{PREFIX_ERROR} {message}").ok();
+
                            spinner.finish_and_clear();
+
                            writeln!(
+
                                completion_target.writer(),
+
                                "{} {message}",
+
                                super::PREFIX_ERROR
+
                            )
+
                            .ok();
                            break;
                        }
                    }
@@ -227,8 +260,6 @@ pub fn spinner_to(
                    thread::sleep(DEFAULT_TICK);
                }

-
                write!(animation, "{}", crossterm::cursor::Show).ok();
-

                #[cfg(unix)]
                if sig_result.is_ok() {
                    let _ = radicle_signals::uninstall();