mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
Compare commits
3 Commits
pr17694
...
starr/tui-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
154d8ca3ed | ||
|
|
777e79c770 | ||
|
|
b58103fa3d |
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
84
codex-rs/tui/src/osc8.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
187
codex-rs/tui/src/terminal_wrappers.rs
Normal file
187
codex-rs/tui/src/terminal_wrappers.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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_app_server/src/osc8.rs
Normal file
84
codex-rs/tui_app_server/src/osc8.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
187
codex-rs/tui_app_server/src/terminal_wrappers.rs
Normal file
187
codex-rs/tui_app_server/src/terminal_wrappers.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user