Compare commits

...

4 Commits

Author SHA1 Message Date
starr-openai
74ec45df64 Preserve OSC-8 links when truncating lines
Count OSC-8-wrapped spans by visible text width and rewrap truncated prefixes so generic line truncation does not split hyperlink escape sequences.

Co-authored-by: Codex <noreply@openai.com>
2026-03-19 16:04:48 -07:00
starr-openai
aa0ab63b14 Fix stale TUI link styling expectation
Co-authored-by: Codex <noreply@openai.com>
2026-03-19 14:39:21 -07:00
starr-openai
2a9ce23940 Differentiate link label and destination styling
Co-authored-by: Codex <noreply@openai.com>
2026-03-19 14:22:26 -07:00
starr-openai
cca76f843e Render OSC-8 markdown links in TUI
Co-authored-by: Codex <noreply@openai.com>
2026-03-19 11:02:11 -07:00
12 changed files with 676 additions and 86 deletions

View File

@@ -110,6 +110,7 @@ mod model_migration;
mod multi_agents;
mod notifications;
pub mod onboarding;
mod osc8;
mod oss_selection;
mod pager_overlay;
pub mod public_widgets;

View File

@@ -3,9 +3,13 @@ use ratatui::text::Span;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use crate::osc8::osc8_hyperlink;
use crate::osc8::parse_osc8_hyperlink;
use crate::osc8::strip_osc8_hyperlinks;
pub(crate) fn line_width(line: &Line<'_>) -> usize {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.map(|span| visible_width(span.content.as_ref()))
.sum()
}
@@ -23,7 +27,7 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
let mut spans_out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
for span in spans {
let span_width = UnicodeWidthStr::width(span.content.as_ref());
let span_width = visible_width(span.content.as_ref());
if span_width == 0 {
spans_out.push(span);
@@ -42,8 +46,11 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
let style = span.style;
let text = span.content.as_ref();
let parsed_link = parse_osc8_hyperlink(text);
let visible_text =
parsed_link.map_or_else(|| strip_osc8_hyperlinks(text), |link| link.text.to_string());
let mut end_idx = 0usize;
for (idx, ch) in text.char_indices() {
for (idx, ch) in visible_text.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > max_width {
break;
@@ -53,7 +60,12 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
}
if end_idx > 0 {
spans_out.push(Span::styled(text[..end_idx].to_string(), style));
let truncated_text = &visible_text[..end_idx];
let content = parsed_link.map_or_else(
|| truncated_text.to_string(),
|link| osc8_hyperlink(link.destination, truncated_text),
);
spans_out.push(Span::styled(content, style));
}
break;
@@ -66,6 +78,13 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
}
}
fn visible_width(text: &str) -> usize {
parse_osc8_hyperlink(text).map_or_else(
|| UnicodeWidthStr::width(strip_osc8_hyperlinks(text).as_str()),
|link| UnicodeWidthStr::width(link.text),
)
}
/// Truncate a styled line to `max_width` and append an ellipsis on overflow.
///
/// Intended for short UI rows. This preserves a fast no-overflow path (width
@@ -98,3 +117,56 @@ pub(crate) fn truncate_line_with_ellipsis_if_overflow(
spans,
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use super::*;
#[test]
fn line_width_counts_osc8_wrapped_text_as_visible_text_only() {
let line = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
]);
assert_eq!(line_width(&line), 8);
}
#[test]
fn truncate_line_to_width_preserves_osc8_wrapped_prefix() {
let line = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
]);
let truncated = truncate_line_to_width(line, 6);
let expected = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "do")).underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_with_ellipsis_if_overflow_preserves_osc8_wrapped_prefix() {
let line = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
]);
let truncated = truncate_line_with_ellipsis_if_overflow(line, 7);
let expected = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "do")).underlined(),
"".underlined(),
]);
assert_eq!(truncated, expected);
}
}

View File

@@ -5,6 +5,7 @@
//! transcripts show the real file target (including normalized location suffixes) and can shorten
//! absolute paths relative to a known working directory.
use crate::osc8::osc8_hyperlink;
use crate::render::highlight::highlight_code_to_lines;
use crate::render::line_utils::line_to_static;
use crate::wrapping::RtOptions;
@@ -42,7 +43,8 @@ struct MarkdownStyles {
strikethrough: Style,
ordered_list_marker: Style,
unordered_list_marker: Style,
link: Style,
link_label: Style,
link_destination: Style,
blockquote: Style,
}
@@ -63,7 +65,8 @@ impl Default for MarkdownStyles {
strikethrough: Style::new().crossed_out(),
ordered_list_marker: Style::new().light_blue(),
unordered_list_marker: Style::new(),
link: Style::new().cyan().underlined(),
link_label: Style::new().underlined(),
link_destination: Style::new().cyan().underlined(),
blockquote: Style::new().green(),
}
}
@@ -272,7 +275,12 @@ where
Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
Tag::Strong => self.push_inline_style(self.styles.strong),
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
Tag::Link { dest_url, .. } => {
self.push_link(dest_url.to_string());
if self.remote_link_destination().is_some() {
self.push_inline_style(self.styles.link_label);
}
}
Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
| Tag::Table(_)
@@ -407,7 +415,7 @@ where
if i > 0 {
self.push_line(Line::default());
}
let content = line.to_string();
let content = self.maybe_wrap_remote_link_text(line);
let span = Span::styled(
content,
self.inline_styles.last().copied().unwrap_or_default(),
@@ -426,7 +434,13 @@ where
self.push_line(Line::default());
self.pending_marker_line = false;
}
let span = Span::from(code.into_string()).style(self.styles.code);
let style = self
.inline_styles
.last()
.copied()
.unwrap_or_default()
.patch(self.styles.code);
let span = Span::from(self.maybe_wrap_remote_link_text(code.as_ref())).style(style);
self.push_span(span);
}
@@ -445,7 +459,7 @@ where
self.push_line(Line::default());
}
let style = self.inline_styles.last().copied().unwrap_or_default();
self.push_span(Span::styled(line.to_string(), style));
self.push_span(Span::styled(self.maybe_wrap_remote_link_text(line), style));
}
self.needs_newline = !inline;
}
@@ -596,8 +610,12 @@ where
fn pop_link(&mut self) {
if let Some(link) = self.link.take() {
if link.show_destination {
self.pop_inline_style();
self.push_span(" (".into());
self.push_span(Span::styled(link.destination, self.styles.link));
self.push_span(Span::styled(
osc8_hyperlink(&link.destination, &link.destination),
self.styles.link_destination,
));
self.push_span(")".into());
} else if let Some(local_target_display) = link.local_target_display {
if self.pending_marker_line {
@@ -617,6 +635,20 @@ where
}
}
fn remote_link_destination(&self) -> Option<&str> {
self.link
.as_ref()
.filter(|link| link.show_destination)
.map(|link| link.destination.as_str())
}
fn maybe_wrap_remote_link_text(&self, text: &str) -> String {
self.remote_link_destination().map_or_else(
|| text.to_string(),
|destination| osc8_hyperlink(destination, text),
)
}
fn suppressing_local_link_label(&self) -> bool {
self.link
.as_ref()

View File

@@ -9,6 +9,8 @@ use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
use crate::markdown_render::render_markdown_text;
use crate::markdown_render::render_markdown_text_with_width_and_cwd;
use crate::osc8::osc8_hyperlink;
use crate::osc8::strip_osc8_hyperlinks;
use insta::assert_snapshot;
fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
@@ -651,9 +653,12 @@ fn strong_emphasis() {
fn link() {
let text = render_markdown_text("[Link](https://example.com)");
let expected = Text::from(Line::from_iter([
"Link".into(),
osc8_hyperlink("https://example.com", "Link")
.underlined(),
" (".into(),
"https://example.com".cyan().underlined(),
osc8_hyperlink("https://example.com", "https://example.com")
.cyan()
.underlined(),
")".into(),
]));
assert_eq!(text, expected);
@@ -776,17 +781,44 @@ fn file_link_uses_target_path_for_hash_range() {
}
#[test]
fn url_link_shows_destination() {
fn url_link_renders_clickable_label_with_destination() {
let text = render_markdown_text("[docs](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
"docs".into(),
osc8_hyperlink("https://example.com/docs", "docs")
.underlined(),
" (".into(),
"https://example.com/docs".cyan().underlined(),
osc8_hyperlink("https://example.com/docs", "https://example.com/docs")
.cyan()
.underlined(),
")".into(),
]));
assert_eq!(text, expected);
}
#[test]
fn url_link_with_inline_code_is_clickable() {
let text = render_markdown_text("[`docs`](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
osc8_hyperlink("https://example.com/docs", "docs")
.cyan()
.underlined(),
" (".into(),
osc8_hyperlink("https://example.com/docs", "https://example.com/docs")
.cyan()
.underlined(),
")".into(),
]));
assert_eq!(text, expected);
}
#[test]
fn url_link_sanitizes_control_chars() {
assert_eq!(
osc8_hyperlink("https://example.com/\u{1b}]8;;\u{07}injected", "unsafe"),
"\u{1b}]8;;https://example.com/]8;;injected\u{7}unsafe\u{1b}]8;;\u{7}"
);
}
#[test]
fn markdown_render_file_link_snapshot() {
let text = render_markdown_text_for_cwd(
@@ -797,10 +829,12 @@ fn markdown_render_file_link_snapshot() {
.lines
.iter()
.map(|l| {
l.spans
let line = l
.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
.collect::<String>();
strip_osc8_hyperlinks(&line)
})
.collect::<Vec<_>>()
.join("\n");
@@ -819,10 +853,12 @@ fn unordered_list_local_file_link_stays_inline_with_following_text() {
.lines
.iter()
.map(|line| {
line.spans
let rendered = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
.collect::<String>();
strip_osc8_hyperlinks(&rendered)
})
.collect::<Vec<_>>();
assert_eq!(
@@ -1161,10 +1197,12 @@ URL with parentheses: [link](https://example.com/path_(with)_parens).
.lines
.iter()
.map(|l| {
l.spans
let line = l
.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
.collect::<String>();
strip_osc8_hyperlinks(&line)
})
.collect::<Vec<_>>()
.join("\n");

83
codex-rs/tui/src/osc8.rs Normal file
View File

@@ -0,0 +1,83 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ParsedOsc8<'a> {
pub(crate) destination: &'a str,
pub(crate) text: &'a str,
}
const OSC8_OPEN_PREFIX: &str = "\u{1b}]8;;";
const OSC8_CLOSE: &str = "\u{1b}]8;;\u{7}";
pub(crate) fn sanitize_osc8_url(destination: &str) -> String {
destination
.chars()
.filter(|&c| c != '\x1B' && c != '\x07')
.collect()
}
pub(crate) fn osc8_hyperlink<S: AsRef<str>>(destination: &str, text: S) -> String {
let safe_destination = sanitize_osc8_url(destination);
if safe_destination.is_empty() {
return text.as_ref().to_string();
}
format!(
"{OSC8_OPEN_PREFIX}{safe_destination}\u{7}{}{OSC8_CLOSE}",
text.as_ref()
)
}
pub(crate) fn parse_osc8_hyperlink(text: &str) -> Option<ParsedOsc8<'_>> {
let after_open = text.strip_prefix(OSC8_OPEN_PREFIX)?;
let url_end = after_open.find('\x07')?;
let destination = &after_open[..url_end];
let after_destination = &after_open[url_end + 1..];
let label = after_destination.strip_suffix(OSC8_CLOSE)?;
Some(ParsedOsc8 {
destination,
text: label,
})
}
pub(crate) fn strip_osc8_hyperlinks(text: &str) -> String {
let mut remaining = text;
let mut rendered = String::new();
while let Some(open_pos) = remaining.find(OSC8_OPEN_PREFIX) {
rendered.push_str(&remaining[..open_pos]);
let after_open = &remaining[open_pos + OSC8_OPEN_PREFIX.len()..];
let Some(url_end) = after_open.find('\x07') else {
rendered.push_str(&remaining[open_pos..]);
return rendered;
};
let after_url = &after_open[url_end + 1..];
let Some(close_pos) = after_url.find(OSC8_CLOSE) else {
rendered.push_str(&remaining[open_pos..]);
return rendered;
};
rendered.push_str(&after_url[..close_pos]);
remaining = &after_url[close_pos + OSC8_CLOSE.len()..];
}
rendered.push_str(remaining);
rendered
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parses_wrapped_text() {
let wrapped = osc8_hyperlink("https://example.com", "docs");
let parsed = parse_osc8_hyperlink(&wrapped).expect("expected osc8 span");
assert_eq!(parsed.destination, "https://example.com");
assert_eq!(parsed.text, "docs");
}
#[test]
fn strips_wrapped_text() {
let wrapped = format!("See {}", osc8_hyperlink("https://example.com", "docs"));
assert_eq!(strip_osc8_hyperlinks(&wrapped), "See docs");
}
}

View File

@@ -32,6 +32,9 @@ use std::borrow::Cow;
use std::ops::Range;
use textwrap::Options;
use crate::osc8::osc8_hyperlink;
use crate::osc8::parse_osc8_hyperlink;
use crate::osc8::strip_osc8_hyperlinks;
use crate::render::line_utils::push_owned_lines;
/// Returns byte-ranges into `text` for each wrapped line, including
@@ -177,12 +180,7 @@ fn map_owned_wrapped_line_to_range(
///
/// Concatenates all span contents and delegates to [`text_contains_url_like`].
pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
text_contains_url_like(&text)
text_contains_url_like(&visible_line_text(line))
}
/// Returns `true` if `line` contains both a URL-like token and at least one
@@ -191,12 +189,15 @@ pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
/// Decorative marker tokens (for example list prefixes like `-`, `1.`, `|`,
/// `│`) are ignored for the non-URL side of this check.
pub(crate) fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
let text: String = line
.spans
text_has_mixed_url_and_non_url_tokens(&visible_line_text(line))
}
fn visible_line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
text_has_mixed_url_and_non_url_tokens(&text)
.map(|span| strip_osc8_hyperlinks(span.content.as_ref()))
.collect::<Vec<_>>()
.join("")
}
/// Returns `true` if any whitespace-delimited token in `text` looks like a URL.
@@ -644,11 +645,16 @@ where
let mut span_bounds = Vec::new();
let mut acc = 0usize;
for s in &line.spans {
let text = s.content.as_ref();
let parsed = parse_osc8_hyperlink(s.content.as_ref());
let text = parsed.map_or_else(|| s.content.as_ref(), |link| link.text);
let start = acc;
flat.push_str(text);
acc += text.len();
span_bounds.push((start..acc, s.style));
span_bounds.push(SpanBound {
range: start..acc,
style: s.style,
osc8_destination: parsed.map(|link| link.destination),
});
}
let rt_opts: RtOptions<'a> = width_or_options.into();
@@ -841,15 +847,15 @@ where
fn slice_line_spans<'a>(
original: &'a Line<'a>,
span_bounds: &[(Range<usize>, ratatui::style::Style)],
span_bounds: &[SpanBound<'a>],
range: &Range<usize>,
) -> Line<'a> {
let start_byte = range.start;
let end_byte = range.end;
let mut acc: Vec<Span<'a>> = Vec::new();
for (i, (range, style)) in span_bounds.iter().enumerate() {
let s = range.start;
let e = range.end;
for (i, bound) in span_bounds.iter().enumerate() {
let s = bound.range.start;
let e = bound.range.end;
if e <= start_byte {
continue;
}
@@ -861,11 +867,15 @@ fn slice_line_spans<'a>(
if seg_end > seg_start {
let local_start = seg_start - s;
let local_end = seg_end - s;
let content = original.spans[i].content.as_ref();
let slice = &content[local_start..local_end];
let slice = slice_span_content(
original.spans[i].content.as_ref(),
bound,
local_start,
local_end,
);
acc.push(Span {
style: *style,
content: std::borrow::Cow::Borrowed(slice),
style: bound.style,
content: slice,
});
}
if e >= end_byte {
@@ -879,9 +889,40 @@ fn slice_line_spans<'a>(
}
}
#[derive(Clone, Debug)]
struct SpanBound<'a> {
range: Range<usize>,
style: ratatui::style::Style,
osc8_destination: Option<&'a str>,
}
fn slice_span_content<'a>(
content: &'a str,
bound: &SpanBound<'a>,
local_start: usize,
local_end: usize,
) -> Cow<'a, str> {
if let Some(destination) = bound.osc8_destination {
if let Some(parsed) = parse_osc8_hyperlink(content) {
Cow::Owned(osc8_hyperlink(
destination,
&parsed.text[local_start..local_end],
))
} else {
Cow::Borrowed(&content[local_start..local_end])
}
} else {
Cow::Borrowed(&content[local_start..local_end])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::osc8::ParsedOsc8;
use crate::osc8::osc8_hyperlink;
use crate::osc8::parse_osc8_hyperlink;
use crate::osc8::strip_osc8_hyperlinks;
use itertools::Itertools as _;
use pretty_assertions::assert_eq;
use ratatui::style::Color;
@@ -965,6 +1006,34 @@ mod tests {
assert_eq!(concat_line(&out[0]), "");
}
#[test]
fn osc8_wrapped_span_wraps_by_visible_text() {
let url = "https://example.com/docs";
let line = Line::from(vec![osc8_hyperlink(url, "abcdefghij").cyan().underlined()]);
let out = word_wrap_line(&line, 5);
assert_eq!(out.len(), 2);
let first = concat_line(&out[0]);
let second = concat_line(&out[1]);
assert_eq!(strip_osc8_hyperlinks(&first), "abcde");
assert_eq!(strip_osc8_hyperlinks(&second), "fghij");
assert_eq!(
parse_osc8_hyperlink(&first).expect("first line should stay hyperlinked"),
ParsedOsc8 {
destination: url,
text: "abcde",
}
);
assert_eq!(
parse_osc8_hyperlink(&second).expect("second line should stay hyperlinked"),
ParsedOsc8 {
destination: url,
text: "fghij",
}
);
}
#[test]
fn leading_spaces_preserved_on_first_line() {
let line = Line::from(" hello");

View File

@@ -124,6 +124,7 @@ mod model_migration;
mod multi_agents;
mod notifications;
pub mod onboarding;
mod osc8;
mod oss_selection;
mod pager_overlay;
pub mod public_widgets;

View File

@@ -3,9 +3,13 @@ use ratatui::text::Span;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use crate::osc8::osc8_hyperlink;
use crate::osc8::parse_osc8_hyperlink;
use crate::osc8::strip_osc8_hyperlinks;
pub(crate) fn line_width(line: &Line<'_>) -> usize {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.map(|span| visible_width(span.content.as_ref()))
.sum()
}
@@ -23,7 +27,7 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
let mut spans_out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
for span in spans {
let span_width = UnicodeWidthStr::width(span.content.as_ref());
let span_width = visible_width(span.content.as_ref());
if span_width == 0 {
spans_out.push(span);
@@ -42,8 +46,11 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
let style = span.style;
let text = span.content.as_ref();
let parsed_link = parse_osc8_hyperlink(text);
let visible_text =
parsed_link.map_or_else(|| strip_osc8_hyperlinks(text), |link| link.text.to_string());
let mut end_idx = 0usize;
for (idx, ch) in text.char_indices() {
for (idx, ch) in visible_text.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > max_width {
break;
@@ -53,7 +60,12 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
}
if end_idx > 0 {
spans_out.push(Span::styled(text[..end_idx].to_string(), style));
let truncated_text = &visible_text[..end_idx];
let content = parsed_link.map_or_else(
|| truncated_text.to_string(),
|link| osc8_hyperlink(link.destination, truncated_text),
);
spans_out.push(Span::styled(content, style));
}
break;
@@ -66,6 +78,13 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
}
}
fn visible_width(text: &str) -> usize {
parse_osc8_hyperlink(text).map_or_else(
|| UnicodeWidthStr::width(strip_osc8_hyperlinks(text).as_str()),
|link| UnicodeWidthStr::width(link.text),
)
}
/// Truncate a styled line to `max_width` and append an ellipsis on overflow.
///
/// Intended for short UI rows. This preserves a fast no-overflow path (width
@@ -98,3 +117,56 @@ pub(crate) fn truncate_line_with_ellipsis_if_overflow(
spans,
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use super::*;
#[test]
fn line_width_counts_osc8_wrapped_text_as_visible_text_only() {
let line = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
]);
assert_eq!(line_width(&line), 8);
}
#[test]
fn truncate_line_to_width_preserves_osc8_wrapped_prefix() {
let line = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
]);
let truncated = truncate_line_to_width(line, 6);
let expected = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "do")).underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_with_ellipsis_if_overflow_preserves_osc8_wrapped_prefix() {
let line = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
]);
let truncated = truncate_line_with_ellipsis_if_overflow(line, 7);
let expected = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "do")).underlined(),
"".underlined(),
]);
assert_eq!(truncated, expected);
}
}

View File

@@ -5,6 +5,7 @@
//! transcripts show the real file target (including normalized location suffixes) and can shorten
//! absolute paths relative to a known working directory.
use crate::osc8::osc8_hyperlink;
use crate::render::highlight::highlight_code_to_lines;
use crate::render::line_utils::line_to_static;
use crate::wrapping::RtOptions;
@@ -42,7 +43,8 @@ struct MarkdownStyles {
strikethrough: Style,
ordered_list_marker: Style,
unordered_list_marker: Style,
link: Style,
link_label: Style,
link_destination: Style,
blockquote: Style,
}
@@ -63,7 +65,8 @@ impl Default for MarkdownStyles {
strikethrough: Style::new().crossed_out(),
ordered_list_marker: Style::new().light_blue(),
unordered_list_marker: Style::new(),
link: Style::new().cyan().underlined(),
link_label: Style::new().underlined(),
link_destination: Style::new().cyan().underlined(),
blockquote: Style::new().green(),
}
}
@@ -272,7 +275,12 @@ where
Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
Tag::Strong => self.push_inline_style(self.styles.strong),
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
Tag::Link { dest_url, .. } => {
self.push_link(dest_url.to_string());
if self.remote_link_destination().is_some() {
self.push_inline_style(self.styles.link_label);
}
}
Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
| Tag::Table(_)
@@ -407,7 +415,7 @@ where
if i > 0 {
self.push_line(Line::default());
}
let content = line.to_string();
let content = self.maybe_wrap_remote_link_text(line);
let span = Span::styled(
content,
self.inline_styles.last().copied().unwrap_or_default(),
@@ -426,7 +434,13 @@ where
self.push_line(Line::default());
self.pending_marker_line = false;
}
let span = Span::from(code.into_string()).style(self.styles.code);
let style = self
.inline_styles
.last()
.copied()
.unwrap_or_default()
.patch(self.styles.code);
let span = Span::from(self.maybe_wrap_remote_link_text(code.as_ref())).style(style);
self.push_span(span);
}
@@ -445,7 +459,7 @@ where
self.push_line(Line::default());
}
let style = self.inline_styles.last().copied().unwrap_or_default();
self.push_span(Span::styled(line.to_string(), style));
self.push_span(Span::styled(self.maybe_wrap_remote_link_text(line), style));
}
self.needs_newline = !inline;
}
@@ -596,8 +610,12 @@ where
fn pop_link(&mut self) {
if let Some(link) = self.link.take() {
if link.show_destination {
self.pop_inline_style();
self.push_span(" (".into());
self.push_span(Span::styled(link.destination, self.styles.link));
self.push_span(Span::styled(
osc8_hyperlink(&link.destination, &link.destination),
self.styles.link_destination,
));
self.push_span(")".into());
} else if let Some(local_target_display) = link.local_target_display {
if self.pending_marker_line {
@@ -617,6 +635,20 @@ where
}
}
fn remote_link_destination(&self) -> Option<&str> {
self.link
.as_ref()
.filter(|link| link.show_destination)
.map(|link| link.destination.as_str())
}
fn maybe_wrap_remote_link_text(&self, text: &str) -> String {
self.remote_link_destination().map_or_else(
|| text.to_string(),
|destination| osc8_hyperlink(destination, text),
)
}
fn suppressing_local_link_label(&self) -> bool {
self.link
.as_ref()

View File

@@ -9,6 +9,8 @@ use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
use crate::markdown_render::render_markdown_text;
use crate::markdown_render::render_markdown_text_with_width_and_cwd;
use crate::osc8::osc8_hyperlink;
use crate::osc8::strip_osc8_hyperlinks;
use insta::assert_snapshot;
fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
@@ -651,9 +653,12 @@ fn strong_emphasis() {
fn link() {
let text = render_markdown_text("[Link](https://example.com)");
let expected = Text::from(Line::from_iter([
"Link".into(),
osc8_hyperlink("https://example.com", "Link")
.underlined(),
" (".into(),
"https://example.com".cyan().underlined(),
osc8_hyperlink("https://example.com", "https://example.com")
.cyan()
.underlined(),
")".into(),
]));
assert_eq!(text, expected);
@@ -776,17 +781,44 @@ fn file_link_uses_target_path_for_hash_range() {
}
#[test]
fn url_link_shows_destination() {
fn url_link_renders_clickable_label_with_destination() {
let text = render_markdown_text("[docs](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
"docs".into(),
osc8_hyperlink("https://example.com/docs", "docs")
.underlined(),
" (".into(),
"https://example.com/docs".cyan().underlined(),
osc8_hyperlink("https://example.com/docs", "https://example.com/docs")
.cyan()
.underlined(),
")".into(),
]));
assert_eq!(text, expected);
}
#[test]
fn url_link_with_inline_code_is_clickable() {
let text = render_markdown_text("[`docs`](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
osc8_hyperlink("https://example.com/docs", "docs")
.cyan()
.underlined(),
" (".into(),
osc8_hyperlink("https://example.com/docs", "https://example.com/docs")
.cyan()
.underlined(),
")".into(),
]));
assert_eq!(text, expected);
}
#[test]
fn url_link_sanitizes_control_chars() {
assert_eq!(
osc8_hyperlink("https://example.com/\u{1b}]8;;\u{07}injected", "unsafe"),
"\u{1b}]8;;https://example.com/]8;;injected\u{7}unsafe\u{1b}]8;;\u{7}"
);
}
#[test]
fn markdown_render_file_link_snapshot() {
let text = render_markdown_text_for_cwd(
@@ -797,10 +829,12 @@ fn markdown_render_file_link_snapshot() {
.lines
.iter()
.map(|l| {
l.spans
let line = l
.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
.collect::<String>();
strip_osc8_hyperlinks(&line)
})
.collect::<Vec<_>>()
.join("\n");
@@ -819,10 +853,12 @@ fn unordered_list_local_file_link_stays_inline_with_following_text() {
.lines
.iter()
.map(|line| {
line.spans
let rendered = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
.collect::<String>();
strip_osc8_hyperlinks(&rendered)
})
.collect::<Vec<_>>();
assert_eq!(
@@ -1161,10 +1197,12 @@ URL with parentheses: [link](https://example.com/path_(with)_parens).
.lines
.iter()
.map(|l| {
l.spans
let line = l
.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
.collect::<String>();
strip_osc8_hyperlinks(&line)
})
.collect::<Vec<_>>()
.join("\n");

View File

@@ -0,0 +1,83 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ParsedOsc8<'a> {
pub(crate) destination: &'a str,
pub(crate) text: &'a str,
}
const OSC8_OPEN_PREFIX: &str = "\u{1b}]8;;";
const OSC8_CLOSE: &str = "\u{1b}]8;;\u{7}";
pub(crate) fn sanitize_osc8_url(destination: &str) -> String {
destination
.chars()
.filter(|&c| c != '\x1B' && c != '\x07')
.collect()
}
pub(crate) fn osc8_hyperlink<S: AsRef<str>>(destination: &str, text: S) -> String {
let safe_destination = sanitize_osc8_url(destination);
if safe_destination.is_empty() {
return text.as_ref().to_string();
}
format!(
"{OSC8_OPEN_PREFIX}{safe_destination}\u{7}{}{OSC8_CLOSE}",
text.as_ref()
)
}
pub(crate) fn parse_osc8_hyperlink(text: &str) -> Option<ParsedOsc8<'_>> {
let after_open = text.strip_prefix(OSC8_OPEN_PREFIX)?;
let url_end = after_open.find('\x07')?;
let destination = &after_open[..url_end];
let after_destination = &after_open[url_end + 1..];
let label = after_destination.strip_suffix(OSC8_CLOSE)?;
Some(ParsedOsc8 {
destination,
text: label,
})
}
pub(crate) fn strip_osc8_hyperlinks(text: &str) -> String {
let mut remaining = text;
let mut rendered = String::new();
while let Some(open_pos) = remaining.find(OSC8_OPEN_PREFIX) {
rendered.push_str(&remaining[..open_pos]);
let after_open = &remaining[open_pos + OSC8_OPEN_PREFIX.len()..];
let Some(url_end) = after_open.find('\x07') else {
rendered.push_str(&remaining[open_pos..]);
return rendered;
};
let after_url = &after_open[url_end + 1..];
let Some(close_pos) = after_url.find(OSC8_CLOSE) else {
rendered.push_str(&remaining[open_pos..]);
return rendered;
};
rendered.push_str(&after_url[..close_pos]);
remaining = &after_url[close_pos + OSC8_CLOSE.len()..];
}
rendered.push_str(remaining);
rendered
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parses_wrapped_text() {
let wrapped = osc8_hyperlink("https://example.com", "docs");
let parsed = parse_osc8_hyperlink(&wrapped).expect("expected osc8 span");
assert_eq!(parsed.destination, "https://example.com");
assert_eq!(parsed.text, "docs");
}
#[test]
fn strips_wrapped_text() {
let wrapped = format!("See {}", osc8_hyperlink("https://example.com", "docs"));
assert_eq!(strip_osc8_hyperlinks(&wrapped), "See docs");
}
}

View File

@@ -32,6 +32,9 @@ use std::borrow::Cow;
use std::ops::Range;
use textwrap::Options;
use crate::osc8::osc8_hyperlink;
use crate::osc8::parse_osc8_hyperlink;
use crate::osc8::strip_osc8_hyperlinks;
use crate::render::line_utils::push_owned_lines;
/// Returns byte-ranges into `text` for each wrapped line, including
@@ -177,12 +180,7 @@ fn map_owned_wrapped_line_to_range(
///
/// Concatenates all span contents and delegates to [`text_contains_url_like`].
pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
text_contains_url_like(&text)
text_contains_url_like(&visible_line_text(line))
}
/// Returns `true` if `line` contains both a URL-like token and at least one
@@ -191,12 +189,15 @@ pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
/// Decorative marker tokens (for example list prefixes like `-`, `1.`, `|`,
/// `│`) are ignored for the non-URL side of this check.
pub(crate) fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
let text: String = line
.spans
text_has_mixed_url_and_non_url_tokens(&visible_line_text(line))
}
fn visible_line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
text_has_mixed_url_and_non_url_tokens(&text)
.map(|span| strip_osc8_hyperlinks(span.content.as_ref()))
.collect::<Vec<_>>()
.join("")
}
/// Returns `true` if any whitespace-delimited token in `text` looks like a URL.
@@ -644,11 +645,16 @@ where
let mut span_bounds = Vec::new();
let mut acc = 0usize;
for s in &line.spans {
let text = s.content.as_ref();
let parsed = parse_osc8_hyperlink(s.content.as_ref());
let text = parsed.map_or_else(|| s.content.as_ref(), |link| link.text);
let start = acc;
flat.push_str(text);
acc += text.len();
span_bounds.push((start..acc, s.style));
span_bounds.push(SpanBound {
range: start..acc,
style: s.style,
osc8_destination: parsed.map(|link| link.destination),
});
}
let rt_opts: RtOptions<'a> = width_or_options.into();
@@ -841,15 +847,15 @@ where
fn slice_line_spans<'a>(
original: &'a Line<'a>,
span_bounds: &[(Range<usize>, ratatui::style::Style)],
span_bounds: &[SpanBound<'a>],
range: &Range<usize>,
) -> Line<'a> {
let start_byte = range.start;
let end_byte = range.end;
let mut acc: Vec<Span<'a>> = Vec::new();
for (i, (range, style)) in span_bounds.iter().enumerate() {
let s = range.start;
let e = range.end;
for (i, bound) in span_bounds.iter().enumerate() {
let s = bound.range.start;
let e = bound.range.end;
if e <= start_byte {
continue;
}
@@ -861,11 +867,15 @@ fn slice_line_spans<'a>(
if seg_end > seg_start {
let local_start = seg_start - s;
let local_end = seg_end - s;
let content = original.spans[i].content.as_ref();
let slice = &content[local_start..local_end];
let slice = slice_span_content(
original.spans[i].content.as_ref(),
bound,
local_start,
local_end,
);
acc.push(Span {
style: *style,
content: std::borrow::Cow::Borrowed(slice),
style: bound.style,
content: slice,
});
}
if e >= end_byte {
@@ -879,9 +889,40 @@ fn slice_line_spans<'a>(
}
}
#[derive(Clone, Debug)]
struct SpanBound<'a> {
range: Range<usize>,
style: ratatui::style::Style,
osc8_destination: Option<&'a str>,
}
fn slice_span_content<'a>(
content: &'a str,
bound: &SpanBound<'a>,
local_start: usize,
local_end: usize,
) -> Cow<'a, str> {
if let Some(destination) = bound.osc8_destination {
if let Some(parsed) = parse_osc8_hyperlink(content) {
Cow::Owned(osc8_hyperlink(
destination,
&parsed.text[local_start..local_end],
))
} else {
Cow::Borrowed(&content[local_start..local_end])
}
} else {
Cow::Borrowed(&content[local_start..local_end])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::osc8::ParsedOsc8;
use crate::osc8::osc8_hyperlink;
use crate::osc8::parse_osc8_hyperlink;
use crate::osc8::strip_osc8_hyperlinks;
use itertools::Itertools as _;
use pretty_assertions::assert_eq;
use ratatui::style::Color;
@@ -965,6 +1006,34 @@ mod tests {
assert_eq!(concat_line(&out[0]), "");
}
#[test]
fn osc8_wrapped_span_wraps_by_visible_text() {
let url = "https://example.com/docs";
let line = Line::from(vec![osc8_hyperlink(url, "abcdefghij").cyan().underlined()]);
let out = word_wrap_line(&line, 5);
assert_eq!(out.len(), 2);
let first = concat_line(&out[0]);
let second = concat_line(&out[1]);
assert_eq!(strip_osc8_hyperlinks(&first), "abcde");
assert_eq!(strip_osc8_hyperlinks(&second), "fghij");
assert_eq!(
parse_osc8_hyperlink(&first).expect("first line should stay hyperlinked"),
ParsedOsc8 {
destination: url,
text: "abcde",
}
);
assert_eq!(
parse_osc8_hyperlink(&second).expect("second line should stay hyperlinked"),
ParsedOsc8 {
destination: url,
text: "fghij",
}
);
}
#[test]
fn leading_spaces_preserved_on_first_line() {
let line = Line::from(" hello");