From 9bfbaba5f135e1c2b14215d45577884b08286efe Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 5 Jan 2026 15:23:33 -0800 Subject: [PATCH] Fix tui2 code block wrapping and copy - wrap preformatted markdown lines with joiners so code fences reflow instead of truncating - preserve code block copy fidelity by joining soft-wrap segments and trimming repeated indent - extend markdown/code snapshots with long fenced lines to cover wrapping --- codex-rs/tui/src/chatwidget/tests.rs | 3 +- ...t_markdown_code_blocks_vt100_snapshot.snap | 38 +++++++++- codex-rs/tui2/src/chatwidget/tests.rs | 3 +- codex-rs/tui2/src/history_cell.rs | 75 ++++++++++++++----- codex-rs/tui2/src/transcript_copy.rs | 52 +++++++++++-- codex-rs/tui2/src/transcript_render.rs | 4 +- 6 files changed, 146 insertions(+), 29 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a0ff8d42e9..5cdc39c3d3 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3574,7 +3574,8 @@ printf 'fenced within fenced\n' { // comment allowed in jsonc "path": "C:\\Program Files\\App", - "regex": "^foo.*(bar)?$" + "regex": "^foo.*(bar)?$", + "long": "this code line is intentionally very very long so that it wraps inside the viewport without being truncated on the right side" } ``` "#; diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap index 9ab9b03380..f912aacfd9 100644 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap @@ -1,7 +1,41 @@ --- source: tui2/src/chatwidget/tests.rs +assertion_line: 3280 expression: term.backend().vt100().screen().contents() --- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + • -- Indented code block (4 spaces) SELECT * FROM "users" @@ -14,5 +48,7 @@ expression: term.backend().vt100().screen().contents() { // comment allowed in jsonc "path": "C:\\Program Files\\App", - "regex": "^foo.*(bar)?$" + "regex": "^foo.*(bar)?$", + "long": "this code line is intentionally very very long so that it wraps + inside the viewport without being truncated on the right side" } diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 8b216812df..27aeca782f 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -3226,7 +3226,8 @@ printf 'fenced within fenced\n' { // comment allowed in jsonc "path": "C:\\Program Files\\App", - "regex": "^foo.*(bar)?$" + "regex": "^foo.*(bar)?$", + "long": "this code line is intentionally very very long so that it wraps inside the viewport without being truncated on the right side" } ``` "#; diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index 4d96b389bc..5ed4025698 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -442,24 +442,6 @@ impl HistoryCell for AgentMessageCell { Line::from(spans) }; - // Preformatted lines are rendered as a single visual line (no wrapping). - // This preserves code-block whitespace and keeps code copy behavior stable. - if logical.is_preformatted { - let mut spans = gutter_first_visual_line.spans.clone(); - spans.extend(logical.initial_indent.spans.iter().cloned()); - spans.extend(logical.content.spans.iter().cloned()); - out_lines.push(Line::from(spans).style(logical.line_style)); - joiner_before.push(None); - at_cell_start = false; - continue; - } - - // Prose path: wrap to current width and capture joiners. - // - // `word_wrap_line_with_joiners` guarantees: - // - `wrapped.len() == wrapped_joiners.len()` - // - `wrapped_joiners[0] == None` (first visual segment of a logical line is a hard break) - // - subsequent entries are `Some(joiner)` (soft-wrap continuations). let opts = RtOptions::new(width as usize) .initial_indent(compose_indent( &gutter_first_visual_line, @@ -470,6 +452,25 @@ impl HistoryCell for AgentMessageCell { &logical.subsequent_indent, )); + // Preformatted lines still wrap visually, but we capture joiners so copy/paste can + // reconstruct the original logical line without inserting hard breaks. + if logical.is_preformatted { + let (wrapped, wrapped_joiners) = + crate::wrapping::word_wrap_line_with_joiners(&logical.content, opts); + for (visual, joiner) in wrapped.into_iter().zip(wrapped_joiners) { + out_lines.push(line_to_static(&visual).style(logical.line_style)); + joiner_before.push(joiner); + at_cell_start = false; + } + continue; + } + + // Prose path: wrap to current width and capture joiners. + // + // `word_wrap_line_with_joiners` guarantees: + // - `wrapped.len() == wrapped_joiners.len()` + // - `wrapped_joiners[0] == None` (first visual segment of a logical line is a hard break) + // - subsequent entries are `Some(joiner)` (soft-wrap continuations). let (wrapped, wrapped_joiners) = crate::wrapping::word_wrap_line_with_joiners(&logical.content, opts); for (visual, joiner) in wrapped.into_iter().zip(wrapped_joiners) { @@ -1917,6 +1918,44 @@ mod tests { ); } + #[test] + fn agent_message_cell_wraps_preformatted_lines_with_joiners() { + let logical_lines = vec![crate::markdown_render::MarkdownLogicalLine { + content: Line::from( + "let long_code_line = call_with_many_arguments(1, 2, 3, 4); and_more_tokens();", + ), + initial_indent: Line::from(" "), + subsequent_indent: Line::from(" "), + line_style: Style::new().fg(ratatui::style::Color::Cyan), + is_preformatted: true, + }]; + let cell = AgentMessageCell::new_logical(logical_lines, true); + + let rendered = cell.transcript_lines_with_joiners(40); + + assert!( + rendered.lines.len() > 1, + "expected wrapped output, got {:?}", + render_lines(&rendered.lines) + ); + assert_eq!(rendered.joiner_before.first(), Some(&None)); + assert!( + rendered + .joiner_before + .iter() + .skip(1) + .all(std::option::Option::is_some), + "expected soft-wrap joiners for continuations: {:?}", + rendered.joiner_before + ); + assert!( + rendered + .lines + .iter() + .all(|line| line.style.fg == Some(ratatui::style::Color::Cyan)) + ); + } + #[tokio::test] async fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config().await; diff --git a/codex-rs/tui2/src/transcript_copy.rs b/codex-rs/tui2/src/transcript_copy.rs index 141ce901f7..e4e81e2f7f 100644 --- a/codex-rs/tui2/src/transcript_copy.rs +++ b/codex-rs/tui2/src/transcript_copy.rs @@ -152,6 +152,7 @@ pub(crate) fn selection_to_copy_text( } let line = lines.get(line_index)?; + let joiner = joiner_before.get(line_index).cloned().unwrap_or(None); // Code blocks (and other preformatted content) are detected via styling and copied as // "verbatim lines" (no inline Markdown re-encoding). This also enables special handling for @@ -213,7 +214,7 @@ pub(crate) fn selection_to_copy_text( // Convert the selected `Line` into Markdown source: // - For prose: wrap inline-code spans in backticks. // - For code blocks: return the raw flat text so we preserve indentation/spacing. - let line_text = line_to_markdown(&selected_line, is_code_block_line); + let mut line_text = line_to_markdown(&selected_line, is_code_block_line); // Track transitions into/out of code/preformatted runs and emit triple-backtick fences. // We always separate a code run from prior prose with a newline. @@ -236,11 +237,23 @@ pub(crate) fn selection_to_copy_text( } // When copying inside a code run, every selected visual line becomes a literal line inside - // the fence (no soft-wrap joining). We preserve explicit blank lines by writing empty - // strings as a line. + // the fence. We preserve explicit blank lines by writing empty strings as a line. When the + // on-screen line is a soft-wrap continuation, we join with the recorded wrap joiner instead + // of inserting a hard newline so long logical lines stay intact. if in_code_run { - if wrote_any && (!out.ends_with('\n') || prev_selected_line.is_some()) { - out.push('\n'); + let is_continuation = + prev_selected_line == Some(line_index.saturating_sub(1)) && joiner.is_some(); + if wrote_any { + if prev_selected_line == Some(line_index.saturating_sub(1)) + && let Some(joiner) = &joiner + { + out.push_str(joiner.as_str()); + } else if !out.ends_with('\n') || prev_selected_line.is_some() { + out.push('\n'); + } + } + if is_continuation { + line_text = line_text.trim_start_matches(' ').to_string(); } out.push_str(line_text.as_str()); prev_selected_line = Some(line_index); @@ -253,7 +266,6 @@ pub(crate) fn selection_to_copy_text( // recorded joiner (often spaces) instead of a newline. // - Otherwise, insert a newline to preserve hard breaks. if wrote_any { - let joiner = joiner_before.get(line_index).cloned().unwrap_or(None); if prev_selected_line == Some(line_index.saturating_sub(1)) && let Some(joiner) = joiner { @@ -751,6 +763,34 @@ mod tests { assert_eq!(out, "```\n fn main() {}\n println!(\"hi\");\n```"); } + #[test] + fn copy_selection_soft_wrapped_code_joins_segments() { + let style = Style::new().fg(Color::Cyan); + let lines = vec![ + Line::from("• let very_long_code_line = call_with_many_arguments(1, 2, 3, 4);") + .style(style), + Line::from(" // trailing comment continues here").style(style), + ]; + let joiner_before = vec![None, Some(" ".to_string())]; + + let start = TranscriptSelectionPoint { + line_index: 0, + column: 0, + }; + let end = TranscriptSelectionPoint { + line_index: 1, + column: 120, + }; + + let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, lines.len(), 80) + .expect("expected text"); + + assert_eq!( + out, + "```\n let very_long_code_line = call_with_many_arguments(1, 2, 3, 4); // trailing comment continues here\n```" + ); + } + #[test] fn copy_selection_code_block_end_col_at_viewport_edge_copies_full_line() { let style = Style::new().fg(Color::Cyan); diff --git a/codex-rs/tui2/src/transcript_render.rs b/codex-rs/tui2/src/transcript_render.rs index 729ef54bc3..dc6b5fe0d8 100644 --- a/codex-rs/tui2/src/transcript_render.rs +++ b/codex-rs/tui2/src/transcript_render.rs @@ -199,8 +199,8 @@ pub(crate) fn append_wrapped_transcript_cell( line_in_cell: visual_line_in_cell, }); visual_line_in_cell = visual_line_in_cell.saturating_add(1); - // Preformatted lines are treated as hard breaks; we keep the cell-provided joiner - // (which is typically `None`). + // Preformatted lines are treated as hard breaks; we keep the cell-provided joiner (which + // may describe an earlier soft wrap inside the cell). out.joiner_before.push( rendered .joiner_before