wrap markdown at render time (#4506)

This results in correctly indenting list items with long lines.

<img width="1006" height="251" alt="Screenshot 2025-09-30 at 10 00
48 AM"
src="https://github.com/user-attachments/assets/0a076cf6-ca3c-4efb-b3af-dc07617cdb6f"
/>
This commit is contained in:
Jeremy Rose
2025-09-30 16:13:55 -07:00
committed by GitHub
parent 9c259737d3
commit 01e6503672
8 changed files with 347 additions and 103 deletions

View File

@@ -256,6 +256,8 @@ pub(crate) struct ChatWidget {
ghost_snapshots_disabled: bool,
// Whether to add a final message separator after the last message
needs_final_message_separator: bool,
last_rendered_width: std::cell::Cell<Option<usize>>,
}
struct UserMessage {
@@ -658,7 +660,10 @@ impl ChatWidget {
self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds));
self.needs_final_message_separator = false;
}
self.stream_controller = Some(StreamController::new(self.config.clone()));
self.stream_controller = Some(StreamController::new(
self.config.clone(),
self.last_rendered_width.get().map(|w| w.saturating_sub(2)),
));
}
if let Some(controller) = self.stream_controller.as_mut()
&& controller.push(&delta)
@@ -912,6 +917,7 @@ impl ChatWidget {
ghost_snapshots: Vec::new(),
ghost_snapshots_disabled: true,
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
}
}
@@ -974,6 +980,7 @@ impl ChatWidget {
ghost_snapshots: Vec::new(),
ghost_snapshots_disabled: true,
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
}
}
@@ -1447,7 +1454,7 @@ impl ChatWidget {
} else {
// Show explanation when there are no structured findings.
let mut rendered: Vec<ratatui::text::Line<'static>> = vec!["".into()];
append_markdown(&explanation, &mut rendered, &self.config);
append_markdown(&explanation, None, &mut rendered, &self.config);
let body_cell = AgentMessageCell::new(rendered, false);
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(body_cell)));
@@ -1456,7 +1463,7 @@ impl ChatWidget {
let message_text =
codex_core::review_format::format_review_findings_block(&output.findings, None);
let mut message_lines: Vec<ratatui::text::Line<'static>> = Vec::new();
append_markdown(&message_text, &mut message_lines, &self.config);
append_markdown(&message_text, None, &mut message_lines, &self.config);
let body_cell = AgentMessageCell::new(message_lines, true);
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(body_cell)));
@@ -1998,6 +2005,7 @@ impl WidgetRef for &ChatWidget {
tool.render_ref(area, buf);
}
}
self.last_rendered_width.set(Some(area.width as usize));
}
}

View File

@@ -341,6 +341,7 @@ fn make_chatwidget_manual() -> (
ghost_snapshots: Vec::new(),
ghost_snapshots_disabled: false,
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
};
(widget, rx, op_rx)
}

View File

@@ -6,6 +6,7 @@ use crate::exec_cell::output_lines;
use crate::exec_cell::spinner;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::MarkdownCitationContext;
use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
@@ -128,38 +129,44 @@ impl HistoryCell for UserHistoryCell {
#[derive(Debug)]
pub(crate) struct ReasoningSummaryCell {
_header: Vec<Line<'static>>,
content: Vec<Line<'static>>,
_header: String,
content: String,
citation_context: MarkdownCitationContext,
}
impl ReasoningSummaryCell {
pub(crate) fn new(header: Vec<Line<'static>>, content: Vec<Line<'static>>) -> Self {
pub(crate) fn new(
header: String,
content: String,
citation_context: MarkdownCitationContext,
) -> Self {
Self {
_header: header,
content,
citation_context,
}
}
}
impl HistoryCell for ReasoningSummaryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let summary_lines = self
.content
.iter()
.map(|line| {
Line::from(
line.spans
.iter()
.map(|span| {
Span::styled(
span.content.clone().into_owned(),
span.style
.add_modifier(Modifier::ITALIC)
.add_modifier(Modifier::DIM),
)
})
.collect::<Vec<_>>(),
)
let mut lines: Vec<Line<'static>> = Vec::new();
append_markdown(
&self.content,
Some((width as usize).saturating_sub(2)),
&mut lines,
self.citation_context.clone(),
);
let summary_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
let summary_lines = lines
.into_iter()
.map(|mut line| {
line.spans = line
.spans
.into_iter()
.map(|span| span.patch_style(summary_style))
.collect();
line
})
.collect::<Vec<_>>();
@@ -174,7 +181,14 @@ impl HistoryCell for ReasoningSummaryCell {
fn transcript_lines(&self) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
out.push("thinking".magenta().bold().into());
out.extend(self.content.clone());
let mut lines = Vec::new();
append_markdown(
&self.content,
None,
&mut lines,
self.citation_context.clone(),
);
out.extend(lines);
out
}
}
@@ -1065,7 +1079,7 @@ pub(crate) fn new_reasoning_block(
) -> TranscriptOnlyHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("thinking".magenta().italic()));
append_markdown(&full_reasoning_buffer, &mut lines, config);
append_markdown(&full_reasoning_buffer, None, &mut lines, config);
TranscriptOnlyHistoryCell { lines }
}
@@ -1089,14 +1103,12 @@ pub(crate) fn new_reasoning_summary_block(
// then we don't have a summary to inject into history
if after_close_idx < full_reasoning_buffer.len() {
let header_buffer = full_reasoning_buffer[..after_close_idx].to_string();
let mut header_lines = Vec::new();
append_markdown(&header_buffer, &mut header_lines, config);
let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string();
let mut summary_lines = Vec::new();
append_markdown(&summary_buffer, &mut summary_lines, config);
return Box::new(ReasoningSummaryCell::new(header_lines, summary_lines));
return Box::new(ReasoningSummaryCell::new(
header_buffer,
summary_buffer,
config.into(),
));
}
}
}

View File

@@ -2,17 +2,47 @@ use codex_core::config::Config;
use codex_core::config_types::UriBasedFileOpener;
use ratatui::text::Line;
use std::path::Path;
use std::path::PathBuf;
pub(crate) fn append_markdown(
markdown_source: &str,
lines: &mut Vec<Line<'static>>,
config: &Config,
) {
append_markdown_with_opener_and_cwd(markdown_source, lines, config.file_opener, &config.cwd);
#[derive(Clone, Debug)]
pub struct MarkdownCitationContext {
file_opener: UriBasedFileOpener,
cwd: PathBuf,
}
fn append_markdown_with_opener_and_cwd(
impl MarkdownCitationContext {
pub(crate) fn new(file_opener: UriBasedFileOpener, cwd: PathBuf) -> Self {
Self { file_opener, cwd }
}
}
impl From<&Config> for MarkdownCitationContext {
fn from(config: &Config) -> Self {
MarkdownCitationContext::new(config.file_opener, config.cwd.clone())
}
}
pub(crate) fn append_markdown<C>(
markdown_source: &str,
width: Option<usize>,
lines: &mut Vec<Line<'static>>,
citation_context: C,
) where
C: Into<MarkdownCitationContext>,
{
let citation_context: MarkdownCitationContext = citation_context.into();
append_markdown_with_opener_and_cwd(
markdown_source,
width,
lines,
citation_context.file_opener,
&citation_context.cwd,
);
}
pub(crate) fn append_markdown_with_opener_and_cwd(
markdown_source: &str,
width: Option<usize>,
lines: &mut Vec<Line<'static>>,
file_opener: UriBasedFileOpener,
cwd: &Path,
@@ -20,6 +50,7 @@ fn append_markdown_with_opener_and_cwd(
// Render via pulldown-cmark and rewrite citations during traversal (outside code blocks).
let rendered = crate::markdown_render::render_markdown_text_with_citations(
markdown_source,
width,
file_opener.get_scheme(),
cwd,
);
@@ -36,7 +67,7 @@ mod tests {
let src = "Before 【F:/x.rs†L1】\n```\nInside 【F:/x.rs†L2】\n```\nAfter 【F:/x.rs†L3】\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::VsCode, cwd);
append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::VsCode, cwd);
let rendered: Vec<String> = out
.iter()
.map(|l| {
@@ -69,7 +100,7 @@ mod tests {
let src = "Before\n\n code 1\n\nAfter\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::None, cwd);
let lines: Vec<String> = out
.iter()
.map(|l| {
@@ -87,7 +118,7 @@ mod tests {
let src = "Start 【F:/x.rs†L1】\n\n Inside 【F:/x.rs†L2】\n\nEnd 【F:/x.rs†L3】\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::VsCode, cwd);
append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::VsCode, cwd);
let rendered: Vec<String> = out
.iter()
.map(|l| {
@@ -117,7 +148,7 @@ mod tests {
let src = "Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::None, cwd);
assert_eq!(
out.len(),
1,
@@ -143,6 +174,7 @@ mod tests {
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(
"1. Tight item\n",
None,
&mut out,
UriBasedFileOpener::None,
cwd,
@@ -166,7 +198,7 @@ mod tests {
let src = "Loose vs. tight list items:\n1. Tight item\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::None, cwd);
let lines: Vec<String> = out
.iter()

View File

@@ -1,4 +1,7 @@
use crate::citation_regex::CITATION_REGEX;
use crate::render::line_utils::line_to_static;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use pulldown_cmark::CodeBlockKind;
use pulldown_cmark::CowStr;
use pulldown_cmark::Event;
@@ -32,25 +35,30 @@ impl IndentContext {
}
}
#[allow(dead_code)]
pub fn render_markdown_text(input: &str) -> Text<'static> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options);
let mut w = Writer::new(parser, None, None);
let mut w = Writer::new(parser, None, None, None);
w.run();
w.text
}
pub(crate) fn render_markdown_text_with_citations(
input: &str,
width: Option<usize>,
scheme: Option<&str>,
cwd: &Path,
) -> Text<'static> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options);
let mut w = Writer::new(parser, scheme.map(str::to_string), Some(cwd.to_path_buf()));
let mut w = Writer::new(
parser,
scheme.map(str::to_string),
Some(cwd.to_path_buf()),
width,
);
w.run();
w.text
}
@@ -71,13 +79,24 @@ where
scheme: Option<String>,
cwd: Option<std::path::PathBuf>,
in_code_block: bool,
wrap_width: Option<usize>,
current_line_content: Option<Line<'static>>,
current_initial_indent: Vec<Span<'static>>,
current_subsequent_indent: Vec<Span<'static>>,
current_line_style: Style,
current_line_in_code_block: bool,
}
impl<'a, I> Writer<'a, I>
where
I: Iterator<Item = Event<'a>>,
{
fn new(iter: I, scheme: Option<String>, cwd: Option<std::path::PathBuf>) -> Self {
fn new(
iter: I,
scheme: Option<String>,
cwd: Option<std::path::PathBuf>,
wrap_width: Option<usize>,
) -> Self {
Self {
iter,
text: Text::default(),
@@ -91,6 +110,12 @@ where
scheme,
cwd,
in_code_block: false,
wrap_width,
current_line_content: None,
current_initial_indent: Vec::new(),
current_subsequent_indent: Vec::new(),
current_line_style: Style::default(),
current_line_in_code_block: false,
}
}
@@ -98,6 +123,7 @@ where
while let Some(ev) = self.iter.next() {
self.handle_event(ev);
}
self.flush_current_line();
}
fn handle_event(&mut self, event: Event<'a>) {
@@ -109,6 +135,7 @@ where
Event::SoftBreak => self.soft_break(),
Event::HardBreak => self.hard_break(),
Event::Rule => {
self.flush_current_line();
if !self.text.lines.is_empty() {
self.push_blank_line();
}
@@ -237,16 +264,21 @@ where
self.push_line(Line::default());
}
self.pending_marker_line = false;
if self.in_code_block
&& !self.needs_newline
&& self
.text
.lines
.last()
if self.in_code_block && !self.needs_newline {
let has_content = self
.current_line_content
.as_ref()
.map(|line| !line.spans.is_empty())
.unwrap_or(false)
{
self.push_line(Line::default());
.unwrap_or_else(|| {
self.text
.lines
.last()
.map(|line| !line.spans.is_empty())
.unwrap_or(false)
});
if has_content {
self.push_line(Line::default());
}
}
for (i, line) in text.lines().enumerate() {
if self.needs_newline {
@@ -351,6 +383,7 @@ where
}
fn start_codeblock(&mut self, _lang: Option<String>, indent: Option<Span<'static>>) {
self.flush_current_line();
if !self.text.lines.is_empty() {
self.push_blank_line();
}
@@ -360,16 +393,10 @@ where
None,
false,
));
// let opener = match lang {
// Some(l) if !l.is_empty() => format!("```{l}"),
// _ => "```".to_string(),
// };
// self.push_line(opener.into());
self.needs_newline = true;
}
fn end_codeblock(&mut self) {
// self.push_line("```".into());
self.needs_newline = true;
self.in_code_block = false;
self.indent_stack.pop();
@@ -397,11 +424,34 @@ where
}
}
fn flush_current_line(&mut self) {
if let Some(line) = self.current_line_content.take() {
let style = self.current_line_style;
// NB we don't wrap code in code blocks, in order to preserve whitespace for copy/paste.
if !self.current_line_in_code_block
&& let Some(width) = self.wrap_width
{
let opts = RtOptions::new(width)
.initial_indent(self.current_initial_indent.clone().into())
.subsequent_indent(self.current_subsequent_indent.clone().into());
for wrapped in word_wrap_line(&line, opts) {
let owned = line_to_static(&wrapped).style(style);
self.text.lines.push(owned);
}
} else {
let mut spans = self.current_initial_indent.clone();
let mut line = line;
spans.append(&mut line.spans);
self.text.lines.push(Line::from_iter(spans).style(style));
}
self.current_initial_indent.clear();
self.current_subsequent_indent.clear();
self.current_line_in_code_block = false;
}
}
fn push_line(&mut self, line: Line<'static>) {
let mut line = line;
let was_pending = self.pending_marker_line;
let mut spans = self.current_prefix_spans();
spans.append(&mut line.spans);
self.flush_current_line();
let blockquote_active = self
.indent_stack
.iter()
@@ -411,31 +461,38 @@ where
} else {
line.style
};
self.text.lines.push(Line::from_iter(spans).style(style));
if was_pending {
self.pending_marker_line = false;
}
let was_pending = self.pending_marker_line;
self.current_initial_indent = self.prefix_spans(was_pending);
self.current_subsequent_indent = self.prefix_spans(false);
self.current_line_style = style;
self.current_line_content = Some(line);
self.current_line_in_code_block = self.in_code_block;
self.pending_marker_line = false;
}
fn push_span(&mut self, span: Span<'static>) {
if let Some(last) = self.text.lines.last_mut() {
last.push_span(span);
if let Some(line) = self.current_line_content.as_mut() {
line.push_span(span);
} else {
self.push_line(Line::from(vec![span]));
}
}
fn push_blank_line(&mut self) {
self.flush_current_line();
if self.indent_stack.iter().all(|ctx| ctx.is_list) {
self.text.lines.push(Line::default());
} else {
self.push_line(Line::default());
self.flush_current_line();
}
}
fn current_prefix_spans(&self) -> Vec<Span<'static>> {
fn prefix_spans(&self, pending_marker_line: bool) -> Vec<Span<'static>> {
let mut prefix: Vec<Span<'static>> = Vec::new();
let last_marker_index = if self.pending_marker_line {
let last_marker_index = if pending_marker_line {
self.indent_stack
.iter()
.enumerate()
@@ -447,7 +504,7 @@ where
let last_list_index = self.indent_stack.iter().rposition(|ctx| ctx.is_list);
for (i, ctx) in self.indent_stack.iter().enumerate() {
if self.pending_marker_line {
if pending_marker_line {
if Some(i) == last_marker_index
&& let Some(marker) = &ctx.marker
{
@@ -514,6 +571,19 @@ mod markdown_render_tests {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::text::Text;
fn lines_to_strings(text: &Text<'_>) -> Vec<String> {
text.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect()
}
#[test]
fn citation_is_rewritten_with_absolute_path() {
@@ -546,17 +616,136 @@ mod tests {
let unchanged = rewrite_file_citations_with_scheme(markdown, Some("vscode"), cwd);
// The helper itself always rewrites this test validates behaviour of
// append_markdown when `file_opener` is None.
let rendered = render_markdown_text_with_citations(markdown, None, cwd);
let rendered = render_markdown_text_with_citations(markdown, None, None, cwd);
// Convert lines back to string for comparison.
let rendered: String = rendered
.lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("");
let rendered: String = lines_to_strings(&rendered).join("");
assert_eq!(markdown, rendered);
// Ensure helper rewrites.
assert_ne!(markdown, unchanged);
}
#[test]
fn wraps_plain_text_when_width_provided() {
let markdown = "This is a simple sentence that should wrap.";
let cwd = Path::new("/");
let rendered = render_markdown_text_with_citations(markdown, Some(16), None, cwd);
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec![
"This is a simple".to_string(),
"sentence that".to_string(),
"should wrap.".to_string(),
]
);
}
#[test]
fn wraps_list_items_preserving_indent() {
let markdown = "- first second third fourth";
let cwd = Path::new("/");
let rendered = render_markdown_text_with_citations(markdown, Some(14), None, cwd);
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec!["- first second".to_string(), " third fourth".to_string(),]
);
}
#[test]
fn wraps_nested_lists() {
let markdown =
"- outer item with several words to wrap\n - inner item that also needs wrapping";
let cwd = Path::new("/");
let rendered = render_markdown_text_with_citations(markdown, Some(20), None, cwd);
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec![
"- outer item with".to_string(),
" several words".to_string(),
" to wrap".to_string(),
" - inner item".to_string(),
" that also".to_string(),
" needs wrapping".to_string(),
]
);
}
#[test]
fn wraps_ordered_lists() {
let markdown = "1. ordered item contains many words for wrapping";
let cwd = Path::new("/");
let rendered = render_markdown_text_with_citations(markdown, Some(18), None, cwd);
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec![
"1. ordered item".to_string(),
" contains many".to_string(),
" words for".to_string(),
" wrapping".to_string(),
]
);
}
#[test]
fn wraps_blockquotes() {
let markdown = "> block quote with content that should wrap nicely";
let cwd = Path::new("/");
let rendered = render_markdown_text_with_citations(markdown, Some(22), None, cwd);
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec![
"> block quote with".to_string(),
"> content that should".to_string(),
"> wrap nicely".to_string(),
]
);
}
#[test]
fn wraps_blockquotes_inside_lists() {
let markdown = "- list item\n > block quote inside list that wraps";
let cwd = Path::new("/");
let rendered = render_markdown_text_with_citations(markdown, Some(24), None, cwd);
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec![
"- list item".to_string(),
" > block quote inside".to_string(),
" > list that wraps".to_string(),
]
);
}
#[test]
fn wraps_list_items_containing_blockquotes() {
let markdown = "1. item with quote\n > quoted text that should wrap";
let cwd = Path::new("/");
let rendered = render_markdown_text_with_citations(markdown, Some(24), None, cwd);
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec![
"1. item with quote".to_string(),
" > quoted text that".to_string(),
" > should wrap".to_string(),
]
);
}
#[test]
fn does_not_wrap_code_blocks() {
let markdown = "````\nfn main() { println!(\"hi from a long line\"); }\n````";
let cwd = Path::new("/");
let rendered = render_markdown_text_with_citations(markdown, Some(10), None, cwd);
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec!["fn main() { println!(\"hi from a long line\"); }".to_string(),]
);
}
}

View File

@@ -8,13 +8,15 @@ use crate::markdown;
pub(crate) struct MarkdownStreamCollector {
buffer: String,
committed_line_count: usize,
width: Option<usize>,
}
impl MarkdownStreamCollector {
pub fn new() -> Self {
pub fn new(width: Option<usize>) -> Self {
Self {
buffer: String::new(),
committed_line_count: 0,
width,
}
}
@@ -40,7 +42,7 @@ impl MarkdownStreamCollector {
return Vec::new();
};
let mut rendered: Vec<Line<'static>> = Vec::new();
markdown::append_markdown(&source, &mut rendered, config);
markdown::append_markdown(&source, self.width, &mut rendered, config);
let mut complete_line_count = rendered.len();
if complete_line_count > 0
&& crate::render::line_utils::is_blank_line_spaces_only(
@@ -81,7 +83,7 @@ impl MarkdownStreamCollector {
tracing::trace!("markdown finalize (raw source):\n---\n{source}\n---");
let mut rendered: Vec<Line<'static>> = Vec::new();
markdown::append_markdown(&source, &mut rendered, config);
markdown::append_markdown(&source, self.width, &mut rendered, config);
let out = if self.committed_line_count >= rendered.len() {
Vec::new()
@@ -101,7 +103,7 @@ pub(crate) fn simulate_stream_markdown_for_tests(
finalize: bool,
config: &Config,
) -> Vec<Line<'static>> {
let mut collector = MarkdownStreamCollector::new();
let mut collector = MarkdownStreamCollector::new(None);
let mut out = Vec::new();
for d in deltas {
collector.push_delta(d);
@@ -136,7 +138,7 @@ mod tests {
#[test]
fn no_commit_until_newline() {
let cfg = test_config();
let mut c = super::MarkdownStreamCollector::new();
let mut c = super::MarkdownStreamCollector::new(None);
c.push_delta("Hello, world");
let out = c.commit_complete_lines(&cfg);
assert!(out.is_empty(), "should not commit without newline");
@@ -148,7 +150,7 @@ mod tests {
#[test]
fn finalize_commits_partial_line() {
let cfg = test_config();
let mut c = super::MarkdownStreamCollector::new();
let mut c = super::MarkdownStreamCollector::new(None);
c.push_delta("Line without newline");
let out = c.finalize_and_drain(&cfg);
assert_eq!(out.len(), 1);
@@ -277,7 +279,7 @@ mod tests {
// Stream a paragraph line, then a heading on the next line.
// Expect two distinct rendered lines: "Hello." and "Heading".
let mut c = super::MarkdownStreamCollector::new();
let mut c = super::MarkdownStreamCollector::new(None);
c.push_delta("Hello.\n");
let out1 = c.commit_complete_lines(&cfg);
let s1: Vec<String> = out1
@@ -335,7 +337,7 @@ mod tests {
// Paragraph without trailing newline, then a chunk that starts with the newline
// and the heading text, then a final newline. The collector should first commit
// only the paragraph line, and later commit the heading as its own line.
let mut c = super::MarkdownStreamCollector::new();
let mut c = super::MarkdownStreamCollector::new(None);
c.push_delta("Sounds good!");
// No commit yet
assert!(c.commit_complete_lines(&cfg).is_empty());
@@ -380,7 +382,7 @@ mod tests {
// Sanity check raw markdown rendering for a simple line does not produce spurious extras.
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown("Hello.\n", &mut rendered, &cfg);
crate::markdown::append_markdown("Hello.\n", None, &mut rendered, &cfg);
let rendered_strings: Vec<String> = rendered
.iter()
.map(|l| {
@@ -442,7 +444,7 @@ mod tests {
let streamed_str = lines_to_plain_strings(&streamed);
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown(input, &mut rendered_all, &cfg);
crate::markdown::append_markdown(input, None, &mut rendered_all, &cfg);
let rendered_all_str = lines_to_plain_strings(&rendered_all);
assert_eq!(
@@ -552,7 +554,7 @@ mod tests {
let full: String = deltas.iter().copied().collect();
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown(&full, &mut rendered_all, &cfg);
crate::markdown::append_markdown(&full, None, &mut rendered_all, &cfg);
let rendered_all_strs = lines_to_plain_strings(&rendered_all);
assert_eq!(
@@ -641,7 +643,7 @@ mod tests {
// Compute a full render for diagnostics only.
let full: String = deltas.iter().copied().collect();
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown(&full, &mut rendered_all, &cfg);
crate::markdown::append_markdown(&full, None, &mut rendered_all, &cfg);
// Also assert exact expected plain strings for clarity.
let expected = vec![
@@ -669,7 +671,7 @@ mod tests {
let streamed_strs = lines_to_plain_strings(&streamed);
let full: String = deltas.iter().copied().collect();
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown(&full, &mut rendered, &cfg);
crate::markdown::append_markdown(&full, None, &mut rendered, &cfg);
let rendered_strs = lines_to_plain_strings(&rendered);
assert_eq!(streamed_strs, rendered_strs, "full:\n---\n{full}\n---");
}

View File

@@ -15,10 +15,10 @@ pub(crate) struct StreamController {
}
impl StreamController {
pub(crate) fn new(config: Config) -> Self {
pub(crate) fn new(config: Config, width: Option<usize>) -> Self {
Self {
config,
state: StreamState::new(),
state: StreamState::new(width),
finishing_after_drain: false,
header_emitted: false,
}
@@ -118,7 +118,7 @@ mod tests {
#[test]
fn controller_loose_vs_tight_with_commit_ticks_matches_full() {
let cfg = test_config();
let mut ctrl = StreamController::new(cfg.clone());
let mut ctrl = StreamController::new(cfg.clone(), None);
let mut lines = Vec::new();
// Exact deltas from the session log (section: Loose vs. tight list items)
@@ -223,7 +223,7 @@ mod tests {
// Full render of the same source
let source: String = deltas.iter().copied().collect();
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
crate::markdown::append_markdown(&source, &mut rendered, &cfg);
crate::markdown::append_markdown(&source, None, &mut rendered, &cfg);
let rendered_strs = lines_to_plain_strings(&rendered);
assert_eq!(streamed, rendered_strs);

View File

@@ -12,9 +12,9 @@ pub(crate) struct StreamState {
}
impl StreamState {
pub(crate) fn new() -> Self {
pub(crate) fn new(width: Option<usize>) -> Self {
Self {
collector: MarkdownStreamCollector::new(),
collector: MarkdownStreamCollector::new(width),
queued_lines: VecDeque::new(),
has_seen_delta: false,
}