mirror of
https://github.com/openai/codex.git
synced 2026-04-27 01:41:01 +03:00
Compare commits
2 Commits
codex-debu
...
starr/tui-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a5a3cef8c | ||
|
|
69920b84f9 |
@@ -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}]"));
|
||||
}
|
||||
}
|
||||
|
||||
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}]"));
|
||||
}
|
||||
}
|
||||
|
||||
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