From 0b43721d8d1f734658e41bffe12a6ba6c9240abd Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 30 Sep 2026 10:11:19 +0000 Subject: [PATCH] Copy selected file paths as plain text in the TUI (#49564) ## Why Copying a rendered local file target such as `repo/foo_bar!` should preserve the selected path without adding Markdown escapes. ## What changed Treat selections contained within rendered file targets as plain text, including partial selections and targets in tables. Coalesce adjacent file targets and inline code into literal ranges while preserving Markdown escaping when the selection includes surrounding content. ## Testing Add regression coverage for partial paths, adjacent literal spans, styled links, table cells, special characters, and mixed selections at narrow and wide rendering widths. GitOrigin-RevId: 94bcdacf14b013f9198cb84adfdb237eb644dd48 --- codex-rs/tui/src/markdown_copy.rs | 34 +++++++++++++----- codex-rs/tui/src/markdown_copy/table.rs | 2 +- codex-rs/tui/src/markdown_render.rs | 4 +++ .../transcript_view/markdown_copy_tests.rs | 36 +++++++++++++++++++ ...election_copies_only_selected_content.snap | 18 ++++++++++ 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/markdown_copy.rs b/codex-rs/tui/src/markdown_copy.rs index d2513211a8..ebc933a76b 100644 --- a/codex-rs/tui/src/markdown_copy.rs +++ b/codex-rs/tui/src/markdown_copy.rs @@ -26,6 +26,8 @@ pub(crate) enum Inline { Link(Arc), Delimiter(&'static str), Code, + /// Copy a generated file target literally without changing mixed-selection Markdown. + Literal, /// Balance parser events for links whose visible text has no copied wrapper. Ignored, } @@ -66,6 +68,7 @@ pub(crate) struct CopyLine { /// A visual separator inserted between sibling list items, never source content. pub(crate) omit: bool, runs: Vec<(Range, Vec)>, + literal_ranges: Vec>, } impl CopyLine { @@ -73,9 +76,13 @@ impl CopyLine { if length == 0 { return; } - let mut inline = inline[..inline.len().min(MAX_COPY_DEPTH)] + let inline = &inline[..inline.len().min(MAX_COPY_DEPTH)]; + let literal = inline .iter() - .filter(|mark| **mark != Inline::Ignored) + .any(|mark| matches!(mark, Inline::Code | Inline::Literal)); + let mut inline = inline + .iter() + .filter(|mark| !matches!(mark, Inline::Ignored | Inline::Literal)) .cloned() .collect::>(); inline.sort(); @@ -84,6 +91,16 @@ impl CopyLine { .runs .last() .map_or(/*default*/ 0, |(range, _)| range.end); + // Keep literal spans separate so they never split context-sensitive prose escaping. + if literal { + if let Some(range) = self.literal_ranges.last_mut() + && range.end == start + { + range.end += length; + } else { + self.literal_ranges.push(start..start + length); + } + } if let Some((range, previous)) = self.runs.last_mut() && previous == &inline { @@ -93,11 +110,12 @@ impl CopyLine { } } - fn is_inline_code(&self, range: &Range) -> bool { + fn is_literal(&self, range: &Range) -> bool { !range.is_empty() - && self.runs.iter().any(|(run, inline)| { - run.start <= range.start && range.end <= run.end && inline.contains(&Inline::Code) - }) + && self + .literal_ranges + .iter() + .any(|literal| literal.start <= range.start && range.end <= literal.end) } fn render(&self, text: &str, range: Range, depth: usize) -> String { @@ -172,7 +190,7 @@ impl CopyLine { .replace('\r', "%0D"); append_inline(&mut out, &format!("[{trimmed}](<{destination}>)")); } - Inline::Code | Inline::Ignored => unreachable!(), + Inline::Code | Inline::Literal | Inline::Ignored => unreachable!(), } out.push_str(&body[body.trim_end().len()..]); } @@ -267,7 +285,7 @@ pub(crate) fn selection(lines: &[SelectedLine], plain: &str) -> (String, CopyFor .source .copy .as_ref() - .is_some_and(|copy| copy.is_inline_code(&line.range)) + .is_some_and(|copy| copy.is_literal(&line.range)) { return (plain.to_owned(), CopyFormat::PlainText); } diff --git a/codex-rs/tui/src/markdown_copy/table.rs b/codex-rs/tui/src/markdown_copy/table.rs index affc6dcbfd..54ff6d11b8 100644 --- a/codex-rs/tui/src/markdown_copy/table.rs +++ b/codex-rs/tui/src/markdown_copy/table.rs @@ -167,7 +167,7 @@ pub(super) fn render( && source .copy .as_ref() - .is_some_and(|copy| copy.is_inline_code(&source.range)) + .is_some_and(|copy| copy.is_literal(&source.range)) { return ( source.text[source.range.clone()].to_owned(), diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index c5033f8045..e07cb56868 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -2211,7 +2211,9 @@ impl<'a, 'policy> Writer<'a, 'policy> { } self.push_span_to_table_cell(" (".into()); } + self.copy_inline.push(crate::markdown_copy::Inline::Literal); self.push_span_to_table_cell(span); + self.copy_inline.pop(); if show_label { self.push_span_to_table_cell(")".into()); } @@ -2231,7 +2233,9 @@ impl<'a, 'policy> Writer<'a, 'policy> { } self.push_span(" (".into()); } + self.copy_inline.push(crate::markdown_copy::Inline::Literal); self.push_span(span); + self.copy_inline.pop(); if show_label { self.push_span(")".into()); } 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 6d7d5304a0..9c965bae02 100644 --- a/codex-rs/tui/src/transcript_view/markdown_copy_tests.rs +++ b/codex-rs/tui/src/transcript_view/markdown_copy_tests.rs @@ -128,6 +128,23 @@ fn inline_code_selection_copies_only_selected_content() { ("before ``a`b`` after", "a`"), ("- **`café界_*`**", "café界_*"), ("before ` leading and trailing ` after", " leading"), + (r"before ``real\_literal\!`` after", r"real\_literal\!"), + ("before [`foo_bar!`](/repo/foo_bar!) after", "repo/foo_bar!"), + ("before [`foo_bar!`](/repo/foo_bar!) after", "bar!"), + ( + "[foo_bar!](/foo_bar!)[baz_qux!](/baz_qux!)", + "foo_bar!baz_qux!", + ), + ("`foo_bar!`[baz_qux!](/baz_qux!)", "foo_bar!baz_qux!"), + ("before **[file](/repo/foo_bar!)** after", "foo_bar!"), + ( + "| File |\n|---|\n| [`foo_bar!`](/repo/foo_bar!) |", + "foo_bar!", + ), + ( + "before [file]() after", + "repo/`*_[]<>~#|&!=-+.)", + ), ] { for width in [18, 80] { let layout = markdown_layout(source, width); @@ -146,6 +163,25 @@ fn inline_code_selection_copies_only_selected_content() { insta::assert_snapshot!(copies.join("\n")); } +#[test] +fn mixed_file_targets_preserve_markdown_escaping() { + for (source, expected) in [ + ("before [+](/+) after", "before + after"), + ("before [1.)]() after", "before 1.) after"), + ( + "| File |\n|---|\n| [`foo_bar!`](/repo/foo_bar!) |", + "| File |\n|---|\n| repo/foo\\_bar\\! |", + ), + ] { + let layout = markdown_layout(source, /*width*/ 80); + assert_eq!( + payload(&layout, 0..layout.text().len()), + (expected.into(), CopyFormat::Markdown), + "{source}" + ); + } +} + #[test] fn inline_boundaries_preserve_selected_text_and_formatting() { let mut copies = Vec::new(); diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__inline_code_selection_copies_only_selected_content.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__inline_code_selection_copies_only_selected_content.snap index 9feeaa9dd6..8c2c1a8886 100644 --- a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__inline_code_selection_copies_only_selected_content.snap +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__inline_code_selection_copies_only_selected_content.snap @@ -16,3 +16,21 @@ Selected "a`" → ("a`", PlainText) Selected "café界_*" → ("café界_*", PlainText) before ` leading and trailing ` after Selected " leading" → (" leading", PlainText) +before ``real\_literal\!`` after +Selected "real\\_literal\\!" → ("real\\_literal\\!", PlainText) +before [`foo_bar!`](/repo/foo_bar!) after +Selected "repo/foo_bar!" → ("repo/foo_bar!", PlainText) +before [`foo_bar!`](/repo/foo_bar!) after +Selected "bar!" → ("bar!", PlainText) +[foo_bar!](/foo_bar!)[baz_qux!](/baz_qux!) +Selected "foo_bar!baz_qux!" → ("foo_bar!baz_qux!", PlainText) +`foo_bar!`[baz_qux!](/baz_qux!) +Selected "foo_bar!baz_qux!" → ("foo_bar!baz_qux!", PlainText) +before **[file](/repo/foo_bar!)** after +Selected "foo_bar!" → ("foo_bar!", PlainText) +| File | +|---| +| [`foo_bar!`](/repo/foo_bar!) | +Selected "foo_bar!" → ("foo_bar!", PlainText) +before [file]() after +Selected "repo/`*_[]<>~#|&!=-+.)" → ("repo/`*_[]<>~#|&!=-+.)", PlainText)