diff --git a/codex-rs/tui/src/bottom_pane/apply_patch_header.rs b/codex-rs/tui/src/bottom_pane/apply_patch_header.rs index 33dc4ffb87..63a5354183 100644 --- a/codex-rs/tui/src/bottom_pane/apply_patch_header.rs +++ b/codex-rs/tui/src/bottom_pane/apply_patch_header.rs @@ -3,11 +3,10 @@ use super::approval_overlay::ApplyPatchApprovalRequest; use crate::diff_model::FileChange; use crate::render::renderable::Renderable; +use crate::terminal_hyperlinks::HyperlinkText; use codex_utils_path_uri::LegacyAppPathString; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Wrap; pub(super) fn build_header(request: &ApplyPatchApprovalRequest) -> Box { let mut header = Vec::new(); @@ -70,5 +69,5 @@ pub(super) fn build_header(request: &ApplyPatchApprovalRequest) -> Box Box { header.extend(full_cmd_lines); } } - Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) + Box::new(crate::terminal_hyperlinks::HyperlinkText::new(header)) } ApprovalRequest::Permissions(request) => { let mut header: Vec> = Vec::new(); @@ -763,7 +763,7 @@ fn build_header(request: &ApprovalRequest) -> Box { rule_line.fg(crate::style::accent_color()), ])); } - Box::new(Paragraph::new(header).wrap(Wrap { trim: false })) + Box::new(crate::terminal_hyperlinks::HyperlinkText::new(header)) } ApprovalRequest::ApplyPatch(request) => super::apply_patch_header::build_header(request), ApprovalRequest::McpElicitation(request) => { @@ -780,7 +780,7 @@ fn build_header(request: &ApprovalRequest) -> Box { Line::from(""), Line::from(request.message.clone()), ]); - Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })) + Box::new(crate::terminal_hyperlinks::HyperlinkText::new(lines)) } } } @@ -1438,6 +1438,60 @@ mod tests { assert!(saw_denied, "expected deny shortcut to emit denied decision"); } + #[test] + fn approval_headers_preserve_wrapped_url_destinations() { + let url = "https://github.com/openai/codex/pull/12345?diff=split"; + for mut request in [ + make_exec_request(), + make_permissions_request(), + make_elicitation_request(), + ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest { + thread_id: ThreadId::new(), + thread_label: None, + id: "patch".into(), + reason: None, + cwd: absolute_path("/tmp"), + changes: HashMap::new(), + }), + ] { + match &mut request { + ApprovalRequest::Exec(request) => request.reason = Some(url.into()), + ApprovalRequest::Permissions(request) => request.reason = Some(url.into()), + ApprovalRequest::McpElicitation(request) => request.message = url.into(), + ApprovalRequest::ApplyPatch(request) => request.reason = Some(url.into()), + } + let header = build_header(&request); + let area = Rect::new(0, 0, 32, header.desired_height(/*width*/ 32)); + let mut buf = Buffer::empty(area); + header.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); + if matches!(request, ApprovalRequest::Exec(_)) { + let visible = buf + .content + .chunks(32) + .map(|row| { + row.iter() + .map(|cell| crate::terminal_hyperlinks::strip_osc8(cell.symbol())) + .collect::() + .trim_end() + .to_owned() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!("approval_header_wrapped_url", visible); + } + } + } + #[test] fn network_deny_shortcut_submits_policy_deny_decision() { let (tx, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_header_wrapped_url.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_header_wrapped_url.snap new file mode 100644 index 0000000000..54ba899a29 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_header_wrapped_url.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/approval_overlay.rs +assertion_line: 1462 +expression: visible +--- +Reason: +https://github.com/openai/codex/ +pull/12345?diff=split + +$ echo hi diff --git a/codex-rs/tui/src/terminal_hyperlinks.rs b/codex-rs/tui/src/terminal_hyperlinks.rs index b887f46a43..ae5ba3428f 100644 --- a/codex-rs/tui/src/terminal_hyperlinks.rs +++ b/codex-rs/tui/src/terminal_hyperlinks.rs @@ -7,6 +7,7 @@ mod paragraph; mod source; pub(crate) use paragraph::HyperlinkParagraph; +pub(crate) use paragraph::HyperlinkText; pub(crate) use source::LineWrapPolicy; pub(crate) use source::LogicalLineSource; diff --git a/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs b/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs index 3a5975ee32..d4373fe7b3 100644 --- a/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs +++ b/codex-rs/tui/src/terminal_hyperlinks/paragraph.rs @@ -1,16 +1,38 @@ //! Paragraph rendering that keeps visible text and hyperlink annotations aligned. use super::HyperlinkLine; +use super::annotate_web_urls; use super::mark_buffer_hyperlinks; use super::visible_lines_ref; +use crate::render::renderable::Renderable; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Style; +use ratatui::text::Line; use ratatui::text::Text; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::Wrap; +/// Owns a word-wrapped paragraph whose web destinations survive clipping and wrapping. +pub(crate) struct HyperlinkText(Vec); + +impl HyperlinkText { + pub(crate) fn new(lines: Vec>) -> Self { + Self(annotate_web_urls(lines)) + } +} + +impl Renderable for HyperlinkText { + fn render(&self, area: Rect, buf: &mut Buffer) { + HyperlinkParagraph::new(&self.0, Style::default()).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + HyperlinkParagraph::new(&self.0, Style::default()).line_count(width) as u16 + } +} + /// Word-wraps without trimming and applies the same vertical scroll to text and links. pub(crate) struct HyperlinkParagraph<'a> { lines: &'a [HyperlinkLine],