From 44fe0289c6ef35a0153c291e81cf9a232b49499c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 6 Oct 2026 17:55:51 +0000 Subject: [PATCH] Make URLs clickable in TUI user input questions (#51449) ## What changed Render web URLs in `request_user_input` question text as terminal hyperlinks. Preserve the complete destination when a URL wraps across rows, along with explicit newlines and blank lines in the question. ## Testing Add a regression test that checks each linked fragment retains the complete URL, including its query string, and snapshots the wrapped question layout. GitOrigin-RevId: 473c8a8a00797c0b353a9cdb762dea2c718d9190 --- .../bottom_pane/request_user_input/layout.rs | 8 +-- .../src/bottom_pane/request_user_input/mod.rs | 68 +++++++++++++++++-- .../bottom_pane/request_user_input/render.rs | 29 ++------ ...tests__request_user_input_wrapped_url.snap | 21 ++++++ 4 files changed, 95 insertions(+), 31 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_url.snap diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs index 27d53229b6..e1ec6c7162 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -7,7 +7,7 @@ pub(super) struct LayoutSections { pub(super) progress_area: Rect, pub(super) question_area: Rect, // Wrapped question text lines to render in the question area. - pub(super) question_lines: Vec, + pub(super) question_lines: Vec, pub(super) options_area: Rect, pub(super) notes_area: Rect, // Number of footer rows (status + hints). @@ -63,7 +63,7 @@ impl RequestUserInputOverlay { fn layout_with_options( &self, args: OptionsLayoutArgs, - question_lines: &mut Vec, + question_lines: &mut Vec, ) -> LayoutPlan { let OptionsLayoutArgs { available_height, @@ -206,7 +206,7 @@ impl RequestUserInputOverlay { question_height: u16, notes_pref_height: u16, footer_pref: u16, - question_lines: &mut Vec, + question_lines: &mut Vec, ) -> LayoutPlan { let required = question_height; if required > available_height { @@ -226,7 +226,7 @@ impl RequestUserInputOverlay { &self, available_height: u16, question_height: u16, - question_lines: &mut Vec, + question_lines: &mut Vec, ) -> LayoutPlan { let max_question_height = available_height; let adjusted_question_height = question_height.min(max_question_height); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index b84b3e5f05..66c4a1c9dd 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -387,13 +387,30 @@ impl RequestUserInputOverlay { .is_some_and(|answer| answer.notes_visible || self.notes_has_content(idx)) } - pub(super) fn wrapped_question_lines(&self, width: u16) -> Vec { + pub(super) fn wrapped_question_lines( + &self, + width: u16, + ) -> Vec { self.current_question() .map(|q| { - textwrap::wrap(&q.question, width.max(1) as usize) - .into_iter() - .map(|line| line.to_string()) - .collect::>() + q.question + .split('\n') + .flat_map(|text| { + let source = crate::terminal_hyperlinks::annotate_web_urls_in_line( + text.to_owned().into(), + ); + let wrapped = + crate::wrapping::wrap_ranges_trim(text, usize::from(width.max(1))) + .into_iter() + .map(|range| crate::wrapping::WrappedLine { + line: (&text[range.clone()]).into(), + range, + prefix_bytes: 0, + }) + .collect(); + crate::terminal_hyperlinks::remap_source_wrapped_line(&source, wrapped) + }) + .collect() }) .unwrap_or_default() } @@ -3709,6 +3726,47 @@ mod tests { ); } + #[test] + fn wrapped_question_preserves_the_complete_url_destination() { + let url = "https://github.com/openai/codex/pull/12345?diff=split"; + let (tx, _rx) = test_sender(); + let mut question = question_without_options("q1", "Release"); + question.question = format!("Review the release\n\nReview {url} before choosing."); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let area = Rect::new(0, 0, 40, 18); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + let linked = buf + .content + .iter() + .filter(|cell| cell.symbol().contains("\x1b]8;;")) + .map(|cell| { + assert!(cell.symbol().starts_with(&format!("\x1b]8;;{url}\x07"))); + crate::terminal_hyperlinks::strip_osc8(cell.symbol()) + }) + .collect::(); + assert_eq!(linked, url); + let visible = buf + .content + .chunks(40) + .map(|row| { + row.iter() + .map(|cell| crate::terminal_hyperlinks::strip_osc8(cell.symbol())) + .collect::() + .trim_end() + .to_owned() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!("request_user_input_wrapped_url", visible); + } + #[test] fn request_user_input_freeform_snapshot() { let (tx, _rx) = test_sender(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs index 669b22b0e6..fb95a97014 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -276,30 +276,15 @@ impl RequestUserInputOverlay { Paragraph::new(progress_line).render(sections.progress_area, buf); // Question prompt text. - let question_y = sections.question_area.y; let answered = self.is_question_answered(self.current_index(), &self.composer.current_text()); - for (offset, line) in sections.question_lines.iter().enumerate() { - if question_y.saturating_add(offset as u16) - >= sections.question_area.y + sections.question_area.height - { - break; - } - let question_line = if answered { - Line::from(line.clone()) - } else { - Line::from(line.clone()).fg(crate::style::accent_color()) - }; - Paragraph::new(question_line).render( - Rect { - x: sections.question_area.x, - y: question_y.saturating_add(offset as u16), - width: sections.question_area.width, - height: 1, - }, - buf, - ); - } + let style = if answered { + ratatui::style::Style::default() + } else { + ratatui::style::Style::default().fg(crate::style::accent_color()) + }; + crate::terminal_hyperlinks::HyperlinkParagraph::new(§ions.question_lines, style) + .render(sections.question_area, buf); // Build rows with selection markers for the shared selection renderer. let option_rows = self.option_rows(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_url.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_url.snap new file mode 100644 index 0000000000..2ed673c95b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_url.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: visible +--- + + Question 1/1 (1 unanswered) + Review the release + + Review https://github.com/openai/ + codex/pull/12345?diff=split before + choosing. + + › Type your answer (optional) + + + + + + + enter to submit answer + esc to interrupt