From 96a96ddce5e41aecf3c46ca0c569447c4d435896 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 26 Sep 2026 21:47:01 +0000 Subject: [PATCH] Preserve table cell source metadata through TUI rendering (#48548) ## What changed - Retain table identity, column alignments, cell coordinates, source byte ranges, and inline formatting in copy metadata. - Carry cell fragments through grid padding, wrapping, key/value layouts, and pipe output, including escaped pipes and empty cells. - Preserve logical source metadata when remapping wrapped lines, even without hyperlinks. ## Testing Add coverage for cell fragments across narrow and wide layouts, blank table continuations, and Markdown selection copy structure and visible transformations. GitOrigin-RevId: 8422f224cffe2e74813394f0623b9167bdd4ac59 (cherry picked from commit 16c21e3f36e3ece9d505e552905c74f640af0942) --- codex-rs/tui/src/markdown_copy.rs | 3 + codex-rs/tui/src/markdown_copy/table.rs | 98 +++++++++++ codex-rs/tui/src/markdown_copy/table_tests.rs | 68 ++++++++ codex-rs/tui/src/markdown_render.rs | 163 ++++++++++++++++-- .../src/markdown_render/table_key_value.rs | 52 +++++- ...nuations_do_not_repeat_the_first_cell.snap | 11 ++ codex-rs/tui/src/terminal_hyperlinks.rs | 12 +- .../transcript_view/markdown_copy_tests.rs | 3 + .../markdown_element_copy_tests.rs | 75 ++++++++ 9 files changed, 462 insertions(+), 23 deletions(-) create mode 100644 codex-rs/tui/src/markdown_copy/table.rs create mode 100644 codex-rs/tui/src/markdown_copy/table_tests.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__markdown_render__tests__blank_table_continuations_do_not_repeat_the_first_cell.snap create mode 100644 codex-rs/tui/src/transcript_view/markdown_element_copy_tests.rs diff --git a/codex-rs/tui/src/markdown_copy.rs b/codex-rs/tui/src/markdown_copy.rs index 62b8b81acc..7579a003a9 100644 --- a/codex-rs/tui/src/markdown_copy.rs +++ b/codex-rs/tui/src/markdown_copy.rs @@ -14,6 +14,8 @@ use pulldown_cmark::Tag; use crate::clipboard_copy::CopyFormat; use crate::terminal_hyperlinks::LogicalLineSource; +pub(crate) mod table; + // Bound retained inline stacks and container prefixes independently of parser nesting. pub(crate) const MAX_COPY_DEPTH: usize = 64; @@ -56,6 +58,7 @@ pub(crate) struct CopyLine { /// Restore the containing item when a multiline selection starts in its later paragraph. pub(crate) item_prefix: String, pub(crate) code: bool, + pub(crate) table: Option, pub(crate) rule: bool, pub(crate) heading: usize, pub(crate) hard_break: bool, diff --git a/codex-rs/tui/src/markdown_copy/table.rs b/codex-rs/tui/src/markdown_copy/table.rs new file mode 100644 index 0000000000..0add17b119 --- /dev/null +++ b/codex-rs/tui/src/markdown_copy/table.rs @@ -0,0 +1,98 @@ +//! Retain original table-cell ranges through grid padding, wrapping, and record labels. +//! +//! Each displayed fragment keeps its source line and cell coordinates so selection +//! serialization can recover only visible, selected content. + +use super::CopyLine; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::LogicalLineSource; +use std::ops::Range; +use std::sync::Arc; + +#[cfg(test)] +#[path = "table_tests.rs"] +mod tests; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TableLine { + pub(crate) table: Arc<[&'static str]>, + fragments: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Fragment { + output: Range, + row: usize, + column: usize, + source: LogicalLineSource, +} + +impl TableLine { + pub(crate) fn empty(table: &Arc<[&'static str]>) -> Self { + Self { + table: Arc::clone(table), + fragments: Vec::new(), + } + } +} + +pub(crate) fn annotate_cell( + lines: &mut [HyperlinkLine], + copies: Vec, + table: &Arc<[&'static str]>, + row: usize, + column: usize, +) { + for (line, copy) in lines.iter_mut().zip(copies) { + let mut source = LogicalLineSource::from_line(&line.line); + source.copy = Some(Arc::new(copy)); + attach( + line, + Some(TableLine { + table: Arc::clone(table), + fragments: vec![Fragment { + output: source.range.clone(), + row, + column, + source, + }], + }), + ); + } +} + +pub(crate) fn attach(line: &mut HyperlinkLine, table: Option) { + if let Some(table) = table { + let mut source = LogicalLineSource::from_line(&line.line); + source.copy = Some(Arc::new(CopyLine { + table: Some(table), + ..Default::default() + })); + line.source = Some(source); + } +} + +/// Project a displayed cell fragment into the byte coordinates of its composed row. +pub(crate) fn append(output: &mut Option, source: &LogicalLineSource, offset: usize) { + let Some(table) = source.copy.as_ref().and_then(|copy| copy.table.as_ref()) else { + return; + }; + let output = output.get_or_insert_with(|| TableLine::empty(&table.table)); + for fragment in &table.fragments { + let start = source.range.start.max(fragment.output.start); + let end = source.range.end.min(fragment.output.end); + if start > end || start == end && !fragment.source.text.is_empty() { + continue; + } + let mut cell = fragment.source.clone(); + cell.range = cell.range.start + start - fragment.output.start + ..cell.range.start + end - fragment.output.start; + output.fragments.push(Fragment { + output: offset + source.prefix_bytes + start - source.range.start + ..offset + source.prefix_bytes + end - source.range.start, + row: fragment.row, + column: fragment.column, + source: cell, + }); + } +} diff --git a/codex-rs/tui/src/markdown_copy/table_tests.rs b/codex-rs/tui/src/markdown_copy/table_tests.rs new file mode 100644 index 0000000000..de29148762 --- /dev/null +++ b/codex-rs/tui/src/markdown_copy/table_tests.rs @@ -0,0 +1,68 @@ +//! Exercise retained cell coordinates, inline styles, and byte ranges through each layout. + +use super::*; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; + +#[test] +fn table_layouts_preserve_cell_fragments() { + for (markdown, expected) in [ + ( + "| **alpha head** | B |\n|---|---|\n| **one two three four** | `value` |\n| | last |", + BTreeMap::from([ + ((0, 0), "**alpha head**"), + ((0, 1), "B"), + ((1, 0), "**one two three four**"), + ((1, 1), "`value`"), + ((2, 0), ""), + ((2, 1), "last"), + ]), + ), + ( + "| **alpha head** | `a\\|b` |\n|---|---|", + BTreeMap::from([((0, 0), "**alpha head**"), ((0, 1), "`a|b`")]), + ), + ] { + for width in [4, 12, 80] { + let lines = crate::markdown_render::render_markdown_lines_with_width_and_cwd( + markdown, + Some(width), + /*cwd*/ None, + ); + let mut seen = BTreeMap::new(); + let mut identity = None; + for source in lines.into_iter().filter_map(|line| line.source) { + let Some(table) = source.copy.as_ref().and_then(|copy| copy.table.as_ref()) else { + continue; + }; + let identity = identity.get_or_insert_with(|| Arc::clone(&table.table)); + assert!(Arc::ptr_eq(identity, &table.table)); + for fragment in &table.fragments { + let cell = &fragment.source; + assert_eq!( + &source.text[fragment.output.clone()], + &cell.text[cell.range.clone()], + "width {width}, cell ({}, {})", + fragment.row, + fragment.column, + ); + let markdown = cell.copy.as_ref().unwrap().render( + &cell.text, + 0..cell.text.len(), + /*depth*/ 0, + ); + let key = (fragment.row, fragment.column); + assert_eq!(markdown, expected[&key]); + seen.insert(key, markdown); + } + } + assert_eq!( + seen, + expected + .iter() + .map(|(key, value)| (*key, value.to_string())) + .collect() + ); + } + } +} diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index d15b95f31a..7f6823512d 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -193,6 +193,7 @@ impl IndentContext { #[derive(Clone, Debug, Default)] struct TableCell { lines: Vec, + copies: Vec, } // TableCell mutators inlined — called per-span during table event parsing. @@ -201,11 +202,27 @@ impl TableCell { fn ensure_line(&mut self) { if self.lines.is_empty() { self.lines.push(HyperlinkLine::new(Line::default())); + self.copies.push(Default::default()); } } - fn push_annotated(&mut self, mut appended: HyperlinkLine) { + fn push_annotated( + &mut self, + mut appended: HyperlinkLine, + inline: &[crate::markdown_copy::Inline], + ) { self.ensure_line(); + if let Some(copy) = self.copies.last_mut() { + copy.push( + appended + .line + .spans + .iter() + .map(|span| span.content.len()) + .sum(), + inline, + ); + } if let Some(line) = self.lines.last_mut() { let shift = line.width(); line.line.spans.append(&mut appended.line.spans); @@ -220,6 +237,7 @@ impl TableCell { #[inline] fn hard_break(&mut self) { self.lines.push(HyperlinkLine::new(Line::default())); + self.copies.push(Default::default()); } fn plain_text(&self) -> String { @@ -1111,17 +1129,44 @@ impl<'a, 'policy> Writer<'a, 'policy> { return; }; + let table: std::sync::Arc<[&str]> = table_state + .alignments + .iter() + .map(|alignment| match alignment { + Alignment::None => "---", + Alignment::Left => ":---", + Alignment::Center => ":---:", + Alignment::Right => "---:", + }) + .collect(); let RenderedTableLines { table_lines, table_lines_prewrapped, spillover_lines, - } = self.render_table_lines(table_state); + } = self.render_table_lines(table_state, &table); let mut pending_marker_line = self.pending_marker_line; - for line in table_lines { + for mut line in table_lines { + if line + .source + .as_ref() + .and_then(|source| source.copy.as_ref()) + .is_none() + { + crate::markdown_copy::table::attach( + &mut line, + Some(crate::markdown_copy::table::TableLine::empty(&table)), + ); + } if table_lines_prewrapped { self.push_prewrapped_line(line, pending_marker_line); } else { + let table = line + .source + .as_ref() + .and_then(|source| source.copy.as_ref()) + .and_then(|copy| copy.table.clone()); self.push_hyperlink_line(line); + self.copy_line.table = table; self.copy_line.code = true; self.flush_current_line(); } @@ -1237,7 +1282,7 @@ impl<'a, 'policy> Writer<'a, 'policy> { if let Some(table_state) = self.table_state.as_mut() && let Some(cell) = table_state.current_cell.as_mut() { - cell.push_annotated(annotated); + cell.push_annotated(annotated, &self.copy_inline); } } @@ -1277,7 +1322,7 @@ impl<'a, 'policy> Writer<'a, 'policy> { if let Some(table_state) = self.table_state.as_mut() && let Some(cell) = table_state.current_cell.as_mut() { - cell.push_annotated(std::mem::take(&mut annotated)); + cell.push_annotated(std::mem::take(&mut annotated), &self.copy_inline); } } @@ -1292,7 +1337,11 @@ impl<'a, 'policy> Writer<'a, 'policy> { /// Falls back to key/value records when body rows cannot fit in the aligned /// grid; header-only tables retain raw pipe output because they contain no /// records to transpose. - fn render_table_lines(&self, mut table_state: TableState) -> RenderedTableLines { + fn render_table_lines( + &self, + mut table_state: TableState, + table: &std::sync::Arc<[&'static str]>, + ) -> RenderedTableLines { let column_count = table_state.alignments.len(); if column_count == 0 { return RenderedTableLines { @@ -1326,6 +1375,21 @@ impl<'a, 'policy> Writer<'a, 'policy> { for row in &mut rows { Self::normalize_row(row, column_count); } + for (row_index, row) in std::iter::once(&mut header) + .chain(rows.iter_mut()) + .enumerate() + { + for (column, cell) in row.iter_mut().enumerate() { + cell.ensure_line(); + crate::markdown_copy::table::annotate_cell( + &mut cell.lines, + std::mem::take(&mut cell.copies), + table, + row_index, + column, + ); + } + } let metrics = Self::collect_table_column_metrics(&header, &rows, column_count); let available_width = self.available_table_width(column_count); @@ -1691,16 +1755,25 @@ impl<'a, 'policy> Writer<'a, 'policy> { .get(row_line) .is_some_and(|line| Self::line_display_width(&line.line) > 0) }) else { - out.push(HyperlinkLine::new(Line::default().style(row_style))); + let mut line = wrapped_cells + .iter() + .find_map(|cell| cell.get(row_line)) + .cloned() + .unwrap_or_default(); + line.line.style = row_style; + out.push(line); continue; }; let mut spans = Vec::new(); + let mut copy = None; + let mut byte_offset = 0; for (column, width) in column_widths .iter() .enumerate() .take(last_visible_column + 1) { spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING))); + byte_offset += TABLE_CELL_PADDING; let mut line = wrapped_cells[column] .get(row_line) .cloned() @@ -1715,19 +1788,33 @@ impl<'a, 'policy> Writer<'a, 'policy> { if left_padding > 0 { spans.push(Span::raw(" ".repeat(left_padding))); } + byte_offset += left_padding; + if let Some(source) = &line.source { + crate::markdown_copy::table::append(&mut copy, source, byte_offset); + } + byte_offset += line + .line + .spans + .iter() + .map(|span| span.content.len()) + .sum::(); spans.append(&mut line.line.spans); let is_last_column = column == last_visible_column; if right_padding > 0 && !is_last_column { spans.push(Span::raw(" ".repeat(right_padding))); + byte_offset += right_padding; } if !is_last_column { spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING))); + byte_offset += TABLE_CELL_PADDING; } if !is_last_column { spans.push(Span::raw(" ".repeat(TABLE_COLUMN_GAP))); + byte_offset += TABLE_COLUMN_GAP; } } let mut out_line = HyperlinkLine::new(Line::from(spans).style(row_style)); + crate::markdown_copy::table::attach(&mut out_line, copy); let mut column_start = 0usize; for (column, width) in column_widths .iter() @@ -1779,12 +1866,16 @@ impl<'a, 'policy> Writer<'a, 'policy> { fn row_to_pipe_line(row: &[TableCell]) -> HyperlinkLine { let mut out = HyperlinkLine::new(Line::default()); + let mut copy = None; + let mut byte_offset = 1; out.push_span("|".into(), /*destination*/ None); for cell in row { out.push_span(" ".into(), /*destination*/ None); + byte_offset += 1; for (index, line) in cell.lines.iter().enumerate() { if index > 0 { out.push_span(" ".into(), /*destination*/ None); + byte_offset += 1; } let text = line .line @@ -1802,7 +1893,7 @@ impl<'a, 'policy> Writer<'a, 'policy> { out.push_span(Span::raw(std::mem::take(current_text)), destination); } }; - for ch in text.chars() { + for (byte, ch) in text.char_indices() { let destination = line .hyperlinks .iter() @@ -1814,15 +1905,23 @@ impl<'a, 'policy> Writer<'a, 'policy> { } if ch == '|' { current_text.push_str("\\|"); + byte_offset += 1; } else { current_text.push(ch); } + if let Some(mut source) = line.source.clone() { + source.range = byte..byte + ch.len_utf8(); + crate::markdown_copy::table::append(&mut copy, &source, byte_offset); + } + byte_offset += ch.len_utf8(); column += char_width(ch); } flush(&mut out, &mut current_text, current_destination); } out.push_span(" |".into(), /*destination*/ None); + byte_offset += 2; } + crate::markdown_copy::table::attach(&mut out, copy); out } @@ -1860,7 +1959,7 @@ impl<'a, 'policy> Writer<'a, 'policy> { .map(|line| line_to_static(&line)) .collect::>(); if rendered.is_empty() { - wrapped.push(HyperlinkLine::new(Line::default())); + wrapped.push(source_line.clone()); } else { wrapped.extend(remap_wrapped_line(source_line, rendered)); }; @@ -2057,7 +2156,7 @@ impl<'a, 'policy> Writer<'a, 'policy> { if let Some(table_state) = self.table_state.as_mut() && let Some(cell) = table_state.current_cell.as_mut() { - cell.push_annotated(destination); + cell.push_annotated(destination, &self.copy_inline); } self.push_span_to_table_cell(")".into()); } else { @@ -2087,8 +2186,14 @@ impl<'a, 'policy> Writer<'a, 'policy> { let span = Span::styled(local_target_display, style); if self.in_table_cell() { if show_label { - for label_span in link.local_label_spans { + for (label_span, inline) in link + .local_label_spans + .into_iter() + .zip(link.local_label_inline) + { + let previous = std::mem::replace(&mut self.copy_inline, inline); self.push_span_to_table_cell(label_span); + self.copy_inline = previous; } self.push_span_to_table_cell(" (".into()); } @@ -2218,7 +2323,12 @@ impl<'a, 'policy> Writer<'a, 'policy> { let mut spans = self.prefix_spans(pending_marker_line); let mut source = crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line); - let mut copy = crate::markdown_copy::CopyLine::default(); + let mut copy = line + .source + .as_ref() + .and_then(|source| source.copy.as_deref()) + .cloned() + .unwrap_or_default(); copy.prefix = self.copy_prefix(pending_marker_line); copy.continuation = self.copy_prefix(/*pending_marker_line*/ false); copy.item_prefix = self.copy_prefix(/*pending_marker_line*/ true); @@ -2612,9 +2722,9 @@ mod tests { #[test] fn wrap_cell_preserves_hard_break_lines() { let mut cell = TableCell::default(); - cell.push_annotated(Line::from("first line").into()); + cell.push_annotated(Line::from("first line").into(), &[]); cell.hard_break(); - cell.push_annotated(Line::from("second line").into()); + cell.push_annotated(Line::from("second line").into(), &[]); let writer = W::new( "", @@ -2640,6 +2750,29 @@ mod tests { ); } + #[test] + fn blank_table_continuations_do_not_repeat_the_first_cell() { + let mut cell = make_cell("first"); + cell.hard_break(); + cell.hard_break(); + cell.push_annotated(Line::from("last").into(), &[]); + cell.hard_break(); + let writer = W::new( + "", + /*wrap_width*/ Some(80), + /*cwd*/ None, + &never_hide_link_destination, + ); + let lines = writer.render_table_row( + &[make_cell("short"), cell], + &[5, 5], + &[Alignment::None, Alignment::None], + Style::default(), + ); + let rendered: Vec<_> = lines.iter().map(|line| line.line.to_string()).collect(); + insta::assert_debug_snapshot!(rendered); + } + // --------------------------------------------------------------- // Type alias for calling private associated functions on Writer. // --------------------------------------------------------------- @@ -2648,7 +2781,7 @@ mod tests { /// Build a single-line `TableCell` from plain text. fn make_cell(text: &str) -> TableCell { let mut cell = TableCell::default(); - cell.push_annotated(Line::from(text.to_string()).into()); + cell.push_annotated(Line::from(text.to_string()).into(), &[]); cell } diff --git a/codex-rs/tui/src/markdown_render/table_key_value.rs b/codex-rs/tui/src/markdown_render/table_key_value.rs index 29c0559f77..63bd72d843 100644 --- a/codex-rs/tui/src/markdown_render/table_key_value.rs +++ b/codex-rs/tui/src/markdown_render/table_key_value.rs @@ -163,8 +163,13 @@ fn render_aligned_field( let wrapped_value = wrap_cell(value, value_width); for (line_index, value_line) in wrapped_value.into_iter().enumerate() { let mut spans = Vec::new(); + let mut copy = None; if line_index == 0 { let label = header.plain_text(); + let label_source = label_line(header, label_style); + if let Some(source) = &label_source.source { + crate::markdown_copy::table::append(&mut copy, source, FIELD_LEADING_PADDING); + } spans.push(Span::raw(" ".repeat(FIELD_LEADING_PADDING))); spans.push(Span::styled(label.clone(), label_style)); spans.push(Span::raw(" ".repeat( @@ -173,7 +178,7 @@ fn render_aligned_field( } else { spans.push(Span::raw(" ".repeat(value_indent))); } - push_prefixed_value_line(out, spans, value_line); + push_prefixed_value_line(out, spans, value_line, copy); } } @@ -187,16 +192,22 @@ fn render_stacked_field( let label_width = available_width .map(|width| width.saturating_sub(FIELD_LEADING_PADDING).max(1)) .unwrap_or_else(|| display_width(&header.plain_text()).max(1)); - let label = Line::from(Span::styled(header.plain_text(), label_style)); + let label = label_line(header, label_style); let mut wrapped_labels = Vec::new(); push_owned_lines( - &word_wrap_line(&label, RtOptions::new(label_width)), + &word_wrap_line(&label.line, RtOptions::new(label_width)), &mut wrapped_labels, ); - for label_line in wrapped_labels { + for label_line in remap_wrapped_line(&label, wrapped_labels) { let mut spans = vec![Span::raw(" ".repeat(FIELD_LEADING_PADDING))]; - spans.extend(label_line.spans); - out.push(HyperlinkLine::new(Line::from(spans))); + let mut copy = None; + if let Some(source) = &label_line.source { + crate::markdown_copy::table::append(&mut copy, source, FIELD_LEADING_PADDING); + } + spans.extend(label_line.line.spans); + let mut line = HyperlinkLine::new(Line::from(spans)); + crate::markdown_copy::table::attach(&mut line, copy); + out.push(line); } let value_width = available_width @@ -207,6 +218,7 @@ fn render_stacked_field( out, vec![Span::raw(" ".repeat(STACKED_VALUE_INDENT))], value_line, + /*copy*/ None, ); } } @@ -215,13 +227,19 @@ fn push_prefixed_value_line( out: &mut Vec, mut prefix: Vec>, mut value_line: HyperlinkLine, + mut copy: Option, ) { + let bytes = prefix.iter().map(|span| span.content.len()).sum(); + if let Some(source) = &value_line.source { + crate::markdown_copy::table::append(&mut copy, source, bytes); + } let shift = prefix .iter() .map(|span| display_width(span.content.as_ref())) .sum::(); prefix.append(&mut value_line.line.spans); let mut output_line = HyperlinkLine::new(Line::from(prefix)); + crate::markdown_copy::table::attach(&mut output_line, copy); output_line .hyperlinks .extend(value_line.hyperlinks.into_iter().map(|mut link| { @@ -231,6 +249,26 @@ fn push_prefixed_value_line( out.push(output_line); } +fn label_line(header: &TableCell, style: Style) -> HyperlinkLine { + let mut line = HyperlinkLine::new(Line::from(Span::styled(header.plain_text(), style))); + let mut copy = None; + let mut offset = 0; + for header_line in &header.lines { + if let Some(source) = &header_line.source { + crate::markdown_copy::table::append(&mut copy, source, offset); + } + offset += header_line + .line + .spans + .iter() + .map(|span| span.content.len()) + .sum::() + + 1; + } + crate::markdown_copy::table::attach(&mut line, copy); + line +} + fn wrap_cell(cell: &TableCell, width: usize) -> Vec { if cell.lines.is_empty() { return vec![HyperlinkLine::new(Line::default())]; @@ -243,7 +281,7 @@ fn wrap_cell(cell: &TableCell, width: usize) -> Vec { .map(|line| line_to_static(&line)) .collect::>(); if rendered.is_empty() { - wrapped.push(HyperlinkLine::new(Line::default())); + wrapped.push(source_line.clone()); } else { wrapped.extend(remap_wrapped_line(source_line, rendered)); } diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__tests__blank_table_continuations_do_not_repeat_the_first_cell.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__tests__blank_table_continuations_do_not_repeat_the_first_cell.snap new file mode 100644 index 0000000000..45017facb1 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__tests__blank_table_continuations_do_not_repeat_the_first_cell.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/markdown_render.rs +assertion_line: 2773 +expression: rendered +--- +[ + " short first", + "", + " last", + "", +] diff --git a/codex-rs/tui/src/terminal_hyperlinks.rs b/codex-rs/tui/src/terminal_hyperlinks.rs index 6db9897f3f..bea2472ad1 100644 --- a/codex-rs/tui/src/terminal_hyperlinks.rs +++ b/codex-rs/tui/src/terminal_hyperlinks.rs @@ -426,10 +426,16 @@ pub(crate) fn remap_wrapped_line( wrapped: Vec>, ) -> Vec { let mut out = plain_hyperlink_lines(wrapped); - if source.hyperlinks.is_empty() { + if source.hyperlinks.is_empty() && source.source.is_none() { return out; } let source_text = line_text(&source.line); + if source_text.trim().is_empty() { + for line in &mut out { + line.source = source.source.clone(); + } + return out; + } let mut source_byte = 0usize; let mut source_column = 0usize; let mut link_index = 0usize; @@ -447,6 +453,10 @@ pub(crate) fn remap_wrapped_line( continue; }; let mapped = &rendered[rendered_start..]; + line.source = source + .source + .as_ref() + .map(|source| source.wrapped(source_byte..source_byte + mapped.len(), rendered_start)); let mut output_column = display_width(&rendered[..rendered_start]); for grapheme in mapped.graphemes(/*is_extended*/ true) { let width = display_width(grapheme); diff --git a/codex-rs/tui/src/transcript_view/markdown_copy_tests.rs b/codex-rs/tui/src/transcript_view/markdown_copy_tests.rs index 1ce0b74220..a3ae0aa448 100644 --- a/codex-rs/tui/src/transcript_view/markdown_copy_tests.rs +++ b/codex-rs/tui/src/transcript_view/markdown_copy_tests.rs @@ -7,6 +7,9 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use std::path::Path; +#[path = "markdown_element_copy_tests.rs"] +mod elements; + #[test] fn selected_markdown_preserves_lists_and_inline_code() { let cells: Vec> = vec![Arc::new(AgentMarkdownCell::new( diff --git a/codex-rs/tui/src/transcript_view/markdown_element_copy_tests.rs b/codex-rs/tui/src/transcript_view/markdown_element_copy_tests.rs new file mode 100644 index 0000000000..a22d3a8242 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/markdown_element_copy_tests.rs @@ -0,0 +1,75 @@ +//! Coverage of the Markdown grammar and visible transformations used by selection copy. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn supported_elements_preserve_structure_at_narrow_and_wide_widths() { + let samples = [ + "# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six", + "Setext heading\n===\n\nSubheading\n---", + "**strong** _emphasis_ ~~deleted~~ and `inline_code` with ``a`b``.", + "Escaped \\*stars\\*, & entity, café 界, and 👩‍💻.", + "- first\n- second\n - nested\n\n3. third\n4. fourth", + "- [ ] pending\n- [x] complete", + "> quoted **text**\n>\n> - item\n> - nested", + "First paragraph.\n\nHard break: \nnext line.\n\n---\n\nLast paragraph.", + "Prose\n\n```\nlet x = \"*literal* \";\n```\n\nAfter", + "Image ![**alt** text](https://example.com/image.png).", + "Literal HTML and unsupported [^footnote].", + ]; + for source in samples { + for width in [18, 100] { + let layout = markdown_layout(source, width); + let copied = payload(&layout, 0..layout.text().len()).0; + assert_eq!( + crate::clipboard_html::render_markdown(&copied), + crate::clipboard_html::render_markdown(source), + "width {width}: {source}\n{copied}" + ); + } + } +} + +#[test] +fn transformed_content_copies_visible_text_without_active_html_or_images() { + for (source, expected) in [ + ("Soft break\nsame paragraph.", "Soft break\nsame paragraph."), + ("[**local**](/repo/file.rs)", "**local** (repo/file.rs)"), + ( + "[label][reference]\n\n[reference]: https://example.com", + "[label (https://example.com)](https://example.com)", + ), + ( + "", + "[https://example.com (https://example.com)](https://example.com)", + ), + ("![image alt](https://example.com/image.png)", "image alt"), + ] { + let layout = markdown_layout(source, /*width*/ 40); + let copied = payload(&layout, 0..layout.text().len()).0; + assert_eq!( + crate::clipboard_html::render_markdown(&copied), + crate::clipboard_html::render_markdown(expected), + "{source}\n{copied}" + ); + } +} + +#[test] +fn math_and_mermaid_copy_the_visible_rendering() { + for source in [ + "Inline $\\alpha_1$ and \\(x^{2}\\).", + "\\[\n\\frac{a}{b}\n\\]", + "```mermaid\nflowchart LR\nA[Start] --> B[Finish]\n```", + ] { + let layout = markdown_layout(source, /*width*/ 80); + let (copied, format) = payload(&layout, 0..layout.text().len()); + if format == CopyFormat::PlainText { + assert_eq!(copied, layout.text()); + } else { + let rendered = markdown_layout(&copied, /*width*/ 80); + assert_eq!(rendered.text(), layout.text()); + } + } +}