bug: Missing newlines before paragraphs and code blocks in streamed Markdown rendering
Describe the bug
When streaming responses from an OpenAI-compatible API (such as Grok or custom proxy gateways), paragraphs and code blocks sometimes lose their leading and trailing newlines, causing them to merge directly into the previous output line without proper line breaks in the terminal.
For example, a response might render as:
最简单的Python入门例子:Hello World!1. 打开记事本 (instead of having double newlines before 1.)
And:
粘贴进去(全部内容就是它):╭─ python (instead of having double newlines before the code block)
Root Cause Analysis
The bug is located in TerminalRenderer::render_markdown inside rust/crates/rusty-claude-cli/src/render.rs:
#[must_use]
pub fn render_markdown(&self, markdown: &str) -> String {
let normalized = normalize_nested_fences(markdown);
let mut output = String::new();
// ...
for event in Parser::new_ext(&normalized, Options::all()) {
self.render_event(...);
}
output.trim_end().to_string() // <--- HERE
}
During streaming, MarkdownStreamState::push parses intermediate chunks (split at boundaries using find_stream_safe_boundary).
- The
readychunk (e.g., up to an empty line representing a paragraph break) correctly ends with trailing newlines (e.g.,\n\ngenerated by paragraph boundaries). - However,
renderer.markdown_to_ansi(&ready)callsrender_markdown(), which unconditionally performs.trim_end()on the rendered string. - This strips all trailing newlines (including the structural
\n\nrepresenting paragraph/block separators). - Since these newlines are stripped from the output chunk and the original
readytext has been drained fromself.pending, the newlines are completely lost from the terminal print. - When the next chunk (e.g. list items or code block starts) is printed, it is outputted directly appended to the previous text without any line breaks.
Suggested Fix
Introduce a stream-friendly rendering method in TerminalRenderer that preserves trailing whitespace/newlines during stream chunk generation, and use it inside MarkdownStreamState::push.
For example:
- In
TerminalRenderer(insiderust/crates/rusty-claude-cli/src/render.rs):
#[must_use]
pub fn render_markdown_stream(&self, markdown: &str) -> String {
let normalized = normalize_nested_fences(markdown);
let mut output = String::new();
let mut state = RenderState::default();
let mut code_language = String::new();
let mut code_buffer = String::new();
let mut in_code_block = false;
for event in Parser::new_ext(&normalized, Options::all()) {
self.render_event(
event,
&mut state,
&mut output,
&mut code_buffer,
&mut code_language,
&mut in_code_block,
);
}
output // Do not call trim_end() here
}
#[must_use]
pub fn markdown_to_ansi_stream(&self, markdown: &str) -> String {
self.render_markdown_stream(markdown)
}
- In
MarkdownStreamState::push:
#[must_use]
pub fn push(&mut self, renderer: &TerminalRenderer, delta: &str) -> Option<String> {
self.pending.push_str(delta);
let split = find_stream_safe_boundary(&self.pending)?;
let ready = self.pending[..split].to_string();
self.pending.drain(..split);
Some(renderer.markdown_to_ansi_stream(&ready)) // Use the stream-specific method
}
This keeps MarkdownStreamState::flush using the standard markdown_to_ansi (which trims trailing newlines at the very end of the stream), while correctly outputting structural paragraph and code block newlines during streaming.
Source: ultraworkers/claw-code