From 0ca3ba473d5e9cd4880783b8cc48cd2d6f4bee95 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Thu, 23 Jul 2026 19:37:32 -0700 Subject: [PATCH] fix(spinner): suppress ANSI output when stderr is not a terminal Detects is_terminal at SpinnerManager construction. When stderr is not a TTY (piped, redirected, or -p prompt mode), the spinner thread is never started and write_ln/ewrite_ln emit plain text only. Fixes #3615. --- crates/forge_spinner/src/lib.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/forge_spinner/src/lib.rs b/crates/forge_spinner/src/lib.rs index a61233c1e4..03150dc661 100644 --- a/crates/forge_spinner/src/lib.rs +++ b/crates/forge_spinner/src/lib.rs @@ -1,3 +1,4 @@ +use std::io::IsTerminal; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread::{self, JoinHandle}; @@ -213,10 +214,14 @@ pub struct SpinnerManager { word_index: Option, message: Option, printer: Arc

, + /// Whether stderr is a terminal. When false, the spinner thread is never + /// started and no ANSI escape codes are emitted, keeping piped output clean. + is_terminal: bool, } impl SpinnerManager

{ /// Creates a new SpinnerManager with the given output printer. + /// Automatically detects whether stderr is a terminal. pub fn new(printer: Arc

) -> Self { Self { spinner: None, @@ -224,13 +229,19 @@ impl SpinnerManager

{ word_index: None, message: None, printer, + is_terminal: std::io::stderr().is_terminal(), } } - /// Start the spinner with a message + /// Start the spinner with a message. + /// No-ops when stderr is not a terminal (e.g. piped output). pub fn start(&mut self, message: Option<&str>) -> Result<()> { self.stop(None)?; + if !self.is_terminal { + return Ok(()); + } + let words = [ "Thinking", "Processing", @@ -351,6 +362,12 @@ impl SpinnerManager

{ let _ = self.printer.write_err(line.as_bytes()); let _ = self.printer.flush_err(); } + + /// Force terminal detection for testing purposes. + #[cfg(test)] + fn set_terminal_for_testing(&mut self, is_terminal: bool) { + self.is_terminal = is_terminal; + } } impl Drop for SpinnerManager

{ @@ -470,6 +487,7 @@ mod tests { #[test] fn test_spinner_pause_resume_keeps_same_active_spinner() { let mut fixture_spinner = fixture_spinner(); + fixture_spinner.set_terminal_for_testing(true); fixture_spinner.start(Some("Thinking")).unwrap(); fixture_spinner.pause();