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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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---");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user