diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 6b7f56bc87..65ef9f651d 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -8,7 +8,6 @@ use crate::style::accent_color; use crate::token_usage::TokenUsage; use crate::token_usage::TokenUsageInfo; use crate::version::CODEX_CLI_VERSION; -use crate::width::display_width; use chrono::DateTime; use chrono::Local; use codex_app_server_protocol::AskForApproval; @@ -949,20 +948,22 @@ impl HistoryCell for Arc { ) -> Vec { let mut lines = crate::terminal_hyperlinks::plain_hyperlink_lines(self.display_lines(width)); - for line in &mut lines { - let visible = line - .line - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(); - if let Some(start_byte) = visible.find(CHATGPT_USAGE_URL) { - let start = display_width(&visible[..start_byte]); - line.hyperlinks - .push(crate::terminal_hyperlinks::TerminalHyperlink::web( - start..start + display_width(CHATGPT_USAGE_URL), - CHATGPT_USAGE_URL.to_string(), - )); + // The usage URL has a known destination; wrapping preserves its underlined spans. + // Attach that destination to every fragment instead of searching for the full URL. + if self.show_chatgpt_usage_link { + for line in &mut lines { + let mut column = 0; + for span in &line.line.spans { + let end = column + span.width(); + if span.style.add_modifier.contains(Modifier::UNDERLINED) { + line.hyperlinks + .push(crate::terminal_hyperlinks::TerminalHyperlink::web( + column..end, + CHATGPT_USAGE_URL.to_string(), + )); + } + column = end; + } } } lines diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index cf4646bc51..c6e33d8fc8 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -828,23 +828,33 @@ async fn status_uses_server_provider_id_and_auth_requirement() { sanitize_directory(render_lines(&composite.display_lines(/*width*/ 120))).join("\n"); assert_snapshot!("status_server_auth_required", rendered); - for width in [42, 120] { - let destinations: Vec = composite - .display_hyperlink_lines(width) - .into_iter() - .flat_map(|line| line.hyperlinks.into_iter()) - .map(|link| link.destination) - .collect(); - assert_eq!(destinations, vec!["https://chatgpt.com/settings/usage"]); + for width in [24, 42, 120] { + let lines = composite.display_hyperlink_lines(width); + let mut fragments = String::new(); + let mut destinations = Vec::new(); + for line in lines { + let visible = line.line.to_string(); + for link in line.hyperlinks { + fragments.extend( + visible + .chars() + .skip(link.columns.start) + .take(link.columns.len()), + ); + destinations.push(link.destination); + } + } + assert_eq!( + fragments, "https://chatgpt.com/settings/usage", + "width {width}" + ); + assert!(!destinations.is_empty()); + assert!( + destinations + .iter() + .all(|destination| destination == "https://chatgpt.com/settings/usage") + ); } - - let narrow_destinations: Vec = composite - .display_hyperlink_lines(/*width*/ 24) - .into_iter() - .flat_map(|line| line.hyperlinks.into_iter()) - .map(|link| link.destination) - .collect(); - assert_eq!(narrow_destinations, Vec::::new()); } #[tokio::test]