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

View File

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

View File

@@ -2,17 +2,47 @@ use codex_core::config::Config;
use codex_core::config_types::UriBasedFileOpener; use codex_core::config_types::UriBasedFileOpener;
use ratatui::text::Line; use ratatui::text::Line;
use std::path::Path; use std::path::Path;
use std::path::PathBuf;
pub(crate) fn append_markdown( #[derive(Clone, Debug)]
markdown_source: &str, pub struct MarkdownCitationContext {
lines: &mut Vec<Line<'static>>, file_opener: UriBasedFileOpener,
config: &Config, cwd: PathBuf,
) {
append_markdown_with_opener_and_cwd(markdown_source, lines, config.file_opener, &config.cwd);
} }
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, 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>>, lines: &mut Vec<Line<'static>>,
file_opener: UriBasedFileOpener, file_opener: UriBasedFileOpener,
cwd: &Path, 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). // Render via pulldown-cmark and rewrite citations during traversal (outside code blocks).
let rendered = crate::markdown_render::render_markdown_text_with_citations( let rendered = crate::markdown_render::render_markdown_text_with_citations(
markdown_source, markdown_source,
width,
file_opener.get_scheme(), file_opener.get_scheme(),
cwd, 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 src = "Before 【F:/x.rs†L1】\n```\nInside 【F:/x.rs†L2】\n```\nAfter 【F:/x.rs†L3】\n";
let cwd = Path::new("/"); let cwd = Path::new("/");
let mut out = Vec::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 let rendered: Vec<String> = out
.iter() .iter()
.map(|l| { .map(|l| {
@@ -69,7 +100,7 @@ mod tests {
let src = "Before\n\n code 1\n\nAfter\n"; let src = "Before\n\n code 1\n\nAfter\n";
let cwd = Path::new("/"); let cwd = Path::new("/");
let mut out = Vec::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 let lines: Vec<String> = out
.iter() .iter()
.map(|l| { .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 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 cwd = Path::new("/");
let mut out = Vec::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 let rendered: Vec<String> = out
.iter() .iter()
.map(|l| { .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 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 cwd = Path::new("/");
let mut out = Vec::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!( assert_eq!(
out.len(), out.len(),
1, 1,
@@ -143,6 +174,7 @@ mod tests {
let mut out = Vec::new(); let mut out = Vec::new();
append_markdown_with_opener_and_cwd( append_markdown_with_opener_and_cwd(
"1. Tight item\n", "1. Tight item\n",
None,
&mut out, &mut out,
UriBasedFileOpener::None, UriBasedFileOpener::None,
cwd, cwd,
@@ -166,7 +198,7 @@ mod tests {
let src = "Loose vs. tight list items:\n1. Tight item\n"; let src = "Loose vs. tight list items:\n1. Tight item\n";
let cwd = Path::new("/"); let cwd = Path::new("/");
let mut out = Vec::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 let lines: Vec<String> = out
.iter() .iter()

View File

@@ -1,4 +1,7 @@
use crate::citation_regex::CITATION_REGEX; 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::CodeBlockKind;
use pulldown_cmark::CowStr; use pulldown_cmark::CowStr;
use pulldown_cmark::Event; use pulldown_cmark::Event;
@@ -32,25 +35,30 @@ impl IndentContext {
} }
} }
#[allow(dead_code)]
pub fn render_markdown_text(input: &str) -> Text<'static> { pub fn render_markdown_text(input: &str) -> Text<'static> {
let mut options = Options::empty(); let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options); 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.run();
w.text w.text
} }
pub(crate) fn render_markdown_text_with_citations( pub(crate) fn render_markdown_text_with_citations(
input: &str, input: &str,
width: Option<usize>,
scheme: Option<&str>, scheme: Option<&str>,
cwd: &Path, cwd: &Path,
) -> Text<'static> { ) -> Text<'static> {
let mut options = Options::empty(); let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options); 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.run();
w.text w.text
} }
@@ -71,13 +79,24 @@ where
scheme: Option<String>, scheme: Option<String>,
cwd: Option<std::path::PathBuf>, cwd: Option<std::path::PathBuf>,
in_code_block: bool, 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> impl<'a, I> Writer<'a, I>
where where
I: Iterator<Item = Event<'a>>, 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 { Self {
iter, iter,
text: Text::default(), text: Text::default(),
@@ -91,6 +110,12 @@ where
scheme, scheme,
cwd, cwd,
in_code_block: false, 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() { while let Some(ev) = self.iter.next() {
self.handle_event(ev); self.handle_event(ev);
} }
self.flush_current_line();
} }
fn handle_event(&mut self, event: Event<'a>) { fn handle_event(&mut self, event: Event<'a>) {
@@ -109,6 +135,7 @@ where
Event::SoftBreak => self.soft_break(), Event::SoftBreak => self.soft_break(),
Event::HardBreak => self.hard_break(), Event::HardBreak => self.hard_break(),
Event::Rule => { Event::Rule => {
self.flush_current_line();
if !self.text.lines.is_empty() { if !self.text.lines.is_empty() {
self.push_blank_line(); self.push_blank_line();
} }
@@ -237,16 +264,21 @@ where
self.push_line(Line::default()); self.push_line(Line::default());
} }
self.pending_marker_line = false; self.pending_marker_line = false;
if self.in_code_block if self.in_code_block && !self.needs_newline {
&& !self.needs_newline let has_content = self
&& self .current_line_content
.text .as_ref()
.lines
.last()
.map(|line| !line.spans.is_empty()) .map(|line| !line.spans.is_empty())
.unwrap_or(false) .unwrap_or_else(|| {
{ self.text
self.push_line(Line::default()); .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() { for (i, line) in text.lines().enumerate() {
if self.needs_newline { if self.needs_newline {
@@ -351,6 +383,7 @@ where
} }
fn start_codeblock(&mut self, _lang: Option<String>, indent: Option<Span<'static>>) { fn start_codeblock(&mut self, _lang: Option<String>, indent: Option<Span<'static>>) {
self.flush_current_line();
if !self.text.lines.is_empty() { if !self.text.lines.is_empty() {
self.push_blank_line(); self.push_blank_line();
} }
@@ -360,16 +393,10 @@ where
None, None,
false, false,
)); ));
// let opener = match lang {
// Some(l) if !l.is_empty() => format!("```{l}"),
// _ => "```".to_string(),
// };
// self.push_line(opener.into());
self.needs_newline = true; self.needs_newline = true;
} }
fn end_codeblock(&mut self) { fn end_codeblock(&mut self) {
// self.push_line("```".into());
self.needs_newline = true; self.needs_newline = true;
self.in_code_block = false; self.in_code_block = false;
self.indent_stack.pop(); 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>) { fn push_line(&mut self, line: Line<'static>) {
let mut line = line; self.flush_current_line();
let was_pending = self.pending_marker_line;
let mut spans = self.current_prefix_spans();
spans.append(&mut line.spans);
let blockquote_active = self let blockquote_active = self
.indent_stack .indent_stack
.iter() .iter()
@@ -411,31 +461,38 @@ where
} else { } else {
line.style line.style
}; };
self.text.lines.push(Line::from_iter(spans).style(style)); let was_pending = self.pending_marker_line;
if was_pending {
self.pending_marker_line = false; 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>) { fn push_span(&mut self, span: Span<'static>) {
if let Some(last) = self.text.lines.last_mut() { if let Some(line) = self.current_line_content.as_mut() {
last.push_span(span); line.push_span(span);
} else { } else {
self.push_line(Line::from(vec![span])); self.push_line(Line::from(vec![span]));
} }
} }
fn push_blank_line(&mut self) { fn push_blank_line(&mut self) {
self.flush_current_line();
if self.indent_stack.iter().all(|ctx| ctx.is_list) { if self.indent_stack.iter().all(|ctx| ctx.is_list) {
self.text.lines.push(Line::default()); self.text.lines.push(Line::default());
} else { } else {
self.push_line(Line::default()); 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 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 self.indent_stack
.iter() .iter()
.enumerate() .enumerate()
@@ -447,7 +504,7 @@ where
let last_list_index = self.indent_stack.iter().rposition(|ctx| ctx.is_list); let last_list_index = self.indent_stack.iter().rposition(|ctx| ctx.is_list);
for (i, ctx) in self.indent_stack.iter().enumerate() { for (i, ctx) in self.indent_stack.iter().enumerate() {
if self.pending_marker_line { if pending_marker_line {
if Some(i) == last_marker_index if Some(i) == last_marker_index
&& let Some(marker) = &ctx.marker && let Some(marker) = &ctx.marker
{ {
@@ -514,6 +571,19 @@ mod markdown_render_tests {
mod tests { mod tests {
use super::*; use super::*;
use pretty_assertions::assert_eq; 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] #[test]
fn citation_is_rewritten_with_absolute_path() { fn citation_is_rewritten_with_absolute_path() {
@@ -546,17 +616,136 @@ mod tests {
let unchanged = rewrite_file_citations_with_scheme(markdown, Some("vscode"), cwd); let unchanged = rewrite_file_citations_with_scheme(markdown, Some("vscode"), cwd);
// The helper itself always rewrites this test validates behaviour of // The helper itself always rewrites this test validates behaviour of
// append_markdown when `file_opener` is None. // 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. // Convert lines back to string for comparison.
let rendered: String = rendered let rendered: String = lines_to_strings(&rendered).join("");
.lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("");
assert_eq!(markdown, rendered); assert_eq!(markdown, rendered);
// Ensure helper rewrites. // Ensure helper rewrites.
assert_ne!(markdown, unchanged); 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 { pub(crate) struct MarkdownStreamCollector {
buffer: String, buffer: String,
committed_line_count: usize, committed_line_count: usize,
width: Option<usize>,
} }
impl MarkdownStreamCollector { impl MarkdownStreamCollector {
pub fn new() -> Self { pub fn new(width: Option<usize>) -> Self {
Self { Self {
buffer: String::new(), buffer: String::new(),
committed_line_count: 0, committed_line_count: 0,
width,
} }
} }
@@ -40,7 +42,7 @@ impl MarkdownStreamCollector {
return Vec::new(); return Vec::new();
}; };
let mut rendered: Vec<Line<'static>> = 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(); let mut complete_line_count = rendered.len();
if complete_line_count > 0 if complete_line_count > 0
&& crate::render::line_utils::is_blank_line_spaces_only( && 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---"); tracing::trace!("markdown finalize (raw source):\n---\n{source}\n---");
let mut rendered: Vec<Line<'static>> = 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 out = if self.committed_line_count >= rendered.len() { let out = if self.committed_line_count >= rendered.len() {
Vec::new() Vec::new()
@@ -101,7 +103,7 @@ pub(crate) fn simulate_stream_markdown_for_tests(
finalize: bool, finalize: bool,
config: &Config, config: &Config,
) -> Vec<Line<'static>> { ) -> Vec<Line<'static>> {
let mut collector = MarkdownStreamCollector::new(); let mut collector = MarkdownStreamCollector::new(None);
let mut out = Vec::new(); let mut out = Vec::new();
for d in deltas { for d in deltas {
collector.push_delta(d); collector.push_delta(d);
@@ -136,7 +138,7 @@ mod tests {
#[test] #[test]
fn no_commit_until_newline() { fn no_commit_until_newline() {
let cfg = test_config(); let cfg = test_config();
let mut c = super::MarkdownStreamCollector::new(); let mut c = super::MarkdownStreamCollector::new(None);
c.push_delta("Hello, world"); c.push_delta("Hello, world");
let out = c.commit_complete_lines(&cfg); let out = c.commit_complete_lines(&cfg);
assert!(out.is_empty(), "should not commit without newline"); assert!(out.is_empty(), "should not commit without newline");
@@ -148,7 +150,7 @@ mod tests {
#[test] #[test]
fn finalize_commits_partial_line() { fn finalize_commits_partial_line() {
let cfg = test_config(); let cfg = test_config();
let mut c = super::MarkdownStreamCollector::new(); let mut c = super::MarkdownStreamCollector::new(None);
c.push_delta("Line without newline"); c.push_delta("Line without newline");
let out = c.finalize_and_drain(&cfg); let out = c.finalize_and_drain(&cfg);
assert_eq!(out.len(), 1); assert_eq!(out.len(), 1);
@@ -277,7 +279,7 @@ mod tests {
// Stream a paragraph line, then a heading on the next line. // Stream a paragraph line, then a heading on the next line.
// Expect two distinct rendered lines: "Hello." and "Heading". // 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"); c.push_delta("Hello.\n");
let out1 = c.commit_complete_lines(&cfg); let out1 = c.commit_complete_lines(&cfg);
let s1: Vec<String> = out1 let s1: Vec<String> = out1
@@ -335,7 +337,7 @@ mod tests {
// Paragraph without trailing newline, then a chunk that starts with the newline // 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 // 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. // 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!"); c.push_delta("Sounds good!");
// No commit yet // No commit yet
assert!(c.commit_complete_lines(&cfg).is_empty()); 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. // Sanity check raw markdown rendering for a simple line does not produce spurious extras.
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new(); 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 let rendered_strings: Vec<String> = rendered
.iter() .iter()
.map(|l| { .map(|l| {
@@ -442,7 +444,7 @@ mod tests {
let streamed_str = lines_to_plain_strings(&streamed); let streamed_str = lines_to_plain_strings(&streamed);
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new(); 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); let rendered_all_str = lines_to_plain_strings(&rendered_all);
assert_eq!( assert_eq!(
@@ -552,7 +554,7 @@ mod tests {
let full: String = deltas.iter().copied().collect(); let full: String = deltas.iter().copied().collect();
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new(); 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); let rendered_all_strs = lines_to_plain_strings(&rendered_all);
assert_eq!( assert_eq!(
@@ -641,7 +643,7 @@ mod tests {
// Compute a full render for diagnostics only. // Compute a full render for diagnostics only.
let full: String = deltas.iter().copied().collect(); let full: String = deltas.iter().copied().collect();
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new(); 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. // Also assert exact expected plain strings for clarity.
let expected = vec![ let expected = vec![
@@ -669,7 +671,7 @@ mod tests {
let streamed_strs = lines_to_plain_strings(&streamed); let streamed_strs = lines_to_plain_strings(&streamed);
let full: String = deltas.iter().copied().collect(); let full: String = deltas.iter().copied().collect();
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new(); 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); let rendered_strs = lines_to_plain_strings(&rendered);
assert_eq!(streamed_strs, rendered_strs, "full:\n---\n{full}\n---"); assert_eq!(streamed_strs, rendered_strs, "full:\n---\n{full}\n---");
} }

View File

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

View File

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