mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
Render Markdown links as clickable labels in supported terminals (#40471)
## What changed - Render web-link labels in cyan with an underline and hide the repeated destination in recognized terminals with hyperlink support. - Keep destinations visible for unknown terminals, multiplexers, non-terminal output, and empty labels so the URL remains usable. - Preserve OSC 8 link targets through prose and table wrapping, streaming updates, transcript clipping, and pager scrolling. ## Testing - Add coverage for terminal detection, label formatting and fallbacks, wrapped tables and prose, streaming parity, history output, and scrolled transcript rendering. GitOrigin-RevId: 4a09e41e349ba299022a9c00f0c25892d9aaefe3
This commit is contained in:
committed by
copyberry
parent
c3d5e4418f
commit
bb8cada846
@@ -171,7 +171,6 @@ use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::transcript::ActiveCellLayoutCache;
|
||||
use super::transcript::ActiveCellLayoutCacheKey;
|
||||
use super::*;
|
||||
use crate::terminal_hyperlinks::HyperlinkParagraph;
|
||||
use std::cell::Cell;
|
||||
|
||||
impl ChatWidget {
|
||||
@@ -112,8 +113,8 @@ struct PersistentActiveCellLayout<'a> {
|
||||
impl Renderable for TranscriptAreaRenderable<'_> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let area = self.child_area(area);
|
||||
let lines = self.child.display_lines(area.width);
|
||||
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
|
||||
let lines = self.child.display_hyperlink_lines(area.width);
|
||||
let paragraph = HyperlinkParagraph::new(&lines, Style::default());
|
||||
let y = if area.height == 0 {
|
||||
0
|
||||
} else {
|
||||
@@ -133,7 +134,7 @@ impl Renderable for TranscriptAreaRenderable<'_> {
|
||||
u16::try_from(overflow).unwrap_or(u16::MAX)
|
||||
};
|
||||
Clear.render(area, buf);
|
||||
paragraph.scroll((y, 0)).render(area, buf);
|
||||
paragraph.scroll(y).render(area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
|
||||
@@ -75,6 +75,73 @@ fn contains_text(buffer: &Buffer, text: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_transcript_preserves_clipped_markdown_hyperlinks() {
|
||||
let cell = history_cell::AgentMarkdownCell::new(
|
||||
"Earlier content\n\n[OSC8 label](https://example.com/)".to_string(),
|
||||
std::path::Path::new("/tmp"),
|
||||
);
|
||||
let renderable = TranscriptAreaRenderable {
|
||||
child: &cell,
|
||||
top: 1,
|
||||
right: 2,
|
||||
persistent_layout: None,
|
||||
};
|
||||
let area = Rect::new(
|
||||
/*x*/ 2, /*y*/ 1, /*width*/ 40, /*height*/ 3,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
renderable.render(area, &mut buffer);
|
||||
|
||||
let linked_text = buffer
|
||||
.content
|
||||
.iter()
|
||||
.map(ratatui::buffer::Cell::symbol)
|
||||
.filter(|symbol| symbol.starts_with("\x1b]8;;https://example.com/\x07"))
|
||||
.map(crate::terminal_hyperlinks::strip_osc8)
|
||||
.collect::<String>();
|
||||
assert_eq!(linked_text, "OSC8 labelhttps://example.com/");
|
||||
|
||||
let visible_rows = buffer
|
||||
.content
|
||||
.chunks(usize::from(area.width))
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| crate::terminal_hyperlinks::strip_osc8(cell.symbol()))
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
insta::assert_debug_snapshot!(visible_rows, @r#"
|
||||
[
|
||||
"",
|
||||
"",
|
||||
" OSC8 label (https://example.com/)",
|
||||
]
|
||||
"#);
|
||||
|
||||
let size = ratatui::layout::Size::new(/*width*/ 44, /*height*/ 5);
|
||||
let mut terminal =
|
||||
crate::custom_terminal::Terminal::with_screen_size_and_cursor_position_for_test(
|
||||
ratatui::backend::CrosstermBackend::new(Vec::new()),
|
||||
size,
|
||||
area.as_position(),
|
||||
);
|
||||
terminal.set_viewport_area(Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
size.width,
|
||||
size.height,
|
||||
));
|
||||
terminal
|
||||
.draw_with_size(size, |frame| renderable.render(area, frame.buffer_mut()))
|
||||
.expect("render terminal frame");
|
||||
let output = String::from_utf8(terminal.backend().writer().clone()).expect("UTF-8 output");
|
||||
assert!(output.contains("\x1b]8;;https://example.com/\x07OSC8 label\x1b]8;;\x07"));
|
||||
assert!(output.contains("\x1b]8;;https://example.com/\x07https://example.com/\x1b]8;;\x07"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initial_session_header_starts_at_the_top_of_the_viewport() {
|
||||
let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await;
|
||||
|
||||
@@ -33,11 +33,10 @@ use crate::session_state::ThreadSessionState;
|
||||
use crate::style::proposed_plan_style;
|
||||
use crate::style::user_message_style;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
|
||||
use crate::terminal_hyperlinks::HyperlinkParagraph;
|
||||
use crate::terminal_hyperlinks::plain_hyperlink_lines;
|
||||
use crate::terminal_hyperlinks::prefix_hyperlink_lines;
|
||||
use crate::terminal_hyperlinks::visible_lines;
|
||||
use crate::terminal_hyperlinks::visible_lines_ref;
|
||||
#[cfg(test)]
|
||||
use crate::test_support::PathBufExt;
|
||||
#[cfg(test)]
|
||||
@@ -291,8 +290,7 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
impl Renderable for Box<dyn HistoryCell> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let hyperlink_lines = self.display_hyperlink_lines(area.width);
|
||||
let lines = visible_lines_ref(&hyperlink_lines);
|
||||
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
|
||||
let paragraph = HyperlinkParagraph::new(&hyperlink_lines, Style::default());
|
||||
let y = if area.height == 0 {
|
||||
0
|
||||
} else {
|
||||
@@ -304,8 +302,7 @@ impl Renderable for Box<dyn HistoryCell> {
|
||||
// Active-cell content can reflow dramatically during resize/stream updates. Clear the
|
||||
// entire draw area first so stale glyphs from previous frames never linger.
|
||||
Clear.render(area, buf);
|
||||
paragraph.scroll((y, 0)).render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &hyperlink_lines, usize::from(y));
|
||||
paragraph.scroll(y).render(area, buf);
|
||||
}
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
HistoryCell::desired_height(self.as_ref(), width)
|
||||
|
||||
@@ -539,6 +539,55 @@ mod tests {
|
||||
assert_eq!(line.line.spans[0].content, destination);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_link_label_survives_history_wrapping() {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
let destination = "https://developers.openai.com/";
|
||||
let markdown = "Instead of a Markdown-labeled link. That is the most reliably clickable form. Official OpenAI documentation does not appear to document this exact terminal-link behavior; this is an inference from how terminal hyperlink rendering works. [OpenAI Developers](https://developers.openai.com/)";
|
||||
for label in ["OpenAI Developers", "`OpenAI Developers`"] {
|
||||
for markdown in [
|
||||
markdown.replace("[OpenAI Developers]", &format!("[{label}]")),
|
||||
format!("| Link |\n| --- |\n| [{label}]({destination}) |"),
|
||||
] {
|
||||
for width in [32, 80, 200] {
|
||||
let lines = crate::markdown::render_markdown_agent_with_links_and_cwd(
|
||||
&markdown,
|
||||
Some(width),
|
||||
/*cwd*/ None,
|
||||
);
|
||||
let lines = crate::terminal_hyperlinks::prefix_hyperlink_lines(
|
||||
lines,
|
||||
" ".into(),
|
||||
" ".into(),
|
||||
);
|
||||
let (wrapped, _) = wrap_history_hyperlink_lines(
|
||||
&lines,
|
||||
width + 2,
|
||||
HistoryLineWrapPolicy::PreWrap,
|
||||
);
|
||||
let mut actual = Vec::new();
|
||||
for line in &wrapped {
|
||||
write_history_line(&mut actual, line, width + 2)
|
||||
.expect("write history line");
|
||||
}
|
||||
let output = String::from_utf8(actual).expect("UTF-8 terminal output");
|
||||
let open = format!("\x1b]8;;{destination}\x07");
|
||||
let linked_text = output
|
||||
.split(&open)
|
||||
.skip(1)
|
||||
.map(|part| part.split("\x1b]8;;\x07").next().unwrap())
|
||||
.collect::<String>();
|
||||
assert_eq!(
|
||||
linked_text.replace(' ', ""),
|
||||
format!("OpenAIDevelopers{destination}"),
|
||||
"label {label}, width {width}: {wrapped:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_blockquote_line_emits_green_fg() {
|
||||
// Set up a small off-screen terminal
|
||||
|
||||
@@ -94,8 +94,10 @@ pub(crate) fn render_markdown_agent_with_links_cwd_and_visualizations(
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let rewritten = rewrite_inline_visualizations(markdown_source, inline_visualization_context);
|
||||
let normalized = unwrap_markdown_fences(&rewritten.markdown);
|
||||
let is_hidden_link_destination =
|
||||
|destination: &str| rewritten.trusted_file_links.contains_key(destination);
|
||||
let is_hidden_link_destination = |destination: &str| {
|
||||
rewritten.trusted_file_links.contains_key(destination)
|
||||
|| crate::markdown_render::hide_web_link_destination(destination)
|
||||
};
|
||||
let mut lines =
|
||||
crate::markdown_render::render_markdown_lines_with_width_cwd_and_hidden_link_destinations(
|
||||
&normalized,
|
||||
@@ -126,6 +128,7 @@ pub(crate) fn render_streaming_markdown_agent_with_links_and_cwd(
|
||||
&normalized,
|
||||
width,
|
||||
cwd,
|
||||
&crate::markdown_render::hide_web_link_destination,
|
||||
);
|
||||
if normalized != markdown_source {
|
||||
// Fence unwrapping removes opening/closing lines. A normalized tail that is still a raw
|
||||
|
||||
@@ -78,9 +78,11 @@ use url::Url;
|
||||
|
||||
mod streaming;
|
||||
mod table_key_value;
|
||||
mod web_links;
|
||||
|
||||
pub(crate) use streaming::StreamingMarkdownRender;
|
||||
pub(crate) use streaming::render_streaming_markdown_lines_with_width_and_cwd;
|
||||
pub(crate) use web_links::hide_web_link_destination;
|
||||
|
||||
const TABLE_COLUMN_GAP: usize = 2;
|
||||
const TABLE_CELL_PADDING: usize = 1;
|
||||
@@ -161,14 +163,6 @@ impl TableCell {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn push_span(&mut self, span: Span<'static>) {
|
||||
self.ensure_line();
|
||||
if let Some(line) = self.lines.last_mut() {
|
||||
line.line.push_span(span);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_annotated(&mut self, mut appended: HyperlinkLine) {
|
||||
self.ensure_line();
|
||||
if let Some(line) = self.lines.last_mut() {
|
||||
@@ -318,6 +312,8 @@ pub(crate) fn render_markdown_text_with_width_and_cwd(
|
||||
)))
|
||||
}
|
||||
|
||||
/// Keep destinations visible by default, including for callers that discard hyperlink metadata.
|
||||
/// Semantic output paths supply their hidden-destination policy explicitly.
|
||||
pub(crate) fn render_markdown_lines_with_width_and_cwd(
|
||||
input: &str,
|
||||
width: Option<usize>,
|
||||
@@ -355,6 +351,7 @@ struct LinkState {
|
||||
destination: String,
|
||||
show_destination: bool,
|
||||
style_label: bool,
|
||||
has_visible_label: bool,
|
||||
/// Pre-rendered display text for local file links.
|
||||
///
|
||||
/// When this is present, the markdown label is intentionally suppressed so the rendered
|
||||
@@ -1024,10 +1021,16 @@ where
|
||||
}
|
||||
|
||||
fn push_span_to_table_cell(&mut self, span: Span<'static>) {
|
||||
let span = self.style_link_label(span);
|
||||
let mut annotated = HyperlinkLine::new(Line::default());
|
||||
annotated.push_span(
|
||||
span,
|
||||
self.link.as_ref().map(|link| link.destination.as_str()),
|
||||
);
|
||||
if let Some(table_state) = self.table_state.as_mut()
|
||||
&& let Some(cell) = table_state.current_cell.as_mut()
|
||||
{
|
||||
cell.push_span(span);
|
||||
cell.push_annotated(annotated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1050,7 +1053,7 @@ where
|
||||
}
|
||||
|
||||
fn push_text_spans_to_table_cell(&mut self, text: &str, style: Style) {
|
||||
let span = Span::styled(text.to_string(), style);
|
||||
let span = self.style_link_label(Span::styled(text.to_string(), style));
|
||||
let destination = self
|
||||
.link
|
||||
.as_ref()
|
||||
@@ -1794,6 +1797,17 @@ where
|
||||
self.inline_styles.pop();
|
||||
}
|
||||
|
||||
fn style_link_label(&mut self, mut span: Span<'static>) -> Span<'static> {
|
||||
if let Some(link) = self.link.as_mut()
|
||||
&& web_destination(&link.destination).is_some()
|
||||
{
|
||||
link.has_visible_label |=
|
||||
!span.content.trim().is_empty() && display_width(&span.content) > 0;
|
||||
span.style = span.style.patch(self.styles.link);
|
||||
}
|
||||
span
|
||||
}
|
||||
|
||||
fn push_link(&mut self, dest_url: String) {
|
||||
let style_label = (self.is_hidden_link_destination)(&dest_url);
|
||||
if style_label {
|
||||
@@ -1803,6 +1817,7 @@ where
|
||||
self.link = Some(LinkState {
|
||||
show_destination,
|
||||
style_label,
|
||||
has_visible_label: false,
|
||||
local_target_display: if is_local_path_like_link(&dest_url) {
|
||||
render_local_link_target(&dest_url, self.cwd.as_deref())
|
||||
} else {
|
||||
@@ -1817,7 +1832,9 @@ where
|
||||
if link.style_label {
|
||||
self.pop_inline_style();
|
||||
}
|
||||
if link.show_destination {
|
||||
if link.show_destination
|
||||
|| (!link.has_visible_label && web_destination(&link.destination).is_some())
|
||||
{
|
||||
// Link destinations are rendered as " (url)" suffixes. When parsing table cells,
|
||||
// append the suffix into the active cell buffer rather than the outer paragraph
|
||||
// line to avoid detached url lines.
|
||||
@@ -1970,10 +1987,15 @@ where
|
||||
}
|
||||
|
||||
fn push_span(&mut self, span: Span<'static>) {
|
||||
let span = self.style_link_label(span);
|
||||
if self.current_line_content.is_none() {
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
if let Some(line) = self.current_line_content.as_mut() {
|
||||
line.line.push_span(span);
|
||||
} else {
|
||||
self.push_line(Line::from(vec![span]));
|
||||
line.push_span(
|
||||
span,
|
||||
self.link.as_ref().map(|link| link.destination.as_str()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1993,7 +2015,7 @@ where
|
||||
}
|
||||
|
||||
fn push_text_spans(&mut self, text: &str, style: Style) {
|
||||
let span = Span::styled(text.to_string(), style);
|
||||
let span = self.style_link_label(Span::styled(text.to_string(), style));
|
||||
let destination = self
|
||||
.link
|
||||
.as_ref()
|
||||
@@ -2469,9 +2491,9 @@ mod tests {
|
||||
#[test]
|
||||
fn wrap_cell_preserves_hard_break_lines() {
|
||||
let mut cell = TableCell::default();
|
||||
cell.push_span("first line".into());
|
||||
cell.push_annotated(Line::from("first line").into());
|
||||
cell.hard_break();
|
||||
cell.push_span("second line".into());
|
||||
cell.push_annotated(Line::from("second line").into());
|
||||
|
||||
let writer = W::new(
|
||||
"",
|
||||
@@ -2506,7 +2528,7 @@ mod tests {
|
||||
/// Build a single-line `TableCell` from plain text.
|
||||
fn make_cell(text: &str) -> TableCell {
|
||||
let mut cell = TableCell::default();
|
||||
cell.push_span(Span::raw(text.to_string()));
|
||||
cell.push_annotated(Line::from(text.to_string()).into());
|
||||
cell
|
||||
}
|
||||
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
---
|
||||
source: tui/src/markdown_render/web_links_tests.rs
|
||||
expression: presentations
|
||||
---
|
||||
[
|
||||
Text::from_iter([
|
||||
Line::from_iter([
|
||||
Span::from("plain "),
|
||||
Span::from("plain").cyan().underlined(),
|
||||
Span::from(" "),
|
||||
Span::from("code").cyan(),
|
||||
Span::from(" "),
|
||||
Span::from("code").cyan().underlined(),
|
||||
]),
|
||||
Line::default(),
|
||||
Line::from_iter([
|
||||
Span::from(" "),
|
||||
Span::from("Resource"),
|
||||
]).fg(Color::Rgb(249, 226, 175)).bold(),
|
||||
Line::from(Span::from("━━━━━━━━━━").dim()),
|
||||
Line::from_iter([
|
||||
Span::from(" "),
|
||||
Span::from("docs").cyan().underlined(),
|
||||
]),
|
||||
]),
|
||||
Text::from_iter([
|
||||
Line::from_iter([
|
||||
Span::from("plain "),
|
||||
Span::from("plain").cyan().underlined(),
|
||||
Span::from(" ("),
|
||||
Span::from("https://example.com").cyan().underlined(),
|
||||
Span::from(")"),
|
||||
Span::from(" "),
|
||||
Span::from("code").cyan(),
|
||||
]),
|
||||
Line::from_iter([
|
||||
Span::from("code").cyan().underlined(),
|
||||
Span::from(" ("),
|
||||
Span::from("https://example.com").cyan().underlined(),
|
||||
Span::from(")"),
|
||||
]),
|
||||
Line::default(),
|
||||
Line::from_iter([
|
||||
Span::from(" "),
|
||||
Span::from("Resource"),
|
||||
]).fg(Color::Rgb(249, 226, 175)).bold(),
|
||||
Line::from(Span::from("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").dim()),
|
||||
Line::from_iter([
|
||||
Span::from(" "),
|
||||
Span::from("docs").cyan().underlined(),
|
||||
Span::from(" ("),
|
||||
Span::from("https://example.com/docs").cyan().underlined(),
|
||||
Span::from(")"),
|
||||
]),
|
||||
]),
|
||||
]
|
||||
@@ -10,7 +10,6 @@ use super::Options;
|
||||
use super::Parser;
|
||||
use super::Tag;
|
||||
use super::Writer;
|
||||
use super::never_hide_link_destination;
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -34,6 +33,7 @@ pub(crate) fn render_streaming_markdown_lines_with_width_and_cwd(
|
||||
input: &str,
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
is_hidden_link_destination: &dyn Fn(&str) -> bool,
|
||||
) -> StreamingMarkdownRender {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
@@ -47,7 +47,7 @@ pub(crate) fn render_streaming_markdown_lines_with_width_and_cwd(
|
||||
last_start: 0,
|
||||
first_is_html: false,
|
||||
};
|
||||
let mut writer = Writer::new(input, parser, width, cwd, &never_hide_link_destination);
|
||||
let mut writer = Writer::new(input, parser, width, cwd, is_hidden_link_destination);
|
||||
writer.run();
|
||||
StreamingMarkdownRender {
|
||||
lines: writer.text,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Presentation policy for web-link destinations in semantic Markdown output.
|
||||
//!
|
||||
//! Terminal identity is a conservative heuristic, not end-to-end capability negotiation. Unknown
|
||||
//! terminals and multiplexers retain the destination so a label never hides the only usable URL.
|
||||
|
||||
use codex_terminal_detection::TerminalInfo;
|
||||
use codex_terminal_detection::TerminalName;
|
||||
use codex_terminal_detection::terminal_info;
|
||||
use std::io::IsTerminal;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::terminal_hyperlinks::web_destination;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum WebLinkDisplay {
|
||||
LabelOnly,
|
||||
WithDestination,
|
||||
}
|
||||
|
||||
impl WebLinkDisplay {
|
||||
fn for_terminal(terminal: &TerminalInfo, term: Option<&str>) -> Self {
|
||||
if terminal.multiplexer.is_some()
|
||||
|| term.is_some_and(|term| {
|
||||
term == "dumb" || term.starts_with("screen") || term.starts_with("tmux")
|
||||
})
|
||||
{
|
||||
return Self::WithDestination;
|
||||
}
|
||||
match terminal.name {
|
||||
TerminalName::Ghostty
|
||||
| TerminalName::Iterm2
|
||||
| TerminalName::WezTerm
|
||||
| TerminalName::Kitty
|
||||
| TerminalName::VsCode
|
||||
| TerminalName::Alacritty
|
||||
| TerminalName::WindowsTerminal
|
||||
| TerminalName::Konsole
|
||||
| TerminalName::GnomeTerminal
|
||||
| TerminalName::Vte => Self::LabelOnly,
|
||||
TerminalName::AppleTerminal
|
||||
| TerminalName::WarpTerminal
|
||||
| TerminalName::Dumb
|
||||
| TerminalName::Unknown => Self::WithDestination,
|
||||
}
|
||||
}
|
||||
|
||||
fn hide_destination(self, destination: &str) -> bool {
|
||||
self == Self::LabelOnly && web_destination(destination).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hide_web_link_destination(destination: &str) -> bool {
|
||||
static DISPLAY: LazyLock<WebLinkDisplay> = LazyLock::new(|| {
|
||||
if !std::io::stdout().is_terminal() || std::env::var_os("STY").is_some() {
|
||||
return WebLinkDisplay::WithDestination;
|
||||
}
|
||||
WebLinkDisplay::for_terminal(&terminal_info(), std::env::var("TERM").ok().as_deref())
|
||||
});
|
||||
DISPLAY.hide_destination(destination)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "web_links_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,194 @@
|
||||
use super::WebLinkDisplay;
|
||||
use crate::markdown_render::render_markdown_lines_with_width_cwd_and_hidden_link_destinations;
|
||||
use crate::markdown_render::render_streaming_markdown_lines_with_width_and_cwd;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::TerminalHyperlink;
|
||||
use crate::terminal_hyperlinks::visible_lines;
|
||||
use codex_terminal_detection::Multiplexer;
|
||||
use codex_terminal_detection::TerminalInfo;
|
||||
use codex_terminal_detection::TerminalName;
|
||||
use insta::assert_debug_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Text;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn terminal(name: TerminalName) -> TerminalInfo {
|
||||
TerminalInfo {
|
||||
name,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(markdown: &str, width: usize, display: WebLinkDisplay) -> Vec<HyperlinkLine> {
|
||||
render_markdown_lines_with_width_cwd_and_hidden_link_destinations(
|
||||
markdown,
|
||||
Some(width),
|
||||
/*cwd*/ None,
|
||||
&|destination| display.hide_destination(destination),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supporting_terminals_render_only_the_styled_label_and_keep_its_target() {
|
||||
for name in [
|
||||
TerminalName::Ghostty,
|
||||
TerminalName::Iterm2,
|
||||
TerminalName::WezTerm,
|
||||
TerminalName::Kitty,
|
||||
TerminalName::VsCode,
|
||||
TerminalName::Alacritty,
|
||||
TerminalName::WindowsTerminal,
|
||||
TerminalName::Konsole,
|
||||
TerminalName::GnomeTerminal,
|
||||
TerminalName::Vte,
|
||||
] {
|
||||
let display = WebLinkDisplay::for_terminal(&terminal(name), /*term*/ None);
|
||||
for (markdown, label) in [
|
||||
("[label](https://example.com)", "label".cyan().underlined()),
|
||||
(
|
||||
"[`label`](https://example.com)",
|
||||
"label".cyan().underlined(),
|
||||
),
|
||||
(
|
||||
"[**label**](https://example.com)",
|
||||
"label".cyan().bold().underlined(),
|
||||
),
|
||||
(
|
||||
"[*label*](https://example.com)",
|
||||
"label".cyan().italic().underlined(),
|
||||
),
|
||||
("[<b>](https://example.com)", "<b>".cyan().underlined()),
|
||||
(
|
||||
"[https://example.com](https://example.com)",
|
||||
"https://example.com".cyan().underlined(),
|
||||
),
|
||||
] {
|
||||
let label_width = label.width();
|
||||
assert_eq!(
|
||||
render(markdown, /*width*/ 80, display),
|
||||
vec![HyperlinkLine {
|
||||
line: Line::from(label),
|
||||
hyperlinks: vec![TerminalHyperlink::web(
|
||||
0..label_width,
|
||||
"https://example.com".into(),
|
||||
)],
|
||||
}],
|
||||
"terminal: {name:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_terminals_and_multiplexers_keep_visible_destinations() {
|
||||
let mut tmux = terminal(TerminalName::Ghostty);
|
||||
tmux.multiplexer = Some(Multiplexer::Tmux { version: None });
|
||||
let mut zellij = terminal(TerminalName::Ghostty);
|
||||
zellij.multiplexer = Some(Multiplexer::Zellij { version: None });
|
||||
let markdown = "[label](https://example.com)";
|
||||
let expected = render(markdown, /*width*/ 80, WebLinkDisplay::WithDestination);
|
||||
for (terminal, term) in [
|
||||
(terminal(TerminalName::Unknown), None),
|
||||
(terminal(TerminalName::AppleTerminal), None),
|
||||
(terminal(TerminalName::Dumb), None),
|
||||
(tmux, None),
|
||||
(zellij, None),
|
||||
(terminal(TerminalName::Ghostty), Some("screen-256color")),
|
||||
(terminal(TerminalName::Ghostty), Some("tmux-256color")),
|
||||
(terminal(TerminalName::Ghostty), Some("dumb")),
|
||||
] {
|
||||
assert_eq!(
|
||||
render(
|
||||
markdown,
|
||||
/*width*/ 80,
|
||||
WebLinkDisplay::for_terminal(&terminal, term),
|
||||
),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_only_policy_keeps_non_web_links_and_literal_urls_unchanged() {
|
||||
let markdown = "[file](./src/main.rs) [mail](mailto:a@example.com)\n\nhttps://example.com\n\n`https://example.com/code`";
|
||||
assert_eq!(
|
||||
render(markdown, /*width*/ 80, WebLinkDisplay::LabelOnly),
|
||||
render(markdown, /*width*/ 80, WebLinkDisplay::WithDestination),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_web_link_labels_keep_a_visible_destination() {
|
||||
for markdown in [
|
||||
"[](https://example.com)",
|
||||
"[ ](https://example.com)",
|
||||
"[\u{200d}](https://example.com)",
|
||||
] {
|
||||
assert_eq!(
|
||||
render(markdown, /*width*/ 80, WebLinkDisplay::LabelOnly),
|
||||
render(markdown, /*width*/ 80, WebLinkDisplay::WithDestination),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_only_web_links_keep_targets_after_prose_and_table_wrapping() {
|
||||
let links = "[plain label](https://example.com/plain) [`code label`](https://example.com/code) [<b>](https://example.com/html)";
|
||||
for markdown in [
|
||||
links.to_string(),
|
||||
format!("| Links |\n| --- |\n| {links} |"),
|
||||
] {
|
||||
for width in [12, 40] {
|
||||
let lines = render(&markdown, width, WebLinkDisplay::LabelOnly);
|
||||
let targets = lines
|
||||
.iter()
|
||||
.flat_map(|line| &line.hyperlinks)
|
||||
.map(|link| link.destination.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert_eq!(
|
||||
targets,
|
||||
BTreeSet::from([
|
||||
"https://example.com/plain",
|
||||
"https://example.com/code",
|
||||
"https://example.com/html",
|
||||
]),
|
||||
);
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.all(|line| !line.line.to_string().contains("https://"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_and_full_render_agree_with_label_only_links() {
|
||||
let markdown = "See [**label**](https://example.com).\n\n| Resource |\n| --- |\n| [`code`](https://example.com/code) |\n\nTrailing text.";
|
||||
let display = WebLinkDisplay::LabelOnly;
|
||||
for width in [12, 40] {
|
||||
for end in 1..=markdown.len() {
|
||||
let prefix = &markdown[..end];
|
||||
let streamed = render_streaming_markdown_lines_with_width_and_cwd(
|
||||
prefix,
|
||||
Some(width),
|
||||
/*cwd*/ None,
|
||||
&|destination| display.hide_destination(destination),
|
||||
);
|
||||
assert_eq!(streamed.lines, render(prefix, width, display));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_only_and_fallback_presentations_snapshot() {
|
||||
let markdown = "plain [plain](https://example.com) `code` [`code`](https://example.com)\n\n| Resource |\n| --- |\n| [docs](https://example.com/docs) |";
|
||||
let presentations = [WebLinkDisplay::LabelOnly, WebLinkDisplay::WithDestination]
|
||||
.map(|display| Text::from(visible_lines(render(markdown, /*width*/ 40, display))));
|
||||
assert_debug_snapshot!(presentations);
|
||||
}
|
||||
@@ -757,7 +757,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(),
|
||||
@@ -765,6 +765,65 @@ fn link() {
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_link_labels_use_link_style_and_preserve_inline_formatting() {
|
||||
for (label, expected_label) in [
|
||||
("plain", "plain".cyan().underlined()),
|
||||
("`code`", "code".cyan().underlined()),
|
||||
("**bold**", "bold".cyan().bold().underlined()),
|
||||
("*italic*", "italic".cyan().italic().underlined()),
|
||||
] {
|
||||
let text = render_markdown_text(&format!(
|
||||
"before [{label}](https://example.com) after `code`"
|
||||
));
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"before ".into(),
|
||||
expected_label,
|
||||
" (".into(),
|
||||
"https://example.com".cyan().underlined(),
|
||||
")".into(),
|
||||
" after ".into(),
|
||||
"code".cyan(),
|
||||
]));
|
||||
assert_eq!(text, expected, "label: {label}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_link_labels_keep_link_style_in_wrapped_prose_and_tables() {
|
||||
let links =
|
||||
"[plain](https://example.com) [`code`](https://example.com) [<b>](https://example.com)";
|
||||
for markdown in [links.to_string(), format!("| Links |\n| --- |\n| {links} |")]
|
||||
{
|
||||
for width in [32, 80] {
|
||||
let text = render_markdown_text_with_width(&markdown, Some(width));
|
||||
let labels = text
|
||||
.lines
|
||||
.iter()
|
||||
.flat_map(|line| &line.spans)
|
||||
.filter(|span| matches!(span.content.as_ref(), "plain" | "code" | "<b>"))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec![
|
||||
"plain".cyan().underlined(),
|
||||
"code".cyan().underlined(),
|
||||
"<b>".cyan().underlined()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_link_labels_have_a_visible_underline_snapshot() {
|
||||
let text = render_markdown_text(
|
||||
"plain [plain](https://example.com) `code` [`code`](https://example.com)",
|
||||
);
|
||||
assert_debug_snapshot!(text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_location_suffix_regexes() {
|
||||
let _colon = &*COLON_LOCATION_SUFFIX_RE;
|
||||
@@ -912,7 +971,7 @@ fn file_link_uses_target_path_for_hash_range() {
|
||||
fn url_link_shows_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(),
|
||||
|
||||
@@ -7,15 +7,11 @@ use crate::history_cell::UserHistoryCell;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::style::user_message_style;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
|
||||
use crate::terminal_hyperlinks::visible_lines_ref;
|
||||
use crate::terminal_hyperlinks::HyperlinkParagraph;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
/// Renders a committed history cell directly into the visible transcript viewport.
|
||||
pub(super) struct CellRenderable {
|
||||
@@ -40,12 +36,9 @@ impl Renderable for CellRenderable {
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let p = Paragraph::new(Text::from(visible_lines_ref(&hyperlink_lines)))
|
||||
.style(style)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((scroll_offset, 0));
|
||||
p.render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &hyperlink_lines, usize::from(scroll_offset));
|
||||
HyperlinkParagraph::new(&hyperlink_lines, style)
|
||||
.scroll(scroll_offset)
|
||||
.render(area, buf);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -66,17 +59,14 @@ impl Renderable for HyperlinkLinesRenderable {
|
||||
|
||||
/// Keep live-tail hyperlinks aligned with the same visible rows as their text.
|
||||
fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool {
|
||||
Paragraph::new(Text::from(visible_lines_ref(&self.lines)))
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((scroll_offset, 0))
|
||||
HyperlinkParagraph::new(&self.lines, Style::default())
|
||||
.scroll(scroll_offset)
|
||||
.render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &self.lines, usize::from(scroll_offset));
|
||||
true
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
Paragraph::new(Text::from(visible_lines_ref(&self.lines)))
|
||||
.wrap(Wrap { trim: false })
|
||||
HyperlinkParagraph::new(&self.lines, Style::default())
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
.unwrap_or(/*default*/ 0)
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/markdown_render_tests.rs
|
||||
expression: text
|
||||
---
|
||||
Text::from(Line::from_iter([Span::from("plain "), Span::from("plain").cyan().underlined(), Span::from(" ("), Span::from("https://example.com").cyan().underlined(), Span::from(")"), Span::from(" "), Span::from("code").cyan(), Span::from(" "), Span::from("code").cyan().underlined(), Span::from(" ("), Span::from("https://example.com").cyan().underlined(), Span::from(")")]))
|
||||
+1
-1
@@ -17,7 +17,7 @@ expression: render.lines
|
||||
HyperlinkLine {
|
||||
line: Line::from_iter([
|
||||
Span::from("First paragraph with a "),
|
||||
Span::from("link"),
|
||||
Span::from("link").cyan().underlined(),
|
||||
]),
|
||||
hyperlinks: [
|
||||
TerminalHyperlink {
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
//! Layout code measures and wraps ordinary ratatui lines. Hyperlink annotations are applied only
|
||||
//! when text reaches a terminal buffer or scrollback writer so OSC 8 bytes never affect geometry.
|
||||
|
||||
mod paragraph;
|
||||
|
||||
pub(crate) use paragraph::HyperlinkParagraph;
|
||||
|
||||
use std::num::NonZeroU16;
|
||||
use std::ops::Range;
|
||||
|
||||
@@ -778,10 +782,7 @@ mod tests {
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
Paragraph::new(Text::from(line.line.clone()))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, &mut buf);
|
||||
mark_buffer_hyperlinks(&mut buf, area, &[line], /*scroll_rows*/ 0);
|
||||
HyperlinkParagraph::new(&[line], Style::default()).render(area, &mut buf);
|
||||
|
||||
let linked_text = area
|
||||
.positions()
|
||||
@@ -819,11 +820,9 @@ mod tests {
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let buf = frame.buffer_mut();
|
||||
Paragraph::new(Text::from(visible_lines_ref(&lines)))
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((/*vertical*/ 2, /*horizontal*/ 0))
|
||||
HyperlinkParagraph::new(&lines, Style::default())
|
||||
.scroll(/*rows*/ 2)
|
||||
.render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &lines, /*scroll_rows*/ 2);
|
||||
|
||||
let linked_text = area
|
||||
.positions()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Paragraph rendering that keeps visible text and hyperlink annotations aligned.
|
||||
|
||||
use super::HyperlinkLine;
|
||||
use super::mark_buffer_hyperlinks;
|
||||
use super::visible_lines_ref;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
/// Word-wraps without trimming and applies the same vertical scroll to text and links.
|
||||
pub(crate) struct HyperlinkParagraph<'a> {
|
||||
lines: &'a [HyperlinkLine],
|
||||
paragraph: Paragraph<'a>,
|
||||
scroll_rows: u16,
|
||||
}
|
||||
|
||||
impl<'a> HyperlinkParagraph<'a> {
|
||||
pub(crate) fn new(lines: &'a [HyperlinkLine], style: Style) -> Self {
|
||||
Self {
|
||||
lines,
|
||||
paragraph: Paragraph::new(Text::from(visible_lines_ref(lines)))
|
||||
.style(style)
|
||||
.wrap(Wrap { trim: false }),
|
||||
scroll_rows: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn line_count(&self, width: u16) -> usize {
|
||||
self.paragraph.line_count(width)
|
||||
}
|
||||
|
||||
pub(crate) fn scroll(mut self, rows: u16) -> Self {
|
||||
self.scroll_rows = rows;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for HyperlinkParagraph<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
self.paragraph
|
||||
.scroll((self.scroll_rows, 0))
|
||||
.render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, self.lines, usize::from(self.scroll_rows));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user