mirror of
https://github.com/openai/codex.git
synced 2026-05-17 01:32:32 +00:00
Compare commits
2 Commits
rust-v0.13
...
starr/osc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f706d6d8 | ||
|
|
7af548f57e |
@@ -699,6 +699,7 @@ impl ModifierDiff {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
@@ -748,4 +749,12 @@ mod tests {
|
||||
"expected clear-to-end to start after the remaining wide char; commands: {commands:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_width_ignores_osc8_payload() {
|
||||
assert_eq!(
|
||||
display_width(&osc8_hyperlink("https://example.com/docs", "docs")),
|
||||
4
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +333,7 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::markdown_render::render_markdown_text;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
@@ -365,6 +366,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_spans_preserves_osc8_wrapped_content() {
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
let wrapped = osc8_hyperlink("https://example.com/docs", "docs");
|
||||
let spans = [wrapped.cyan().underlined()];
|
||||
|
||||
let mut actual: Vec<u8> = Vec::new();
|
||||
write_spans(&mut actual, spans.iter()).unwrap();
|
||||
|
||||
let actual = String::from_utf8(actual).unwrap();
|
||||
assert!(actual.contains("\u{1b}]8;;https://example.com/docs\u{7}docs\u{1b}]8;;\u{7}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_blockquote_line_emits_green_fg() {
|
||||
// Set up a small off-screen terminal
|
||||
|
||||
@@ -104,6 +104,7 @@ mod model_migration;
|
||||
mod multi_agents;
|
||||
mod notifications;
|
||||
pub mod onboarding;
|
||||
mod osc8;
|
||||
mod oss_selection;
|
||||
mod pager_overlay;
|
||||
pub mod public_widgets;
|
||||
|
||||
@@ -11,10 +11,11 @@ pub(crate) fn append_markdown(
|
||||
cwd: Option<&Path>,
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
) {
|
||||
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd(
|
||||
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd_and_options(
|
||||
markdown_source,
|
||||
width,
|
||||
cwd,
|
||||
crate::markdown_render::MarkdownRenderOptions::INTERACTIVE,
|
||||
);
|
||||
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! transcripts show the real file target (including normalized location suffixes) and can shorten
|
||||
//! absolute paths relative to a known working directory.
|
||||
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::render::highlight::highlight_code_to_lines;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::wrapping::RtOptions;
|
||||
@@ -46,6 +47,15 @@ struct MarkdownStyles {
|
||||
blockquote: Style,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct MarkdownRenderOptions {
|
||||
pub(crate) emit_osc8: bool,
|
||||
}
|
||||
|
||||
impl MarkdownRenderOptions {
|
||||
pub(crate) const INTERACTIVE: Self = Self { emit_osc8: true };
|
||||
}
|
||||
|
||||
impl Default for MarkdownStyles {
|
||||
fn default() -> Self {
|
||||
use ratatui::style::Stylize;
|
||||
@@ -63,7 +73,7 @@ impl Default for MarkdownStyles {
|
||||
strikethrough: Style::new().crossed_out(),
|
||||
ordered_list_marker: Style::new().light_blue(),
|
||||
unordered_list_marker: Style::new(),
|
||||
link: Style::new().cyan().underlined(),
|
||||
link: Style::new().blue().underlined(),
|
||||
blockquote: Style::new().green(),
|
||||
}
|
||||
}
|
||||
@@ -87,13 +97,29 @@ impl IndentContext {
|
||||
}
|
||||
|
||||
pub fn render_markdown_text(input: &str) -> Text<'static> {
|
||||
render_markdown_text_with_width(input, None)
|
||||
render_markdown_text_with_options(input, MarkdownRenderOptions::default())
|
||||
}
|
||||
|
||||
pub(crate) fn render_markdown_text_with_options(
|
||||
input: &str,
|
||||
options: MarkdownRenderOptions,
|
||||
) -> Text<'static> {
|
||||
render_markdown_text_with_width_and_options(input, None, options)
|
||||
}
|
||||
|
||||
/// Render markdown using the current process working directory for local file-link display.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> {
|
||||
render_markdown_text_with_width_and_options(input, width, MarkdownRenderOptions::default())
|
||||
}
|
||||
|
||||
pub(crate) fn render_markdown_text_with_width_and_options(
|
||||
input: &str,
|
||||
width: Option<usize>,
|
||||
options: MarkdownRenderOptions,
|
||||
) -> Text<'static> {
|
||||
let cwd = std::env::current_dir().ok();
|
||||
render_markdown_text_with_width_and_cwd(input, width, cwd.as_deref())
|
||||
render_markdown_text_with_width_and_cwd_and_options(input, width, cwd.as_deref(), options)
|
||||
}
|
||||
|
||||
/// Render markdown with an explicit working directory for local file links.
|
||||
@@ -101,15 +127,30 @@ pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>)
|
||||
/// The `cwd` parameter controls how absolute local targets are shortened before display. Passing
|
||||
/// the session cwd keeps full renders, history cells, and streamed deltas visually aligned even
|
||||
/// when rendering happens away from the process cwd.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn render_markdown_text_with_width_and_cwd(
|
||||
input: &str,
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Text<'static> {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
let parser = Parser::new_ext(input, options);
|
||||
let mut w = Writer::new(parser, width, cwd);
|
||||
render_markdown_text_with_width_and_cwd_and_options(
|
||||
input,
|
||||
width,
|
||||
cwd,
|
||||
MarkdownRenderOptions::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_markdown_text_with_width_and_cwd_and_options(
|
||||
input: &str,
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
render_options: MarkdownRenderOptions,
|
||||
) -> Text<'static> {
|
||||
let mut parser_options = Options::empty();
|
||||
parser_options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
let parser = Parser::new_ext(input, parser_options);
|
||||
let mut w = Writer::new(parser, width, cwd, render_options);
|
||||
w.run();
|
||||
w.text
|
||||
}
|
||||
@@ -170,13 +211,19 @@ where
|
||||
current_subsequent_indent: Vec<Span<'static>>,
|
||||
current_line_style: Style,
|
||||
current_line_in_code_block: bool,
|
||||
render_options: MarkdownRenderOptions,
|
||||
}
|
||||
|
||||
impl<'a, I> Writer<'a, I>
|
||||
where
|
||||
I: Iterator<Item = Event<'a>>,
|
||||
{
|
||||
fn new(iter: I, wrap_width: Option<usize>, cwd: Option<&Path>) -> Self {
|
||||
fn new(
|
||||
iter: I,
|
||||
wrap_width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
render_options: MarkdownRenderOptions,
|
||||
) -> Self {
|
||||
Self {
|
||||
iter,
|
||||
text: Text::default(),
|
||||
@@ -200,6 +247,7 @@ where
|
||||
current_subsequent_indent: Vec::new(),
|
||||
current_line_style: Style::default(),
|
||||
current_line_in_code_block: false,
|
||||
render_options,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +320,12 @@ where
|
||||
Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
|
||||
Tag::Strong => self.push_inline_style(self.styles.strong),
|
||||
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
|
||||
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
|
||||
Tag::Link { dest_url, .. } => {
|
||||
self.push_link(dest_url.to_string());
|
||||
if self.remote_link_destination().is_some() {
|
||||
self.push_inline_style(self.styles.link);
|
||||
}
|
||||
}
|
||||
Tag::HtmlBlock
|
||||
| Tag::FootnoteDefinition(_)
|
||||
| Tag::Table(_)
|
||||
@@ -404,7 +457,7 @@ where
|
||||
if i > 0 {
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
let content = line.to_string();
|
||||
let content = self.maybe_wrap_remote_link_text(line);
|
||||
let span = Span::styled(
|
||||
content,
|
||||
self.inline_styles.last().copied().unwrap_or_default(),
|
||||
@@ -423,7 +476,18 @@ where
|
||||
self.push_line(Line::default());
|
||||
self.pending_marker_line = false;
|
||||
}
|
||||
let span = Span::from(code.into_string()).style(self.styles.code);
|
||||
let style = self
|
||||
.inline_styles
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.patch(self.styles.code)
|
||||
.patch(
|
||||
self.remote_link_destination()
|
||||
.map(|_| self.styles.link)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let span = Span::from(self.maybe_wrap_remote_link_text(code.as_ref())).style(style);
|
||||
self.push_span(span);
|
||||
}
|
||||
|
||||
@@ -442,7 +506,7 @@ where
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
let style = self.inline_styles.last().copied().unwrap_or_default();
|
||||
self.push_span(Span::styled(line.to_string(), style));
|
||||
self.push_span(Span::styled(self.maybe_wrap_remote_link_text(line), style));
|
||||
}
|
||||
self.needs_newline = !inline;
|
||||
}
|
||||
@@ -590,6 +654,7 @@ where
|
||||
fn pop_link(&mut self) {
|
||||
if let Some(link) = self.link.take() {
|
||||
if link.show_destination {
|
||||
self.pop_inline_style();
|
||||
self.push_span(" (".into());
|
||||
self.push_span(Span::styled(link.destination, self.styles.link));
|
||||
self.push_span(")".into());
|
||||
@@ -611,6 +676,26 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_link_destination(&self) -> Option<&str> {
|
||||
self.link
|
||||
.as_ref()
|
||||
.filter(|link| link.show_destination)
|
||||
.map(|link| link.destination.as_str())
|
||||
}
|
||||
|
||||
fn maybe_wrap_remote_link_text(&self, text: &str) -> String {
|
||||
self.remote_link_destination().map_or_else(
|
||||
|| text.to_string(),
|
||||
|destination| {
|
||||
if self.render_options.emit_osc8 {
|
||||
osc8_hyperlink(destination, text)
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn suppressing_local_link_label(&self) -> bool {
|
||||
self.link
|
||||
.as_ref()
|
||||
|
||||
@@ -7,10 +7,18 @@ use std::path::Path;
|
||||
|
||||
use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
|
||||
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
|
||||
use crate::markdown_render::MarkdownRenderOptions;
|
||||
use crate::markdown_render::render_markdown_text;
|
||||
use crate::markdown_render::render_markdown_text_with_options;
|
||||
use crate::markdown_render::render_markdown_text_with_width_and_cwd;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
fn render_markdown_text_interactive(input: &str) -> Text<'static> {
|
||||
render_markdown_text_with_options(input, MarkdownRenderOptions::INTERACTIVE)
|
||||
}
|
||||
|
||||
fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
|
||||
render_markdown_text_with_width_and_cwd(input, None, Some(cwd))
|
||||
}
|
||||
@@ -651,9 +659,9 @@ fn strong_emphasis() {
|
||||
fn link() {
|
||||
let text = render_markdown_text("[Link](https://example.com)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"Link".into(),
|
||||
"Link".blue().underlined(),
|
||||
" (".into(),
|
||||
"https://example.com".cyan().underlined(),
|
||||
"https://example.com".blue().underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
@@ -776,17 +784,53 @@ fn file_link_uses_target_path_for_hash_range() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_shows_destination() {
|
||||
let text = render_markdown_text("[docs](https://example.com/docs)");
|
||||
fn url_link_renders_clickable_label_with_destination() {
|
||||
let text = render_markdown_text_interactive("[docs](https://example.com/docs)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"docs".into(),
|
||||
osc8_hyperlink("https://example.com/docs", "docs")
|
||||
.blue()
|
||||
.underlined(),
|
||||
" (".into(),
|
||||
"https://example.com/docs".cyan().underlined(),
|
||||
"https://example.com/docs".blue().underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_with_inline_code_is_clickable() {
|
||||
let text = render_markdown_text_interactive("[`docs`](https://example.com/docs)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
osc8_hyperlink("https://example.com/docs", "docs")
|
||||
.blue()
|
||||
.underlined(),
|
||||
" (".into(),
|
||||
"https://example.com/docs".blue().underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_without_osc8_still_shows_visible_destination() {
|
||||
let text = render_markdown_text("[docs](https://example.com/docs)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"docs".blue().underlined(),
|
||||
" (".into(),
|
||||
"https://example.com/docs".blue().underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_sanitizes_control_chars() {
|
||||
assert_eq!(
|
||||
osc8_hyperlink("https://example.com/\u{1b}]8;;\u{07}injected", "unsafe"),
|
||||
"\u{1b}]8;;https://example.com/]8;;injected\u{7}unsafe\u{1b}]8;;\u{7}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_render_file_link_snapshot() {
|
||||
let text = render_markdown_text_for_cwd(
|
||||
@@ -797,10 +841,12 @@ fn markdown_render_file_link_snapshot() {
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
let line = l
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
.collect::<String>();
|
||||
strip_osc8_hyperlinks(&line)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
@@ -819,10 +865,12 @@ fn unordered_list_local_file_link_stays_inline_with_following_text() {
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
let rendered = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
.collect::<String>();
|
||||
strip_osc8_hyperlinks(&rendered)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
@@ -1161,10 +1209,12 @@ URL with parentheses: [link](https://example.com/path_(with)_parens).
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
let line = l
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
.collect::<String>();
|
||||
strip_osc8_hyperlinks(&line)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::key_hint;
|
||||
use crate::markdown_render::render_markdown_text_with_width;
|
||||
use crate::markdown_render::MarkdownRenderOptions;
|
||||
use crate::markdown_render::render_markdown_text_with_width_and_options;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
@@ -321,7 +322,11 @@ impl ModelMigrationScreen {
|
||||
let horizontal_inset = 2;
|
||||
let content_width = area_width.saturating_sub(horizontal_inset);
|
||||
let wrap_width = (content_width > 0).then_some(content_width as usize);
|
||||
let rendered = render_markdown_text_with_width(markdown, wrap_width);
|
||||
let rendered = render_markdown_text_with_width_and_options(
|
||||
markdown,
|
||||
wrap_width,
|
||||
MarkdownRenderOptions::INTERACTIVE,
|
||||
);
|
||||
for line in rendered.lines {
|
||||
column.push(
|
||||
Paragraph::new(line)
|
||||
|
||||
@@ -37,6 +37,8 @@ use std::sync::RwLock;
|
||||
use crate::LoginStatus;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::sanitize_osc8_destination;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
@@ -48,13 +50,7 @@ use crate::tui::FrameRequester;
|
||||
/// row boundary, which breaks normal terminal URL detection for long URLs that
|
||||
/// wrap across multiple rows.
|
||||
pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
|
||||
// Sanitize: strip any characters that could break out of the OSC 8
|
||||
// sequence (ESC or BEL) to prevent terminal escape injection from a
|
||||
// malformed or compromised upstream URL.
|
||||
let safe_url: String = url
|
||||
.chars()
|
||||
.filter(|&c| c != '\x1B' && c != '\x07')
|
||||
.collect();
|
||||
let safe_url = sanitize_osc8_destination(url);
|
||||
if safe_url.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -70,7 +66,7 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
|
||||
if sym.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
cell.set_symbol(&format!("\x1B]8;;{safe_url}\x07{sym}\x1B]8;;\x07"));
|
||||
cell.set_symbol(&osc8_hyperlink(&safe_url, &sym));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
106
codex-rs/tui/src/osc8.rs
Normal file
106
codex-rs/tui/src/osc8.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ParsedOsc8<'a> {
|
||||
pub(crate) destination: &'a str,
|
||||
pub(crate) text: &'a str,
|
||||
}
|
||||
|
||||
const OSC8_OPEN_PREFIX: &str = "\u{1b}]8;;";
|
||||
const OSC8_CLOSE: &str = "\u{1b}]8;;\u{7}";
|
||||
|
||||
/// Strip bytes that could terminate or escape an OSC 8 destination early.
|
||||
pub(crate) fn sanitize_osc8_destination(destination: &str) -> String {
|
||||
destination
|
||||
.chars()
|
||||
.filter(|&c| c != '\x1B' && c != '\x07')
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Wrap visible text in a single OSC 8 hyperlink span.
|
||||
pub(crate) fn osc8_hyperlink(destination: &str, text: &str) -> String {
|
||||
let safe_destination = sanitize_osc8_destination(destination);
|
||||
if safe_destination.is_empty() {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
format!("{OSC8_OPEN_PREFIX}{safe_destination}\u{7}{text}{OSC8_CLOSE}")
|
||||
}
|
||||
|
||||
/// Parse a string that consists entirely of one OSC 8 hyperlink span.
|
||||
pub(crate) fn parse_osc8_hyperlink(text: &str) -> Option<ParsedOsc8<'_>> {
|
||||
let after_open = text.strip_prefix(OSC8_OPEN_PREFIX)?;
|
||||
let destination_end = after_open.find('\x07')?;
|
||||
let destination = &after_open[..destination_end];
|
||||
let after_destination = &after_open[destination_end + 1..];
|
||||
let label = after_destination.strip_suffix(OSC8_CLOSE)?;
|
||||
Some(ParsedOsc8 {
|
||||
destination,
|
||||
text: label,
|
||||
})
|
||||
}
|
||||
|
||||
/// Strip OSC 8 wrappers while preserving visible label text.
|
||||
pub(crate) fn strip_osc8_hyperlinks(text: &str) -> String {
|
||||
let mut remaining = text;
|
||||
let mut rendered = String::new();
|
||||
|
||||
while let Some(open_pos) = remaining.find(OSC8_OPEN_PREFIX) {
|
||||
rendered.push_str(&remaining[..open_pos]);
|
||||
let after_open = &remaining[open_pos + OSC8_OPEN_PREFIX.len()..];
|
||||
let Some(destination_end) = after_open.find('\x07') else {
|
||||
rendered.push_str(&remaining[open_pos..]);
|
||||
return rendered;
|
||||
};
|
||||
let after_destination = &after_open[destination_end + 1..];
|
||||
let Some(close_pos) = after_destination.find(OSC8_CLOSE) else {
|
||||
rendered.push_str(&remaining[open_pos..]);
|
||||
return rendered;
|
||||
};
|
||||
rendered.push_str(&after_destination[..close_pos]);
|
||||
remaining = &after_destination[close_pos + OSC8_CLOSE.len()..];
|
||||
}
|
||||
|
||||
rendered.push_str(remaining);
|
||||
rendered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parses_wrapped_text() {
|
||||
let wrapped = osc8_hyperlink("https://example.com", "docs");
|
||||
let parsed = parse_osc8_hyperlink(&wrapped).expect("expected osc8 span");
|
||||
assert_eq!(parsed.destination, "https://example.com");
|
||||
assert_eq!(parsed.text, "docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_mixed_text() {
|
||||
let wrapped = format!("See {}", osc8_hyperlink("https://example.com", "docs"));
|
||||
assert_eq!(parse_osc8_hyperlink(&wrapped), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_wrapped_text() {
|
||||
let wrapped = format!("See {}", osc8_hyperlink("https://example.com", "docs"));
|
||||
assert_eq!(strip_osc8_hyperlinks(&wrapped), "See docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_multiple_wrapped_segments() {
|
||||
let wrapped = format!(
|
||||
"{} {}",
|
||||
osc8_hyperlink("https://example.com/docs", "docs"),
|
||||
osc8_hyperlink("https://example.com/api", "api")
|
||||
);
|
||||
assert_eq!(strip_osc8_hyperlinks(&wrapped), "docs api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_sequences_are_preserved() {
|
||||
let malformed = "\u{1b}]8;;https://example.com\u{7}docs";
|
||||
assert_eq!(strip_osc8_hyperlinks(malformed), malformed);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
use textwrap::Options;
|
||||
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::parse_osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
|
||||
/// Returns byte-ranges into `text` for each wrapped line, including
|
||||
@@ -177,12 +180,7 @@ fn map_owned_wrapped_line_to_range(
|
||||
///
|
||||
/// Concatenates all span contents and delegates to [`text_contains_url_like`].
|
||||
pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
|
||||
let text: String = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect();
|
||||
text_contains_url_like(&text)
|
||||
text_contains_url_like(&visible_line_text(line))
|
||||
}
|
||||
|
||||
/// Returns `true` if `line` contains both a URL-like token and at least one
|
||||
@@ -191,12 +189,15 @@ pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
|
||||
/// Decorative marker tokens (for example list prefixes like `-`, `1.`, `|`,
|
||||
/// `│`) are ignored for the non-URL side of this check.
|
||||
pub(crate) fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
|
||||
let text: String = line
|
||||
.spans
|
||||
text_has_mixed_url_and_non_url_tokens(&visible_line_text(line))
|
||||
}
|
||||
|
||||
fn visible_line_text(line: &Line<'_>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect();
|
||||
text_has_mixed_url_and_non_url_tokens(&text)
|
||||
.map(|span| strip_osc8_hyperlinks(span.content.as_ref()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// Returns `true` if any whitespace-delimited token in `text` looks like a URL.
|
||||
@@ -644,11 +645,16 @@ where
|
||||
let mut span_bounds = Vec::new();
|
||||
let mut acc = 0usize;
|
||||
for s in &line.spans {
|
||||
let text = s.content.as_ref();
|
||||
let parsed = parse_osc8_hyperlink(s.content.as_ref());
|
||||
let text = parsed.map_or_else(|| s.content.as_ref(), |link| link.text);
|
||||
let start = acc;
|
||||
flat.push_str(text);
|
||||
acc += text.len();
|
||||
span_bounds.push((start..acc, s.style));
|
||||
span_bounds.push(SpanBound {
|
||||
range: start..acc,
|
||||
style: s.style,
|
||||
osc8_destination: parsed.map(|link| link.destination),
|
||||
});
|
||||
}
|
||||
|
||||
let rt_opts: RtOptions<'a> = width_or_options.into();
|
||||
@@ -841,15 +847,15 @@ where
|
||||
|
||||
fn slice_line_spans<'a>(
|
||||
original: &'a Line<'a>,
|
||||
span_bounds: &[(Range<usize>, ratatui::style::Style)],
|
||||
span_bounds: &[SpanBound<'a>],
|
||||
range: &Range<usize>,
|
||||
) -> Line<'a> {
|
||||
let start_byte = range.start;
|
||||
let end_byte = range.end;
|
||||
let mut acc: Vec<Span<'a>> = Vec::new();
|
||||
for (i, (range, style)) in span_bounds.iter().enumerate() {
|
||||
let s = range.start;
|
||||
let e = range.end;
|
||||
for (i, bound) in span_bounds.iter().enumerate() {
|
||||
let s = bound.range.start;
|
||||
let e = bound.range.end;
|
||||
if e <= start_byte {
|
||||
continue;
|
||||
}
|
||||
@@ -861,11 +867,15 @@ fn slice_line_spans<'a>(
|
||||
if seg_end > seg_start {
|
||||
let local_start = seg_start - s;
|
||||
let local_end = seg_end - s;
|
||||
let content = original.spans[i].content.as_ref();
|
||||
let slice = &content[local_start..local_end];
|
||||
let slice = slice_span_content(
|
||||
original.spans[i].content.as_ref(),
|
||||
bound,
|
||||
local_start,
|
||||
local_end,
|
||||
);
|
||||
acc.push(Span {
|
||||
style: *style,
|
||||
content: std::borrow::Cow::Borrowed(slice),
|
||||
style: bound.style,
|
||||
content: slice,
|
||||
});
|
||||
}
|
||||
if e >= end_byte {
|
||||
@@ -879,9 +889,39 @@ fn slice_line_spans<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SpanBound<'a> {
|
||||
range: Range<usize>,
|
||||
style: ratatui::style::Style,
|
||||
osc8_destination: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn slice_span_content<'a>(
|
||||
content: &'a str,
|
||||
bound: &SpanBound<'a>,
|
||||
local_start: usize,
|
||||
local_end: usize,
|
||||
) -> Cow<'a, str> {
|
||||
if let Some(destination) = bound.osc8_destination {
|
||||
if let Some(parsed) = parse_osc8_hyperlink(content) {
|
||||
Cow::Owned(osc8_hyperlink(
|
||||
destination,
|
||||
&parsed.text[local_start..local_end],
|
||||
))
|
||||
} else {
|
||||
Cow::Borrowed(&content[local_start..local_end])
|
||||
}
|
||||
} else {
|
||||
Cow::Borrowed(&content[local_start..local_end])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::parse_osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
use itertools::Itertools as _;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Color;
|
||||
@@ -1000,6 +1040,34 @@ mod tests {
|
||||
assert_eq!(concat_line(&out[1]), "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc8_wrapped_span_wraps_by_visible_text() {
|
||||
let url = "https://example.com/docs";
|
||||
let line = Line::from(vec![osc8_hyperlink(url, "abcdefghij").cyan().underlined()]);
|
||||
let out = word_wrap_line(&line, 5);
|
||||
assert_eq!(out.len(), 2);
|
||||
|
||||
let first = concat_line(&out[0]);
|
||||
let second = concat_line(&out[1]);
|
||||
|
||||
assert_eq!(strip_osc8_hyperlinks(&first), "abcde");
|
||||
assert_eq!(strip_osc8_hyperlinks(&second), "fghij");
|
||||
assert_eq!(
|
||||
parse_osc8_hyperlink(&first).expect("first line should stay hyperlinked"),
|
||||
crate::osc8::ParsedOsc8 {
|
||||
destination: url,
|
||||
text: "abcde",
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_osc8_hyperlink(&second).expect("second line should stay hyperlinked"),
|
||||
crate::osc8::ParsedOsc8 {
|
||||
destination: url,
|
||||
text: "fghij",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_consumes_width_leaving_one_char_space() {
|
||||
let opts = RtOptions::new(4)
|
||||
|
||||
@@ -699,6 +699,7 @@ impl ModifierDiff {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
@@ -748,4 +749,12 @@ mod tests {
|
||||
"expected clear-to-end to start after the remaining wide char; commands: {commands:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_width_ignores_osc8_payload() {
|
||||
assert_eq!(
|
||||
display_width(&osc8_hyperlink("https://example.com/docs", "docs")),
|
||||
4
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +333,7 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::markdown_render::render_markdown_text;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
@@ -365,6 +366,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_spans_preserves_osc8_wrapped_content() {
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
let wrapped = osc8_hyperlink("https://example.com/docs", "docs");
|
||||
let spans = [wrapped.cyan().underlined()];
|
||||
|
||||
let mut actual: Vec<u8> = Vec::new();
|
||||
write_spans(&mut actual, spans.iter()).unwrap();
|
||||
|
||||
let actual = String::from_utf8(actual).unwrap();
|
||||
assert!(actual.contains("\u{1b}]8;;https://example.com/docs\u{7}docs\u{1b}]8;;\u{7}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_blockquote_line_emits_green_fg() {
|
||||
// Set up a small off-screen terminal
|
||||
|
||||
@@ -109,6 +109,7 @@ mod model_migration;
|
||||
mod multi_agents;
|
||||
mod notifications;
|
||||
pub mod onboarding;
|
||||
mod osc8;
|
||||
mod oss_selection;
|
||||
mod pager_overlay;
|
||||
pub mod public_widgets;
|
||||
|
||||
@@ -11,10 +11,11 @@ pub(crate) fn append_markdown(
|
||||
cwd: Option<&Path>,
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
) {
|
||||
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd(
|
||||
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd_and_options(
|
||||
markdown_source,
|
||||
width,
|
||||
cwd,
|
||||
crate::markdown_render::MarkdownRenderOptions::INTERACTIVE,
|
||||
);
|
||||
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! transcripts show the real file target (including normalized location suffixes) and can shorten
|
||||
//! absolute paths relative to a known working directory.
|
||||
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::render::highlight::highlight_code_to_lines;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::wrapping::RtOptions;
|
||||
@@ -46,6 +47,15 @@ struct MarkdownStyles {
|
||||
blockquote: Style,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct MarkdownRenderOptions {
|
||||
pub(crate) emit_osc8: bool,
|
||||
}
|
||||
|
||||
impl MarkdownRenderOptions {
|
||||
pub(crate) const INTERACTIVE: Self = Self { emit_osc8: true };
|
||||
}
|
||||
|
||||
impl Default for MarkdownStyles {
|
||||
fn default() -> Self {
|
||||
use ratatui::style::Stylize;
|
||||
@@ -63,7 +73,7 @@ impl Default for MarkdownStyles {
|
||||
strikethrough: Style::new().crossed_out(),
|
||||
ordered_list_marker: Style::new().light_blue(),
|
||||
unordered_list_marker: Style::new(),
|
||||
link: Style::new().cyan().underlined(),
|
||||
link: Style::new().blue().underlined(),
|
||||
blockquote: Style::new().green(),
|
||||
}
|
||||
}
|
||||
@@ -87,13 +97,29 @@ impl IndentContext {
|
||||
}
|
||||
|
||||
pub fn render_markdown_text(input: &str) -> Text<'static> {
|
||||
render_markdown_text_with_width(input, None)
|
||||
render_markdown_text_with_options(input, MarkdownRenderOptions::default())
|
||||
}
|
||||
|
||||
pub(crate) fn render_markdown_text_with_options(
|
||||
input: &str,
|
||||
options: MarkdownRenderOptions,
|
||||
) -> Text<'static> {
|
||||
render_markdown_text_with_width_and_options(input, None, options)
|
||||
}
|
||||
|
||||
/// Render markdown using the current process working directory for local file-link display.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> {
|
||||
render_markdown_text_with_width_and_options(input, width, MarkdownRenderOptions::default())
|
||||
}
|
||||
|
||||
pub(crate) fn render_markdown_text_with_width_and_options(
|
||||
input: &str,
|
||||
width: Option<usize>,
|
||||
options: MarkdownRenderOptions,
|
||||
) -> Text<'static> {
|
||||
let cwd = std::env::current_dir().ok();
|
||||
render_markdown_text_with_width_and_cwd(input, width, cwd.as_deref())
|
||||
render_markdown_text_with_width_and_cwd_and_options(input, width, cwd.as_deref(), options)
|
||||
}
|
||||
|
||||
/// Render markdown with an explicit working directory for local file links.
|
||||
@@ -101,15 +127,30 @@ pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>)
|
||||
/// The `cwd` parameter controls how absolute local targets are shortened before display. Passing
|
||||
/// the session cwd keeps full renders, history cells, and streamed deltas visually aligned even
|
||||
/// when rendering happens away from the process cwd.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn render_markdown_text_with_width_and_cwd(
|
||||
input: &str,
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Text<'static> {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
let parser = Parser::new_ext(input, options);
|
||||
let mut w = Writer::new(parser, width, cwd);
|
||||
render_markdown_text_with_width_and_cwd_and_options(
|
||||
input,
|
||||
width,
|
||||
cwd,
|
||||
MarkdownRenderOptions::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_markdown_text_with_width_and_cwd_and_options(
|
||||
input: &str,
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
options: MarkdownRenderOptions,
|
||||
) -> Text<'static> {
|
||||
let mut parser_options = Options::empty();
|
||||
parser_options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
let parser = Parser::new_ext(input, parser_options);
|
||||
let mut w = Writer::new(parser, width, cwd, options);
|
||||
w.run();
|
||||
w.text
|
||||
}
|
||||
@@ -170,13 +211,19 @@ where
|
||||
current_subsequent_indent: Vec<Span<'static>>,
|
||||
current_line_style: Style,
|
||||
current_line_in_code_block: bool,
|
||||
render_options: MarkdownRenderOptions,
|
||||
}
|
||||
|
||||
impl<'a, I> Writer<'a, I>
|
||||
where
|
||||
I: Iterator<Item = Event<'a>>,
|
||||
{
|
||||
fn new(iter: I, wrap_width: Option<usize>, cwd: Option<&Path>) -> Self {
|
||||
fn new(
|
||||
iter: I,
|
||||
wrap_width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
render_options: MarkdownRenderOptions,
|
||||
) -> Self {
|
||||
Self {
|
||||
iter,
|
||||
text: Text::default(),
|
||||
@@ -200,6 +247,7 @@ where
|
||||
current_subsequent_indent: Vec::new(),
|
||||
current_line_style: Style::default(),
|
||||
current_line_in_code_block: false,
|
||||
render_options,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +320,12 @@ where
|
||||
Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
|
||||
Tag::Strong => self.push_inline_style(self.styles.strong),
|
||||
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
|
||||
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
|
||||
Tag::Link { dest_url, .. } => {
|
||||
self.push_link(dest_url.to_string());
|
||||
if self.remote_link_destination().is_some() {
|
||||
self.push_inline_style(self.styles.link);
|
||||
}
|
||||
}
|
||||
Tag::HtmlBlock
|
||||
| Tag::FootnoteDefinition(_)
|
||||
| Tag::Table(_)
|
||||
@@ -404,7 +457,7 @@ where
|
||||
if i > 0 {
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
let content = line.to_string();
|
||||
let content = self.maybe_wrap_remote_link_text(line);
|
||||
let span = Span::styled(
|
||||
content,
|
||||
self.inline_styles.last().copied().unwrap_or_default(),
|
||||
@@ -423,7 +476,18 @@ where
|
||||
self.push_line(Line::default());
|
||||
self.pending_marker_line = false;
|
||||
}
|
||||
let span = Span::from(code.into_string()).style(self.styles.code);
|
||||
let style = self
|
||||
.inline_styles
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.patch(self.styles.code)
|
||||
.patch(
|
||||
self.remote_link_destination()
|
||||
.map(|_| self.styles.link)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let span = Span::from(self.maybe_wrap_remote_link_text(code.as_ref())).style(style);
|
||||
self.push_span(span);
|
||||
}
|
||||
|
||||
@@ -442,7 +506,7 @@ where
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
let style = self.inline_styles.last().copied().unwrap_or_default();
|
||||
self.push_span(Span::styled(line.to_string(), style));
|
||||
self.push_span(Span::styled(self.maybe_wrap_remote_link_text(line), style));
|
||||
}
|
||||
self.needs_newline = !inline;
|
||||
}
|
||||
@@ -590,6 +654,7 @@ where
|
||||
fn pop_link(&mut self) {
|
||||
if let Some(link) = self.link.take() {
|
||||
if link.show_destination {
|
||||
self.pop_inline_style();
|
||||
self.push_span(" (".into());
|
||||
self.push_span(Span::styled(link.destination, self.styles.link));
|
||||
self.push_span(")".into());
|
||||
@@ -618,6 +683,26 @@ where
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn remote_link_destination(&self) -> Option<&str> {
|
||||
self.link
|
||||
.as_ref()
|
||||
.filter(|link| link.show_destination)
|
||||
.map(|link| link.destination.as_str())
|
||||
}
|
||||
|
||||
fn maybe_wrap_remote_link_text(&self, text: &str) -> String {
|
||||
self.remote_link_destination().map_or_else(
|
||||
|| text.to_string(),
|
||||
|destination| {
|
||||
if self.render_options.emit_osc8 {
|
||||
osc8_hyperlink(destination, text)
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn flush_current_line(&mut self) {
|
||||
if let Some(line) = self.current_line_content.take() {
|
||||
let style = self.current_line_style;
|
||||
|
||||
@@ -7,10 +7,18 @@ use std::path::Path;
|
||||
|
||||
use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
|
||||
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
|
||||
use crate::markdown_render::MarkdownRenderOptions;
|
||||
use crate::markdown_render::render_markdown_text;
|
||||
use crate::markdown_render::render_markdown_text_with_options;
|
||||
use crate::markdown_render::render_markdown_text_with_width_and_cwd;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
fn render_markdown_text_interactive(input: &str) -> Text<'static> {
|
||||
render_markdown_text_with_options(input, MarkdownRenderOptions::INTERACTIVE)
|
||||
}
|
||||
|
||||
fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
|
||||
render_markdown_text_with_width_and_cwd(input, None, Some(cwd))
|
||||
}
|
||||
@@ -651,9 +659,9 @@ fn strong_emphasis() {
|
||||
fn link() {
|
||||
let text = render_markdown_text("[Link](https://example.com)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"Link".into(),
|
||||
"Link".blue().underlined(),
|
||||
" (".into(),
|
||||
"https://example.com".cyan().underlined(),
|
||||
"https://example.com".blue().underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
@@ -776,17 +784,53 @@ fn file_link_uses_target_path_for_hash_range() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_shows_destination() {
|
||||
let text = render_markdown_text("[docs](https://example.com/docs)");
|
||||
fn url_link_renders_clickable_label_with_destination() {
|
||||
let text = render_markdown_text_interactive("[docs](https://example.com/docs)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"docs".into(),
|
||||
osc8_hyperlink("https://example.com/docs", "docs")
|
||||
.blue()
|
||||
.underlined(),
|
||||
" (".into(),
|
||||
"https://example.com/docs".cyan().underlined(),
|
||||
"https://example.com/docs".blue().underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_with_inline_code_is_clickable() {
|
||||
let text = render_markdown_text_interactive("[`docs`](https://example.com/docs)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
osc8_hyperlink("https://example.com/docs", "docs")
|
||||
.blue()
|
||||
.underlined(),
|
||||
" (".into(),
|
||||
"https://example.com/docs".blue().underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_without_osc8_still_shows_visible_destination() {
|
||||
let text = render_markdown_text("[docs](https://example.com/docs)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"docs".blue().underlined(),
|
||||
" (".into(),
|
||||
"https://example.com/docs".blue().underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_sanitizes_control_chars() {
|
||||
assert_eq!(
|
||||
osc8_hyperlink("https://example.com/\u{1b}]8;;\u{07}injected", "unsafe"),
|
||||
"\u{1b}]8;;https://example.com/]8;;injected\u{7}unsafe\u{1b}]8;;\u{7}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_render_file_link_snapshot() {
|
||||
let text = render_markdown_text_for_cwd(
|
||||
@@ -797,10 +841,12 @@ fn markdown_render_file_link_snapshot() {
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
let line = l
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
.collect::<String>();
|
||||
strip_osc8_hyperlinks(&line)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
@@ -819,10 +865,12 @@ fn unordered_list_local_file_link_stays_inline_with_following_text() {
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
let rendered = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
.collect::<String>();
|
||||
strip_osc8_hyperlinks(&rendered)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
@@ -1161,10 +1209,12 @@ URL with parentheses: [link](https://example.com/path_(with)_parens).
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
let line = l
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
.collect::<String>();
|
||||
strip_osc8_hyperlinks(&line)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::key_hint;
|
||||
use crate::markdown_render::render_markdown_text_with_width;
|
||||
use crate::markdown_render::MarkdownRenderOptions;
|
||||
use crate::markdown_render::render_markdown_text_with_width_and_options;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
@@ -321,7 +322,11 @@ impl ModelMigrationScreen {
|
||||
let horizontal_inset = 2;
|
||||
let content_width = area_width.saturating_sub(horizontal_inset);
|
||||
let wrap_width = (content_width > 0).then_some(content_width as usize);
|
||||
let rendered = render_markdown_text_with_width(markdown, wrap_width);
|
||||
let rendered = render_markdown_text_with_width_and_options(
|
||||
markdown,
|
||||
wrap_width,
|
||||
MarkdownRenderOptions::INTERACTIVE,
|
||||
);
|
||||
for line in rendered.lines {
|
||||
column.push(
|
||||
Paragraph::new(line)
|
||||
|
||||
@@ -40,6 +40,8 @@ use uuid::Uuid;
|
||||
use crate::LoginStatus;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::sanitize_osc8_destination;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
@@ -54,10 +56,7 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
|
||||
// Sanitize: strip any characters that could break out of the OSC 8
|
||||
// sequence (ESC or BEL) to prevent terminal escape injection from a
|
||||
// malformed or compromised upstream URL.
|
||||
let safe_url: String = url
|
||||
.chars()
|
||||
.filter(|&c| c != '\x1B' && c != '\x07')
|
||||
.collect();
|
||||
let safe_url = sanitize_osc8_destination(url);
|
||||
if safe_url.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -73,7 +72,7 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
|
||||
if sym.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
cell.set_symbol(&format!("\x1B]8;;{safe_url}\x07{sym}\x1B]8;;\x07"));
|
||||
cell.set_symbol(&osc8_hyperlink(&safe_url, &sym));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
106
codex-rs/tui_app_server/src/osc8.rs
Normal file
106
codex-rs/tui_app_server/src/osc8.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ParsedOsc8<'a> {
|
||||
pub(crate) destination: &'a str,
|
||||
pub(crate) text: &'a str,
|
||||
}
|
||||
|
||||
const OSC8_OPEN_PREFIX: &str = "\u{1b}]8;;";
|
||||
const OSC8_CLOSE: &str = "\u{1b}]8;;\u{7}";
|
||||
|
||||
/// Strip bytes that could terminate or escape an OSC 8 destination early.
|
||||
pub(crate) fn sanitize_osc8_destination(destination: &str) -> String {
|
||||
destination
|
||||
.chars()
|
||||
.filter(|&c| c != '\x1B' && c != '\x07')
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Wrap visible text in a single OSC 8 hyperlink span.
|
||||
pub(crate) fn osc8_hyperlink(destination: &str, text: &str) -> String {
|
||||
let safe_destination = sanitize_osc8_destination(destination);
|
||||
if safe_destination.is_empty() {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
format!("{OSC8_OPEN_PREFIX}{safe_destination}\u{7}{text}{OSC8_CLOSE}")
|
||||
}
|
||||
|
||||
/// Parse a string that consists entirely of one OSC 8 hyperlink span.
|
||||
pub(crate) fn parse_osc8_hyperlink(text: &str) -> Option<ParsedOsc8<'_>> {
|
||||
let after_open = text.strip_prefix(OSC8_OPEN_PREFIX)?;
|
||||
let destination_end = after_open.find('\x07')?;
|
||||
let destination = &after_open[..destination_end];
|
||||
let after_destination = &after_open[destination_end + 1..];
|
||||
let label = after_destination.strip_suffix(OSC8_CLOSE)?;
|
||||
Some(ParsedOsc8 {
|
||||
destination,
|
||||
text: label,
|
||||
})
|
||||
}
|
||||
|
||||
/// Strip OSC 8 wrappers while preserving visible label text.
|
||||
pub(crate) fn strip_osc8_hyperlinks(text: &str) -> String {
|
||||
let mut remaining = text;
|
||||
let mut rendered = String::new();
|
||||
|
||||
while let Some(open_pos) = remaining.find(OSC8_OPEN_PREFIX) {
|
||||
rendered.push_str(&remaining[..open_pos]);
|
||||
let after_open = &remaining[open_pos + OSC8_OPEN_PREFIX.len()..];
|
||||
let Some(destination_end) = after_open.find('\x07') else {
|
||||
rendered.push_str(&remaining[open_pos..]);
|
||||
return rendered;
|
||||
};
|
||||
let after_destination = &after_open[destination_end + 1..];
|
||||
let Some(close_pos) = after_destination.find(OSC8_CLOSE) else {
|
||||
rendered.push_str(&remaining[open_pos..]);
|
||||
return rendered;
|
||||
};
|
||||
rendered.push_str(&after_destination[..close_pos]);
|
||||
remaining = &after_destination[close_pos + OSC8_CLOSE.len()..];
|
||||
}
|
||||
|
||||
rendered.push_str(remaining);
|
||||
rendered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parses_wrapped_text() {
|
||||
let wrapped = osc8_hyperlink("https://example.com", "docs");
|
||||
let parsed = parse_osc8_hyperlink(&wrapped).expect("expected osc8 span");
|
||||
assert_eq!(parsed.destination, "https://example.com");
|
||||
assert_eq!(parsed.text, "docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_mixed_text() {
|
||||
let wrapped = format!("See {}", osc8_hyperlink("https://example.com", "docs"));
|
||||
assert_eq!(parse_osc8_hyperlink(&wrapped), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_wrapped_text() {
|
||||
let wrapped = format!("See {}", osc8_hyperlink("https://example.com", "docs"));
|
||||
assert_eq!(strip_osc8_hyperlinks(&wrapped), "See docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_multiple_wrapped_segments() {
|
||||
let wrapped = format!(
|
||||
"{} {}",
|
||||
osc8_hyperlink("https://example.com/docs", "docs"),
|
||||
osc8_hyperlink("https://example.com/api", "api")
|
||||
);
|
||||
assert_eq!(strip_osc8_hyperlinks(&wrapped), "docs api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_sequences_are_preserved() {
|
||||
let malformed = "\u{1b}]8;;https://example.com\u{7}docs";
|
||||
assert_eq!(strip_osc8_hyperlinks(malformed), malformed);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
use textwrap::Options;
|
||||
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::parse_osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
|
||||
/// Returns byte-ranges into `text` for each wrapped line, including
|
||||
@@ -177,12 +180,7 @@ fn map_owned_wrapped_line_to_range(
|
||||
///
|
||||
/// Concatenates all span contents and delegates to [`text_contains_url_like`].
|
||||
pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
|
||||
let text: String = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect();
|
||||
text_contains_url_like(&text)
|
||||
text_contains_url_like(&visible_line_text(line))
|
||||
}
|
||||
|
||||
/// Returns `true` if `line` contains both a URL-like token and at least one
|
||||
@@ -191,12 +189,15 @@ pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
|
||||
/// Decorative marker tokens (for example list prefixes like `-`, `1.`, `|`,
|
||||
/// `│`) are ignored for the non-URL side of this check.
|
||||
pub(crate) fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
|
||||
let text: String = line
|
||||
.spans
|
||||
text_has_mixed_url_and_non_url_tokens(&visible_line_text(line))
|
||||
}
|
||||
|
||||
fn visible_line_text(line: &Line<'_>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect();
|
||||
text_has_mixed_url_and_non_url_tokens(&text)
|
||||
.map(|span| strip_osc8_hyperlinks(span.content.as_ref()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// Returns `true` if any whitespace-delimited token in `text` looks like a URL.
|
||||
@@ -644,11 +645,16 @@ where
|
||||
let mut span_bounds = Vec::new();
|
||||
let mut acc = 0usize;
|
||||
for s in &line.spans {
|
||||
let text = s.content.as_ref();
|
||||
let parsed = parse_osc8_hyperlink(s.content.as_ref());
|
||||
let text = parsed.map_or_else(|| s.content.as_ref(), |link| link.text);
|
||||
let start = acc;
|
||||
flat.push_str(text);
|
||||
acc += text.len();
|
||||
span_bounds.push((start..acc, s.style));
|
||||
span_bounds.push(SpanBound {
|
||||
range: start..acc,
|
||||
style: s.style,
|
||||
osc8_destination: parsed.map(|link| link.destination),
|
||||
});
|
||||
}
|
||||
|
||||
let rt_opts: RtOptions<'a> = width_or_options.into();
|
||||
@@ -841,15 +847,15 @@ where
|
||||
|
||||
fn slice_line_spans<'a>(
|
||||
original: &'a Line<'a>,
|
||||
span_bounds: &[(Range<usize>, ratatui::style::Style)],
|
||||
span_bounds: &[SpanBound<'a>],
|
||||
range: &Range<usize>,
|
||||
) -> Line<'a> {
|
||||
let start_byte = range.start;
|
||||
let end_byte = range.end;
|
||||
let mut acc: Vec<Span<'a>> = Vec::new();
|
||||
for (i, (range, style)) in span_bounds.iter().enumerate() {
|
||||
let s = range.start;
|
||||
let e = range.end;
|
||||
for (i, bound) in span_bounds.iter().enumerate() {
|
||||
let s = bound.range.start;
|
||||
let e = bound.range.end;
|
||||
if e <= start_byte {
|
||||
continue;
|
||||
}
|
||||
@@ -861,11 +867,15 @@ fn slice_line_spans<'a>(
|
||||
if seg_end > seg_start {
|
||||
let local_start = seg_start - s;
|
||||
let local_end = seg_end - s;
|
||||
let content = original.spans[i].content.as_ref();
|
||||
let slice = &content[local_start..local_end];
|
||||
let slice = slice_span_content(
|
||||
original.spans[i].content.as_ref(),
|
||||
bound,
|
||||
local_start,
|
||||
local_end,
|
||||
);
|
||||
acc.push(Span {
|
||||
style: *style,
|
||||
content: std::borrow::Cow::Borrowed(slice),
|
||||
style: bound.style,
|
||||
content: slice,
|
||||
});
|
||||
}
|
||||
if e >= end_byte {
|
||||
@@ -879,9 +889,39 @@ fn slice_line_spans<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SpanBound<'a> {
|
||||
range: Range<usize>,
|
||||
style: ratatui::style::Style,
|
||||
osc8_destination: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn slice_span_content<'a>(
|
||||
content: &'a str,
|
||||
bound: &SpanBound<'a>,
|
||||
local_start: usize,
|
||||
local_end: usize,
|
||||
) -> Cow<'a, str> {
|
||||
if let Some(destination) = bound.osc8_destination {
|
||||
if let Some(parsed) = parse_osc8_hyperlink(content) {
|
||||
Cow::Owned(osc8_hyperlink(
|
||||
destination,
|
||||
&parsed.text[local_start..local_end],
|
||||
))
|
||||
} else {
|
||||
Cow::Borrowed(&content[local_start..local_end])
|
||||
}
|
||||
} else {
|
||||
Cow::Borrowed(&content[local_start..local_end])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::parse_osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
use itertools::Itertools as _;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Color;
|
||||
@@ -1000,6 +1040,34 @@ mod tests {
|
||||
assert_eq!(concat_line(&out[1]), "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc8_wrapped_span_wraps_by_visible_text() {
|
||||
let url = "https://example.com/docs";
|
||||
let line = Line::from(vec![osc8_hyperlink(url, "abcdefghij").cyan().underlined()]);
|
||||
let out = word_wrap_line(&line, 5);
|
||||
assert_eq!(out.len(), 2);
|
||||
|
||||
let first = concat_line(&out[0]);
|
||||
let second = concat_line(&out[1]);
|
||||
|
||||
assert_eq!(strip_osc8_hyperlinks(&first), "abcde");
|
||||
assert_eq!(strip_osc8_hyperlinks(&second), "fghij");
|
||||
assert_eq!(
|
||||
parse_osc8_hyperlink(&first).expect("first line should stay hyperlinked"),
|
||||
crate::osc8::ParsedOsc8 {
|
||||
destination: url,
|
||||
text: "abcde",
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_osc8_hyperlink(&second).expect("second line should stay hyperlinked"),
|
||||
crate::osc8::ParsedOsc8 {
|
||||
destination: url,
|
||||
text: "fghij",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_consumes_width_leaving_one_char_space() {
|
||||
let opts = RtOptions::new(4)
|
||||
|
||||
Reference in New Issue
Block a user