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)