diff --git a/crates/forge_main/src/stream_renderer.rs b/crates/forge_main/src/stream_renderer.rs index cc12cfa445..b0c7cbadcd 100644 --- a/crates/forge_main/src/stream_renderer.rs +++ b/crates/forge_main/src/stream_renderer.rs @@ -97,13 +97,16 @@ pub struct StreamingWriter { active: Option>, spinner: SharedSpinner

, printer: Arc

, + /// The terminal width that the active renderer is currently using. + /// Tracked so we can detect changes and update the renderer in place. + last_width: usize, } 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 } + Self { active: None, spinner, printer, last_width: term_width() } } /// Writes markdown content with normal styling. @@ -133,6 +136,29 @@ impl StreamingWriter

{ } fn ensure_renderer(&mut self, new_style: Style) -> Result<()> { + // Poll the current terminal width. The ioctl(TIOCGWINSZ) syscall + // costs ~100ns-1us reading a kernel-cached struct, so polling on + // every call is negligible compared to terminal write I/O. + let current_width = term_width(); + + // If the width changed and we have an active renderer, update it + // in place. This preserves all parser/renderer state (code block + // context, blockquote depth, list numbering, table rows). + if current_width != self.last_width { + // Pause the spinner before updating the width. During a resize + // the terminal reflows content, and the spinner's + // `finish_and_clear()` would write stale cursor positions, + // erasing visible output. `stop(None)` is idempotent -- it is + // a no-op when no spinner is active. The spinner resumes + // naturally via `resume_spinner()` on the next newline write. + let _ = self.spinner.stop(None); + + if let Some(ref mut active) = self.active { + active.renderer.set_width(current_width); + } + self.last_width = current_width; + } + let needs_switch = self.active.as_ref().is_some_and(|a| a.style != new_style); if needs_switch && let Some(old) = self.active.take() { @@ -145,7 +171,7 @@ impl StreamingWriter

{ printer: self.printer.clone(), style: new_style, }; - let renderer = StreamdownRenderer::new(writer, term_width()); + let renderer = StreamdownRenderer::new(writer, current_width); self.active = Some(ActiveRenderer { renderer, style: new_style }); } Ok(()) diff --git a/crates/forge_markdown_stream/src/lib.rs b/crates/forge_markdown_stream/src/lib.rs index 86af890e11..955bd93dfb 100644 --- a/crates/forge_markdown_stream/src/lib.rs +++ b/crates/forge_markdown_stream/src/lib.rs @@ -73,6 +73,15 @@ impl StreamdownRenderer { } } + /// Update the rendering width in place. + /// + /// Delegates to [`Renderer::set_width`], which is safe to call between + /// events because the width is read fresh on each `render_event()` call + /// and no cached state depends on it. + pub fn set_width(&mut self, width: usize) { + self.renderer.set_width(width); + } + /// Push a token to the renderer. /// /// Tokens are buffered until a complete line is received, then rendered. diff --git a/crates/forge_markdown_stream/src/renderer.rs b/crates/forge_markdown_stream/src/renderer.rs index 00404c1882..d9e9173b8e 100644 --- a/crates/forge_markdown_stream/src/renderer.rs +++ b/crates/forge_markdown_stream/src/renderer.rs @@ -54,6 +54,15 @@ impl Renderer { } } + /// Update the rendering width. + /// + /// The `width` field is read fresh by `current_width()` on each + /// `render_event()` call and is not used to derive any cached state, so + /// updating it in place between events is safe. + pub fn set_width(&mut self, width: usize) { + self.width = width; + } + /// Set a new theme. #[allow(dead_code)] pub fn set_theme(&mut self, theme: Theme) {