From 1a1335eefe734187e3e6b827a99b354356fa5a78 Mon Sep 17 00:00:00 2001 From: pash-openai Date: Wed, 7 Oct 2026 22:38:34 +0000 Subject: [PATCH] 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 eef47199272f4eb456f45d7b795109f8c3029c76) --- .../src/bottom_pane/async_questions/mod.rs | 26 ++++++++----- ..._questions__tests__question_multiline.snap | 19 ++++++++++ .../async_questions/state_tests.rs | 38 +++++++++++++++++++ 3 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_multiline.snap diff --git a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs index 7753a3a677..de4232ef8f 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs @@ -158,16 +158,24 @@ impl AsyncQuestions { pub(super) fn wrapped_question_lines(&self, width: u16) -> Vec { 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() } diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_multiline.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_multiline.snap new file mode 100644 index 0000000000..54cdcd05c7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_multiline.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs index 1345dd543f..713ac5194a 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs @@ -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::(); + 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();