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
This commit is contained in:
Charlie Marsh
2026-10-06 18:47:46 +00:00
committed by copyberry
parent a9abdeaff1
commit 44fe0289c6
4 changed files with 95 additions and 31 deletions
@@ -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<String>,
pub(super) question_lines: Vec<crate::terminal_hyperlinks::HyperlinkLine>,
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<String>,
question_lines: &mut Vec<crate::terminal_hyperlinks::HyperlinkLine>,
) -> 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<String>,
question_lines: &mut Vec<crate::terminal_hyperlinks::HyperlinkLine>,
) -> 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<String>,
question_lines: &mut Vec<crate::terminal_hyperlinks::HyperlinkLine>,
) -> LayoutPlan {
let max_question_height = available_height;
let adjusted_question_height = question_height.min(max_question_height);
@@ -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<String> {
pub(super) fn wrapped_question_lines(
&self,
width: u16,
) -> Vec<crate::terminal_hyperlinks::HyperlinkLine> {
self.current_question()
.map(|q| {
textwrap::wrap(&q.question, width.max(1) as usize)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
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::<String>();
assert_eq!(linked, url);
let visible = buf
.content
.chunks(40)
.map(|row| {
row.iter()
.map(|cell| crate::terminal_hyperlinks::strip_osc8(cell.symbol()))
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("request_user_input_wrapped_url", visible);
}
#[test]
fn request_user_input_freeform_snapshot() {
let (tx, _rx) = test_sender();
@@ -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(&sections.question_lines, style)
.render(sections.question_area, buf);
// Build rows with selection markers for the shared selection renderer.
let option_rows = self.option_rows();
@@ -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