mirror of
https://github.com/openai/codex.git
synced 2026-04-19 22:11:52 +03:00
Compare commits
1 Commits
dev/shaqay
...
starr/osc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25ef3726e9 |
271
codex-rs/tui/src/buffer_hyperlinks.rs
Normal file
271
codex-rs/tui/src/buffer_hyperlinks.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
105
codex-rs/tui/src/osc8.rs
Normal 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}\\"
|
||||
);
|
||||
}
|
||||
}
|
||||
189
codex-rs/tui/src/terminal_wrappers.rs
Normal file
189
codex-rs/tui/src/terminal_wrappers.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user