Compare commits

...

3 Commits

Author SHA1 Message Date
starr-openai
154d8ca3ed Generalize TUI terminal-wrapper layout handling
The failure mode here is that layout code was treating OSC-8 escape bytes as part of visible text. That is the wrong abstraction for width, wrapping, and truncation: those paths need to operate on visible cells while preserving zero-width wrapper bytes atomically around the sliced text.

Introduce a generic full-span wrapper shape for that split, use it in width/wrap/truncate, accept both BEL and ST terminators on input, preserve opener params when rewrapping slices, and add edge-case unit coverage for malformed wrappers and grapheme-safe truncation.

Co-authored-by: Codex <noreply@openai.com>
2026-03-19 20:17:02 -07:00
starr-openai
777e79c770 Render markdown links as OSC-8 in TUI
Make remote markdown link labels and visible destinations clickable, keep destination styling distinct, preserve outer inline styles on the visible URL, and add markdown-level regressions including wrapped labels.

Co-authored-by: Codex <noreply@openai.com>
2026-03-19 20:02:06 -07:00
starr-openai
b58103fa3d Make TUI size handling OSC-8-aware
Add shared OSC-8 helpers and make low-level wrapping and truncation count visible text width, preserving wrapped/truncated hyperlinks across ratatui spans.

Co-authored-by: Codex <noreply@openai.com>
2026-03-19 20:02:06 -07:00
16 changed files with 1482 additions and 144 deletions

View File

@@ -43,7 +43,8 @@ use ratatui::layout::Size;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::terminal_wrappers::visible_width;
/// Returns the display width of a cell symbol, ignoring OSC escape sequences.
///
@@ -54,28 +55,7 @@ use unicode_width::UnicodeWidthStr;
/// This function strips them first so that only visible characters contribute
/// to the width.
fn display_width(s: &str) -> usize {
// Fast path: no escape sequences present.
if !s.contains('\x1B') {
return s.width();
}
// Strip OSC sequences: ESC ] ... BEL
let mut visible = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(ch) = chars.next() {
if ch == '\x1B' && chars.clone().next() == Some(']') {
// Consume the ']' and everything up to and including BEL.
chars.next(); // skip ']'
for c in chars.by_ref() {
if c == '\x07' {
break;
}
}
continue;
}
visible.push(ch);
}
visible.width()
visible_width(s)
}
#[derive(Debug, Hash)]
@@ -703,6 +683,22 @@ mod tests {
use ratatui::layout::Rect;
use ratatui::style::Style;
#[test]
fn display_width_ignores_bel_terminated_osc8_wrapper() {
assert_eq!(
display_width("\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}"),
4
);
}
#[test]
fn display_width_ignores_st_terminated_osc8_wrapper() {
assert_eq!(
display_width("\u{1b}]8;;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\"),
4
);
}
#[test]
fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() {
let area = Rect::new(0, 0, 3, 2);

View File

@@ -112,6 +112,7 @@ mod model_migration;
mod multi_agents;
mod notifications;
pub mod onboarding;
mod osc8;
mod oss_selection;
mod pager_overlay;
pub mod public_widgets;
@@ -128,6 +129,7 @@ mod streaming;
mod style;
mod terminal_palette;
mod terminal_title;
mod terminal_wrappers;
mod text_formatting;
mod theme_picker;
mod tooltips;

View File

@@ -1,11 +1,15 @@
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthChar;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
use crate::terminal_wrappers::visible_width as wrapped_visible_width;
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,18 +46,31 @@ 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_wrapper = parse_zero_width_terminal_wrapper(text);
let visible_text = parsed_wrapper.map_or_else(
|| strip_zero_width_terminal_wrappers(text),
|wrapper| wrapper.text.to_string(),
);
// Truncate by visible grapheme clusters, not scalar values. This keeps
// multi-codepoint emoji intact and lets zero-width wrappers stay
// attached to the truncated visible prefix.
let mut end_idx = 0usize;
for (idx, ch) in text.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > max_width {
for grapheme in UnicodeSegmentation::graphemes(visible_text.as_str(), true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if used + grapheme_width > max_width {
break;
}
end_idx = idx + ch.len_utf8();
used += ch_width;
end_idx += grapheme.len();
used += grapheme_width;
}
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_wrapper.map_or_else(
|| truncated_text.to_string(),
|wrapper| format!("{}{}{}", wrapper.prefix, truncated_text, wrapper.suffix),
);
spans_out.push(Span::styled(content, style));
}
break;
@@ -66,6 +83,10 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
}
}
fn visible_width(text: &str) -> usize {
wrapped_visible_width(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 +119,128 @@ 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 crate::osc8::osc8_hyperlink;
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_to_width_preserves_osc8_between_ascii_spans() {
let line = Line::from(vec![
"A".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "BC"))
.cyan()
.underlined(),
"DE".into(),
]);
let truncated = truncate_line_to_width(line, 4);
let expected = Line::from(vec![
"A".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "BC"))
.cyan()
.underlined(),
"D".into(),
]);
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);
}
#[test]
fn truncate_line_to_width_preserves_st_terminated_wrapper_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
let line = Line::from(vec!["See ".into(), Span::from(wrapped).cyan().underlined()]);
let truncated = truncate_line_to_width(line, 6);
let expected = Line::from(vec![
"See ".into(),
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\do\u{1b}]8;;\u{1b}\\")
.cyan()
.underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_to_width_cuts_by_grapheme_not_scalar_value() {
let line = Line::from(vec![
Span::from(osc8_hyperlink(
"https://example.com/docs",
"👨\u{200d}👩\u{200d}👧\u{200d}👦x",
))
.underlined(),
]);
let truncated = truncate_line_to_width(line, 2);
let expected = Line::from(vec![
Span::from(osc8_hyperlink(
"https://example.com/docs",
"👨\u{200d}👩\u{200d}👧\u{200d}👦",
))
.underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_to_width_preserves_malformed_unterminated_wrapper_verbatim_until_limit() {
let malformed = "See \u{1b}]8;;https://example.com\u{7}docs";
let line = Line::from(malformed);
let truncated = truncate_line_to_width(line, 7);
assert_eq!(truncated, Line::from("See \u{1b}]"));
}
}

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,18 @@ where
fn pop_link(&mut self) {
if let Some(link) = self.link.take() {
if link.show_destination {
self.pop_inline_style();
let destination_style = self
.inline_styles
.last()
.copied()
.unwrap_or_default()
.patch(self.styles.link_destination);
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),
destination_style,
));
self.push_span(")".into());
} else if let Some(local_target_display) = link.local_target_display {
if self.pending_marker_line {
@@ -617,6 +641,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,10 @@ 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::ParsedOsc8;
use crate::osc8::osc8_hyperlink;
use crate::osc8::parse_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 +655,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 +783,93 @@ 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 nested_styled_url_link_preserves_destination_outer_style() {
let text = render_markdown_text("***[docs](https://example.com/docs)***");
let expected = Text::from(Line::from_iter([
osc8_hyperlink("https://example.com/docs", "docs")
.bold()
.italic()
.underlined(),
" (".into(),
osc8_hyperlink("https://example.com/docs", "https://example.com/docs")
.bold()
.italic()
.cyan()
.underlined(),
")".into(),
]));
assert_eq!(text, expected);
}
#[test]
fn wrapped_url_link_label_stays_clickable_across_lines() {
let text = render_markdown_text_with_width_and_cwd(
"[abcdefgh](https://example.com/docs)",
Some(4),
None,
);
let wrapped_label_lines = text.lines.iter().take(3).cloned().collect::<Vec<_>>();
let expected = vec![
Line::from(osc8_hyperlink("https://example.com/docs", "abc").underlined()),
Line::from(osc8_hyperlink("https://example.com/docs", "def").underlined()),
Line::from(osc8_hyperlink("https://example.com/docs", "gh").underlined()),
];
assert_eq!(wrapped_label_lines, expected);
let first = text.lines[0]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(
parse_osc8_hyperlink(&first),
Some(ParsedOsc8 {
destination: "https://example.com/docs",
text: "abc",
})
);
}
#[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 +880,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 +904,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 +1248,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");

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

@@ -0,0 +1,84 @@
#[cfg(test)]
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
#[cfg(test)]
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
#[cfg(test)]
#[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;;";
#[cfg(test)]
const OSC8_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()
)
}
#[cfg(test)]
pub(crate) fn parse_osc8_hyperlink(text: &str) -> Option<ParsedOsc8<'_>> {
let wrapped = parse_zero_width_terminal_wrapper(text)?;
let opener_payload = wrapped.prefix.strip_prefix(OSC8_PREFIX)?;
let params_end = opener_payload.find(';')?;
let after_params = &opener_payload[params_end + 1..];
let destination = after_params
.strip_suffix('\x07')
.or_else(|| after_params.strip_suffix("\x1b\\"))?;
Some(ParsedOsc8 {
destination,
text: wrapped.text,
})
}
#[cfg(test)]
pub(crate) fn strip_osc8_hyperlinks(text: &str) -> String {
strip_zero_width_terminal_wrappers(text)
}
#[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");
}
#[test]
fn parses_st_terminated_wrapped_text_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
let parsed = parse_osc8_hyperlink(wrapped).expect("expected osc8 span");
assert_eq!(parsed.destination, "https://example.com");
assert_eq!(parsed.text, "docs");
}
}

View File

@@ -0,0 +1,187 @@
use unicode_width::UnicodeWidthStr;
/// A balanced zero-width terminal wrapper around visible text.
///
/// This is deliberately narrower than "arbitrary ANSI". It models the shape we
/// need for OSC-8 hyperlinks in layout code: an opener with no display width,
/// visible text that should be measured/wrapped/truncated, and a closer with no
/// display width. Keeping the wrapper bytes separate from the visible text lets
/// us preserve them atomically when a line is split.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ParsedTerminalWrapper<'a> {
pub(crate) prefix: &'a str,
pub(crate) text: &'a str,
pub(crate) suffix: &'a str,
}
const OSC8_PREFIX: &str = "\u{1b}]8;";
const OSC8_CLOSE_BEL: &str = "\u{1b}]8;;\u{7}";
const OSC8_CLOSE_ST: &str = "\u{1b}]8;;\u{1b}\\";
const OSC_STRING_TERMINATORS: [&str; 2] = ["\u{7}", "\u{1b}\\"];
/// Parse a full-span terminal wrapper.
///
/// Today this recognizes OSC-8 hyperlinks only, but it returns a generic
/// wrapper shape so width and slicing code do not need to know about
/// hyperlink-specific fields like URL or params.
pub(crate) fn parse_zero_width_terminal_wrapper(text: &str) -> Option<ParsedTerminalWrapper<'_>> {
let after_prefix = text.strip_prefix(OSC8_PREFIX)?;
let params_end = after_prefix.find(';')?;
let after_params = &after_prefix[params_end + 1..];
let (destination_end, opener_terminator) = find_osc_string_terminator(after_params)?;
let prefix_len = OSC8_PREFIX.len() + params_end + 1 + destination_end + opener_terminator.len();
let prefix = &text[..prefix_len];
let after_opener = &text[prefix_len..];
if let Some(visible) = after_opener.strip_suffix(OSC8_CLOSE_BEL) {
return Some(ParsedTerminalWrapper {
prefix,
text: visible,
suffix: OSC8_CLOSE_BEL,
});
}
if let Some(visible) = after_opener.strip_suffix(OSC8_CLOSE_ST) {
return Some(ParsedTerminalWrapper {
prefix,
text: visible,
suffix: OSC8_CLOSE_ST,
});
}
None
}
/// Strip the zero-width wrapper bytes from any recognized wrapped runs.
///
/// Malformed or unterminated escape sequences are preserved verbatim. That
/// keeps layout helpers fail-safe: they may over-measure malformed input, but
/// they will not silently delete bytes from it.
pub(crate) fn strip_zero_width_terminal_wrappers(text: &str) -> String {
if !text.contains('\x1B') {
return text.to_string();
}
let mut remaining = text;
let mut rendered = String::with_capacity(text.len());
while let Some(open_pos) = remaining.find(OSC8_PREFIX) {
rendered.push_str(&remaining[..open_pos]);
let candidate = &remaining[open_pos..];
let Some((consumed, visible)) = consume_wrapped_prefix(candidate) else {
rendered.push_str(candidate);
return rendered;
};
rendered.push_str(visible);
remaining = &candidate[consumed..];
}
rendered.push_str(remaining);
rendered
}
/// Measure display width after removing recognized zero-width terminal wrappers.
pub(crate) fn visible_width(text: &str) -> usize {
UnicodeWidthStr::width(strip_zero_width_terminal_wrappers(text).as_str())
}
fn consume_wrapped_prefix(text: &str) -> Option<(usize, &str)> {
let after_prefix = text.strip_prefix(OSC8_PREFIX)?;
let params_end = after_prefix.find(';')?;
let after_params = &after_prefix[params_end + 1..];
let (destination_end, opener_terminator) = find_osc_string_terminator(after_params)?;
let opener_len = OSC8_PREFIX.len() + params_end + 1 + destination_end + opener_terminator.len();
let after_opener = &text[opener_len..];
let mut best: Option<(usize, &str)> = None;
for suffix in [OSC8_CLOSE_BEL, OSC8_CLOSE_ST] {
if let Some(close_pos) = after_opener.find(suffix)
&& best.is_none_or(|(best_pos, _)| close_pos < best_pos)
{
best = Some((close_pos, suffix));
}
}
let (close_pos, suffix) = best?;
Some((
opener_len + close_pos + suffix.len(),
&after_opener[..close_pos],
))
}
fn find_osc_string_terminator(text: &str) -> Option<(usize, &'static str)> {
let mut best: Option<(usize, &'static str)> = None;
for terminator in OSC_STRING_TERMINATORS {
if let Some(pos) = text.find(terminator)
&& best.is_none_or(|(best_pos, _)| pos < best_pos)
{
best = Some((pos, terminator));
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parses_bel_terminated_wrapper() {
let wrapped = "\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}";
assert_eq!(
parse_zero_width_terminal_wrapper(wrapped),
Some(ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com\u{7}",
text: "docs",
suffix: "\u{1b}]8;;\u{7}",
})
);
}
#[test]
fn parses_st_terminated_wrapper_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
assert_eq!(
parse_zero_width_terminal_wrapper(wrapped),
Some(ParsedTerminalWrapper {
prefix: "\u{1b}]8;id=abc;https://example.com\u{1b}\\",
text: "docs",
suffix: "\u{1b}]8;;\u{1b}\\",
})
);
}
#[test]
fn strips_multiple_wrapped_runs_and_keeps_plain_text() {
let text = concat!(
"See ",
"\u{1b}]8;;https://a.example\u{7}alpha\u{1b}]8;;\u{7}",
" and ",
"\u{1b}]8;id=1;https://b.example\u{1b}\\beta\u{1b}]8;;\u{1b}\\",
"."
);
assert_eq!(
strip_zero_width_terminal_wrappers(text),
"See alpha and beta."
);
}
#[test]
fn preserves_malformed_unterminated_wrapper_verbatim() {
let text = "See \u{1b}]8;;https://example.com\u{7}docs";
assert_eq!(strip_zero_width_terminal_wrappers(text), text);
assert_eq!(parse_zero_width_terminal_wrapper(text), None);
}
#[test]
fn visible_width_ignores_wrapper_bytes() {
let text = "\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}";
assert_eq!(visible_width(text), 4);
}
}

View File

@@ -33,6 +33,8 @@ use std::ops::Range;
use textwrap::Options;
use crate::render::line_utils::push_owned_lines;
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
/// Returns byte-ranges into `text` for each wrapped line, including
/// trailing whitespace and a +1 sentinel byte. Used by the textarea
@@ -177,12 +179,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 +188,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_zero_width_terminal_wrappers(span.content.as_ref()))
.collect::<Vec<_>>()
.join("")
}
/// Returns `true` if any whitespace-delimited token in `text` looks like a URL.
@@ -639,16 +639,25 @@ pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) ->
where
O: Into<RtOptions<'a>>,
{
// Flatten the line and record span byte ranges.
// Flatten the line to visible text and record the original span bounds.
// Zero-width terminal wrappers stay out of `flat` so textwrap sees only
// display cells, but we keep their exact prefix/suffix bytes for
// rewrapping each sliced fragment later.
let mut flat = String::new();
let mut span_bounds = Vec::new();
let mut acc = 0usize;
for s in &line.spans {
let text = s.content.as_ref();
let parsed = parse_zero_width_terminal_wrapper(s.content.as_ref());
let text = parsed.map_or_else(|| s.content.as_ref(), |wrapper| wrapper.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,
wrapper_prefix: parsed.map(|wrapper| wrapper.prefix),
wrapper_suffix: parsed.map(|wrapper| wrapper.suffix),
});
}
let rt_opts: RtOptions<'a> = width_or_options.into();
@@ -841,15 +850,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 +870,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 +892,44 @@ fn slice_line_spans<'a>(
}
}
#[derive(Clone, Debug)]
struct SpanBound<'a> {
range: Range<usize>,
style: ratatui::style::Style,
wrapper_prefix: Option<&'a str>,
wrapper_suffix: Option<&'a str>,
}
fn slice_span_content<'a>(
content: &'a str,
bound: &SpanBound<'a>,
local_start: usize,
local_end: usize,
) -> Cow<'a, str> {
// If the original span was wrapped in a zero-width terminal control
// sequence, re-emit that wrapper around the visible slice instead of
// cutting through the escape payload.
if let (Some(prefix), Some(suffix)) = (bound.wrapper_prefix, bound.wrapper_suffix) {
if let Some(parsed) = parse_zero_width_terminal_wrapper(content) {
Cow::Owned(format!(
"{prefix}{}{suffix}",
&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::osc8_hyperlink;
use crate::terminal_wrappers::ParsedTerminalWrapper;
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
use itertools::Itertools as _;
use pretty_assertions::assert_eq;
use ratatui::style::Color;
@@ -965,6 +1013,85 @@ 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_zero_width_terminal_wrappers(&first), "abcde");
assert_eq!(strip_zero_width_terminal_wrappers(&second), "fghij");
assert_eq!(
parse_zero_width_terminal_wrapper(&first).expect("first line should stay hyperlinked"),
ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com/docs\u{7}",
text: "abcde",
suffix: "\u{1b}]8;;\u{7}",
}
);
assert_eq!(
parse_zero_width_terminal_wrapper(&second)
.expect("second line should stay hyperlinked"),
ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com/docs\u{7}",
text: "fghij",
suffix: "\u{1b}]8;;\u{7}",
}
);
}
#[test]
fn osc8_wrapper_with_params_and_st_is_preserved_across_wraps() {
let line = Line::from(vec![
"x".into(),
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\")
.underlined(),
]);
let out = word_wrap_line(&line, 3);
let expected = vec![
Line::from(vec![
"x".into(),
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\do\u{1b}]8;;\u{1b}\\")
.underlined(),
]),
Line::from(vec![
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\cs\u{1b}]8;;\u{1b}\\")
.underlined(),
]),
];
assert_eq!(out, expected);
}
#[test]
fn mixed_ascii_and_osc8_wrapped_spans_preserve_ratatui_spans_across_wraps() {
let url = "https://example.com/docs";
let line = Line::from(vec![
"ab".into(),
osc8_hyperlink(url, "cdef").cyan().underlined(),
"gh".into(),
]);
let out = word_wrap_line(&line, 4);
let expected = vec![
Line::from(vec![
"ab".into(),
osc8_hyperlink(url, "cd").cyan().underlined(),
]),
Line::from(vec![
osc8_hyperlink(url, "ef").cyan().underlined(),
"gh".into(),
]),
];
assert_eq!(out, expected);
}
#[test]
fn leading_spaces_preserved_on_first_line() {
let line = Line::from(" hello");

View File

@@ -43,7 +43,8 @@ use ratatui::layout::Size;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::terminal_wrappers::visible_width;
/// Returns the display width of a cell symbol, ignoring OSC escape sequences.
///
@@ -54,28 +55,7 @@ use unicode_width::UnicodeWidthStr;
/// This function strips them first so that only visible characters contribute
/// to the width.
fn display_width(s: &str) -> usize {
// Fast path: no escape sequences present.
if !s.contains('\x1B') {
return s.width();
}
// Strip OSC sequences: ESC ] ... BEL
let mut visible = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(ch) = chars.next() {
if ch == '\x1B' && chars.clone().next() == Some(']') {
// Consume the ']' and everything up to and including BEL.
chars.next(); // skip ']'
for c in chars.by_ref() {
if c == '\x07' {
break;
}
}
continue;
}
visible.push(ch);
}
visible.width()
visible_width(s)
}
#[derive(Debug, Hash)]
@@ -703,6 +683,22 @@ mod tests {
use ratatui::layout::Rect;
use ratatui::style::Style;
#[test]
fn display_width_ignores_bel_terminated_osc8_wrapper() {
assert_eq!(
display_width("\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}"),
4
);
}
#[test]
fn display_width_ignores_st_terminated_osc8_wrapper() {
assert_eq!(
display_width("\u{1b}]8;;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\"),
4
);
}
#[test]
fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() {
let area = Rect::new(0, 0, 3, 2);

View File

@@ -126,6 +126,7 @@ mod model_migration;
mod multi_agents;
mod notifications;
pub mod onboarding;
mod osc8;
mod oss_selection;
mod pager_overlay;
pub mod public_widgets;
@@ -141,6 +142,7 @@ mod status_indicator_widget;
mod streaming;
mod style;
mod terminal_palette;
mod terminal_wrappers;
mod text_formatting;
mod theme_picker;
mod tooltips;

View File

@@ -1,11 +1,15 @@
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthChar;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
use crate::terminal_wrappers::visible_width as wrapped_visible_width;
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,18 +46,31 @@ 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_wrapper = parse_zero_width_terminal_wrapper(text);
let visible_text = parsed_wrapper.map_or_else(
|| strip_zero_width_terminal_wrappers(text),
|wrapper| wrapper.text.to_string(),
);
// Truncate by visible grapheme clusters, not scalar values. This keeps
// multi-codepoint emoji intact and lets zero-width wrappers stay
// attached to the truncated visible prefix.
let mut end_idx = 0usize;
for (idx, ch) in text.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > max_width {
for grapheme in UnicodeSegmentation::graphemes(visible_text.as_str(), true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if used + grapheme_width > max_width {
break;
}
end_idx = idx + ch.len_utf8();
used += ch_width;
end_idx += grapheme.len();
used += grapheme_width;
}
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_wrapper.map_or_else(
|| truncated_text.to_string(),
|wrapper| format!("{}{}{}", wrapper.prefix, truncated_text, wrapper.suffix),
);
spans_out.push(Span::styled(content, style));
}
break;
@@ -66,6 +83,10 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
}
}
fn visible_width(text: &str) -> usize {
wrapped_visible_width(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 +119,128 @@ 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 crate::osc8::osc8_hyperlink;
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_to_width_preserves_osc8_between_ascii_spans() {
let line = Line::from(vec![
"A".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "BC"))
.cyan()
.underlined(),
"DE".into(),
]);
let truncated = truncate_line_to_width(line, 4);
let expected = Line::from(vec![
"A".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "BC"))
.cyan()
.underlined(),
"D".into(),
]);
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);
}
#[test]
fn truncate_line_to_width_preserves_st_terminated_wrapper_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
let line = Line::from(vec!["See ".into(), Span::from(wrapped).cyan().underlined()]);
let truncated = truncate_line_to_width(line, 6);
let expected = Line::from(vec![
"See ".into(),
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\do\u{1b}]8;;\u{1b}\\")
.cyan()
.underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_to_width_cuts_by_grapheme_not_scalar_value() {
let line = Line::from(vec![
Span::from(osc8_hyperlink(
"https://example.com/docs",
"👨\u{200d}👩\u{200d}👧\u{200d}👦x",
))
.underlined(),
]);
let truncated = truncate_line_to_width(line, 2);
let expected = Line::from(vec![
Span::from(osc8_hyperlink(
"https://example.com/docs",
"👨\u{200d}👩\u{200d}👧\u{200d}👦",
))
.underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_to_width_preserves_malformed_unterminated_wrapper_verbatim_until_limit() {
let malformed = "See \u{1b}]8;;https://example.com\u{7}docs";
let line = Line::from(malformed);
let truncated = truncate_line_to_width(line, 7);
assert_eq!(truncated, Line::from("See \u{1b}]"));
}
}

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,18 @@ where
fn pop_link(&mut self) {
if let Some(link) = self.link.take() {
if link.show_destination {
self.pop_inline_style();
let destination_style = self
.inline_styles
.last()
.copied()
.unwrap_or_default()
.patch(self.styles.link_destination);
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),
destination_style,
));
self.push_span(")".into());
} else if let Some(local_target_display) = link.local_target_display {
if self.pending_marker_line {
@@ -617,6 +641,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,10 @@ 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::ParsedOsc8;
use crate::osc8::osc8_hyperlink;
use crate::osc8::parse_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 +655,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 +783,93 @@ 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 nested_styled_url_link_preserves_destination_outer_style() {
let text = render_markdown_text("***[docs](https://example.com/docs)***");
let expected = Text::from(Line::from_iter([
osc8_hyperlink("https://example.com/docs", "docs")
.bold()
.italic()
.underlined(),
" (".into(),
osc8_hyperlink("https://example.com/docs", "https://example.com/docs")
.bold()
.italic()
.cyan()
.underlined(),
")".into(),
]));
assert_eq!(text, expected);
}
#[test]
fn wrapped_url_link_label_stays_clickable_across_lines() {
let text = render_markdown_text_with_width_and_cwd(
"[abcdefgh](https://example.com/docs)",
Some(4),
None,
);
let wrapped_label_lines = text.lines.iter().take(3).cloned().collect::<Vec<_>>();
let expected = vec![
Line::from(osc8_hyperlink("https://example.com/docs", "abc").underlined()),
Line::from(osc8_hyperlink("https://example.com/docs", "def").underlined()),
Line::from(osc8_hyperlink("https://example.com/docs", "gh").underlined()),
];
assert_eq!(wrapped_label_lines, expected);
let first = text.lines[0]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(
parse_osc8_hyperlink(&first),
Some(ParsedOsc8 {
destination: "https://example.com/docs",
text: "abc",
})
);
}
#[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 +880,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 +904,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 +1248,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,84 @@
#[cfg(test)]
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
#[cfg(test)]
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
#[cfg(test)]
#[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;;";
#[cfg(test)]
const OSC8_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()
)
}
#[cfg(test)]
pub(crate) fn parse_osc8_hyperlink(text: &str) -> Option<ParsedOsc8<'_>> {
let wrapped = parse_zero_width_terminal_wrapper(text)?;
let opener_payload = wrapped.prefix.strip_prefix(OSC8_PREFIX)?;
let params_end = opener_payload.find(';')?;
let after_params = &opener_payload[params_end + 1..];
let destination = after_params
.strip_suffix('\x07')
.or_else(|| after_params.strip_suffix("\x1b\\"))?;
Some(ParsedOsc8 {
destination,
text: wrapped.text,
})
}
#[cfg(test)]
pub(crate) fn strip_osc8_hyperlinks(text: &str) -> String {
strip_zero_width_terminal_wrappers(text)
}
#[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");
}
#[test]
fn parses_st_terminated_wrapped_text_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
let parsed = parse_osc8_hyperlink(wrapped).expect("expected osc8 span");
assert_eq!(parsed.destination, "https://example.com");
assert_eq!(parsed.text, "docs");
}
}

View File

@@ -0,0 +1,187 @@
use unicode_width::UnicodeWidthStr;
/// A balanced zero-width terminal wrapper around visible text.
///
/// This is deliberately narrower than "arbitrary ANSI". It models the shape we
/// need for OSC-8 hyperlinks in layout code: an opener with no display width,
/// visible text that should be measured/wrapped/truncated, and a closer with no
/// display width. Keeping the wrapper bytes separate from the visible text lets
/// us preserve them atomically when a line is split.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ParsedTerminalWrapper<'a> {
pub(crate) prefix: &'a str,
pub(crate) text: &'a str,
pub(crate) suffix: &'a str,
}
const OSC8_PREFIX: &str = "\u{1b}]8;";
const OSC8_CLOSE_BEL: &str = "\u{1b}]8;;\u{7}";
const OSC8_CLOSE_ST: &str = "\u{1b}]8;;\u{1b}\\";
const OSC_STRING_TERMINATORS: [&str; 2] = ["\u{7}", "\u{1b}\\"];
/// Parse a full-span terminal wrapper.
///
/// Today this recognizes OSC-8 hyperlinks only, but it returns a generic
/// wrapper shape so width and slicing code do not need to know about
/// hyperlink-specific fields like URL or params.
pub(crate) fn parse_zero_width_terminal_wrapper(text: &str) -> Option<ParsedTerminalWrapper<'_>> {
let after_prefix = text.strip_prefix(OSC8_PREFIX)?;
let params_end = after_prefix.find(';')?;
let after_params = &after_prefix[params_end + 1..];
let (destination_end, opener_terminator) = find_osc_string_terminator(after_params)?;
let prefix_len = OSC8_PREFIX.len() + params_end + 1 + destination_end + opener_terminator.len();
let prefix = &text[..prefix_len];
let after_opener = &text[prefix_len..];
if let Some(visible) = after_opener.strip_suffix(OSC8_CLOSE_BEL) {
return Some(ParsedTerminalWrapper {
prefix,
text: visible,
suffix: OSC8_CLOSE_BEL,
});
}
if let Some(visible) = after_opener.strip_suffix(OSC8_CLOSE_ST) {
return Some(ParsedTerminalWrapper {
prefix,
text: visible,
suffix: OSC8_CLOSE_ST,
});
}
None
}
/// Strip the zero-width wrapper bytes from any recognized wrapped runs.
///
/// Malformed or unterminated escape sequences are preserved verbatim. That
/// keeps layout helpers fail-safe: they may over-measure malformed input, but
/// they will not silently delete bytes from it.
pub(crate) fn strip_zero_width_terminal_wrappers(text: &str) -> String {
if !text.contains('\x1B') {
return text.to_string();
}
let mut remaining = text;
let mut rendered = String::with_capacity(text.len());
while let Some(open_pos) = remaining.find(OSC8_PREFIX) {
rendered.push_str(&remaining[..open_pos]);
let candidate = &remaining[open_pos..];
let Some((consumed, visible)) = consume_wrapped_prefix(candidate) else {
rendered.push_str(candidate);
return rendered;
};
rendered.push_str(visible);
remaining = &candidate[consumed..];
}
rendered.push_str(remaining);
rendered
}
/// Measure display width after removing recognized zero-width terminal wrappers.
pub(crate) fn visible_width(text: &str) -> usize {
UnicodeWidthStr::width(strip_zero_width_terminal_wrappers(text).as_str())
}
fn consume_wrapped_prefix(text: &str) -> Option<(usize, &str)> {
let after_prefix = text.strip_prefix(OSC8_PREFIX)?;
let params_end = after_prefix.find(';')?;
let after_params = &after_prefix[params_end + 1..];
let (destination_end, opener_terminator) = find_osc_string_terminator(after_params)?;
let opener_len = OSC8_PREFIX.len() + params_end + 1 + destination_end + opener_terminator.len();
let after_opener = &text[opener_len..];
let mut best: Option<(usize, &str)> = None;
for suffix in [OSC8_CLOSE_BEL, OSC8_CLOSE_ST] {
if let Some(close_pos) = after_opener.find(suffix)
&& best.is_none_or(|(best_pos, _)| close_pos < best_pos)
{
best = Some((close_pos, suffix));
}
}
let (close_pos, suffix) = best?;
Some((
opener_len + close_pos + suffix.len(),
&after_opener[..close_pos],
))
}
fn find_osc_string_terminator(text: &str) -> Option<(usize, &'static str)> {
let mut best: Option<(usize, &'static str)> = None;
for terminator in OSC_STRING_TERMINATORS {
if let Some(pos) = text.find(terminator)
&& best.is_none_or(|(best_pos, _)| pos < best_pos)
{
best = Some((pos, terminator));
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parses_bel_terminated_wrapper() {
let wrapped = "\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}";
assert_eq!(
parse_zero_width_terminal_wrapper(wrapped),
Some(ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com\u{7}",
text: "docs",
suffix: "\u{1b}]8;;\u{7}",
})
);
}
#[test]
fn parses_st_terminated_wrapper_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
assert_eq!(
parse_zero_width_terminal_wrapper(wrapped),
Some(ParsedTerminalWrapper {
prefix: "\u{1b}]8;id=abc;https://example.com\u{1b}\\",
text: "docs",
suffix: "\u{1b}]8;;\u{1b}\\",
})
);
}
#[test]
fn strips_multiple_wrapped_runs_and_keeps_plain_text() {
let text = concat!(
"See ",
"\u{1b}]8;;https://a.example\u{7}alpha\u{1b}]8;;\u{7}",
" and ",
"\u{1b}]8;id=1;https://b.example\u{1b}\\beta\u{1b}]8;;\u{1b}\\",
"."
);
assert_eq!(
strip_zero_width_terminal_wrappers(text),
"See alpha and beta."
);
}
#[test]
fn preserves_malformed_unterminated_wrapper_verbatim() {
let text = "See \u{1b}]8;;https://example.com\u{7}docs";
assert_eq!(strip_zero_width_terminal_wrappers(text), text);
assert_eq!(parse_zero_width_terminal_wrapper(text), None);
}
#[test]
fn visible_width_ignores_wrapper_bytes() {
let text = "\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}";
assert_eq!(visible_width(text), 4);
}
}

View File

@@ -33,6 +33,8 @@ use std::ops::Range;
use textwrap::Options;
use crate::render::line_utils::push_owned_lines;
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
/// Returns byte-ranges into `text` for each wrapped line, including
/// trailing whitespace and a +1 sentinel byte. Used by the textarea
@@ -177,12 +179,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 +188,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_zero_width_terminal_wrappers(span.content.as_ref()))
.collect::<Vec<_>>()
.join("")
}
/// Returns `true` if any whitespace-delimited token in `text` looks like a URL.
@@ -639,16 +639,25 @@ pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) ->
where
O: Into<RtOptions<'a>>,
{
// Flatten the line and record span byte ranges.
// Flatten the line to visible text and record the original span bounds.
// Zero-width terminal wrappers stay out of `flat` so textwrap sees only
// display cells, but we keep their exact prefix/suffix bytes for
// rewrapping each sliced fragment later.
let mut flat = String::new();
let mut span_bounds = Vec::new();
let mut acc = 0usize;
for s in &line.spans {
let text = s.content.as_ref();
let parsed = parse_zero_width_terminal_wrapper(s.content.as_ref());
let text = parsed.map_or_else(|| s.content.as_ref(), |wrapper| wrapper.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,
wrapper_prefix: parsed.map(|wrapper| wrapper.prefix),
wrapper_suffix: parsed.map(|wrapper| wrapper.suffix),
});
}
let rt_opts: RtOptions<'a> = width_or_options.into();
@@ -841,15 +850,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 +870,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 +892,44 @@ fn slice_line_spans<'a>(
}
}
#[derive(Clone, Debug)]
struct SpanBound<'a> {
range: Range<usize>,
style: ratatui::style::Style,
wrapper_prefix: Option<&'a str>,
wrapper_suffix: Option<&'a str>,
}
fn slice_span_content<'a>(
content: &'a str,
bound: &SpanBound<'a>,
local_start: usize,
local_end: usize,
) -> Cow<'a, str> {
// If the original span was wrapped in a zero-width terminal control
// sequence, re-emit that wrapper around the visible slice instead of
// cutting through the escape payload.
if let (Some(prefix), Some(suffix)) = (bound.wrapper_prefix, bound.wrapper_suffix) {
if let Some(parsed) = parse_zero_width_terminal_wrapper(content) {
Cow::Owned(format!(
"{prefix}{}{suffix}",
&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::osc8_hyperlink;
use crate::terminal_wrappers::ParsedTerminalWrapper;
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
use itertools::Itertools as _;
use pretty_assertions::assert_eq;
use ratatui::style::Color;
@@ -965,6 +1013,85 @@ 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_zero_width_terminal_wrappers(&first), "abcde");
assert_eq!(strip_zero_width_terminal_wrappers(&second), "fghij");
assert_eq!(
parse_zero_width_terminal_wrapper(&first).expect("first line should stay hyperlinked"),
ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com/docs\u{7}",
text: "abcde",
suffix: "\u{1b}]8;;\u{7}",
}
);
assert_eq!(
parse_zero_width_terminal_wrapper(&second)
.expect("second line should stay hyperlinked"),
ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com/docs\u{7}",
text: "fghij",
suffix: "\u{1b}]8;;\u{7}",
}
);
}
#[test]
fn osc8_wrapper_with_params_and_st_is_preserved_across_wraps() {
let line = Line::from(vec![
"x".into(),
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\")
.underlined(),
]);
let out = word_wrap_line(&line, 3);
let expected = vec![
Line::from(vec![
"x".into(),
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\do\u{1b}]8;;\u{1b}\\")
.underlined(),
]),
Line::from(vec![
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\cs\u{1b}]8;;\u{1b}\\")
.underlined(),
]),
];
assert_eq!(out, expected);
}
#[test]
fn mixed_ascii_and_osc8_wrapped_spans_preserve_ratatui_spans_across_wraps() {
let url = "https://example.com/docs";
let line = Line::from(vec![
"ab".into(),
osc8_hyperlink(url, "cdef").cyan().underlined(),
"gh".into(),
]);
let out = word_wrap_line(&line, 4);
let expected = vec![
Line::from(vec![
"ab".into(),
osc8_hyperlink(url, "cd").cyan().underlined(),
]),
Line::from(vec![
osc8_hyperlink(url, "ef").cyan().underlined(),
"gh".into(),
]),
];
assert_eq!(out, expected);
}
#[test]
fn leading_spaces_preserved_on_first_line() {
let line = Line::from(" hello");