use std::borrow::Cow; use std::io; use std::sync::{Arc, Mutex}; use anyhow::Result; use bstr::ByteSlice; use colored::Colorize; use forge_domain::ConsoleWriter; use forge_markdown_stream::StreamdownRenderer; use forge_spinner::SpinnerManager; /// Shared spinner wrapper that encapsulates locking for thread-safe spinner /// operations. /// /// Provides the same API as `SpinnerManager` but handles mutex locking /// internally, releasing the lock immediately after each operation completes. pub struct SharedSpinner(Arc>>); impl Clone for SharedSpinner

{ fn clone(&self) -> Self { Self(Arc::clone(&self.0)) } } impl SharedSpinner

{ /// Creates a new shared spinner from a SpinnerManager. pub fn new(spinner: SpinnerManager

) -> Self { Self(Arc::new(Mutex::new(spinner))) } /// Start the spinner with a message. pub fn start(&self, message: Option<&str>) -> Result<()> { self.0 .lock() .unwrap_or_else(|e| e.into_inner()) .start(message) } /// Stop the active spinner if any. pub fn stop(&self, message: Option) -> Result<()> { self.0 .lock() .unwrap_or_else(|e| e.into_inner()) .stop(message) } /// Pause the active spinner if any. pub fn pause(&self) { self.0.lock().unwrap_or_else(|e| e.into_inner()).pause() } /// Resume the active spinner if any. pub fn resume(&self) { self.0.lock().unwrap_or_else(|e| e.into_inner()).resume() } /// Resets the stopwatch to zero. pub fn reset(&self) { self.0.lock().unwrap_or_else(|e| e.into_inner()).reset() } /// Writes a line to stdout, suspending the spinner if active. pub fn write_ln(&self, message: impl ToString) -> Result<()> { self.0 .lock() .unwrap_or_else(|e| e.into_inner()) .write_ln(message) } /// Writes a line to stderr, suspending the spinner if active. pub fn ewrite_ln(&self, message: impl ToString) -> Result<()> { self.0 .lock() .unwrap_or_else(|e| e.into_inner()) .ewrite_ln(message) } } /// Content styling for output. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum Style { #[default] Normal, Dimmed, } impl Style { /// Applies styling to content string. fn apply(self, content: String) -> String { match self { Self::Normal => content, Self::Dimmed => content.dimmed().to_string(), } } } fn term_width() -> usize { terminal_size::terminal_size() .map(|(w, _)| w.0 as usize) .unwrap_or(80) } /// Streaming markdown writer with automatic spinner management. /// /// Coordinates between markdown rendering and spinner visibility: /// - Stops spinner when content is being written /// - Restarts spinner when idle pub struct StreamingWriter { active: Option>, spinner: SharedSpinner

, printer: Arc

, } impl StreamingWriter

{ /// Creates a new stream writer with the given shared spinner and output /// printer. pub fn new(spinner: SharedSpinner

, printer: Arc

) -> Self { Self { active: None, spinner, printer } } /// Writes markdown content with normal styling. pub fn write(&mut self, text: &str) -> Result<()> { self.write_styled(text, Style::Normal) } /// Writes markdown content with dimmed styling (for reasoning blocks). pub fn write_dimmed(&mut self, text: &str) -> Result<()> { self.write_styled(text, Style::Dimmed) } /// Finishes any active renderer. pub fn finish(&mut self) -> Result<()> { if let Some(active) = self.active.take() { active.finish()?; } Ok(()) } fn write_styled(&mut self, text: &str, style: Style) -> Result<()> { self.ensure_renderer(style)?; if let Some(ref mut active) = self.active { active.push(text)?; } Ok(()) } fn ensure_renderer(&mut self, new_style: Style) -> Result<()> { let needs_switch = self.active.as_ref().is_some_and(|a| a.style != new_style); if needs_switch && let Some(old) = self.active.take() { old.finish()?; } if self.active.is_none() { let writer = StreamDirectWriter { spinner: self.spinner.clone(), printer: self.printer.clone(), style: new_style, }; let renderer = StreamdownRenderer::new(writer, term_width()); self.active = Some(ActiveRenderer { renderer, style: new_style }); } Ok(()) } } /// Active renderer with its style. struct ActiveRenderer { renderer: StreamdownRenderer>, style: Style, } impl ActiveRenderer

{ pub fn push(&mut self, text: &str) -> Result<()> { self.renderer.push(text)?; Ok(()) } pub fn finish(self) -> Result<()> { self.renderer.finish()?; Ok(()) } } /// Writer for streamdown that outputs to printer and manages spinner. struct StreamDirectWriter { spinner: SharedSpinner

, printer: Arc

, style: Style, } impl StreamDirectWriter

{ fn pause_spinner(&self) { self.spinner.pause(); } fn resume_spinner(&self) { self.spinner.resume(); } } impl Drop for StreamDirectWriter

{ fn drop(&mut self) { let _ = self.printer.flush(); let _ = self.printer.flush_err(); } } impl io::Write for StreamDirectWriter

{ fn write(&mut self, buf: &[u8]) -> io::Result { self.pause_spinner(); let content = match buf.to_str() { Ok(content) => Cow::Borrowed(content), Err(_) => buf.to_str_lossy(), }; let styled = self.style.apply(content.into_owned()); self.printer.write(styled.as_bytes())?; self.printer.flush()?; // Track if we ended on a newline - only safe to show spinner at line start if buf.last() == Some(&b'\n') { self.resume_spinner(); } // Return `buf.len()`, not `styled.as_bytes().len()`. The `io::Write` contract // requires returning how many bytes were consumed from the input buffer, not // how many bytes were written to the output. Styling adds ANSI escape codes // which makes the output larger than the input. Ok(buf.len()) } fn flush(&mut self) -> io::Result<()> { self.printer.flush() } }