diff --git a/codex-rs/tui/src/app/tests/transcript_selection.rs b/codex-rs/tui/src/app/tests/transcript_selection.rs index 735a7db672..29f2782ef3 100644 --- a/codex-rs/tui/src/app/tests/transcript_selection.rs +++ b/codex-rs/tui/src/app/tests/transcript_selection.rs @@ -72,8 +72,8 @@ async fn text_selection_temporarily_replaces_the_rendered_backtrack_highlight() .await?; } let (_, highlighted) = render(&mut app); - // The selected leading newline paints one blank cell before the first prompt character. - assert_eq!(highlighted, " S"); + // The selected leading newline does not paint an empty cell. + assert_eq!(highlighted, "S"); app.handle_tui_event( &mut tui, diff --git a/codex-rs/tui/src/chatwidget/tests/copy_export_picker_tests.rs b/codex-rs/tui/src/chatwidget/tests/copy_export_picker_tests.rs index c26fbe6849..58e4fb6257 100644 --- a/codex-rs/tui/src/chatwidget/tests/copy_export_picker_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/copy_export_picker_tests.rs @@ -5,6 +5,28 @@ use crate::app_event::TranscriptExportDestination; use crate::clipboard_copy::CopyFormat; use pretty_assertions::assert_eq; +#[tokio::test] +async fn completed_response_copy_preserves_markdown_line_endings() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let markdown = "Hard break: \nstarts a new line.\n\n```text\ncode with trailing spaces \n```"; + replay_agent_message( + &mut chat, + "hard-break", + format!("{markdown}\n\n::git-stage{{cwd=\"/repo\"}}"), + ReplayKind::ThreadSnapshot, + ); + chat.show_copy_picker(); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let copied = std::iter::from_fn(|| rx.try_recv().ok()).find_map(|event| match event { + AppEvent::CopySelection { text, format, .. } => Some((text.to_string(), format)), + _ => None, + }); + assert_eq!(copied, Some((markdown.to_string(), CopyFormat::Markdown))); + insta::assert_debug_snapshot!(crate::clipboard_html::render_markdown( + chat.transcript.last_agent_markdown.as_deref().unwrap() + ), @r#""
Hard break:
\nstarts a new line.
code with trailing spaces \n\n""#);
+}
+
#[tokio::test]
async fn copy_export_picker_custom_keys_preserve_payloads_and_composer_draft() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs
index 1392e14ec4..e8e5b4c459 100644
--- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs
+++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs
@@ -1665,7 +1665,7 @@ async fn slash_copy_picker_preserves_completed_source_whitespace_and_hides_direc
for (key, expected, label) in [
(
'1',
- "Intro\n\n```powershell\nWrite-Output value\nWrite-Output done\n```\n\n> Keep **formatting**\n> > Nested quote\n> hidden\n\n>",
+ "Intro\n\n```powershell\nWrite-Output value \nWrite-Output done\t\n```\n\n> Keep **formatting** \n> > Nested quote\n> hidden\n\n>",
"Whole response",
),
(
diff --git a/codex-rs/tui/src/git_action_directives.rs b/codex-rs/tui/src/git_action_directives.rs
index 7477d7053b..2d86284cbc 100644
--- a/codex-rs/tui/src/git_action_directives.rs
+++ b/codex-rs/tui/src/git_action_directives.rs
@@ -1,4 +1,6 @@
//! Codex App directives embedded in assistant markdown.
+//!
+//! Preserve whitespace on unchanged lines: Markdown hard breaks and code can depend on it.
use crate::assistant_directives::AssistantDirective;
use crate::assistant_directives::QuoteEscaping;
@@ -67,12 +69,16 @@ pub(crate) fn parse_assistant_markdown(markdown: &str, cwd: &Path) -> ParsedAssi
git_actions.push(action);
}
}
- visible_lines.push(visible_line.trim_end().to_string());
+ visible_lines.push(if visible_line == line {
+ visible_line
+ } else {
+ visible_line.trim_end().to_string()
+ });
}
while visible_lines
.last()
- .is_some_and(std::string::String::is_empty)
+ .is_some_and(|line| line.trim().is_empty())
{
visible_lines.pop();
}
diff --git a/codex-rs/tui/src/markdown_copy.rs b/codex-rs/tui/src/markdown_copy.rs
index 7579a003a9..f70d7639d9 100644
--- a/codex-rs/tui/src/markdown_copy.rs
+++ b/codex-rs/tui/src/markdown_copy.rs
@@ -59,6 +59,7 @@ pub(crate) struct CopyLine {
pub(crate) item_prefix: String,
pub(crate) code: bool,
pub(crate) table: Option"), "{copied}\n{html}");
+ assert!(html.contains(""), "{copied}\n{html}");
}
#[test]
diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__nested_tables_keep_their_copy_containers.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__nested_tables_keep_their_copy_containers.snap
index 63f7c7523e..38fb1d81b2 100644
--- a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__nested_tables_keep_their_copy_containers.snap
+++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__nested_tables_keep_their_copy_containers.snap
@@ -1,21 +1,18 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
-assertion_line: 393
+assertion_line: 460
expression: "copies.join(\"\\n---\\n\")"
---
Before
-> ```
-> A B
-> ━━━━━ ━━━━━
-> one two
-> ```
+> | A | B |
+> |---|---|
+> | one | two |
Before
- A B
-━━━━━ ━━━━━
- one two
-
+A B
+one two
+
---
@@ -23,18 +20,15 @@ Before
- intro
- ```
- A B
- ━━━━━ ━━━━━
- one two
- ```
+ | A | B |
+ |---|---|
+ | one | two |
Before
-
intro
- A B
-━━━━━ ━━━━━
- one two
-
+A B
+one two
+
diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__table_spillover_keeps_prose_outside_the_literal_table.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__table_spillover_keeps_prose_outside_the_literal_table.snap
index 66653c0aa7..543eb1234e 100644
--- a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__table_spillover_keeps_prose_outside_the_literal_table.snap
+++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__table_spillover_keeps_prose_outside_the_literal_table.snap
@@ -1,24 +1,22 @@
---
source: tui/src/transcript_view/markdown_copy_tests.rs
-assertion_line: 358
+assertion_line: 420
expression: "format!(\"{copied}\\n{}\", crate::clipboard_html::render_markdown(&copied))"
---
**Before**
-```
- A B
-━━━━━ ━━━━━
- one two
-```
+| A | B |
+|---|---|
+| one | two |
+
HTML block:
\Prose after the table.\
After
Before
- A B
-━━━━━ ━━━━━
- one two
-
+A B
+one two
+
HTML block:
<div>Prose after the table.</div>
After
diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__tables__empty_selected_rows_keep_copy_spacing_without_newline_highlights.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__tables__empty_selected_rows_keep_copy_spacing_without_newline_highlights.snap
new file mode 100644
index 0000000000..8c83e00fc1
--- /dev/null
+++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__tables__empty_selected_rows_keep_copy_spacing_without_newline_highlights.snap
@@ -0,0 +1,27 @@
+---
+source: tui/src/transcript_view/table_copy_tests.rs
+assertion_line: 108
+expression: "highlighted.join(\"\\n\")"
+---
+• Before
+ ^^^^^^^
+
+
+ Task Status Next step
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ ━━━━━━━━ ━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ Task 1 Complete Review results
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ ──────── ───────────── ───────────────────────
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ Task 2 In progress Finish implementation
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ ──────── ───────────── ───────────────────────
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ Task 3 Planned Confirm requirements
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+
+ After
+ ^^^^^
diff --git a/codex-rs/tui/src/transcript_view/table_copy_tests.rs b/codex-rs/tui/src/transcript_view/table_copy_tests.rs
new file mode 100644
index 0000000000..b792eaa31a
--- /dev/null
+++ b/codex-rs/tui/src/transcript_view/table_copy_tests.rs
@@ -0,0 +1,183 @@
+//! Selection regressions through the real Markdown renderer, layout, and clipboard serializer.
+
+use super::*;
+use pretty_assertions::assert_eq;
+
+const TABLE: &str = "| Task | Status | Next step |\n|---|---|---|\n| Task 1 | Complete | Review results |\n| Task 2 | In progress | Finish implementation |\n| Task 3 | Planned | Confirm requirements |";
+
+#[test]
+fn table_copy_preserves_structure_across_grid_and_record_layouts() {
+ let source = format!(
+ "The project is moving forward, with one task complete and two still to finish.\n\n{TABLE}\n\nThe next priority is to finish Task 2 while gathering the requirements needed to begin Task 3."
+ );
+ for width in [12, 24, 40, 80, 120] {
+ let layout = markdown_layout(&source, width);
+ assert_eq!(
+ payload(&layout, 0..layout.text().len()),
+ (source.clone(), CopyFormat::Markdown),
+ "width {width}"
+ );
+ let rewrapped = layout.rewrap(/*width*/ 17);
+ assert_eq!(
+ payload(&rewrapped, 0..rewrapped.text().len()).0,
+ source,
+ "rewrapped {width}"
+ );
+ }
+}
+
+#[test]
+fn partial_table_selection_never_adds_unselected_cell_text() {
+ let code = markdown_layout(
+ "| A | B |\n|---|---|\n| `a\\|b` | value |",
+ /*width*/ 80,
+ );
+ let start = code.text().find("a|b").unwrap();
+ assert_eq!(payload(&code, start..start + 3).0, "`a|b`");
+ let repeated = markdown_layout(
+ "| alpha SECRET omega |\n|---|\n| long_value_one |\n| long_value_two |",
+ /*width*/ 12,
+ );
+ let start = repeated.text().find("omega").unwrap();
+ let end = repeated.text().rfind("alpha").unwrap() + "alpha".len();
+ assert!(!payload(&repeated, start..end).0.contains("SECRET"));
+ assert!(
+ payload(&repeated, start..repeated.text().len())
+ .0
+ .starts_with("| alpha SECRET omega |")
+ );
+ let layout = markdown_layout(
+ "| Label | Value |\n|---|---|\n| **alpha** | beta |\n| gamma | delta |",
+ /*width*/ 80,
+ );
+ let start = layout.text().find("alpha").unwrap();
+ assert_eq!(
+ payload(&layout, start + 1..start + 4),
+ ("**lph**".into(), CopyFormat::Markdown)
+ );
+ let end = layout.text().find("delta").unwrap() + "delta".len();
+ let copied = payload(&layout, start..end).0;
+ assert_eq!(
+ copied,
+ "| | |\n|---|---|\n| **alpha** | beta |\n| gamma | delta |"
+ );
+}
+
+#[test]
+fn table_cells_keep_inline_formatting_alignment_and_literal_pipes() {
+ let source = "| Left | Center | Right |\n|:---|:---:|---:|\n| **bold** _italic_ ~~strike~~ | `a\\|b` | [link]() |\n| café 界 | escaped \\*star\\* | last |";
+ let expected_html = crate::clipboard_html::render_markdown(
+ &source.replace("[link]", "[link (https://example.com/?a=1&b=2)]"),
+ );
+ for width in [16, 48, 120] {
+ let layout = markdown_layout(source, width);
+ let copied = payload(&layout, 0..layout.text().len()).0;
+ assert_eq!(
+ crate::clipboard_html::render_markdown(&copied),
+ expected_html,
+ "width {width}: {copied}"
+ );
+ }
+}
+
+#[test]
+fn header_only_and_empty_cells_keep_their_table_positions() {
+ for source in [
+ "| A | B |\n|---|---|",
+ "| A |\n|---|",
+ "| A | B |\n|---|---|\n| | right |\n| | |\n| left | |",
+ "| A | B |\n|---|---|\n| ` ` | value |",
+ ] {
+ for width in [4, 80] {
+ 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}: {copied}"
+ );
+ }
+ }
+}
+
+#[test]
+fn separate_tables_and_code_remain_separate_blocks() {
+ let source = "Before\n\n| A | B |\n|---|---|\n| one | two |\n\n```text\n| literal | code |\n```\n\n| C | D |\n|---|---|\n| three | four |\n\nAfter";
+ let layout = markdown_layout(source, /*width*/ 80);
+ let copied = payload(&layout, 0..layout.text().len()).0;
+ assert_eq!(copied, source.replace("```text", "```"));
+}
+
+#[test]
+fn table_separator_only_selection_keeps_visible_text() {
+ for width in [12, 80] {
+ let layout = markdown_layout(TABLE, width);
+ let mut offset = 0;
+ let mut checked = false;
+ for line in layout.text().split('\n') {
+ if line.contains('─') || line.contains('━') {
+ assert_eq!(payload(&layout, offset..offset + line.len()).0, line);
+ checked = true;
+ }
+ offset += line.len() + 1;
+ }
+ assert!(checked, "no separator at width {width}");
+ }
+}
+
+#[test]
+fn empty_selected_rows_keep_copy_spacing_without_newline_highlights() {
+ let cells: Vec> = vec![Arc::new(AgentMarkdownCell::new(
+ format!("Before\n\n{TABLE}\n\nAfter"),
+ Path::new("/"),
+ ))];
+ let mut view = TranscriptView::default();
+ let area = Rect::new(
+ /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 20,
+ );
+ let mut buffer = Buffer::empty(area);
+ view.render(area, &mut buffer, &cells);
+ view.begin_selection(&cells, /*column*/ 2, /*row*/ 0, /*clicks*/ 1);
+ view.extend_selection(/*column*/ 79, /*row*/ 19);
+ view.render(area, &mut buffer, &cells);
+ let mut highlighted = Vec::new();
+ for row in 0..area.height {
+ let text = (0..area.width)
+ .map(|column| buffer[(column, row)].symbol())
+ .collect::();
+ let selected = (0..area.width)
+ .map(|column| {
+ if buffer[(column, row)]
+ .modifier
+ .contains(ratatui::style::Modifier::REVERSED)
+ {
+ '^'
+ } else {
+ ' '
+ }
+ })
+ .collect::();
+ if text.trim().is_empty() {
+ assert!(selected.trim().is_empty(), "blank row {row}");
+ }
+ highlighted.push(format!("{}\n{}", text.trim_end(), selected.trim_end()));
+ }
+ let plain = view.selected_text(&cells).unwrap();
+ view.copy_selected_text_with(
+ &cells,
+ &plain,
+ /*clear_selection*/ false,
+ |text, format| {
+ assert_eq!(
+ (text, format),
+ (
+ format!("Before\n\n{TABLE}\n\nAfter").as_str(),
+ CopyFormat::Markdown
+ )
+ );
+ Ok(crate::clipboard_copy::CopyStatus::Confirmed)
+ },
+ )
+ .unwrap();
+ insta::assert_snapshot!(highlighted.join("\n"));
+}
diff --git a/codex-rs/tui/src/transcript_view/text.rs b/codex-rs/tui/src/transcript_view/text.rs
index 5779498cc6..44877552ca 100644
--- a/codex-rs/tui/src/transcript_view/text.rs
+++ b/codex-rs/tui/src/transcript_view/text.rs
@@ -4,7 +4,7 @@
//! lines introduce hard breaks. Display wrapping and synthetic controls never enter `text`.
//! Layout offsets use `usize`; terminal coordinates are narrowed only for visible rows.
//! Disclosure controls follow their activity's source text without changing its indentation.
-//! Selected hard breaks highlight one trailing cell when space permits.
+//! Selected hard breaks on nonempty rows highlight one trailing cell when space permits.
use std::borrow::Cow;
use std::ops::Range;
@@ -318,7 +318,7 @@ impl TextLayout {
}
}
- /// Add a trailing cell for selected hard breaks, including a copied separator to `next`.
+ /// Mark selected hard breaks on nonempty rows, including a copied separator to `next`.
pub(super) fn highlight_selection(
&self,
range: Range,
@@ -329,6 +329,9 @@ impl TextLayout {
) {
self.highlight(range.clone(), area, buf, start_row);
for (screen_row, row) in self.visible_rows(area, start_row) {
+ if row.source.is_empty() {
+ continue;
+ }
let Some(end) = row.line_end else { continue };
let selected = range.contains(&end)
|| (end == self.text.len()
diff --git a/codex-rs/tui/src/transcript_view/text_tests.rs b/codex-rs/tui/src/transcript_view/text_tests.rs
index af220f891e..6b050c00ba 100644
--- a/codex-rs/tui/src/transcript_view/text_tests.rs
+++ b/codex-rs/tui/src/transcript_view/text_tests.rs
@@ -113,7 +113,7 @@ fn selection_highlights_wide_graphemes_without_trailing_padding() {
#[test]
fn selected_newlines_respect_wrapping_and_synthetic_rows() {
- for (width, expected) in [(8, vec![(2, 2), (6, 5), (2, 6)]), (4, vec![(2, 2), (2, 7)])] {
+ for (width, expected) in [(8, vec![(6, 5)]), (4, vec![])] {
let layout = TextLayout::new(
vec!["".into(), "alpha beta ".into(), "".into(), "界".into()],
width,