Preserve line breaks and links in multiline async questions (#51866)

## Why

Rendering an entire question title as one terminal line removes line endings and misaligns hyperlink source ranges with the visible text.

## What changed

Wrap and annotate each logical line of asynchronous question titles separately, preserving blank lines and handling both LF and CRLF endings while retaining complete hyperlink destinations.

## Testing

Add a rendering regression test and snapshot covering multiline titles with code fences, Unicode text, and a URL. Check LF and CRLF rendering equivalence and hyperlink destinations at narrow and wide widths.

GitOrigin-RevId: ccd7256dbaed91ce98374e52b17d3a1b87645574
(cherry picked from commit eef4719927)
This commit is contained in:
pash-openai
2026-10-09 10:28:23 -07:00
committed by David Wiesen
parent a028250541
commit 1a1335eefe
3 changed files with 74 additions and 9 deletions
@@ -158,16 +158,24 @@ impl AsyncQuestions {
pub(super) fn wrapped_question_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.current_question()
.map(|q| {
let source = annotate_web_urls_in_line(q.title.clone().into());
let wrapped = wrap_ranges_trim(&q.title, usize::from(width.max(1)))
.into_iter()
.map(|range| WrappedLine {
line: (&q.title[range.clone()]).into(),
range,
prefix_bytes: 0,
// Line::from removes line endings, so wrap and annotate each logical line
// separately to keep source byte ranges aligned with the visible text.
q.title
.split('\n')
.flat_map(|text| {
let text = text.strip_suffix('\r').unwrap_or(text);
let source = annotate_web_urls_in_line(text.to_owned().into());
let wrapped = wrap_ranges_trim(text, usize::from(width.max(1)))
.into_iter()
.map(|range| WrappedLine {
line: (&text[range.clone()]).into(),
range,
prefix_bytes: 0,
})
.collect();
remap_source_wrapped_line(&source, wrapped)
})
.collect();
remap_source_wrapped_line(&source, wrapped)
.collect()
})
.unwrap_or_default()
}
@@ -0,0 +1,19 @@
---
source: tui/src/bottom_pane/async_questions/state_tests.rs
expression: "rendered[0]"
---
1 of 2
Run this command:
```sh
printf '診 断 '
```
Then review (https://example.com/diagnostics?view=full)?
Type your answer
enter submit ⌃] skip ⇧→ main prompt
⇧← next question
@@ -180,6 +180,44 @@ fn wrapped_question_url_keeps_its_complete_destination() {
}
}
#[test]
fn multiline_question_preserves_line_breaks_and_link_destinations() {
let url = "https://example.com/diagnostics?view=full";
let title = format!("Run this command:\n\n```sh\nprintf '診断'\n```\nThen review ({url})?\n");
let mut rendered = Vec::new();
for line_ending in ["\n", "\r\n"] {
let mut editor = editor();
editor.state.pending[0].question.title = title.replace('\n', line_ending);
for width in [20, 60] {
let height = editor.desired_height(width);
let buffer = render_editor(&editor, width, height);
let linked_text = buffer
.content
.iter()
.filter_map(|cell| {
let symbol = cell.symbol();
symbol.contains("\x1b]8;;").then(|| {
let text = crate::terminal_hyperlinks::strip_osc8(symbol);
assert_eq!(
symbol,
crate::terminal_hyperlinks::osc8_hyperlink(url, &text)
);
text
})
})
.collect::<String>();
assert_eq!(linked_text, url);
let text = crate::terminal_hyperlinks::strip_osc8(&buffer_text(&buffer));
if width == 60 {
rendered.push(text);
}
}
}
assert_eq!(rendered[0], rendered[1]);
insta::assert_snapshot!("question_multiline", rendered[0]);
}
#[test]
fn selected_other_renders_as_a_dim_placeholder() {
let mut editor = editor();