Skip to content

bug: Missing newlines before paragraphs and code blocks in streamed Markdown rendering #3257

Description

@ibmany-spec

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 ready chunk (e.g., up to an empty line representing a paragraph break) correctly ends with trailing newlines (e.g., \n\n generated by paragraph boundaries).
  • However, renderer.markdown_to_ansi(&ready) calls render_markdown(), which unconditionally performs .trim_end() on the rendered string.
  • This strips all trailing newlines (including the structural \n\n representing paragraph/block separators).
  • Since these newlines are stripped from the output chunk and the original ready text has been drained from self.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:

  1. In TerminalRenderer (inside rust/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)
    }
  1. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions