Preserve status usage hyperlinks when the URL wraps (#51457)

## Why

The ChatGPT usage URL in the status card loses its terminal hyperlink when it wraps across lines in a narrow terminal.

## What changed

Attach the usage URL destination to each underlined span so every wrapped fragment remains clickable.

## Testing

Extend status hyperlink coverage at widths of 24, 42, and 120 columns to verify that linked fragments reconstruct the full URL and all point to the same destination.

GitOrigin-RevId: 516421380763f48da8af63b4d374e2b352722ddb
This commit is contained in:
Charlie Marsh
2026-10-06 19:10:59 +00:00
committed by copyberry
parent 4b5c111349
commit e44b9bb3da
2 changed files with 42 additions and 31 deletions
+16 -15
View File
@@ -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<StatusHistoryCell> {
) -> Vec<crate::terminal_hyperlinks::HyperlinkLine> {
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::<String>();
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
+26 -16
View File
@@ -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<String> = 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<String> = 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::<String>::new());
}
#[tokio::test]