diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 981e0dfa..da363d5d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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>, } 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> = 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> = 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)); } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f3799ad3..ac8a79e3 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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) } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 5db6ebe1..6a1ac37a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -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>, - content: Vec>, + _header: String, + content: String, + citation_context: MarkdownCitationContext, } impl ReasoningSummaryCell { - pub(crate) fn new(header: Vec>, content: Vec>) -> 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> { - 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::>(), - ) + let mut lines: Vec> = 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::>(); @@ -174,7 +181,14 @@ impl HistoryCell for ReasoningSummaryCell { fn transcript_lines(&self) -> Vec> { let mut out: Vec> = 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> = 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(), + )); } } } diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 010ff78a..5ec3a27f 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -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>, - 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( markdown_source: &str, + width: Option, + lines: &mut Vec>, + citation_context: C, +) where + C: Into, +{ + 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, lines: &mut Vec>, 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 = 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 = 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 = 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 = out .iter() diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 48393463..d945b39c 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -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, 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, cwd: Option, in_code_block: bool, + wrap_width: Option, + current_line_content: Option>, + current_initial_indent: Vec>, + current_subsequent_indent: Vec>, + current_line_style: Style, + current_line_in_code_block: bool, } impl<'a, I> Writer<'a, I> where I: Iterator>, { - fn new(iter: I, scheme: Option, cwd: Option) -> Self { + fn new( + iter: I, + scheme: Option, + cwd: Option, + wrap_width: Option, + ) -> 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, indent: Option>) { + 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> { + fn prefix_spans(&self, pending_marker_line: bool) -> Vec> { let mut prefix: Vec> = 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 { + text.lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.clone()) + .collect::() + }) + .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::>() - .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(),] + ); + } } diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index 7c439337..efe58118 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -8,13 +8,15 @@ use crate::markdown; pub(crate) struct MarkdownStreamCollector { buffer: String, committed_line_count: usize, + width: Option, } impl MarkdownStreamCollector { - pub fn new() -> Self { + pub fn new(width: Option) -> Self { Self { buffer: String::new(), committed_line_count: 0, + width, } } @@ -40,7 +42,7 @@ impl MarkdownStreamCollector { return Vec::new(); }; let mut rendered: Vec> = 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> = 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> { - 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 = 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> = 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 = rendered .iter() .map(|l| { @@ -442,7 +444,7 @@ mod tests { let streamed_str = lines_to_plain_strings(&streamed); let mut rendered_all: Vec> = 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> = 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> = 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> = 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---"); } diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index ca22bed5..a7c45d1f 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -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) -> 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> = 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); diff --git a/codex-rs/tui/src/streaming/mod.rs b/codex-rs/tui/src/streaming/mod.rs index e048b399..fa007028 100644 --- a/codex-rs/tui/src/streaming/mod.rs +++ b/codex-rs/tui/src/streaming/mod.rs @@ -12,9 +12,9 @@ pub(crate) struct StreamState { } impl StreamState { - pub(crate) fn new() -> Self { + pub(crate) fn new(width: Option) -> Self { Self { - collector: MarkdownStreamCollector::new(), + collector: MarkdownStreamCollector::new(width), queued_lines: VecDeque::new(), has_seen_delta: false, }