Skip to content

Commit e5d8606

Browse files
theahuranori-agent
andcommitted
fix(tui): Reset committed_line_count on markdown line count regression
When pulldown-cmark retroactively reclassifies content (e.g., partial text `[foo` becomes a link reference definition `[foo]: url` producing 0 rendered lines), the committed_line_count could stay above the actual rendered count, permanently blocking new streaming output. Reset the counter to the current render count when the guard fires, so future commits are not blocked after a regression. 🤖 Generated with [Nori](https://usenori.ai) Co-Authored-By: Nori <contact@tilework.tech>
1 parent 3eff567 commit e5d8606

1 file changed

Lines changed: 94 additions & 0 deletions

File tree

codex-rs/tui/src/markdown_stream.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ impl MarkdownStreamCollector {
5252
}
5353

5454
if self.committed_line_count >= complete_line_count {
55+
// When the re-render produces fewer lines than previously committed
56+
// (e.g., pulldown-cmark retroactively reclassified partial content
57+
// as a link reference definition), adjust the counter so future
58+
// commits are not permanently blocked.
59+
self.committed_line_count = complete_line_count;
5560
return Vec::new();
5661
}
5762

@@ -85,6 +90,7 @@ impl MarkdownStreamCollector {
8590
markdown::append_markdown(&source, self.width, &mut rendered);
8691

8792
let out = if self.committed_line_count >= rendered.len() {
93+
self.committed_line_count = rendered.len();
8894
Vec::new()
8995
} else {
9096
rendered[self.committed_line_count..].to_vec()
@@ -667,4 +673,92 @@ mod tests {
667673
])
668674
.await;
669675
}
676+
677+
#[tokio::test]
678+
async fn link_ref_def_reclassification_does_not_freeze_output() {
679+
// When partial text like "[foo\n" is parsed as paragraph content, and then
680+
// the buffer grows to "[foo]: http://example.com\n" (a link reference
681+
// definition that produces 0 rendered lines), committed_line_count can
682+
// become stale. New content after the reclassification must still be emitted.
683+
let mut c = MarkdownStreamCollector::new(None);
684+
685+
// Step 1: "[foo\n" looks like paragraph text.
686+
c.push_delta("[foo\n");
687+
let out1 = c.commit_complete_lines();
688+
// pulldown-cmark renders "[foo" as paragraph text.
689+
assert!(!out1.is_empty(), "partial link ref should render as text");
690+
691+
// Step 2: Complete the link ref def. This reclassifies the previous
692+
// paragraph text as a non-rendered link reference definition.
693+
c.push_delta("]: http://example.com\n");
694+
let _ = c.commit_complete_lines();
695+
696+
// Step 3: Add new content after the reclassification.
697+
c.push_delta("New paragraph.\n");
698+
let out3 = c.commit_complete_lines();
699+
let strings3 = lines_to_plain_strings(&out3);
700+
701+
// The key assertion: "New paragraph." must appear in the output.
702+
// Without the fix, committed_line_count stays at 1 while the link ref def
703+
// produces 0 lines, blocking all output.
704+
assert!(
705+
strings3.iter().any(|s| s.contains("New paragraph.")),
706+
"new content after link ref reclassification must be emitted, got: {strings3:?}"
707+
);
708+
}
709+
710+
#[tokio::test]
711+
async fn line_count_regression_does_not_block_subsequent_content() {
712+
// Generic test: if a re-render ever produces fewer lines than previously
713+
// committed, subsequent content should still be emitted.
714+
let mut c = MarkdownStreamCollector::new(None);
715+
716+
// Stream a paragraph.
717+
c.push_delta("Hello.\n");
718+
let out1 = c.commit_complete_lines();
719+
assert_eq!(out1.len(), 1);
720+
721+
// Stream an empty code fence (which may cause trailing blank trim to
722+
// reduce effective line count).
723+
c.push_delta("```\n```\n");
724+
let _ = c.commit_complete_lines();
725+
726+
// Stream a heading after the empty fence.
727+
c.push_delta("## Heading\n");
728+
let out3 = c.commit_complete_lines();
729+
let strings3 = lines_to_plain_strings(&out3);
730+
731+
// The heading must appear in the output regardless of any intermediate
732+
// line count regression from the empty fence.
733+
assert!(
734+
strings3.iter().any(|s| s.contains("## Heading")),
735+
"heading after empty fence must be emitted, got: {strings3:?}"
736+
);
737+
}
738+
739+
#[tokio::test]
740+
async fn finalize_emits_content_after_line_count_regression() {
741+
// Exercises finalize_and_drain() after a line count regression to ensure
742+
// the counter adjustment works for the finalize path too.
743+
let mut c = MarkdownStreamCollector::new(None);
744+
745+
c.push_delta("[foo\n");
746+
let _ = c.commit_complete_lines();
747+
748+
// Reclassify as link ref def (0 lines).
749+
c.push_delta("]: http://example.com\n");
750+
let _ = c.commit_complete_lines();
751+
752+
// Partial line without trailing newline — commit won't emit it.
753+
c.push_delta("Final text.");
754+
assert!(c.commit_complete_lines().is_empty());
755+
756+
// finalize_and_drain should emit the remaining content.
757+
let out = c.finalize_and_drain();
758+
let strings = lines_to_plain_strings(&out);
759+
assert!(
760+
strings.iter().any(|s| s.contains("Final text.")),
761+
"finalize should emit remaining content after regression, got: {strings:?}"
762+
);
763+
}
670764
}

0 commit comments

Comments
 (0)