From 939a9ffd37cff03ecff32a49b2d88eaedb4303bc Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 30 Apr 2026 13:24:00 -0300 Subject: [PATCH] fix(tui): preserve markdown styles in tables --- codex-rs/tui/src/markdown_render.rs | 90 ++++-- codex-rs/tui/src/markdown_render/table.rs | 286 ++++++++---------- .../tui/src/markdown_render/table_cell.rs | 77 +++++ .../tui/src/markdown_render/table_state.rs | 61 ++++ codex-rs/tui/src/markdown_render_tests.rs | 71 +++++ 5 files changed, 412 insertions(+), 173 deletions(-) create mode 100644 codex-rs/tui/src/markdown_render/table_cell.rs create mode 100644 codex-rs/tui/src/markdown_render/table_state.rs diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 98c1547f13..d98fee1472 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -30,9 +30,11 @@ use std::sync::LazyLock; use url::Url; mod table; -use table::TableState; +mod table_cell; +mod table_state; use table::normalize_table_boundaries; use table::render_table_lines; +use table_state::TableState; struct MarkdownStyles { h1: Style, @@ -285,13 +287,7 @@ 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, .. } => { - if let Some(table) = self.table.as_mut() { - table.start_link(dest_url.to_string()); - } else { - self.push_link(dest_url.to_string()); - } - } + Tag::Link { dest_url, .. } => 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(), @@ -320,8 +316,8 @@ where } TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(), TagEnd::Link => { - if let Some(table) = self.table.as_mut() { - table.end_link(); + if self.table.is_some() { + self.pop_table_link(); } else { self.pop_link(); } @@ -435,8 +431,14 @@ where } fn text(&mut self, text: CowStr<'a>) { - if let Some(table) = self.table.as_mut() { - table.push_text(&text); + if self.table.is_some() { + if self.suppressing_local_link_label() { + return; + } + let style = self.inline_styles.last().copied().unwrap_or_default(); + if let Some(table) = self.table.as_mut() { + table.push_text(&text, style); + } return; } if self.suppressing_local_link_label() { @@ -492,8 +494,13 @@ where } fn code(&mut self, code: CowStr<'a>) { - if let Some(table) = self.table.as_mut() { - table.push_text(&code); + if self.table.is_some() { + if self.suppressing_local_link_label() { + return; + } + if let Some(table) = self.table.as_mut() { + table.push_text(&code, self.styles.code); + } return; } if self.suppressing_local_link_label() { @@ -509,8 +516,14 @@ where } fn html(&mut self, html: CowStr<'a>, inline: bool) { - if let Some(table) = self.table.as_mut() { - table.push_html(&html); + if self.table.is_some() { + if self.suppressing_local_link_label() { + return; + } + let style = self.inline_styles.last().copied().unwrap_or_default(); + if let Some(table) = self.table.as_mut() { + table.push_html(&html, style); + } return; } if self.suppressing_local_link_label() { @@ -533,8 +546,14 @@ where } fn hard_break(&mut self) { - if let Some(table) = self.table.as_mut() { - table.push_text(" "); + if self.table.is_some() { + if self.suppressing_local_link_label() { + return; + } + let style = self.inline_styles.last().copied().unwrap_or_default(); + if let Some(table) = self.table.as_mut() { + table.push_text(" ", style); + } return; } if self.suppressing_local_link_label() { @@ -545,8 +564,14 @@ where } fn soft_break(&mut self) { - if let Some(table) = self.table.as_mut() { - table.push_text(" "); + if self.table.is_some() { + if self.suppressing_local_link_label() { + return; + } + let style = self.inline_styles.last().copied().unwrap_or_default(); + if let Some(table) = self.table.as_mut() { + table.push_text(" ", style); + } return; } if self.suppressing_local_link_label() { @@ -721,6 +746,31 @@ where } } + fn pop_table_link(&mut self) { + let Some(link) = self.link.take() else { + return; + }; + let Some(table) = self.table.as_mut() else { + return; + }; + + if link.show_destination { + table.push_span(" (".into()); + table.push_span(Span::styled(link.destination, self.styles.link)); + table.push_span(")".into()); + } else if let Some(local_target_display) = link.local_target_display { + // Local file links are rendered as code-like path text so the transcript shows the + // resolved target instead of arbitrary caller-provided label text. + let style = self + .inline_styles + .last() + .copied() + .unwrap_or_default() + .patch(self.styles.code); + table.push_span(Span::styled(local_target_display, style)); + } + } + fn suppressing_local_link_label(&self) -> bool { self.link .as_ref() diff --git a/codex-rs/tui/src/markdown_render/table.rs b/codex-rs/tui/src/markdown_render/table.rs index ce4ee0e85e..dfe2d771aa 100644 --- a/codex-rs/tui/src/markdown_render/table.rs +++ b/codex-rs/tui/src/markdown_render/table.rs @@ -1,74 +1,13 @@ +use super::table_cell::TableCell; use std::borrow::Cow; +use crate::render::line_utils::line_to_static; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; use ratatui::text::Line; +use ratatui::text::Span; use unicode_width::UnicodeWidthStr; -#[derive(Debug, Default)] -pub(super) struct TableState { - pub(super) rows: Vec>, - current_row: Vec, - current_cell: String, - in_cell: bool, - current_link_destination: Option, -} - -impl TableState { - pub(super) fn start_row(&mut self) { - self.current_row.clear(); - } - - pub(super) fn start_cell(&mut self) { - self.current_cell.clear(); - self.in_cell = true; - } - - pub(super) fn push_text(&mut self, text: &str) { - if self.in_cell { - self.current_cell.push_str(text); - } - } - - 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; - } - - pub(super) fn end_row(&mut self) { - if !self.current_row.is_empty() { - self.rows.push(std::mem::take(&mut self.current_row)); - } - } -} - #[derive(Debug)] struct TableLayoutCandidate { column_widths: Vec, @@ -84,7 +23,10 @@ struct TableMetrics { hard_wrap_count: usize, } -pub(super) fn render_table_lines(rows: &[Vec], width: Option) -> Vec> { +pub(super) fn render_table_lines( + rows: &[Vec], + width: Option, +) -> Vec> { if rows.is_empty() { return Vec::new(); } @@ -104,10 +46,7 @@ pub(super) fn render_table_lines(rows: &[Vec], width: Option) -> &candidate.column_widths, candidate.padding, candidate.hard_wrap, - ) - .into_iter() - .map(Line::from) - .collect(), + ), None => render_vertical_table(&normalized_rows, available_width), } } @@ -224,17 +163,17 @@ fn is_table_delimiter_source(line: &str) -> bool { }) } -fn normalize_table_rows(rows: &[Vec], column_count: usize) -> Vec> { +fn normalize_table_rows(rows: &[Vec], column_count: usize) -> Vec> { rows.iter() .map(|row| { let mut normalized = row.clone(); - normalized.resize(column_count, String::new()); + normalized.resize(column_count, TableCell::default()); normalized }) .collect() } -fn desired_column_widths(rows: &[Vec], column_count: usize) -> Vec { +fn desired_column_widths(rows: &[Vec], column_count: usize) -> Vec { let mut widths = vec![3; column_count]; for row in rows { for (index, cell) in row.iter().enumerate() { @@ -245,7 +184,7 @@ fn desired_column_widths(rows: &[Vec], column_count: usize) -> Vec], + rows: &[Vec], desired_widths: &[usize], available_width: usize, column_count: usize, @@ -295,7 +234,7 @@ fn width_shape_requires_vertical(column_count: usize, available_width: usize) -> } fn build_table_candidate( - rows: &[Vec], + rows: &[Vec], desired_widths: &[usize], column_widths: Vec, available_width: usize, @@ -368,31 +307,49 @@ fn allocate_table_widths( } fn render_box_table( - rows: &[Vec], + rows: &[Vec], column_widths: &[usize], padding: usize, hard_wrap: bool, -) -> Vec { +) -> Vec> { let mut out = Vec::new(); - out.push(border_line("┌", "┬", "┐", column_widths, padding)); + out.push(Line::from(border_line( + "┌", + "┬", + "┐", + column_widths, + padding, + ))); for (index, row) in rows.iter().enumerate() { out.extend(render_table_row(row, column_widths, padding, hard_wrap)); if index == 0 { - out.push(border_line("├", "┼", "┤", column_widths, padding)); + out.push(Line::from(border_line( + "├", + "┼", + "┤", + column_widths, + padding, + ))); } } - out.push(border_line("└", "┴", "┘", column_widths, padding)); + out.push(Line::from(border_line( + "└", + "┴", + "┘", + column_widths, + padding, + ))); out } fn render_table_row( - row: &[String], + row: &[TableCell], column_widths: &[usize], padding: usize, hard_wrap: bool, -) -> Vec { +) -> Vec> { let wrapped_cells = row .iter() .zip(column_widths) @@ -402,21 +359,31 @@ fn render_table_row( let mut out = Vec::with_capacity(row_height); for line_index in 0..row_height { - let mut line = String::from("│"); + let mut spans = vec![Span::from("│")]; for (cell_lines, width) in wrapped_cells.iter().zip(column_widths) { - let content = cell_lines.get(line_index).map(String::as_str).unwrap_or(""); - line.push_str(&" ".repeat(padding)); - line.push_str(content); - line.push_str(&" ".repeat(width.saturating_sub(content.width()))); - line.push_str(&" ".repeat(padding)); - line.push('│'); + let content = cell_lines.get(line_index); + push_padding(&mut spans, padding); + if let Some(content) = content { + spans.extend(content.spans.iter().cloned()); + push_padding(&mut spans, width.saturating_sub(content.width())); + } else { + push_padding(&mut spans, *width); + } + push_padding(&mut spans, padding); + spans.push(Span::from("│")); } - out.push(line); + out.push(Line::from(spans)); } out } +fn push_padding(spans: &mut Vec>, width: usize) { + if width > 0 { + spans.push(Span::from(" ".repeat(width))); + } +} + fn border_line( left: &str, separator: &str, @@ -431,36 +398,44 @@ fn border_line( format!("{left}{}{right}", cell_segments.join(separator)) } -fn wrap_table_cell(cell: &str, width: usize, hard_wrap: bool) -> Vec { - if cell.is_empty() { - return vec![String::new()]; +fn wrap_table_cell(cell: &TableCell, width: usize, hard_wrap: bool) -> Vec> { + if cell.lines().is_empty() { + return vec![Line::default()]; } let mut lines = Vec::new(); - let options = textwrap::Options::new(width) + let options = RtOptions::new(width) .break_words(hard_wrap) .word_separator(textwrap::WordSeparator::AsciiSpace) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit); - for segment in cell.split('\n') { - let wrapped = textwrap::wrap(segment, options.clone()) + for segment in cell.lines() { + if segment.width() == 0 { + lines.push(Line::default()); + continue; + } + let wrapped = word_wrap_line(segment, options.clone()) .into_iter() - .map(std::borrow::Cow::into_owned) + .map(|line| line_to_static(&line)) .collect::>(); if wrapped.is_empty() { - lines.push(String::new()); + lines.push(Line::default()); } else { lines.extend(wrapped); } } if lines.is_empty() { - vec![String::new()] + vec![Line::default()] } else { lines } } -fn table_metrics(rows: &[Vec], column_widths: &[usize], hard_wrap: bool) -> TableMetrics { +fn table_metrics( + rows: &[Vec], + column_widths: &[usize], + hard_wrap: bool, +) -> TableMetrics { let mut row_heights = Vec::with_capacity(rows.len()); let mut hard_wraps_url_or_code = false; let mut hard_wrap_count = 0usize; @@ -498,14 +473,15 @@ fn table_metrics(rows: &[Vec], column_widths: &[usize], hard_wrap: bool) } } -fn cell_needs_hard_wrap(cell: &str, width: usize) -> bool { - cell.split('\n') +fn cell_needs_hard_wrap(cell: &TableCell, width: usize) -> bool { + cell.plain_text() + .split('\n') .flat_map(str::split_whitespace) .any(|token| token.width() > width) } fn should_render_vertical( - rows: &[Vec], + rows: &[Vec], desired_widths: &[usize], column_widths: &[usize], available_width: usize, @@ -557,11 +533,11 @@ fn table_total_width(column_widths: &[usize], padding: usize) -> usize { + padding * 2 * column_widths.len() } -fn is_index_column(rows: &[Vec], index: usize) -> bool { +fn is_index_column(rows: &[Vec], index: usize) -> bool { let header = rows .first() .and_then(|row| row.get(index)) - .map(|header| normalized_header(header)) + .map(normalized_header) .unwrap_or_default(); if matches!(header.as_str(), "#" | "id" | "idx" | "index" | "row") { return true; @@ -570,15 +546,16 @@ fn is_index_column(rows: &[Vec], index: usize) -> bool { rows.iter() .skip(1) .filter_map(|row| row.get(index)) - .filter(|cell| !cell.trim().is_empty()) - .all(|cell| cell.trim().chars().all(|ch| ch.is_ascii_digit())) + .map(TableCell::trimmed_plain_text) + .filter(|cell| !cell.is_empty()) + .all(|cell| cell.chars().all(|ch| ch.is_ascii_digit())) } -fn is_content_heavy_column(rows: &[Vec], index: usize) -> bool { +fn is_content_heavy_column(rows: &[Vec], index: usize) -> bool { let header = rows .first() .and_then(|row| row.get(index)) - .map(|header| normalized_header(header)) + .map(normalized_header) .unwrap_or_default(); if [ "link", @@ -603,31 +580,32 @@ fn is_content_heavy_column(rows: &[Vec], index: usize) -> bool { .any(|cell| is_url_or_code_like(cell) || cell.width() > 24) } -fn is_url_or_code_like(cell: &str) -> bool { - cell.contains("://") - || cell.contains("::") - || cell.contains("=>") - || cell.contains("->") - || cell.contains('`') - || cell.contains('{') - || cell.contains('}') - || cell.contains('(') - || cell.contains(')') +fn is_url_or_code_like(cell: &TableCell) -> bool { + let text = cell.plain_text(); + text.contains("://") + || text.contains("::") + || text.contains("=>") + || text.contains("->") + || text.contains('`') + || text.contains('{') + || text.contains('}') + || text.contains('(') + || text.contains(')') } -fn has_width_risk_chars(rows: &[Vec]) -> bool { +fn has_width_risk_chars(rows: &[Vec]) -> bool { rows.iter().flatten().any(|cell| { - cell.chars().any(|ch| { + cell.plain_text().chars().any(|ch| { matches!(ch, '\u{fe0f}' | '\u{200d}') || ('\u{1f300}'..='\u{1faff}').contains(&ch) }) }) } -fn normalized_header(header: &str) -> String { - header.trim().to_ascii_lowercase() +fn normalized_header(header: &TableCell) -> String { + header.trimmed_plain_text().to_ascii_lowercase() } -fn render_vertical_table(rows: &[Vec], available_width: usize) -> Vec> { +fn render_vertical_table(rows: &[Vec], available_width: usize) -> Vec> { let Some((headers, body_rows)) = rows.split_first() else { return Vec::new(); }; @@ -644,8 +622,13 @@ fn render_vertical_table(rows: &[Vec], available_width: usize) -> Vec
  • >() + }) + .map(|line| line.width()) .max() .unwrap_or(1); let available_content_width = available_width.saturating_sub(7); @@ -688,20 +671,15 @@ fn render_vertical_table(rows: &[Vec], available_width: usize) -> Vec
  • >(); + let empty_cell = TableCell::default(); + let cell = row.get(*index).unwrap_or(&empty_cell); + let wrapped = if cell.is_blank() { + vec![Line::from("—")] + } else { + wrap_table_cell(cell, value_width, /*hard_wrap*/ true) + }; let wrapped = if wrapped.is_empty() { - vec![String::new()] + vec![Line::default()] } else { wrapped }; @@ -710,11 +688,14 @@ fn render_vertical_table(rows: &[Vec], available_width: usize) -> Vec
  • ], available_width: usize) -> Vec
  • ]) -> Vec { +fn included_vertical_columns(headers: &[TableCell], body_rows: &[Vec]) -> Vec { let first_column_titles_rows = headers .first() - .map(|header| normalized_header(header)) + .map(normalized_header) .is_some_and(|header| matches!(header.as_str(), "#" | "id" | "idx" | "index" | "row")) && headers.len() > 1; (0..headers.len()) .filter(|index| !(first_column_titles_rows && *index == 0)) .filter(|index| { - !headers[*index].trim().is_empty() + !headers[*index].is_blank() || body_rows .iter() - .any(|row| row.get(*index).is_some_and(|cell| !cell.trim().is_empty())) + .any(|row| row.get(*index).is_some_and(|cell| !cell.is_blank())) }) .collect() } -fn vertical_label(headers: &[String], index: usize) -> String { +fn vertical_label(headers: &[TableCell], index: usize) -> String { headers .get(index) - .map(|header| header.trim()) + .map(TableCell::trimmed_plain_text) .filter(|header| !header.is_empty()) - .unwrap_or("Column") - .to_string() + .unwrap_or_else(|| "Column".to_string()) } fn truncate_to_width(input: &str, max_width: usize) -> String { diff --git a/codex-rs/tui/src/markdown_render/table_cell.rs b/codex-rs/tui/src/markdown_render/table_cell.rs new file mode 100644 index 0000000000..3788134930 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/table_cell.rs @@ -0,0 +1,77 @@ +use std::borrow::Cow; + +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(super) struct TableCell { + lines: Vec>, +} + +impl TableCell { + pub(super) fn push_text(&mut self, text: &str, style: Style) { + self.push_span(Span::styled(text.to_string(), style)); + } + + pub(super) fn push_span(&mut self, span: Span<'static>) { + let content = span.content.to_string(); + for (index, segment) in content.split('\n').enumerate() { + if index > 0 { + self.push_line_break(); + } + if segment.is_empty() { + self.ensure_line(); + continue; + } + let mut segment_span = span.clone(); + segment_span.content = Cow::Owned(segment.to_string()); + self.ensure_line().push_span(segment_span); + } + } + + pub(super) fn push_line_break(&mut self) { + self.lines.push(Line::default()); + } + + pub(super) fn plain_text(&self) -> String { + self.lines + .iter() + .map(line_plain_text) + .collect::>() + .join("\n") + } + + pub(super) fn trimmed_plain_text(&self) -> String { + self.plain_text().trim().to_string() + } + + pub(super) fn width(&self) -> usize { + self.lines.iter().map(Line::width).max().unwrap_or(0) + } + + pub(super) fn is_blank(&self) -> bool { + self.lines + .iter() + .all(|line| line.spans.iter().all(|span| span.content.trim().is_empty())) + } + + pub(super) fn lines(&self) -> &[Line<'static>] { + &self.lines + } + + fn ensure_line(&mut self) -> &mut Line<'static> { + if self.lines.is_empty() { + self.lines.push(Line::default()); + } + let last_index = self.lines.len() - 1; + &mut self.lines[last_index] + } +} + +fn line_plain_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() +} diff --git a/codex-rs/tui/src/markdown_render/table_state.rs b/codex-rs/tui/src/markdown_render/table_state.rs new file mode 100644 index 0000000000..5cedceca09 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/table_state.rs @@ -0,0 +1,61 @@ +use ratatui::style::Style; +use ratatui::text::Span; + +use super::table_cell::TableCell; + +#[derive(Debug, Default)] +pub(super) struct TableState { + pub(super) rows: Vec>, + current_row: Vec, + current_cell: TableCell, + in_cell: bool, +} + +impl TableState { + pub(super) fn start_row(&mut self) { + self.current_row.clear(); + } + + pub(super) fn start_cell(&mut self) { + self.current_cell = TableCell::default(); + self.in_cell = true; + } + + pub(super) fn push_text(&mut self, text: &str, style: Style) { + if self.in_cell { + self.current_cell.push_text(text, style); + } + } + + pub(super) fn push_span(&mut self, span: Span<'static>) { + if self.in_cell { + self.current_cell.push_span(span); + } + } + + pub(super) fn push_html(&mut self, html: &str, style: Style) { + let trimmed = html.trim(); + if matches!( + trimmed.to_ascii_lowercase().as_str(), + "
    " | "
    " | "
    " + ) { + if self.in_cell { + self.current_cell.push_line_break(); + } + } else { + self.push_text(html, style); + } + } + + pub(super) fn end_cell(&mut self) { + self.current_row + .push(std::mem::take(&mut self.current_cell)); + self.in_cell = false; + } + + pub(super) fn end_row(&mut self) { + if !self.current_row.is_empty() { + self.rows.push(std::mem::take(&mut self.current_row)); + } + } +} diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index 62d49eef8d..69acd6254c 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -28,6 +28,14 @@ fn plain_lines(text: &Text<'_>) -> Vec { .collect() } +fn find_span<'a>(text: &'a Text<'_>, content: &str) -> &'a Span<'a> { + text.lines + .iter() + .flat_map(|line| line.spans.iter()) + .find(|span| span.content == content) + .unwrap_or_else(|| panic!("expected span containing {content:?} in {text:?}")) +} + fn assert_table_lines_leave_wrap_safety_column(lines: &[String], width: usize) { assert!( lines @@ -295,6 +303,69 @@ fn table_inline_links_and_html_breaks_stay_inside_table() { ); } +#[test] +fn table_preserves_inline_styles_in_boxed_layout() { + let markdown = "| Feature | Sample |\n| --- | --- |\n| Code | `run just fmt` |\n| Bold | **strong** |\n| Italic | *soft* |\n| Strike | ~~gone~~ |\n| Link | [docs](https://example.com/docs) |\n"; + let rendered = + render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(96), /*cwd*/ None); + + assert_eq!(find_span(&rendered, "run just fmt").style, "x".cyan().style); + assert_eq!(find_span(&rendered, "strong").style, "x".bold().style); + assert_eq!(find_span(&rendered, "soft").style, "x".italic().style); + assert_eq!( + find_span(&rendered, "gone").style, + "x".crossed_out().style + ); + assert_eq!( + find_span(&rendered, "https://example.com/docs").style, + "x".cyan().underlined().style + ); +} + +#[test] +fn table_preserves_inline_styles_in_vertical_layout() { + let markdown = + "| Feature | Sample | Notes |\n| --- | --- | --- |\n| Code | `cargo test` | **done** |\n"; + let rendered = + render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(30), /*cwd*/ None); + let lines = plain_lines(&rendered); + + assert!( + lines + .iter() + .any(|line| line.contains("Sample │ cargo")), + "narrow table should render as vertical key/value rows: {lines:?}" + ); + assert_eq!(find_span(&rendered, "cargo test").style, "x".cyan().style); + assert_eq!(find_span(&rendered, "done").style, "x".bold().style); +} + +#[test] +fn table_local_file_links_match_regular_response_display() { + let markdown = "| Path |\n| --- |\n| [label](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3) |\n"; + let rendered = render_markdown_text_with_width_and_cwd( + markdown, + /*width*/ None, + Some(Path::new("/Users/example/code/codex")), + ); + let lines = plain_lines(&rendered); + + assert!( + lines + .iter() + .any(|line| line.contains("codex-rs/tui/src/markdown_render.rs:74:3")), + "local table link should show the resolved target: {lines:?}" + ); + assert!( + lines.iter().all(|line| !line.contains("label")), + "local table link should suppress the markdown label: {lines:?}" + ); + assert_eq!( + find_span(&rendered, "codex-rs/tui/src/markdown_render.rs:74:3").style, + "x".cyan().style + ); +} + #[test] fn table_boundary_normalization_does_not_mutate_code_blocks() { let markdown = "```\n| A | B |\n| --- | --- |\n```\nAfter.\n";