Make URLs in the TUI warnings viewer clickable (#51451)

## What changed

Render web URLs in warning details as terminal hyperlinks, preserving the full destination when a URL wraps across lines or the viewer scrolls.

## Testing

Add a regression test for hyperlink destinations after wrapping and scrolling, plus a snapshot of a warning containing a wrapped URL.

GitOrigin-RevId: 6500e6129a95b2dea8e8f53296ca056e63196a57
This commit is contained in:
Charlie Marsh
2026-10-06 18:51:41 +00:00
committed by copyberry
parent eb2a33aa18
commit 5da229e54d
3 changed files with 81 additions and 13 deletions
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/warnings_view_tests.rs
expression: "draw(&view, 32, 9)"
---
Warnings · 1 of 2 · MCP · ex
Review https://github.com/
openai/codex/pull/12345?
diff=split before
continuing.
k keep & next · ⌃o copy
@@ -46,24 +46,31 @@ impl Renderable for WarningsView {
}
let lines: Vec<_> = details
.lines()
.flat_map(|line| textwrap::wrap(line, usize::from(body.width.max(/*other*/ 1))))
.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(body.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();
let page_size = usize::from(body.height.max(/*other*/ 1));
self.page_size.set(page_size);
self.max_offset.set(lines.len().saturating_sub(page_size));
let offset = self.offset.get().min(self.max_offset.get());
self.offset.set(offset);
for (row, line) in lines
.iter()
.skip(offset)
.take(usize::from(body.height))
.enumerate()
{
Line::from(line.as_ref()).render(
Rect::new(body.x, body.y + row as u16, body.width, /*height*/ 1),
buf,
);
}
crate::terminal_hyperlinks::HyperlinkParagraph::new(
&lines[offset..],
ratatui::style::Style::default(),
)
.render(body, buf);
// Plain k belongs to this viewer, including when configured as a chord prefix.
let hint = |context, action| {
self.keymap.primary_hint(context, action).filter(|hint| {
@@ -39,7 +39,7 @@ fn draw(view: &WarningsView, width: u16, height: u16) -> String {
.chunks(usize::from(width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.map(|cell| crate::terminal_hyperlinks::strip_osc8(cell.symbol()))
.collect::<String>()
.trim_end()
.to_string()
@@ -169,3 +169,51 @@ fn warnings_keep_current_and_next_without_dismissing_unvisited_pages() {
};
assert_eq!((dismissed, kept), (expected, vec![entries()[0].clone()]));
}
#[test]
fn warning_links_preserve_destinations_when_wrapped_and_scrolled() {
let url = "https://github.com/openai/codex/pull/12345?diff=split";
let (tx, _) = unbounded_channel();
let mut data = entries();
data[0].details = format!("Review {url} before continuing.");
let mut view = WarningsView::new(
data,
Arc::default(),
RuntimeKeymap::defaults(),
AppEventSender::new(tx),
);
let area = Rect::new(0, 0, 32, 9);
let mut buffer = Buffer::empty(area);
view.render(area, &mut buffer);
let linked = buffer
.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);
insta::assert_snapshot!(
"warnings_wrapped_url",
draw(&view, /*width*/ 32, /*height*/ 9)
);
key(&mut view, KeyCode::Down);
let area = Rect::new(0, 0, 32, 5);
let mut buffer = Buffer::empty(area);
view.render(area, &mut buffer);
key(&mut view, KeyCode::Down);
view.render(area, &mut buffer);
let linked = buffer
.content
.iter()
.filter(|cell| cell.symbol().contains("\x1b]8;;"))
.collect::<Vec<_>>();
assert!(!linked.is_empty());
assert!(
linked
.iter()
.all(|cell| cell.symbol().starts_with(&format!("\x1b]8;;{url}\x07")))
);
}