Enable plain clicks on transcript links and style bare URLs (#46884)

## Why

Transcript links required a modified click to open, and bare URLs lacked the visual styling of explicit Markdown links.

## What changed

- Open links on release after an unmodified, stationary left click. Cancel activation after dragging, scrolling, or releasing elsewhere so text selection remains usable.
- Apply Markdown link styling to bare URLs while preserving surrounding prose, existing emphasis, Unicode, and table wrapping.

## Testing

Add gesture tests for bare and Markdown links at narrow and wide widths, drag and scroll cancellation, and existing modified-click behavior. Add styling tests and snapshots covering punctuation, emphasis, tabs, Unicode, and wrapped table URLs.

GitOrigin-RevId: 23d12c901932d7e3586d841e5a0ece00e422fccd
This commit is contained in:
Eric Traut
2026-09-20 20:47:14 +00:00
committed by copyberry
parent 2f34d236f5
commit 9fdff739ea
8 changed files with 338 additions and 4 deletions
@@ -0,0 +1,6 @@
---
source: tui/src/markdown_render/web_links_tests.rs
assertion_line: 37
expression: text
---
Text::from(Line::from_iter([Span::from("界 ("), Span::from("https://example.com/a").fg(Color::Reset).underlined(), Span::from("); "), Span::from("https://example.com/b").fg(Color::Reset).underlined(), Span::from("! "), Span::from("https://example.com/c").fg(Color::Reset).bold().underlined()]))
@@ -0,0 +1,20 @@
---
source: tui/src/markdown_render/web_links_tests.rs
assertion_line: 63
expression: text
---
Text::from_iter([
Line::from_iter([
Span::from(" "),
Span::from("Resource"),
]).fg(Color::Rgb(249, 226, 175)).bold(),
Line::from(Span::from("━━━━━━━━━━━━━━━━━━━━").dim()),
Line::from_iter([
Span::from(" "),
Span::from("https://").fg(Color::Reset).underlined(),
]),
Line::from_iter([
Span::from(" "),
Span::from("example.com/docs").fg(Color::Reset).underlined(),
]),
])
+44 -1
View File
@@ -1,4 +1,4 @@
//! Presentation policy for web-link destinations in semantic Markdown output.
//! Styling and destination visibility for web links in semantic Markdown output.
//!
//! Terminal identity is a conservative heuristic, not end-to-end capability negotiation. Unknown
//! terminals and multiplexers retain the destination so a label never hides the only usable URL.
@@ -9,7 +9,50 @@ use codex_terminal_detection::terminal_info;
use std::io::IsTerminal;
use std::sync::LazyLock;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_segmentation::UnicodeSegmentation;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::annotate_web_urls_in_line;
use crate::terminal_hyperlinks::web_destination;
use crate::width::display_width;
/// Give detected URLs the same affordance as explicit Markdown links, preserving surrounding text.
pub(super) fn style_bare_web_urls(span: Span<'static>, link_style: Style) -> HyperlinkLine {
let mut line = annotate_web_urls_in_line(Line::from(span));
if line.hyperlinks.is_empty() {
return line;
}
let mut spans: Vec<Span<'static>> = Vec::new();
let mut links = line.hyperlinks.iter().peekable();
let mut column = 0;
let span = &line.line.spans[0];
for grapheme in span.content.graphemes(/*is_extended*/ true) {
while links.peek().is_some_and(|link| link.columns.end <= column) {
links.next();
}
let style = if links
.peek()
.is_some_and(|link| link.columns.contains(&column))
{
span.style.patch(link_style)
} else {
span.style
};
if let Some(last) = spans.last_mut()
&& last.style == style
{
last.content.to_mut().push_str(grapheme);
} else {
spans.push(Span::styled(grapheme.to_owned(), style));
}
column += display_width(grapheme);
}
line.line.spans = spans;
line
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum WebLinkDisplay {
@@ -10,11 +10,62 @@ use codex_terminal_detection::TerminalInfo;
use codex_terminal_detection::TerminalName;
use insta::assert_debug_snapshot;
use pretty_assertions::assert_eq;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Text;
use std::collections::BTreeSet;
#[test]
fn bare_urls_are_styled_without_coloring_surrounding_prose() {
let text = Text::from(visible_lines(render(
"界 (https://example.com/a); https://example.com/b! **https://example.com/c**",
/*width*/ 100,
WebLinkDisplay::LabelOnly,
)));
assert_eq!(
text,
Text::from(Line::from(vec![
"界 (".into(),
"https://example.com/a".fg(accent_color()).underlined(),
"); ".into(),
"https://example.com/b".fg(accent_color()).underlined(),
"! ".into(),
"https://example.com/c"
.bold()
.fg(accent_color())
.underlined(),
])),
);
assert_debug_snapshot!(text);
}
#[test]
fn bare_url_styling_preserves_tabs_and_unicode() {
let line = super::style_bare_web_urls(
"界\t(https://example.com).".bold(),
Style::new().fg(accent_color()).underlined(),
);
assert_eq!(
line.line,
Line::from(vec![
"界\t(".bold(),
"https://example.com".bold().fg(accent_color()).underlined(),
").".bold(),
]),
);
}
#[test]
fn bare_urls_keep_link_style_when_wrapped_in_tables() {
let text = Text::from(visible_lines(render(
"| Resource |\n| --- |\n| https://example.com/docs |",
/*width*/ 20,
WebLinkDisplay::LabelOnly,
)));
assert_debug_snapshot!(text);
}
fn terminal(name: TerminalName) -> TerminalInfo {
TerminalInfo {
name,