Compare commits

...

1 Commits

Author SHA1 Message Date
starr-openai
25ef3726e9 Add OSC-8 links to TUI markdown rendering
Render remote markdown links as visible labels with visible destinations while attaching OSC-8 wrappers to the cells and scrollback bytes that represent the link. Share terminal-wrapper parsing between width measurement, onboarding links, and history insertion, and cover wrapped links, wide glyph cells, blockquotes, /mcp docs, and control-character sanitization.

Co-authored-by: Codex <noreply@openai.com>
2026-04-07 20:18:48 -07:00
11 changed files with 1231 additions and 102 deletions

View File

@@ -0,0 +1,271 @@
use crate::osc8::osc8_hyperlink;
use ratatui::buffer::Buffer;
use ratatui::layout::Position;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use unicode_width::UnicodeWidthStr;
#[derive(Clone)]
struct CellSnapshot {
position: Position,
symbol: String,
fg: Color,
bg: Color,
modifier: Modifier,
skip: bool,
}
pub(crate) fn mark_markdown_links(buf: &mut Buffer, area: Rect) {
let cells = visible_cells(buf, area);
let mut start = 0;
while start < cells.len() {
let Some(link) = MarkdownLinkCells::parse(&cells, start) else {
start += 1;
continue;
};
for cell in link.label.iter().chain(link.destination_cells.iter()) {
let cell = &mut buf[(cell.position.x, cell.position.y)];
cell.set_symbol(&osc8_hyperlink(&link.destination, cell.symbol()));
}
start = link.end;
}
}
fn visible_cells(buf: &Buffer, area: Rect) -> Vec<CellSnapshot> {
let mut cells = Vec::new();
for y in area.top()..area.bottom() {
let row = (area.left()..area.right())
.map(|x| {
let cell = &buf[(x, y)];
CellSnapshot {
position: Position::new(x, y),
symbol: cell.symbol().to_string(),
fg: cell.fg,
bg: cell.bg,
modifier: cell.modifier,
skip: cell.skip,
}
})
.collect::<Vec<_>>();
let row_content_end = row
.iter()
.rposition(|cell| !is_plain_blank_cell(cell))
.map(|index| index + 1)
.unwrap_or(0);
cells.extend(
row.into_iter()
.take(row_content_end)
.filter(|cell| !cell.skip),
);
}
cells
}
struct MarkdownLinkCells<'a> {
label: &'a [CellSnapshot],
destination_cells: &'a [CellSnapshot],
destination: String,
end: usize,
}
impl<'a> MarkdownLinkCells<'a> {
fn parse(cells: &'a [CellSnapshot], start: usize) -> Option<Self> {
if !is_link_label_start(cells.get(start)?) {
return None;
}
let mut separator = start;
while separator < cells.len() && is_link_label_continue(&cells[separator]) {
separator += 1;
}
let destination_start = match (symbol_at(cells, separator), symbol_at(cells, separator + 1))
{
(Some(" "), Some("(")) => separator + 2,
(Some("("), _) => separator + 1,
_ => return None,
};
let mut close = destination_start;
while close < cells.len() && is_link_destination_cell(&cells[close]) {
close += 1;
}
if close == destination_start || symbol_at(cells, close)? != ")" {
return None;
}
let destination = cells[destination_start..close]
.iter()
.map(|cell| cell.symbol.as_str())
.collect::<String>();
if !is_remote_url(&destination) {
return None;
}
Some(Self {
label: &cells[start..separator],
destination_cells: &cells[destination_start..close],
destination,
end: close + 1,
})
}
}
fn is_link_label_start(cell: &CellSnapshot) -> bool {
is_link_label_continue(cell) && !cell.symbol.trim().is_empty()
}
fn is_link_label_continue(cell: &CellSnapshot) -> bool {
is_underlined_cell(cell) && cell.fg == Color::Cyan && cell.symbol.width() > 0
}
fn is_underlined_cell(cell: &CellSnapshot) -> bool {
cell.modifier.contains(Modifier::UNDERLINED)
}
fn is_link_destination_cell(cell: &CellSnapshot) -> bool {
is_link_label_start(cell)
}
fn is_remote_url(text: &str) -> bool {
text.starts_with("http://") || text.starts_with("https://")
}
fn symbol_at(cells: &[CellSnapshot], index: usize) -> Option<&str> {
cells.get(index).map(|cell| cell.symbol.as_str())
}
fn is_plain_blank_cell(cell: &CellSnapshot) -> bool {
cell.symbol == " "
&& cell.fg == Color::Reset
&& cell.bg == Color::Reset
&& cell.modifier.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown_render::render_markdown_text;
use crate::osc8::parse_osc8_hyperlink;
use pretty_assertions::assert_eq;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
#[test]
fn marks_rendered_remote_markdown_link_as_osc8_label_and_destination() {
let area = Rect::new(0, 0, 80, 2);
let mut buf = Buffer::empty(area);
Paragraph::new(render_markdown_text(
"[OpenAI Platform](https://openai.com)",
))
.render(area, &mut buf);
mark_markdown_links(&mut buf, area);
assert_eq!(
collect_osc8_text(&buf, area, "https://openai.com"),
"OpenAI Platformhttps://openai.com"
);
}
#[test]
fn marks_remote_markdown_link_when_separator_wraps_to_next_row() {
let area = Rect::new(0, 0, 14, 4);
let mut buf = Buffer::empty(area);
Paragraph::new(render_markdown_text(
"[OpenAI Platform](https://openai.com)",
))
.wrap(ratatui::widgets::Wrap { trim: false })
.render(area, &mut buf);
mark_markdown_links(&mut buf, area);
assert_eq!(
collect_osc8_text(&buf, area, "https://openai.com"),
"OpenAIPlatformhttps://openai.com"
);
}
#[test]
fn marks_rendered_markdown_link_inside_blockquote() {
let area = Rect::new(0, 0, 80, 3);
let mut buf = Buffer::empty(area);
Paragraph::new(render_markdown_text("> [OpenAI](https://openai.com)"))
.wrap(ratatui::widgets::Wrap { trim: false })
.render(area, &mut buf);
mark_markdown_links(&mut buf, area);
assert_eq!(
collect_osc8_text(&buf, area, "https://openai.com"),
"OpenAIhttps://openai.com"
);
}
#[test]
fn ignores_blockquote_style_without_markdown_link_destination() {
let area = Rect::new(0, 0, 80, 3);
let mut buf = Buffer::empty(area);
Paragraph::new(render_markdown_text("> underlined but not a link"))
.wrap(ratatui::widgets::Wrap { trim: false })
.render(area, &mut buf);
mark_markdown_links(&mut buf, area);
assert_eq!(collect_osc8_text(&buf, area, "https://openai.com"), "");
}
#[test]
fn marks_label_owner_cells_but_preserves_wide_glyph_skip_cell() {
let area = Rect::new(0, 0, 80, 2);
let mut buf = Buffer::empty(area);
let link_style = Style::new().cyan().underlined();
buf[(0, 0)].set_symbol("").set_style(link_style);
buf[(1, 0)].set_symbol(" ").set_skip(true);
buf[(2, 0)].set_symbol("").set_style(link_style);
buf[(4, 0)].set_symbol("(");
"https://example.com"
.chars()
.enumerate()
.for_each(|(index, ch)| {
buf[(5 + index as u16, 0)]
.set_symbol(ch.encode_utf8(&mut [0; 4]))
.set_style(link_style);
});
buf[(24, 0)].set_symbol(")");
let skip_position = Position::new(1, 0);
assert!(buf[(skip_position.x, skip_position.y)].skip);
mark_markdown_links(&mut buf, area);
assert!(
parse_osc8_hyperlink(buf[(0, 0)].symbol()).is_some(),
"wide glyph owner cell should be OSC-8 wrapped"
);
assert!(
parse_osc8_hyperlink(buf[(2, 0)].symbol()).is_some(),
"wide glyph second owner cell should be OSC-8 wrapped"
);
assert!(
buf[(skip_position.x, skip_position.y)].skip,
"wide-glyph continuation cell must stay skipped"
);
}
fn collect_osc8_text(buf: &Buffer, area: Rect, destination: &str) -> String {
let mut text = String::new();
for position in area.positions() {
let symbol = buf[(position.x, position.y)].symbol();
if let Some(parsed) = parse_osc8_hyperlink(symbol)
&& parsed.destination == destination
{
text.push_str(parsed.text);
}
}
text
}
}

View File

@@ -43,39 +43,19 @@ use ratatui::layout::Size;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
/// Returns the display width of a cell symbol, ignoring OSC escape sequences.
use crate::terminal_wrappers::visible_width;
/// Returns the display width of a cell symbol, ignoring recognized zero-width wrappers.
///
/// OSC sequences (e.g. OSC 8 hyperlinks: `\x1B]8;;URL\x07`) are terminal
/// control sequences that don't consume display columns. The standard
/// OSC-8 sequences in login URL cells are terminal control sequences that don't
/// consume display columns. The standard
/// `UnicodeWidthStr::width()` method incorrectly counts the printable
/// characters inside OSC payloads (like `]`, `8`, `;`, and URL characters).
/// 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)]
@@ -711,6 +691,22 @@ mod tests {
use ratatui::layout::Rect;
use ratatui::style::Style;
#[test]
fn display_width_ignores_bel_terminated_osc8_wrapper() {
assert_eq!(
display_width("\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}"),
4
);
}
#[test]
fn display_width_ignores_st_terminated_osc8_wrapper() {
assert_eq!(
display_width("\u{1b}]8;;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\"),
4
);
}
#[test]
fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() {
let area = Rect::new(0, 0, 3, 2);

View File

@@ -10,6 +10,7 @@
//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the
//! rendered transcript output can change.
use crate::buffer_hyperlinks::mark_markdown_links;
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
@@ -21,6 +22,7 @@ use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::live_wrap::take_prefix_by_width;
use crate::markdown::append_markdown;
use crate::osc8::osc8_hyperlink;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
@@ -176,6 +178,10 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
fn transcript_animation_tick(&self) -> Option<u64> {
None
}
fn should_mark_markdown_links(&self) -> bool {
false
}
}
impl Renderable for Box<dyn HistoryCell> {
@@ -191,6 +197,9 @@ impl Renderable for Box<dyn HistoryCell> {
u16::try_from(overflow).unwrap_or(u16::MAX)
};
paragraph.scroll((y, 0)).render(area, buf);
if self.should_mark_markdown_links() {
mark_markdown_links(buf, area);
}
}
fn desired_height(&self, width: u16) -> u16 {
HistoryCell::desired_height(self.as_ref(), width)
@@ -445,6 +454,10 @@ impl HistoryCell for ReasoningSummaryCell {
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.lines(width)
}
fn should_mark_markdown_links(&self) -> bool {
true
}
}
#[derive(Debug)]
@@ -479,6 +492,10 @@ impl HistoryCell for AgentMessageCell {
fn is_stream_continuation(&self) -> bool {
!self.is_first_line
}
fn should_mark_markdown_links(&self) -> bool {
true
}
}
#[derive(Debug)]
@@ -1107,6 +1124,10 @@ impl HistoryCell for TooltipHistoryCell {
prefix_lines(lines, indent.into(), indent.into())
}
fn should_mark_markdown_links(&self) -> bool {
true
}
}
#[derive(Debug)]
@@ -1124,6 +1145,10 @@ impl HistoryCell for SessionInfoCell {
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.0.transcript_lines(width)
}
fn should_mark_markdown_links(&self) -> bool {
self.0.should_mark_markdown_links()
}
}
pub(crate) fn new_session_info(
@@ -1401,6 +1426,12 @@ impl HistoryCell for CompositeHistoryCell {
}
out
}
fn should_mark_markdown_links(&self) -> bool {
self.parts
.iter()
.any(|part| part.should_mark_markdown_links())
}
}
#[derive(Debug)]
@@ -1794,8 +1825,7 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
" • No MCP servers configured.".italic().into(),
Line::from(vec![
" See the ".into(),
"\u{1b}]8;;https://developers.openai.com/codex/mcp\u{7}MCP docs\u{1b}]8;;\u{7}"
.underlined(),
osc8_hyperlink("https://developers.openai.com/codex/mcp", "MCP docs").underlined(),
" to configure them.".into(),
])
.style(Style::default().add_modifier(Modifier::DIM)),
@@ -2419,6 +2449,10 @@ impl HistoryCell for ProposedPlanCell {
lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style)));
lines
}
fn should_mark_markdown_links(&self) -> bool {
true
}
}
impl HistoryCell for ProposedPlanStreamCell {
@@ -2429,6 +2463,10 @@ impl HistoryCell for ProposedPlanStreamCell {
fn is_stream_continuation(&self) -> bool {
self.is_stream_continuation
}
fn should_mark_markdown_links(&self) -> bool {
true
}
}
#[derive(Debug)]
@@ -2796,6 +2834,7 @@ mod tests {
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::Tool;
use codex_protocol::protocol::ExecCommandSource;
use ratatui::buffer::Buffer;
use rmcp::model::Content;
const SMALL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
@@ -2912,6 +2951,15 @@ mod tests {
render_lines(&cell.transcript_lines(u16::MAX))
}
fn render_cell_symbols(cell: &impl Renderable, width: u16, height: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
cell.render(area, &mut buf);
area.positions()
.map(|position| buf[(position.x, position.y)].symbol())
.collect()
}
fn image_block(data: &str) -> serde_json::Value {
serde_json::to_value(Content::image(data.to_string(), "image/png"))
.expect("image content should serialize")
@@ -4605,6 +4653,37 @@ mod tests {
);
}
#[test]
fn plain_history_cell_does_not_synthesize_osc8_from_link_shaped_styles() {
let cell: Box<dyn HistoryCell> = Box::new(PlainHistoryCell::new(vec![Line::from(vec![
"tool output".underlined(),
" (".into(),
"https://example.com/tool".cyan().underlined(),
")".into(),
])]));
let rendered = render_cell_symbols(&cell, /*width*/ 80, /*height*/ 1);
assert!(
!rendered.contains("\u{1b}]8;;"),
"plain history/tool-style output must not be promoted to terminal hyperlinks: {rendered:?}"
);
}
#[test]
fn empty_mcp_output_keeps_clickable_docs_link() {
let cell: Box<dyn HistoryCell> = Box::new(empty_mcp_output());
let rendered = render_cell_symbols(&cell, /*width*/ 100, /*height*/ 5);
assert!(
rendered.contains(
"\u{1b}]8;;https://developers.openai.com/codex/mcp\u{1b}\\MCP docs\u{1b}]8;;\u{1b}\\"
),
"MCP docs help should remain a real OSC-8 hyperlink: {rendered:?}"
);
}
#[test]
fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() {
let cell =

View File

@@ -2,6 +2,7 @@ use std::fmt;
use std::io;
use std::io::Write;
use crate::osc8::osc8_hyperlink;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use crate::wrapping::line_contains_url_like;
@@ -26,6 +27,7 @@ use ratatui::layout::Size;
use ratatui::prelude::Backend;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -98,23 +100,9 @@ where
// - Non-URL lines also flow through adaptive wrapping; behavior is
// equivalent to standard wrapping when no URL is present.
let wrap_width = area.width.max(1) as usize;
let mut wrapped = Vec::new();
let mut wrapped_rows = 0usize;
for line in &lines {
let line_wrapped =
if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) {
vec![line.clone()]
} else {
adaptive_wrap_line(line, RtOptions::new(wrap_width))
};
wrapped_rows += line_wrapped
.iter()
.map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width))
.sum::<usize>();
wrapped.extend(line_wrapped);
}
let wrapped_lines = wrapped_rows as u16;
let prepared = prepare_history_lines(&lines, wrap_width);
let wrapped = prepared.lines;
let wrapped_lines = prepared.rows;
if matches!(mode, InsertHistoryMode::Zellij) {
let space_below = screen_size.height.saturating_sub(area.bottom());
@@ -141,7 +129,7 @@ where
if i > 0 {
queue!(writer, Print("\r\n"))?;
}
write_history_line(writer, line, wrap_width)?;
write_prepared_history_line(writer, line, wrap_width)?;
}
} else {
let cursor_top = if area.bottom() < screen_size.height {
@@ -187,7 +175,7 @@ where
for line in &wrapped {
queue!(writer, Print("\r\n"))?;
write_history_line(writer, line, wrap_width)?;
write_prepared_history_line(writer, line, wrap_width)?;
}
queue!(writer, ResetScrollRegion)?;
@@ -207,10 +195,56 @@ where
Ok(())
}
struct PreparedHistoryLines<'a> {
lines: Vec<PreparedHistoryLine<'a>>,
rows: u16,
}
struct PreparedHistoryLine<'a> {
line: Line<'a>,
span_hyperlinks: Vec<Option<String>>,
}
fn prepare_history_lines<'a>(lines: &'a [Line<'a>], wrap_width: usize) -> PreparedHistoryLines<'a> {
let mut prepared_lines = Vec::new();
let mut rows = 0usize;
for line in lines {
let wrapped =
if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) {
vec![line.clone()]
} else {
adaptive_wrap_line(line, RtOptions::new(wrap_width))
};
rows += wrapped
.iter()
.map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width))
.sum::<usize>();
prepared_lines.extend(mark_markdown_link_spans(wrapped));
}
PreparedHistoryLines {
lines: prepared_lines,
rows: rows as u16,
}
}
/// Render a single wrapped history line: clear continuation rows for wide lines,
/// set foreground/background colors, and write styled spans. Caller is responsible
/// for cursor positioning and any leading `\r\n`.
#[cfg(test)]
fn write_history_line<W: Write>(writer: &mut W, line: &Line, wrap_width: usize) -> io::Result<()> {
let prepared = mark_markdown_link_spans(vec![line.clone()]);
let prepared = prepared.first().expect("one prepared line");
write_prepared_history_line(writer, prepared, wrap_width)
}
fn write_prepared_history_line<W: Write>(
writer: &mut W,
prepared: &PreparedHistoryLine<'_>,
wrap_width: usize,
) -> io::Result<()> {
let line = &prepared.line;
let physical_rows = line.width().max(1).div_ceil(wrap_width) as u16;
if physical_rows > 1 {
queue!(writer, SavePosition)?;
@@ -244,7 +278,8 @@ fn write_history_line<W: Write>(writer: &mut W, line: &Line, wrap_width: usize)
content: s.content.clone(),
})
.collect();
write_spans(writer, merged_spans.iter())
let merged_span_refs = merged_spans.iter().collect::<Vec<_>>();
write_spans_with_hyperlinks(writer, &merged_span_refs, &prepared.span_hyperlinks)
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -354,37 +389,34 @@ impl ModifierDiff {
}
}
#[cfg(test)]
fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()>
where
I: IntoIterator<Item = &'a Span<'a>>,
{
let spans = content.into_iter().collect::<Vec<_>>();
let hyperlinks = vec![None; spans.len()];
write_spans_with_hyperlinks(&mut writer, &spans, &hyperlinks)
}
fn write_spans_with_hyperlinks(
mut writer: &mut impl Write,
spans: &[&Span<'_>],
hyperlinks: &[Option<String>],
) -> io::Result<()> {
let mut fg = Color::Reset;
let mut bg = Color::Reset;
let mut last_modifier = Modifier::empty();
for span in content {
let mut modifier = Modifier::empty();
modifier.insert(span.style.add_modifier);
modifier.remove(span.style.sub_modifier);
if modifier != last_modifier {
let diff = ModifierDiff {
from: last_modifier,
to: modifier,
};
diff.queue(&mut writer)?;
last_modifier = modifier;
}
let next_fg = span.style.fg.unwrap_or(Color::Reset);
let next_bg = span.style.bg.unwrap_or(Color::Reset);
if next_fg != fg || next_bg != bg {
queue!(
writer,
SetColors(Colors::new(next_fg.into(), next_bg.into()))
)?;
fg = next_fg;
bg = next_bg;
}
queue!(writer, Print(span.content.clone()))?;
for (span, hyperlink) in spans.iter().zip(hyperlinks) {
write_span(
&mut writer,
span,
hyperlink.as_deref(),
&mut fg,
&mut bg,
&mut last_modifier,
)?;
}
queue!(
@@ -395,6 +427,176 @@ where
)
}
fn write_span(
writer: &mut impl Write,
span: &Span<'_>,
hyperlink_destination: Option<&str>,
fg: &mut Color,
bg: &mut Color,
last_modifier: &mut Modifier,
) -> io::Result<()> {
let mut modifier = Modifier::empty();
modifier.insert(span.style.add_modifier);
modifier.remove(span.style.sub_modifier);
if modifier != *last_modifier {
let diff = ModifierDiff {
from: *last_modifier,
to: modifier,
};
diff.queue(&mut *writer)?;
*last_modifier = modifier;
}
let next_fg = span.style.fg.unwrap_or(Color::Reset);
let next_bg = span.style.bg.unwrap_or(Color::Reset);
if next_fg != *fg || next_bg != *bg {
queue!(
writer,
SetColors(Colors::new(next_fg.into(), next_bg.into()))
)?;
*fg = next_fg;
*bg = next_bg;
}
if let Some(destination) = hyperlink_destination {
queue!(
writer,
Print(osc8_hyperlink(destination, span.content.as_ref()))
)?;
} else {
queue!(writer, Print(span.content.clone()))?;
}
Ok(())
}
#[derive(Clone, Copy)]
struct SpanPosition {
line: usize,
span: usize,
}
struct LinkSpanSnapshot<'a> {
position: SpanPosition,
content: &'a str,
style: Style,
}
struct MarkdownLink<'a> {
label: &'a [LinkSpanSnapshot<'a>],
destination_spans: &'a [LinkSpanSnapshot<'a>],
destination: String,
end: usize,
}
impl<'a> MarkdownLink<'a> {
fn parse(spans: &'a [LinkSpanSnapshot<'a>], start: usize) -> Option<Self> {
if !is_link_label_styled(spans.get(start)?) {
return None;
}
let mut separator = start;
while separator < spans.len() && is_link_label_styled(&spans[separator]) {
separator += 1;
}
if !matches!(spans.get(separator)?.content, " (" | "(") {
return None;
}
let destination_start = separator + 1;
let mut close = destination_start;
while close < spans.len() && is_link_destination_styled(&spans[close]) {
close += 1;
}
if close == destination_start || spans.get(close)?.content != ")" {
return None;
}
let destination = spans[destination_start..close]
.iter()
.map(|span| span.content)
.collect::<String>();
if !is_remote_url(&destination) {
return None;
}
Some(Self {
label: &spans[start..separator],
destination_spans: &spans[destination_start..close],
destination,
end: close + 1,
})
}
}
fn mark_markdown_link_spans(lines: Vec<Line<'_>>) -> Vec<PreparedHistoryLine<'_>> {
let mut prepared = lines
.into_iter()
.map(|line| {
let span_hyperlinks = vec![None; line.spans.len()];
PreparedHistoryLine {
line,
span_hyperlinks,
}
})
.collect::<Vec<_>>();
let annotations =
{
let snapshots =
prepared
.iter()
.enumerate()
.flat_map(|(line_index, prepared_line)| {
prepared_line.line.spans.iter().enumerate().map(
move |(span_index, span)| LinkSpanSnapshot {
position: SpanPosition {
line: line_index,
span: span_index,
},
content: span.content.as_ref(),
style: span.style,
},
)
})
.collect::<Vec<_>>();
let mut annotations = Vec::new();
let mut start = 0;
while start < snapshots.len() {
let Some(link) = MarkdownLink::parse(&snapshots, start) else {
start += 1;
continue;
};
for snapshot in link.label.iter().chain(link.destination_spans) {
annotations.push((snapshot.position, link.destination.clone()));
}
start = link.end;
}
annotations
};
for (position, destination) in annotations {
prepared[position.line].span_hyperlinks[position.span] = Some(destination);
}
prepared
}
fn is_link_label_styled(span: &LinkSpanSnapshot<'_>) -> bool {
span.style.add_modifier.contains(Modifier::UNDERLINED)
&& !span.style.sub_modifier.contains(Modifier::UNDERLINED)
&& span.style.fg == Some(Color::Cyan)
&& !span.content.trim().is_empty()
}
fn is_link_destination_styled(span: &LinkSpanSnapshot<'_>) -> bool {
is_link_label_styled(span)
}
fn is_remote_url(text: &str) -> bool {
text.starts_with("http://") || text.starts_with("https://")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -402,6 +604,7 @@ mod tests {
use crate::test_backend::VT100Backend;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Stylize;
#[test]
fn writes_bold_then_regular_spans() {
@@ -431,6 +634,108 @@ mod tests {
);
}
#[test]
fn write_history_line_emits_osc8_for_remote_markdown_link() {
let text = render_markdown_text("[OpenAI](https://openai.com)");
let line = text.lines.first().expect("rendered link line");
let mut actual: Vec<u8> = Vec::new();
write_history_line(&mut actual, line, /*wrap_width*/ 80).unwrap();
let actual = String::from_utf8(actual).unwrap();
assert!(
actual.contains("\u{1b}]8;;https://openai.com\u{1b}\\OpenAI\u{1b}]8;;\u{1b}\\"),
"label should be printed as an ST-terminated OSC-8 hyperlink: {actual:?}"
);
assert!(
actual.contains(
"\u{1b}]8;;https://openai.com\u{1b}\\https://openai.com\u{1b}]8;;\u{1b}\\"
),
"destination should be printed as an ST-terminated OSC-8 hyperlink: {actual:?}"
);
assert!(
!actual.contains('\u{7}'),
"new OSC-8 output should not use BEL"
);
}
#[test]
fn write_history_line_does_not_emit_osc8_for_underlined_non_markdown_url_pattern() {
let line = Line::from(vec![
"underlined note".underlined(),
" (".into(),
"https://example.com/not-a-markdown-destination".underlined(),
")".into(),
]);
let mut actual: Vec<u8> = Vec::new();
write_history_line(&mut actual, &line, /*wrap_width*/ 80).unwrap();
let actual = String::from_utf8(actual).unwrap();
assert!(
!actual.contains("\u{1b}]8;;"),
"plain underlined text should stay as styled text, not OSC-8: {actual:?}"
);
}
#[test]
fn inserted_history_preserves_osc8_when_markdown_link_wraps_before_writing() {
let text = render_markdown_text("[OpenAI Platform](https://openai.com/docs/codex/osc8)");
let width: u16 = 24;
let height: u16 = 8;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(Rect::new(0, height - 1, width, 1));
insert_history_lines(&mut term, text.lines).expect("history insertion should succeed");
let actual = String::from_utf8_lossy(term.backend().written_output());
assert!(
actual.contains(
"\u{1b}]8;;https://openai.com/docs/codex/osc8\u{1b}\\OpenAI Platform\u{1b}]8;;\u{1b}\\"
),
"wrapped label should still be printed as OSC-8: {actual:?}"
);
assert!(
actual.contains("\u{1b}]8;;https://openai.com/docs/codex/osc8\u{1b}\\https://"),
"wrapped destination should still be printed as OSC-8: {actual:?}"
);
}
#[test]
fn write_history_line_does_not_expand_heading_underline_into_link_label() {
let text = render_markdown_text("# See [docs](https://example.com/docs)");
let line = text.lines.first().expect("rendered heading line");
let mut actual: Vec<u8> = Vec::new();
write_history_line(&mut actual, line, /*wrap_width*/ 80).unwrap();
let actual = String::from_utf8(actual).unwrap();
assert!(
actual.contains("\u{1b}]8;;https://example.com/docs\u{1b}\\docs\u{1b}]8;;\u{1b}\\"),
"actual markdown label should be OSC-8: {actual:?}"
);
assert!(
!actual.contains("\u{1b}]8;;https://example.com/docs\u{1b}\\# See"),
"heading marker/text before the link must not be included in OSC-8 label: {actual:?}"
);
}
#[test]
fn write_history_line_emits_osc8_for_link_inside_blockquote() {
let text = render_markdown_text("> [OpenAI](https://openai.com)");
let line = text.lines.first().expect("rendered blockquote link line");
let mut actual: Vec<u8> = Vec::new();
write_history_line(&mut actual, line, /*wrap_width*/ 80).unwrap();
let actual = String::from_utf8(actual).unwrap();
assert!(
actual.contains("\u{1b}]8;;https://openai.com\u{1b}\\OpenAI\u{1b}]8;;\u{1b}\\"),
"blockquote link label should be OSC-8 despite line-level blockquote style: {actual:?}"
);
}
#[test]
fn vt100_blockquote_line_emits_green_fg() {
// Set up a small off-screen terminal

View File

@@ -96,6 +96,7 @@ mod audio_device {
}
}
mod bottom_pane;
mod buffer_hyperlinks;
mod chatwidget;
mod cli;
mod clipboard_paste;
@@ -130,6 +131,7 @@ mod model_migration;
mod multi_agents;
mod notifications;
pub(crate) mod onboarding;
mod osc8;
mod oss_selection;
mod pager_overlay;
pub(crate) mod public_widgets;
@@ -146,6 +148,7 @@ mod streaming;
mod style;
mod terminal_palette;
mod terminal_title;
mod terminal_wrappers;
mod text_formatting;
mod theme_picker;
mod tooltips;

View File

@@ -42,7 +42,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 +64,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().cyan().underlined(),
link_destination: Style::new().cyan().underlined(),
blockquote: Style::new().green(),
}
}
@@ -272,7 +274,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.link.as_ref().is_some_and(|link| link.show_destination) {
self.push_inline_style(self.styles.link_label);
}
}
Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
| Tag::Table(_)
@@ -407,9 +414,8 @@ where
if i > 0 {
self.push_line(Line::default());
}
let content = line.to_string();
let span = Span::styled(
content,
line.to_string(),
self.inline_styles.last().copied().unwrap_or_default(),
);
self.push_span(span);
@@ -426,7 +432,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(code.to_string()).style(style);
self.push_span(span);
}
@@ -596,8 +608,15 @@ 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(link.destination, destination_style));
self.push_span(")".into());
} else if let Some(local_target_display) = link.local_target_display {
if self.pending_marker_line {

View File

@@ -9,6 +9,7 @@ use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
use crate::markdown_render::render_markdown_text;
use crate::markdown_render::render_markdown_text_with_width_and_cwd;
use crate::osc8::osc8_hyperlink;
use insta::assert_snapshot;
fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
@@ -651,7 +652,7 @@ fn strong_emphasis() {
fn link() {
let text = render_markdown_text("[Link](https://example.com)");
let expected = Text::from(Line::from_iter([
"Link".into(),
"Link".cyan().underlined(),
" (".into(),
"https://example.com".cyan().underlined(),
")".into(),
@@ -803,10 +804,10 @@ fn file_link_uses_target_path_for_hash_range() {
}
#[test]
fn url_link_shows_destination() {
fn url_link_renders_underlined_label_with_destination() {
let text = render_markdown_text("[docs](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
"docs".into(),
"docs".cyan().underlined(),
" (".into(),
"https://example.com/docs".cyan().underlined(),
")".into(),
@@ -814,6 +815,59 @@ fn url_link_shows_destination() {
assert_eq!(text, expected);
}
#[test]
fn url_link_with_inline_code_preserves_inline_code_style() {
let text = render_markdown_text("[`docs`](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
"docs".cyan().underlined(),
" (".into(),
"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([
"docs".bold().italic().cyan().underlined(),
" (".into(),
"https://example.com/docs"
.bold()
.italic()
.cyan()
.underlined(),
")".into(),
]));
assert_eq!(text, expected);
}
#[test]
fn wrapped_url_link_label_preserves_link_style_across_lines() {
let text = render_markdown_text_with_width_and_cwd(
"[abcdefgh](https://example.com/docs)",
Some(4),
/*cwd*/ None,
);
let wrapped_label_lines = text.lines.iter().take(3).cloned().collect::<Vec<_>>();
let expected = vec![
Line::from("abc".cyan().underlined()),
Line::from("def".cyan().underlined()),
Line::from("gh".cyan().underlined()),
];
assert_eq!(wrapped_label_lines, expected);
}
#[test]
fn url_link_sanitizes_control_chars() {
assert_eq!(
osc8_hyperlink("https://example.com/\u{1b}]8;;\u{07}injected", "unsafe"),
"\u{1b}]8;;https://example.com/]8;;injected\u{1b}\\unsafe\u{1b}]8;;\u{1b}\\"
);
}
#[test]
fn markdown_render_file_link_snapshot() {
let text = render_markdown_text_for_cwd(

View File

@@ -39,6 +39,7 @@ use uuid::Uuid;
use crate::LoginStatus;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::osc8::osc8_hyperlink;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
@@ -50,17 +51,6 @@ use crate::tui::FrameRequester;
/// row boundary, which breaks normal terminal URL detection for long URLs that
/// wrap across multiple rows.
pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
// Sanitize: strip any characters that could break out of the OSC 8
// sequence (ESC or BEL) to prevent terminal escape injection from a
// malformed or compromised upstream URL.
let safe_url: String = url
.chars()
.filter(|&c| c != '\x1B' && c != '\x07')
.collect();
if safe_url.is_empty() {
return;
}
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
let cell = &mut buf[(x, y)];
@@ -72,11 +62,70 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
if sym.trim().is_empty() {
continue;
}
cell.set_symbol(&format!("\x1B]8;;{safe_url}\x07{sym}\x1B]8;;\x07"));
cell.set_symbol(&osc8_hyperlink(url, sym));
}
}
}
fn mark_text_hyperlink(buf: &mut Buffer, area: Rect, url: &str, text: &str) {
let cells = rendered_text_cells(buf, area);
for start in 0..cells.len() {
let mut remaining = text;
let mut end = start;
while !remaining.is_empty() && end < cells.len() {
let symbol = cells[end].1.as_str();
if let Some(next_remaining) = remaining.strip_prefix(symbol) {
remaining = next_remaining;
} else if let Some(next_remaining) = remaining.trim_start().strip_prefix(symbol) {
remaining = next_remaining;
} else {
break;
}
end += 1;
}
if remaining.is_empty() {
for (position, symbol) in &cells[start..end] {
buf[(position.x, position.y)].set_symbol(&osc8_hyperlink(url, symbol));
}
return;
}
}
}
fn rendered_text_cells(buf: &Buffer, area: Rect) -> Vec<(ratatui::layout::Position, String)> {
let mut cells = Vec::new();
for y in area.top()..area.bottom() {
let row = (area.left()..area.right())
.filter_map(|x| {
let cell = &buf[(x, y)];
if cell.skip {
None
} else {
Some((ratatui::layout::Position::new(x, y), cell))
}
})
.collect::<Vec<_>>();
let row_content_end = row
.iter()
.rposition(|(_, cell)| {
!(cell.symbol() == " "
&& cell.fg == Color::Reset
&& cell.bg == Color::Reset
&& cell.modifier.is_empty())
})
.map(|index| index + 1)
.unwrap_or(0);
cells.extend(
row.into_iter()
.take(row_content_end)
.map(|(position, cell)| (position, cell.symbol().to_string())),
);
}
cells
}
use super::onboarding_screen::StepState;
mod headless_chatgpt_login;
@@ -518,24 +567,28 @@ impl AuthModeWidget {
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
"✓ Signed in with your ChatGPT account".fg(Color::Green).into(),
"✓ Signed in with your ChatGPT account"
.fg(Color::Green)
.into(),
"".into(),
" Before you start:".into(),
"".into(),
" Decide how much autonomy you want to grant Codex".into(),
Line::from(vec![
" For more details see the ".into(),
"\u{1b}]8;;https://developers.openai.com/codex/security\u{7}Codex docs\u{1b}]8;;\u{7}".underlined(),
"Codex docs".underlined(),
])
.dim(),
"".into(),
" Codex can make mistakes".into(),
" Review the code it writes and commands it runs".dim().into(),
" Review the code it writes and commands it runs"
.dim()
.into(),
"".into(),
" Powered by your ChatGPT account".into(),
Line::from(vec![
" Uses your plan's rate limits and ".into(),
"\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}".underlined(),
"training data preferences".underlined(),
])
.dim(),
"".into(),
@@ -545,6 +598,19 @@ impl AuthModeWidget {
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
mark_text_hyperlink(
buf,
area,
"https://developers.openai.com/codex/security",
"Codex docs",
);
mark_text_hyperlink(
buf,
area,
"https://chatgpt.com/#settings",
"training data preferences",
);
}
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
@@ -954,6 +1020,7 @@ pub(super) fn maybe_open_auth_url_in_browser(request_handle: &AppServerRequestHa
#[cfg(test)]
mod tests {
use super::*;
use crate::osc8::parse_osc8_hyperlink;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
use codex_app_server_client::InProcessAppServerClient;
@@ -1103,16 +1170,14 @@ mod tests {
/// Collects all buffer cell symbols that contain the OSC 8 open sequence
/// for the given URL. Returns the concatenated "inner" characters.
fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String {
let open = format!("\x1B]8;;{url}\x07");
let close = "\x1B]8;;\x07";
let mut chars = String::new();
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
let sym = buf[(x, y)].symbol();
if let Some(rest) = sym.strip_prefix(open.as_str())
&& let Some(ch) = rest.strip_suffix(close)
if let Some(parsed) = parse_osc8_hyperlink(sym)
&& parsed.destination == url
{
chars.push_str(ch);
chars.push_str(parsed.text);
}
}
}
@@ -1140,6 +1205,41 @@ mod tests {
assert_eq!(found, url, "OSC 8 hyperlink should cover the full URL");
}
#[test]
fn chatgpt_success_message_renders_osc8_hyperlink_labels() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt());
let area = Rect::new(0, 0, 80, 20);
let mut buf = Buffer::empty(area);
widget.render_chatgpt_success_message(area, &mut buf);
assert_eq!(
collect_osc8_chars(&buf, area, "https://developers.openai.com/codex/security"),
"Codex docs"
);
assert_eq!(
collect_osc8_chars(&buf, area, "https://chatgpt.com/#settings"),
"training data preferences"
);
}
#[test]
fn chatgpt_success_message_links_label_words_split_by_wrapping() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt());
let area = Rect::new(0, 0, 18, 40);
let mut buf = Buffer::empty(area);
widget.render_chatgpt_success_message(area, &mut buf);
let linked = collect_osc8_chars(&buf, area, "https://chatgpt.com/#settings");
assert_eq!(
linked.replace(char::is_whitespace, ""),
"trainingdatapreferences"
);
}
#[test]
fn auth_widget_suppresses_animations_when_device_code_is_visible() {
let runtime = tokio::runtime::Runtime::new().unwrap();

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

@@ -0,0 +1,105 @@
#[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{1b}\\";
pub(crate) fn sanitize_osc8_url(destination: &str) -> String {
destination.chars().filter(|c| !c.is_control()).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{1b}\\{}{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 emits_st_terminated_wrapper() {
assert_eq!(
osc8_hyperlink("https://example.com", "docs"),
"\u{1b}]8;;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\"
);
}
#[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");
}
#[test]
fn sanitizes_destination_controls() {
assert_eq!(
osc8_hyperlink("https://example.com/\u{1b}]8;;\u{7}injected", "unsafe"),
"\u{1b}]8;;https://example.com/]8;;injected\u{1b}\\unsafe\u{1b}]8;;\u{1b}\\"
);
}
#[test]
fn sanitizes_c1_string_terminator_from_destination() {
assert_eq!(
osc8_hyperlink("https://example.com/a\u{009c}unsafe", "unsafe"),
"\u{1b}]8;;https://example.com/aunsafe\u{1b}\\unsafe\u{1b}]8;;\u{1b}\\"
);
}
}

View File

@@ -0,0 +1,189 @@
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)]
#[cfg(test)]
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.
#[cfg(test)]
pub(crate) fn parse_zero_width_terminal_wrapper(text: &str) -> Option<ParsedTerminalWrapper<'_>> {
let after_prefix = text.strip_prefix(OSC8_PREFIX)?;
let params_end = after_prefix.find(';')?;
let after_params = &after_prefix[params_end + 1..];
let (destination_end, opener_terminator) = find_osc_string_terminator(after_params)?;
let prefix_len = OSC8_PREFIX.len() + params_end + 1 + destination_end + opener_terminator.len();
let prefix = &text[..prefix_len];
let after_opener = &text[prefix_len..];
if let Some(visible) = after_opener.strip_suffix(OSC8_CLOSE_BEL) {
return Some(ParsedTerminalWrapper {
prefix,
text: visible,
suffix: OSC8_CLOSE_BEL,
});
}
if let Some(visible) = after_opener.strip_suffix(OSC8_CLOSE_ST) {
return Some(ParsedTerminalWrapper {
prefix,
text: visible,
suffix: OSC8_CLOSE_ST,
});
}
None
}
/// Strip the zero-width wrapper bytes from any recognized wrapped runs.
///
/// Malformed or unterminated escape sequences are preserved verbatim. That
/// keeps layout helpers fail-safe: they may over-measure malformed input, but
/// they will not silently delete bytes from it.
pub(crate) fn strip_zero_width_terminal_wrappers(text: &str) -> String {
if !text.contains('\x1B') {
return text.to_string();
}
let mut remaining = text;
let mut rendered = String::with_capacity(text.len());
while let Some(open_pos) = remaining.find(OSC8_PREFIX) {
rendered.push_str(&remaining[..open_pos]);
let candidate = &remaining[open_pos..];
let Some((consumed, visible)) = consume_wrapped_prefix(candidate) else {
rendered.push_str(candidate);
return rendered;
};
rendered.push_str(visible);
remaining = &candidate[consumed..];
}
rendered.push_str(remaining);
rendered
}
/// Measure display width after removing recognized zero-width terminal wrappers.
pub(crate) fn visible_width(text: &str) -> usize {
UnicodeWidthStr::width(strip_zero_width_terminal_wrappers(text).as_str())
}
fn consume_wrapped_prefix(text: &str) -> Option<(usize, &str)> {
let after_prefix = text.strip_prefix(OSC8_PREFIX)?;
let params_end = after_prefix.find(';')?;
let after_params = &after_prefix[params_end + 1..];
let (destination_end, opener_terminator) = find_osc_string_terminator(after_params)?;
let opener_len = OSC8_PREFIX.len() + params_end + 1 + destination_end + opener_terminator.len();
let after_opener = &text[opener_len..];
let mut best: Option<(usize, &str)> = None;
for suffix in [OSC8_CLOSE_BEL, OSC8_CLOSE_ST] {
if let Some(close_pos) = after_opener.find(suffix)
&& best.is_none_or(|(best_pos, _)| close_pos < best_pos)
{
best = Some((close_pos, suffix));
}
}
let (close_pos, suffix) = best?;
Some((
opener_len + close_pos + suffix.len(),
&after_opener[..close_pos],
))
}
fn find_osc_string_terminator(text: &str) -> Option<(usize, &'static str)> {
let mut best: Option<(usize, &'static str)> = None;
for terminator in OSC_STRING_TERMINATORS {
if let Some(pos) = text.find(terminator)
&& best.is_none_or(|(best_pos, _)| pos < best_pos)
{
best = Some((pos, terminator));
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parses_bel_terminated_wrapper() {
let wrapped = "\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}";
assert_eq!(
parse_zero_width_terminal_wrapper(wrapped),
Some(ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com\u{7}",
text: "docs",
suffix: "\u{1b}]8;;\u{7}",
})
);
}
#[test]
fn parses_st_terminated_wrapper_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
assert_eq!(
parse_zero_width_terminal_wrapper(wrapped),
Some(ParsedTerminalWrapper {
prefix: "\u{1b}]8;id=abc;https://example.com\u{1b}\\",
text: "docs",
suffix: "\u{1b}]8;;\u{1b}\\",
})
);
}
#[test]
fn strips_multiple_wrapped_runs_and_keeps_plain_text() {
let text = concat!(
"See ",
"\u{1b}]8;;https://a.example\u{7}alpha\u{1b}]8;;\u{7}",
" and ",
"\u{1b}]8;id=1;https://b.example\u{1b}\\beta\u{1b}]8;;\u{1b}\\",
"."
);
assert_eq!(
strip_zero_width_terminal_wrappers(text),
"See alpha and beta."
);
}
#[test]
fn preserves_malformed_unterminated_wrapper_verbatim() {
let text = "See \u{1b}]8;;https://example.com\u{7}docs";
assert_eq!(strip_zero_width_terminal_wrappers(text), text);
assert_eq!(parse_zero_width_terminal_wrapper(text), None);
}
#[test]
fn visible_width_ignores_wrapper_bytes() {
let text = "\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}";
assert_eq!(visible_width(text), 4);
}
}

View File

@@ -20,6 +20,7 @@ use ratatui::layout::Size;
/// - getting the cursor position
pub struct VT100Backend {
crossterm_backend: CrosstermBackend<vt100::Parser>,
written: Vec<u8>,
}
impl VT100Backend {
@@ -28,16 +29,23 @@ impl VT100Backend {
crossterm::style::force_color_output(true);
Self {
crossterm_backend: CrosstermBackend::new(vt100::Parser::new(height, width, 0)),
written: Vec::new(),
}
}
pub fn vt100(&self) -> &vt100::Parser {
self.crossterm_backend.writer()
}
#[allow(dead_code)]
pub fn written_output(&self) -> &[u8] {
&self.written
}
}
impl Write for VT100Backend {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.written.extend_from_slice(buf);
self.crossterm_backend.writer_mut().write(buf)
}