Compare commits

...

2 Commits

Author SHA1 Message Date
starr-openai
e9f706d6d8 Limit markdown render test helpers to test builds
Gate width-and-cwd test-only markdown render entrypoints behind cfg(test) so rust-ci clippy no longer fails on dead code in tui_app_server, while keeping tui and tui_app_server consistent.

Co-authored-by: Codex <noreply@openai.com>
2026-03-16 15:30:14 -07:00
starr-openai
7af548f57e Render markdown links with visible URLs and explicit OSC-8 output
Render markdown links as blue underlined label-plus-URL text by default, and emit OSC-8 only at interactive TUI call sites. Add shared OSC-8 helpers plus wrapping, history, and test coverage in both tui and tui_app_server.

Co-authored-by: Codex <noreply@openai.com>
2026-03-16 15:06:57 -07:00
20 changed files with 784 additions and 109 deletions

View File

@@ -699,6 +699,7 @@ impl ModifierDiff {
#[cfg(test)]
mod tests {
use super::*;
use crate::osc8::osc8_hyperlink;
use pretty_assertions::assert_eq;
use ratatui::layout::Rect;
use ratatui::style::Style;
@@ -748,4 +749,12 @@ mod tests {
"expected clear-to-end to start after the remaining wide char; commands: {commands:?}"
);
}
#[test]
fn display_width_ignores_osc8_payload() {
assert_eq!(
display_width(&osc8_hyperlink("https://example.com/docs", "docs")),
4
);
}
}

View File

@@ -333,6 +333,7 @@ where
mod tests {
use super::*;
use crate::markdown_render::render_markdown_text;
use crate::osc8::osc8_hyperlink;
use crate::test_backend::VT100Backend;
use ratatui::layout::Rect;
use ratatui::style::Color;
@@ -365,6 +366,20 @@ mod tests {
);
}
#[test]
fn write_spans_preserves_osc8_wrapped_content() {
use ratatui::style::Stylize;
let wrapped = osc8_hyperlink("https://example.com/docs", "docs");
let spans = [wrapped.cyan().underlined()];
let mut actual: Vec<u8> = Vec::new();
write_spans(&mut actual, spans.iter()).unwrap();
let actual = String::from_utf8(actual).unwrap();
assert!(actual.contains("\u{1b}]8;;https://example.com/docs\u{7}docs\u{1b}]8;;\u{7}"));
}
#[test]
fn vt100_blockquote_line_emits_green_fg() {
// Set up a small off-screen terminal

View File

@@ -104,6 +104,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

@@ -11,10 +11,11 @@ pub(crate) fn append_markdown(
cwd: Option<&Path>,
lines: &mut Vec<Line<'static>>,
) {
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd(
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd_and_options(
markdown_source,
width,
cwd,
crate::markdown_render::MarkdownRenderOptions::INTERACTIVE,
);
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
}

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;
@@ -46,6 +47,15 @@ struct MarkdownStyles {
blockquote: Style,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct MarkdownRenderOptions {
pub(crate) emit_osc8: bool,
}
impl MarkdownRenderOptions {
pub(crate) const INTERACTIVE: Self = Self { emit_osc8: true };
}
impl Default for MarkdownStyles {
fn default() -> Self {
use ratatui::style::Stylize;
@@ -63,7 +73,7 @@ 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: Style::new().blue().underlined(),
blockquote: Style::new().green(),
}
}
@@ -87,13 +97,29 @@ impl IndentContext {
}
pub fn render_markdown_text(input: &str) -> Text<'static> {
render_markdown_text_with_width(input, None)
render_markdown_text_with_options(input, MarkdownRenderOptions::default())
}
pub(crate) fn render_markdown_text_with_options(
input: &str,
options: MarkdownRenderOptions,
) -> Text<'static> {
render_markdown_text_with_width_and_options(input, None, options)
}
/// Render markdown using the current process working directory for local file-link display.
#[cfg(test)]
pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> {
render_markdown_text_with_width_and_options(input, width, MarkdownRenderOptions::default())
}
pub(crate) fn render_markdown_text_with_width_and_options(
input: &str,
width: Option<usize>,
options: MarkdownRenderOptions,
) -> Text<'static> {
let cwd = std::env::current_dir().ok();
render_markdown_text_with_width_and_cwd(input, width, cwd.as_deref())
render_markdown_text_with_width_and_cwd_and_options(input, width, cwd.as_deref(), options)
}
/// Render markdown with an explicit working directory for local file links.
@@ -101,15 +127,30 @@ pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>)
/// The `cwd` parameter controls how absolute local targets are shortened before display. Passing
/// the session cwd keeps full renders, history cells, and streamed deltas visually aligned even
/// when rendering happens away from the process cwd.
#[cfg(test)]
pub(crate) fn render_markdown_text_with_width_and_cwd(
input: &str,
width: Option<usize>,
cwd: Option<&Path>,
) -> Text<'static> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options);
let mut w = Writer::new(parser, width, cwd);
render_markdown_text_with_width_and_cwd_and_options(
input,
width,
cwd,
MarkdownRenderOptions::default(),
)
}
pub(crate) fn render_markdown_text_with_width_and_cwd_and_options(
input: &str,
width: Option<usize>,
cwd: Option<&Path>,
render_options: MarkdownRenderOptions,
) -> Text<'static> {
let mut parser_options = Options::empty();
parser_options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, parser_options);
let mut w = Writer::new(parser, width, cwd, render_options);
w.run();
w.text
}
@@ -170,13 +211,19 @@ where
current_subsequent_indent: Vec<Span<'static>>,
current_line_style: Style,
current_line_in_code_block: bool,
render_options: MarkdownRenderOptions,
}
impl<'a, I> Writer<'a, I>
where
I: Iterator<Item = Event<'a>>,
{
fn new(iter: I, wrap_width: Option<usize>, cwd: Option<&Path>) -> Self {
fn new(
iter: I,
wrap_width: Option<usize>,
cwd: Option<&Path>,
render_options: MarkdownRenderOptions,
) -> Self {
Self {
iter,
text: Text::default(),
@@ -200,6 +247,7 @@ where
current_subsequent_indent: Vec::new(),
current_line_style: Style::default(),
current_line_in_code_block: false,
render_options,
}
}
@@ -272,7 +320,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);
}
}
Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
| Tag::Table(_)
@@ -404,7 +457,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(),
@@ -423,7 +476,18 @@ 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)
.patch(
self.remote_link_destination()
.map(|_| self.styles.link)
.unwrap_or_default(),
);
let span = Span::from(self.maybe_wrap_remote_link_text(code.as_ref())).style(style);
self.push_span(span);
}
@@ -442,7 +506,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;
}
@@ -590,6 +654,7 @@ 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(")".into());
@@ -611,6 +676,26 @@ 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| {
if self.render_options.emit_osc8 {
osc8_hyperlink(destination, text)
} else {
text.to_string()
}
},
)
}
fn suppressing_local_link_label(&self) -> bool {
self.link
.as_ref()

View File

@@ -7,10 +7,18 @@ use std::path::Path;
use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
use crate::markdown_render::MarkdownRenderOptions;
use crate::markdown_render::render_markdown_text;
use crate::markdown_render::render_markdown_text_with_options;
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_interactive(input: &str) -> Text<'static> {
render_markdown_text_with_options(input, MarkdownRenderOptions::INTERACTIVE)
}
fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
render_markdown_text_with_width_and_cwd(input, None, Some(cwd))
}
@@ -651,9 +659,9 @@ fn strong_emphasis() {
fn link() {
let text = render_markdown_text("[Link](https://example.com)");
let expected = Text::from(Line::from_iter([
"Link".into(),
"Link".blue().underlined(),
" (".into(),
"https://example.com".cyan().underlined(),
"https://example.com".blue().underlined(),
")".into(),
]));
assert_eq!(text, expected);
@@ -776,17 +784,53 @@ fn file_link_uses_target_path_for_hash_range() {
}
#[test]
fn url_link_shows_destination() {
let text = render_markdown_text("[docs](https://example.com/docs)");
fn url_link_renders_clickable_label_with_destination() {
let text = render_markdown_text_interactive("[docs](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
"docs".into(),
osc8_hyperlink("https://example.com/docs", "docs")
.blue()
.underlined(),
" (".into(),
"https://example.com/docs".cyan().underlined(),
"https://example.com/docs".blue().underlined(),
")".into(),
]));
assert_eq!(text, expected);
}
#[test]
fn url_link_with_inline_code_is_clickable() {
let text = render_markdown_text_interactive("[`docs`](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
osc8_hyperlink("https://example.com/docs", "docs")
.blue()
.underlined(),
" (".into(),
"https://example.com/docs".blue().underlined(),
")".into(),
]));
assert_eq!(text, expected);
}
#[test]
fn url_link_without_osc8_still_shows_visible_destination() {
let text = render_markdown_text("[docs](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
"docs".blue().underlined(),
" (".into(),
"https://example.com/docs".blue().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 +841,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 +865,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 +1209,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

@@ -1,5 +1,6 @@
use crate::key_hint;
use crate::markdown_render::render_markdown_text_with_width;
use crate::markdown_render::MarkdownRenderOptions;
use crate::markdown_render::render_markdown_text_with_width_and_options;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
@@ -321,7 +322,11 @@ impl ModelMigrationScreen {
let horizontal_inset = 2;
let content_width = area_width.saturating_sub(horizontal_inset);
let wrap_width = (content_width > 0).then_some(content_width as usize);
let rendered = render_markdown_text_with_width(markdown, wrap_width);
let rendered = render_markdown_text_with_width_and_options(
markdown,
wrap_width,
MarkdownRenderOptions::INTERACTIVE,
);
for line in rendered.lines {
column.push(
Paragraph::new(line)

View File

@@ -37,6 +37,8 @@ use std::sync::RwLock;
use crate::LoginStatus;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::osc8::osc8_hyperlink;
use crate::osc8::sanitize_osc8_destination;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
@@ -48,13 +50,7 @@ use crate::tui::FrameRequester;
/// row boundary, which breaks normal terminal URL detection for long URLs that
/// wrap across multiple rows.
pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
// Sanitize: strip any characters that could break out of the OSC 8
// sequence (ESC or BEL) to prevent terminal escape injection from a
// malformed or compromised upstream URL.
let safe_url: String = url
.chars()
.filter(|&c| c != '\x1B' && c != '\x07')
.collect();
let safe_url = sanitize_osc8_destination(url);
if safe_url.is_empty() {
return;
}
@@ -70,7 +66,7 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
if sym.trim().is_empty() {
continue;
}
cell.set_symbol(&format!("\x1B]8;;{safe_url}\x07{sym}\x1B]8;;\x07"));
cell.set_symbol(&osc8_hyperlink(&safe_url, &sym));
}
}
}

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

@@ -0,0 +1,106 @@
#[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}";
/// Strip bytes that could terminate or escape an OSC 8 destination early.
pub(crate) fn sanitize_osc8_destination(destination: &str) -> String {
destination
.chars()
.filter(|&c| c != '\x1B' && c != '\x07')
.collect()
}
/// Wrap visible text in a single OSC 8 hyperlink span.
pub(crate) fn osc8_hyperlink(destination: &str, text: &str) -> String {
let safe_destination = sanitize_osc8_destination(destination);
if safe_destination.is_empty() {
return text.to_string();
}
format!("{OSC8_OPEN_PREFIX}{safe_destination}\u{7}{text}{OSC8_CLOSE}")
}
/// Parse a string that consists entirely of one OSC 8 hyperlink span.
pub(crate) fn parse_osc8_hyperlink(text: &str) -> Option<ParsedOsc8<'_>> {
let after_open = text.strip_prefix(OSC8_OPEN_PREFIX)?;
let destination_end = after_open.find('\x07')?;
let destination = &after_open[..destination_end];
let after_destination = &after_open[destination_end + 1..];
let label = after_destination.strip_suffix(OSC8_CLOSE)?;
Some(ParsedOsc8 {
destination,
text: label,
})
}
/// Strip OSC 8 wrappers while preserving visible label text.
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(destination_end) = after_open.find('\x07') else {
rendered.push_str(&remaining[open_pos..]);
return rendered;
};
let after_destination = &after_open[destination_end + 1..];
let Some(close_pos) = after_destination.find(OSC8_CLOSE) else {
rendered.push_str(&remaining[open_pos..]);
return rendered;
};
rendered.push_str(&after_destination[..close_pos]);
remaining = &after_destination[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 parse_rejects_mixed_text() {
let wrapped = format!("See {}", osc8_hyperlink("https://example.com", "docs"));
assert_eq!(parse_osc8_hyperlink(&wrapped), None);
}
#[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 strips_multiple_wrapped_segments() {
let wrapped = format!(
"{} {}",
osc8_hyperlink("https://example.com/docs", "docs"),
osc8_hyperlink("https://example.com/api", "api")
);
assert_eq!(strip_osc8_hyperlinks(&wrapped), "docs api");
}
#[test]
fn malformed_sequences_are_preserved() {
let malformed = "\u{1b}]8;;https://example.com\u{7}docs";
assert_eq!(strip_osc8_hyperlinks(malformed), malformed);
}
}

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,39 @@ 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::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;
@@ -1000,6 +1040,34 @@ mod tests {
assert_eq!(concat_line(&out[1]), "world");
}
#[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"),
crate::osc8::ParsedOsc8 {
destination: url,
text: "abcde",
}
);
assert_eq!(
parse_osc8_hyperlink(&second).expect("second line should stay hyperlinked"),
crate::osc8::ParsedOsc8 {
destination: url,
text: "fghij",
}
);
}
#[test]
fn indent_consumes_width_leaving_one_char_space() {
let opts = RtOptions::new(4)

View File

@@ -699,6 +699,7 @@ impl ModifierDiff {
#[cfg(test)]
mod tests {
use super::*;
use crate::osc8::osc8_hyperlink;
use pretty_assertions::assert_eq;
use ratatui::layout::Rect;
use ratatui::style::Style;
@@ -748,4 +749,12 @@ mod tests {
"expected clear-to-end to start after the remaining wide char; commands: {commands:?}"
);
}
#[test]
fn display_width_ignores_osc8_payload() {
assert_eq!(
display_width(&osc8_hyperlink("https://example.com/docs", "docs")),
4
);
}
}

View File

@@ -333,6 +333,7 @@ where
mod tests {
use super::*;
use crate::markdown_render::render_markdown_text;
use crate::osc8::osc8_hyperlink;
use crate::test_backend::VT100Backend;
use ratatui::layout::Rect;
use ratatui::style::Color;
@@ -365,6 +366,20 @@ mod tests {
);
}
#[test]
fn write_spans_preserves_osc8_wrapped_content() {
use ratatui::style::Stylize;
let wrapped = osc8_hyperlink("https://example.com/docs", "docs");
let spans = [wrapped.cyan().underlined()];
let mut actual: Vec<u8> = Vec::new();
write_spans(&mut actual, spans.iter()).unwrap();
let actual = String::from_utf8(actual).unwrap();
assert!(actual.contains("\u{1b}]8;;https://example.com/docs\u{7}docs\u{1b}]8;;\u{7}"));
}
#[test]
fn vt100_blockquote_line_emits_green_fg() {
// Set up a small off-screen terminal

View File

@@ -109,6 +109,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

@@ -11,10 +11,11 @@ pub(crate) fn append_markdown(
cwd: Option<&Path>,
lines: &mut Vec<Line<'static>>,
) {
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd(
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd_and_options(
markdown_source,
width,
cwd,
crate::markdown_render::MarkdownRenderOptions::INTERACTIVE,
);
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
}

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;
@@ -46,6 +47,15 @@ struct MarkdownStyles {
blockquote: Style,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct MarkdownRenderOptions {
pub(crate) emit_osc8: bool,
}
impl MarkdownRenderOptions {
pub(crate) const INTERACTIVE: Self = Self { emit_osc8: true };
}
impl Default for MarkdownStyles {
fn default() -> Self {
use ratatui::style::Stylize;
@@ -63,7 +73,7 @@ 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: Style::new().blue().underlined(),
blockquote: Style::new().green(),
}
}
@@ -87,13 +97,29 @@ impl IndentContext {
}
pub fn render_markdown_text(input: &str) -> Text<'static> {
render_markdown_text_with_width(input, None)
render_markdown_text_with_options(input, MarkdownRenderOptions::default())
}
pub(crate) fn render_markdown_text_with_options(
input: &str,
options: MarkdownRenderOptions,
) -> Text<'static> {
render_markdown_text_with_width_and_options(input, None, options)
}
/// Render markdown using the current process working directory for local file-link display.
#[cfg(test)]
pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> {
render_markdown_text_with_width_and_options(input, width, MarkdownRenderOptions::default())
}
pub(crate) fn render_markdown_text_with_width_and_options(
input: &str,
width: Option<usize>,
options: MarkdownRenderOptions,
) -> Text<'static> {
let cwd = std::env::current_dir().ok();
render_markdown_text_with_width_and_cwd(input, width, cwd.as_deref())
render_markdown_text_with_width_and_cwd_and_options(input, width, cwd.as_deref(), options)
}
/// Render markdown with an explicit working directory for local file links.
@@ -101,15 +127,30 @@ pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>)
/// The `cwd` parameter controls how absolute local targets are shortened before display. Passing
/// the session cwd keeps full renders, history cells, and streamed deltas visually aligned even
/// when rendering happens away from the process cwd.
#[cfg(test)]
pub(crate) fn render_markdown_text_with_width_and_cwd(
input: &str,
width: Option<usize>,
cwd: Option<&Path>,
) -> Text<'static> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options);
let mut w = Writer::new(parser, width, cwd);
render_markdown_text_with_width_and_cwd_and_options(
input,
width,
cwd,
MarkdownRenderOptions::default(),
)
}
pub(crate) fn render_markdown_text_with_width_and_cwd_and_options(
input: &str,
width: Option<usize>,
cwd: Option<&Path>,
options: MarkdownRenderOptions,
) -> Text<'static> {
let mut parser_options = Options::empty();
parser_options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, parser_options);
let mut w = Writer::new(parser, width, cwd, options);
w.run();
w.text
}
@@ -170,13 +211,19 @@ where
current_subsequent_indent: Vec<Span<'static>>,
current_line_style: Style,
current_line_in_code_block: bool,
render_options: MarkdownRenderOptions,
}
impl<'a, I> Writer<'a, I>
where
I: Iterator<Item = Event<'a>>,
{
fn new(iter: I, wrap_width: Option<usize>, cwd: Option<&Path>) -> Self {
fn new(
iter: I,
wrap_width: Option<usize>,
cwd: Option<&Path>,
render_options: MarkdownRenderOptions,
) -> Self {
Self {
iter,
text: Text::default(),
@@ -200,6 +247,7 @@ where
current_subsequent_indent: Vec::new(),
current_line_style: Style::default(),
current_line_in_code_block: false,
render_options,
}
}
@@ -272,7 +320,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);
}
}
Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
| Tag::Table(_)
@@ -404,7 +457,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(),
@@ -423,7 +476,18 @@ 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)
.patch(
self.remote_link_destination()
.map(|_| self.styles.link)
.unwrap_or_default(),
);
let span = Span::from(self.maybe_wrap_remote_link_text(code.as_ref())).style(style);
self.push_span(span);
}
@@ -442,7 +506,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;
}
@@ -590,6 +654,7 @@ 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(")".into());
@@ -618,6 +683,26 @@ where
.is_some()
}
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| {
if self.render_options.emit_osc8 {
osc8_hyperlink(destination, text)
} else {
text.to_string()
}
},
)
}
fn flush_current_line(&mut self) {
if let Some(line) = self.current_line_content.take() {
let style = self.current_line_style;

View File

@@ -7,10 +7,18 @@ use std::path::Path;
use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
use crate::markdown_render::MarkdownRenderOptions;
use crate::markdown_render::render_markdown_text;
use crate::markdown_render::render_markdown_text_with_options;
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_interactive(input: &str) -> Text<'static> {
render_markdown_text_with_options(input, MarkdownRenderOptions::INTERACTIVE)
}
fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
render_markdown_text_with_width_and_cwd(input, None, Some(cwd))
}
@@ -651,9 +659,9 @@ fn strong_emphasis() {
fn link() {
let text = render_markdown_text("[Link](https://example.com)");
let expected = Text::from(Line::from_iter([
"Link".into(),
"Link".blue().underlined(),
" (".into(),
"https://example.com".cyan().underlined(),
"https://example.com".blue().underlined(),
")".into(),
]));
assert_eq!(text, expected);
@@ -776,17 +784,53 @@ fn file_link_uses_target_path_for_hash_range() {
}
#[test]
fn url_link_shows_destination() {
let text = render_markdown_text("[docs](https://example.com/docs)");
fn url_link_renders_clickable_label_with_destination() {
let text = render_markdown_text_interactive("[docs](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
"docs".into(),
osc8_hyperlink("https://example.com/docs", "docs")
.blue()
.underlined(),
" (".into(),
"https://example.com/docs".cyan().underlined(),
"https://example.com/docs".blue().underlined(),
")".into(),
]));
assert_eq!(text, expected);
}
#[test]
fn url_link_with_inline_code_is_clickable() {
let text = render_markdown_text_interactive("[`docs`](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
osc8_hyperlink("https://example.com/docs", "docs")
.blue()
.underlined(),
" (".into(),
"https://example.com/docs".blue().underlined(),
")".into(),
]));
assert_eq!(text, expected);
}
#[test]
fn url_link_without_osc8_still_shows_visible_destination() {
let text = render_markdown_text("[docs](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
"docs".blue().underlined(),
" (".into(),
"https://example.com/docs".blue().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 +841,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 +865,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 +1209,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

@@ -1,5 +1,6 @@
use crate::key_hint;
use crate::markdown_render::render_markdown_text_with_width;
use crate::markdown_render::MarkdownRenderOptions;
use crate::markdown_render::render_markdown_text_with_width_and_options;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
@@ -321,7 +322,11 @@ impl ModelMigrationScreen {
let horizontal_inset = 2;
let content_width = area_width.saturating_sub(horizontal_inset);
let wrap_width = (content_width > 0).then_some(content_width as usize);
let rendered = render_markdown_text_with_width(markdown, wrap_width);
let rendered = render_markdown_text_with_width_and_options(
markdown,
wrap_width,
MarkdownRenderOptions::INTERACTIVE,
);
for line in rendered.lines {
column.push(
Paragraph::new(line)

View File

@@ -40,6 +40,8 @@ use uuid::Uuid;
use crate::LoginStatus;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::osc8::osc8_hyperlink;
use crate::osc8::sanitize_osc8_destination;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
@@ -54,10 +56,7 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
// Sanitize: strip any characters that could break out of the OSC 8
// sequence (ESC or BEL) to prevent terminal escape injection from a
// malformed or compromised upstream URL.
let safe_url: String = url
.chars()
.filter(|&c| c != '\x1B' && c != '\x07')
.collect();
let safe_url = sanitize_osc8_destination(url);
if safe_url.is_empty() {
return;
}
@@ -73,7 +72,7 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
if sym.trim().is_empty() {
continue;
}
cell.set_symbol(&format!("\x1B]8;;{safe_url}\x07{sym}\x1B]8;;\x07"));
cell.set_symbol(&osc8_hyperlink(&safe_url, &sym));
}
}
}

View File

@@ -0,0 +1,106 @@
#[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}";
/// Strip bytes that could terminate or escape an OSC 8 destination early.
pub(crate) fn sanitize_osc8_destination(destination: &str) -> String {
destination
.chars()
.filter(|&c| c != '\x1B' && c != '\x07')
.collect()
}
/// Wrap visible text in a single OSC 8 hyperlink span.
pub(crate) fn osc8_hyperlink(destination: &str, text: &str) -> String {
let safe_destination = sanitize_osc8_destination(destination);
if safe_destination.is_empty() {
return text.to_string();
}
format!("{OSC8_OPEN_PREFIX}{safe_destination}\u{7}{text}{OSC8_CLOSE}")
}
/// Parse a string that consists entirely of one OSC 8 hyperlink span.
pub(crate) fn parse_osc8_hyperlink(text: &str) -> Option<ParsedOsc8<'_>> {
let after_open = text.strip_prefix(OSC8_OPEN_PREFIX)?;
let destination_end = after_open.find('\x07')?;
let destination = &after_open[..destination_end];
let after_destination = &after_open[destination_end + 1..];
let label = after_destination.strip_suffix(OSC8_CLOSE)?;
Some(ParsedOsc8 {
destination,
text: label,
})
}
/// Strip OSC 8 wrappers while preserving visible label text.
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(destination_end) = after_open.find('\x07') else {
rendered.push_str(&remaining[open_pos..]);
return rendered;
};
let after_destination = &after_open[destination_end + 1..];
let Some(close_pos) = after_destination.find(OSC8_CLOSE) else {
rendered.push_str(&remaining[open_pos..]);
return rendered;
};
rendered.push_str(&after_destination[..close_pos]);
remaining = &after_destination[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 parse_rejects_mixed_text() {
let wrapped = format!("See {}", osc8_hyperlink("https://example.com", "docs"));
assert_eq!(parse_osc8_hyperlink(&wrapped), None);
}
#[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 strips_multiple_wrapped_segments() {
let wrapped = format!(
"{} {}",
osc8_hyperlink("https://example.com/docs", "docs"),
osc8_hyperlink("https://example.com/api", "api")
);
assert_eq!(strip_osc8_hyperlinks(&wrapped), "docs api");
}
#[test]
fn malformed_sequences_are_preserved() {
let malformed = "\u{1b}]8;;https://example.com\u{7}docs";
assert_eq!(strip_osc8_hyperlinks(malformed), malformed);
}
}

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,39 @@ 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::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;
@@ -1000,6 +1040,34 @@ mod tests {
assert_eq!(concat_line(&out[1]), "world");
}
#[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"),
crate::osc8::ParsedOsc8 {
destination: url,
text: "abcde",
}
);
assert_eq!(
parse_osc8_hyperlink(&second).expect("second line should stay hyperlinked"),
crate::osc8::ParsedOsc8 {
destination: url,
text: "fghij",
}
);
}
#[test]
fn indent_consumes_width_leaving_one_char_space() {
let opts = RtOptions::new(4)