diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 97b3471f54..3a6164d9e6 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -285,7 +285,13 @@ where Tag::Emphasis => self.push_inline_style(self.styles.emphasis), Tag::Strong => self.push_inline_style(self.styles.strong), Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough), - Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()), + Tag::Link { dest_url, .. } => { + if let Some(table) = self.table.as_mut() { + table.start_link(dest_url.to_string()); + } else { + self.push_link(dest_url.to_string()); + } + } Tag::Table(_) => self.start_table(), Tag::TableHead | Tag::TableRow => self.start_table_row(), Tag::TableCell => self.start_table_cell(), @@ -313,7 +319,13 @@ where self.pending_marker_line = false; } TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(), - TagEnd::Link => self.pop_link(), + TagEnd::Link => { + if let Some(table) = self.table.as_mut() { + table.end_link(); + } else { + self.pop_link(); + } + } TagEnd::Table => self.end_table(), TagEnd::TableHead | TagEnd::TableRow => self.end_table_row(), TagEnd::TableCell => self.end_table_cell(), @@ -497,6 +509,10 @@ where } fn html(&mut self, html: CowStr<'a>, inline: bool) { + if let Some(table) = self.table.as_mut() { + table.push_html(&html); + return; + } if self.suppressing_local_link_label() { return; } diff --git a/codex-rs/tui/src/markdown_render/table.rs b/codex-rs/tui/src/markdown_render/table.rs index 1bb8ef2a7d..93734d7155 100644 --- a/codex-rs/tui/src/markdown_render/table.rs +++ b/codex-rs/tui/src/markdown_render/table.rs @@ -8,6 +8,7 @@ pub(super) struct TableState { current_row: Vec, current_cell: String, in_cell: bool, + current_link_destination: Option, } impl TableState { @@ -26,7 +27,35 @@ impl TableState { } } + pub(super) fn push_html(&mut self, html: &str) { + let trimmed = html.trim(); + if matches!( + trimmed.to_ascii_lowercase().as_str(), + "
" | "
" | "
" + ) { + self.push_text("\n"); + } else { + self.push_text(html); + } + } + + pub(super) fn start_link(&mut self, destination: String) { + self.current_link_destination = Some(destination); + } + + pub(super) fn end_link(&mut self) { + let Some(destination) = self.current_link_destination.take() else { + return; + }; + if self.in_cell && !destination.is_empty() { + self.current_cell.push_str(" ("); + self.current_cell.push_str(&destination); + self.current_cell.push(')'); + } + } + pub(super) fn end_cell(&mut self) { + self.current_link_destination = None; self.current_row .push(std::mem::take(&mut self.current_cell)); self.in_cell = false; @@ -299,18 +328,28 @@ fn wrap_table_cell(cell: &str, width: usize, hard_wrap: bool) -> Vec { if cell.is_empty() { return vec![String::new()]; } + let mut lines = Vec::new(); let options = textwrap::Options::new(width) .break_words(hard_wrap) .word_separator(textwrap::WordSeparator::AsciiSpace) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit); - let wrapped = textwrap::wrap(cell, options) - .into_iter() - .map(std::borrow::Cow::into_owned) - .collect::>(); - if wrapped.is_empty() { + + for segment in cell.split('\n') { + let wrapped = textwrap::wrap(segment, options.clone()) + .into_iter() + .map(std::borrow::Cow::into_owned) + .collect::>(); + if wrapped.is_empty() { + lines.push(String::new()); + } else { + lines.extend(wrapped); + } + } + + if lines.is_empty() { vec![String::new()] } else { - wrapped + lines } } diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index d93e2924d1..67370fccef 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -94,6 +94,39 @@ fn table_resize_lifecycle_renderer_uses_vertical_fallback_only_at_tiny_width() { ); } +#[test] +fn table_inline_links_and_html_breaks_stay_inside_table() { + let markdown = "| A | B |\n|---|---|\n| [link](https://example.com) | [CLI docs](https://example.com/cli) |\n| one
two | three
four |\n"; + let rendered = render_markdown_text_with_width_and_cwd(markdown, Some(72), /*cwd*/ None); + let lines = plain_lines(&rendered); + + assert!( + lines + .iter() + .filter(|line| !line.trim().is_empty()) + .all(|line| line.contains('│') || line.contains('─')), + "table inline content should not leak outside table: {lines:?}" + ); + assert!( + lines + .iter() + .any(|line| line.contains("link (https://example.com)")), + "link destination should render in the table cell: {lines:?}" + ); + assert!( + lines + .iter() + .any(|line| line.contains("CLI docs (https://example.com/cli)")), + "second link destination should render in the table cell: {lines:?}" + ); + assert!( + lines.iter().any(|line| line.contains("one")) + && lines.iter().any(|line| line.contains("two")) + && lines.iter().all(|line| !line.contains("
")), + "HTML breaks should render as table cell line breaks: {lines:?}" + ); +} + #[test] fn empty() { assert_eq!(render_markdown_text(""), Text::default());