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
This commit is contained in:
Josh McKinney
2026-01-05 16:31:54 -08:00
parent 60e8cb037a
commit 9bfbaba5f1
6 changed files with 146 additions and 29 deletions
+2 -1
View File
@@ -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"
}
```
"#;
@@ -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"
}
+2 -1
View File
@@ -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"
}
```
"#;
+57 -18
View File
@@ -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;
+46 -6
View File
@@ -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);
+2 -2
View File
@@ -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