Repository navigation
Fix markdown formatting issues in TUI streaming: block-level element newline omission and consecutive empty line accumulation #3259
Description
Activity
Thanks for the detailed analysis! The ensure_newlines helper is a clean approach. This should fix both the stickiness and empty line accumulation. Adding test cases for various Markdown block combinations would be a great follow-up.
Proposed Fix for #3259
Hi, I'd like to work on this issue.
Problem
In
render_event()(render.rs), block-level start events (code block, item, blockquote) don't check for existing trailing newlines before writing, causing:- Stickiness: Text immediately followed by a code block renders inline (e.g.,
1. Enter directory:╭─ rust) - Extra blank lines: Multiple block-end events unconditionally push
\n, stacking 3-4 empty lines
Solution
Add an idempotent
ensure_newlineshelper:fn ensure_newlines(output: &mut String, count: usize) { if output.is_empty() { return; } let current = output.chars().rev().take_while(|&c| c == '\n').count(); if current < count { output.push_str(&"\n".repeat(count - current)); } }
Apply it to block start/end handlers:
Event Handler Current Behavior Proposed Change Start(Tag::CodeBlock)line 317No leading newline check ensure_newlines(output, 1)beforestart_code_block()Start(Tag::Item)line 316No leading newline check ensure_newlines(output, 1)beforestart_item()Start(Tag::BlockQuote)line 293No leading newline check ensure_newlines(output, 1)beforestart_quote()Start(Tag::Heading)line 289push('\n')if not emptyReplace with ensure_newlines(output, 2)End(TagEnd::Paragraph)line 292push_str("\n\n")unconditionallyensure_newlines(output, 2)End(TagEnd::Heading)line 298-301push_str("\n\n")unconditionallyensure_newlines(output, 2)End(TagEnd::List)line 312-314push('\n')unconditionallyensure_newlines(output, 1)End(TagEnd::Item)line 302-304push('\n')unconditionallyensure_newlines(output, 1)End(TagEnd::BlockQuote)line 294-297push('\n')unconditionallyensure_newlines(output, 1)End(TagEnd::Table)line 387-391push_str("\n\n")unconditionallyensure_newlines(output, 2)Streaming path:
MarkdownStreamState::push()uses the samerender_markdown()pipeline, so the fix applies automatically.Happy to implement once approved.
- Stickiness: Text immediately followed by a code block renders inline (e.g.,
@Aditaya08 方案本质上就是解析器在缺少换行符时补齐的 idempotent 处理,思路没问题。有一点值得注意:ensure_newlines 只处理了"尾部换行不足",但如果某段输出本来就以"应补 2 个换行"的上下文(例如 heading 后接 code block)结束,count 取 1 时仍然会额外补行,建议为 start/end 事件明确区分语义(block 边界 vs paragraph 边界),并用一个基于事件类型的统一策略而非逐 handler 打补丁。也建议补上覆盖"text + fenced code block""heading + list""连续列表项结束"三种组合的渲染测试。提前开 PR 的话我可以帮忙 review。
The ensure_newlines helper approach looks solid — it makes the newline handling idempotent, which is exactly what you need to avoid both sticky inline rendering and the 3-4 blank line stacking. One thought: the paragraph-end handler currently unconditionally pushes "\n\n", so switching it to ensure_newlines(output, 2) will change behavior when the output already ends with a newline (e.g. right after another block element). That's the intended fix, but it's worth adding a couple of unit tests covering (a) block element immediately after text and (b) consecutive block-end events, so the case where current >= count (no push at all) is verified explicitly. That'd lock the behaviour in before touching the TUI renderer.
Confirmed on both counts, and the second one has a concrete cause worth writing down.
The empty-line accumulation is exactly what unconditional pushes predict, because pulldown-cmark emits nested end events — a 3-deep list produces
End(Item)→End(Item)→End(List), each appending its own newline. That's why the symptom scales with nesting depth (3–4 blank lines for a 3–4 level list) instead of being a constant offset. Rewriting the append asensure_newlinesis the right shape of fix: it makes each handler idempotent, so the trailing-newline count converges no matter how many end events arrive or in what order. Worth preserving that property even if individual call sites change later.Two edge cases to verify with the table rules:
End(Table)is reached throughEnd(TableHead)/End(TableRow)/End(TableCell), so check thatensure_newlines(_, 2)on the table close doesn't collapse spacing you actually want after the last row. Same question for a fenced block that is the final child of a list item —End(CodeBlock)thenEnd(Item)thenEnd(List).Suggestion: pin it with a snapshot test covering a nested list + fenced code block + blockquote in a single fixture. That combination is where both bugs surfaced, and it's the easiest thing to silently regress the next time render.rs is touched.
Two things worth pinning down before this lands, both from the streaming angle, since that is where the helper earns or loses its keep:
-
The handler table covers Paragraph / Heading / List / Item / BlockQuote, and the write-up mentions table closings, but neither TagEnd::CodeBlock nor Rule (thematic break) appears in the per-handler list. Those are exactly the cases that produce visible stickiness: a horizontal rule rendered straight after a paragraph, and a code block closing directly against the next block. Worth confirming both are routed through ensure_newlines as well, otherwise the accumulation bug just moves one block type over.
-
For streaming, the property that matters is idempotence across repeated renders, not just within one pass. When the renderer re-parses and re-emits the buffer as tokens arrive, any unconditional push in an End handler compounds once per pass. ensure_newlines only ever tops up to the target count, so it stays correct under repetition, which is the invariant worth locking in. It is also worth writing that into a comment on the helper, because the next person touching it will be tempted to replace the reverse scan with a plain push when output already ends in the right number of newlines.
On tests: a table-driven case list mapping markdown input to the exact expected output string beats unit-testing the helper in isolation, because the real regression is event ordering, not arithmetic. Include the empty-output case, the helper must stay a no-op there so the first block does not start with a blank line, and a back-to-back code block case.
-
Confirmed on my side — both symptoms share one root cause: pulldown-cmark is fed incremental chunks, and the newline that terminates a chunk is dropped when the next chunk opens a block-level element, so the block boundary never emits its own separator. Suggested shape for the fix: (1) keep a small render state in the streaming renderer (e.g. pending_break / did the last emitted char end a line) rather than relying on the parser to re-emit boundaries, and flush a blank line before a Start(Block) event when the previous output did not end with one; (2) normalise on emit for the accumulation case — collapse runs of more than two consecutive newlines to exactly two and skip a separator that is already at the end of the buffer, which removes the need to track chunk split positions at all; (3) add a test that re-renders one fixed document with the input split at every byte offset and asserts the concatenated output is byte-identical — that catches both the dropped boundary and the accumulation without hand-picked cases.
Describe the bug
When rendering Markdown text in TUI streaming mode (specifically in non-compact mode):
pulldown-cmarkdoes not emit any line-break or soft-break events between them. This results in the code block top border rendering immediately after the text (e.g.,2. Enter directory:╭─ code), causing layout stickiness.Solution / Implementation
We solved this by introducing an idempotent, smart newline-ensuring helper:
And applied it defensively to block-level start/end event handlers in
render.rs:ensure_newlines(output, 1)to prevent inline stickiness.ensure_newlines(output, 2)to ensure space before headers.ensure_newlines(output, 2)(for paragraphs, headings, lists, table closings) orensure_newlines(output, 1)(for items, blockquotes), which prevents consecutive newlines from accumulating beyond 1 empty line.