Skip to content

Commit bc3ebcb

Browse files
charliemarsh-oaicopyberry
authored andcommitted
Preserve clickable URLs in TUI approval headers (#51439)
## What changed Render command, permission, patch, and MCP elicitation approval headers with `HyperlinkText`, which annotates web URLs and preserves their full destinations across wrapping and clipping. ## Testing Add a regression test covering wrapped URL destinations in all four header types, plus a snapshot of the wrapped command approval header. GitOrigin-RevId: 7f6b79484c9297cd03514da2d6805b0d239fee63
1 parent 8e23d18 commit bc3ebcb

5 files changed

Lines changed: 92 additions & 6 deletions

File tree

‎codex-rs/tui/src/bottom_pane/apply_patch_header.rs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@
33
use super::approval_overlay::ApplyPatchApprovalRequest;
44
use crate::diff_model::FileChange;
55
use crate::render::renderable::Renderable;
6+
use crate::terminal_hyperlinks::HyperlinkText;
67
use codex_utils_path_uri::LegacyAppPathString;
78
use ratatui::style::Stylize;
89
use ratatui::text::Line;
9-
use ratatui::widgets::Paragraph;
10-
use ratatui::widgets::Wrap;
1110

1211
pub(super) fn build_header(request: &ApplyPatchApprovalRequest) -> Box<dyn Renderable> {
1312
let mut header = Vec::new();
@@ -70,5 +69,5 @@ pub(super) fn build_header(request: &ApplyPatchApprovalRequest) -> Box<dyn Rende
7069
destination.bold(),
7170
]));
7271
}
73-
Box::new(Paragraph::new(header).wrap(Wrap { trim: false }))
72+
Box::new(HyperlinkText::new(header))
7473
}

‎codex-rs/tui/src/bottom_pane/approval_overlay.rs‎

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,7 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
735735
header.extend(full_cmd_lines);
736736
}
737737
}
738-
Box::new(Paragraph::new(header).wrap(Wrap { trim: false }))
738+
Box::new(crate::terminal_hyperlinks::HyperlinkText::new(header))
739739
}
740740
ApprovalRequest::Permissions(request) => {
741741
let mut header: Vec<Line<'static>> = Vec::new();
@@ -763,7 +763,7 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
763763
rule_line.fg(crate::style::accent_color()),
764764
]));
765765
}
766-
Box::new(Paragraph::new(header).wrap(Wrap { trim: false }))
766+
Box::new(crate::terminal_hyperlinks::HyperlinkText::new(header))
767767
}
768768
ApprovalRequest::ApplyPatch(request) => super::apply_patch_header::build_header(request),
769769
ApprovalRequest::McpElicitation(request) => {
@@ -780,7 +780,7 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
780780
Line::from(""),
781781
Line::from(request.message.clone()),
782782
]);
783-
Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))
783+
Box::new(crate::terminal_hyperlinks::HyperlinkText::new(lines))
784784
}
785785
}
786786
}
@@ -1438,6 +1438,60 @@ mod tests {
14381438
assert!(saw_denied, "expected deny shortcut to emit denied decision");
14391439
}
14401440

1441+
#[test]
1442+
fn approval_headers_preserve_wrapped_url_destinations() {
1443+
let url = "https://github.com/openai/codex/pull/12345?diff=split";
1444+
for mut request in [
1445+
make_exec_request(),
1446+
make_permissions_request(),
1447+
make_elicitation_request(),
1448+
ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest {
1449+
thread_id: ThreadId::new(),
1450+
thread_label: None,
1451+
id: "patch".into(),
1452+
reason: None,
1453+
cwd: absolute_path("/tmp"),
1454+
changes: HashMap::new(),
1455+
}),
1456+
] {
1457+
match &mut request {
1458+
ApprovalRequest::Exec(request) => request.reason = Some(url.into()),
1459+
ApprovalRequest::Permissions(request) => request.reason = Some(url.into()),
1460+
ApprovalRequest::McpElicitation(request) => request.message = url.into(),
1461+
ApprovalRequest::ApplyPatch(request) => request.reason = Some(url.into()),
1462+
}
1463+
let header = build_header(&request);
1464+
let area = Rect::new(0, 0, 32, header.desired_height(/*width*/ 32));
1465+
let mut buf = Buffer::empty(area);
1466+
header.render(area, &mut buf);
1467+
let linked = buf
1468+
.content
1469+
.iter()
1470+
.filter(|cell| cell.symbol().contains("\x1b]8;;"))
1471+
.map(|cell| {
1472+
assert!(cell.symbol().starts_with(&format!("\x1b]8;;{url}\x07")));
1473+
crate::terminal_hyperlinks::strip_osc8(cell.symbol())
1474+
})
1475+
.collect::<String>();
1476+
assert_eq!(linked, url);
1477+
if matches!(request, ApprovalRequest::Exec(_)) {
1478+
let visible = buf
1479+
.content
1480+
.chunks(32)
1481+
.map(|row| {
1482+
row.iter()
1483+
.map(|cell| crate::terminal_hyperlinks::strip_osc8(cell.symbol()))
1484+
.collect::<String>()
1485+
.trim_end()
1486+
.to_owned()
1487+
})
1488+
.collect::<Vec<_>>()
1489+
.join("\n");
1490+
insta::assert_snapshot!("approval_header_wrapped_url", visible);
1491+
}
1492+
}
1493+
}
1494+
14411495
#[test]
14421496
fn network_deny_shortcut_submits_policy_deny_decision() {
14431497
let (tx, mut rx) = unbounded_channel::<AppEvent>();
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
source: tui/src/bottom_pane/approval_overlay.rs
3+
assertion_line: 1462
4+
expression: visible
5+
---
6+
Reason:
7+
https://github.com/openai/codex/
8+
pull/12345?diff=split
9+
10+
$ echo hi

‎codex-rs/tui/src/terminal_hyperlinks.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod paragraph;
77
mod source;
88

99
pub(crate) use paragraph::HyperlinkParagraph;
10+
pub(crate) use paragraph::HyperlinkText;
1011
pub(crate) use source::LineWrapPolicy;
1112
pub(crate) use source::LogicalLineSource;
1213

‎codex-rs/tui/src/terminal_hyperlinks/paragraph.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,38 @@
11
//! Paragraph rendering that keeps visible text and hyperlink annotations aligned.
22
33
use super::HyperlinkLine;
4+
use super::annotate_web_urls;
45
use super::mark_buffer_hyperlinks;
56
use super::visible_lines_ref;
7+
use crate::render::renderable::Renderable;
68
use ratatui::buffer::Buffer;
79
use ratatui::layout::Rect;
810
use ratatui::style::Style;
11+
use ratatui::text::Line;
912
use ratatui::text::Text;
1013
use ratatui::widgets::Paragraph;
1114
use ratatui::widgets::Widget;
1215
use ratatui::widgets::Wrap;
1316

17+
/// Owns a word-wrapped paragraph whose web destinations survive clipping and wrapping.
18+
pub(crate) struct HyperlinkText(Vec<HyperlinkLine>);
19+
20+
impl HyperlinkText {
21+
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
22+
Self(annotate_web_urls(lines))
23+
}
24+
}
25+
26+
impl Renderable for HyperlinkText {
27+
fn render(&self, area: Rect, buf: &mut Buffer) {
28+
HyperlinkParagraph::new(&self.0, Style::default()).render(area, buf);
29+
}
30+
31+
fn desired_height(&self, width: u16) -> u16 {
32+
HyperlinkParagraph::new(&self.0, Style::default()).line_count(width) as u16
33+
}
34+
}
35+
1436
/// Word-wraps without trimming and applies the same vertical scroll to text and links.
1537
pub(crate) struct HyperlinkParagraph<'a> {
1638
lines: &'a [HyperlinkLine],

0 commit comments

Comments
 (0)