mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
@@ -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(§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();
|
||||
|
||||
+21
@@ -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
|
||||
Reference in New Issue
Block a user