Skip to main content

supercode_frontend_tui/terminal/
hyperlinks.rs

1// Derived from OpenAI Codex: codex-rs/tui/src/terminal_hyperlinks.rs
2// Pinned source: 8604689ec5e3437eb79802d8d72249b7722fbf5b
3// Copyright 2025 OpenAI
4// Licensed under the Apache License, Version 2.0.
5// Modified by the Supercode contributors; see docs/legal/codex-frontend-extraction.toml.
6
7//! Semantic terminal hyperlinks carried separately from visible TUI text.
8//!
9//! Layout code measures and wraps ordinary ratatui lines. Hyperlink annotations are applied only
10//! when text reaches a terminal buffer or scrollback writer so OSC 8 bytes never affect geometry.
11
12use std::ops::Range;
13
14use ratatui::buffer::Buffer;
15use ratatui::buffer::CellDiffOption;
16use ratatui::layout::Rect;
17use ratatui::style::Color;
18use ratatui::style::Modifier;
19use ratatui::text::Line;
20use ratatui::text::Span;
21use ratatui::text::Text;
22use ratatui::widgets::Paragraph;
23use ratatui::widgets::Widget;
24use ratatui::widgets::Wrap;
25use unicode_width::UnicodeWidthChar;
26use unicode_width::UnicodeWidthStr;
27use url::Url;
28
29use crate::foundation::wrapping::adaptive_wrap_line;
30use crate::foundation::wrapping::RtOptions;
31
32fn line_to_static(line: &Line<'_>) -> Line<'static> {
33    Line {
34        style: line.style,
35        alignment: line.alignment,
36        spans: line
37            .spans
38            .iter()
39            .map(|span| Span::styled(span.content.to_string(), span.style))
40            .collect(),
41    }
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct TerminalHyperlink {
46    pub columns: Range<usize>,
47    pub destination: String,
48}
49
50#[derive(Clone, Debug, Default, Eq, PartialEq)]
51pub struct HyperlinkLine {
52    pub line: Line<'static>,
53    pub hyperlinks: Vec<TerminalHyperlink>,
54}
55
56impl HyperlinkLine {
57    pub fn new(line: Line<'static>) -> Self {
58        Self {
59            line,
60            hyperlinks: Vec::new(),
61        }
62    }
63
64    pub fn width(&self) -> usize {
65        self.line.width()
66    }
67
68    pub fn push_span(&mut self, span: Span<'static>, destination: Option<&str>) {
69        let start = self.width();
70        let end = start + span.content.width();
71        self.line.push_span(span);
72        if end > start {
73            if let Some(destination) = destination.and_then(web_destination) {
74                self.hyperlinks.push(TerminalHyperlink {
75                    columns: start..end,
76                    destination,
77                });
78            }
79        }
80    }
81
82    pub fn style(mut self, style: ratatui::style::Style) -> Self {
83        self.line = self.line.style(style);
84        self
85    }
86}
87
88impl From<Line<'static>> for HyperlinkLine {
89    fn from(line: Line<'static>) -> Self {
90        Self::new(line)
91    }
92}
93
94impl From<&'static str> for HyperlinkLine {
95    fn from(text: &'static str) -> Self {
96        Self::new(Line::from(text))
97    }
98}
99
100impl From<String> for HyperlinkLine {
101    fn from(text: String) -> Self {
102        Self::new(Line::from(text))
103    }
104}
105
106pub fn visible_lines(lines: Vec<HyperlinkLine>) -> Vec<Line<'static>> {
107    lines.into_iter().map(|line| line.line).collect()
108}
109
110pub fn plain_hyperlink_lines(lines: Vec<Line<'static>>) -> Vec<HyperlinkLine> {
111    lines.into_iter().map(HyperlinkLine::new).collect()
112}
113
114pub fn prefix_hyperlink_lines(
115    lines: Vec<HyperlinkLine>,
116    initial_prefix: Span<'static>,
117    subsequent_prefix: Span<'static>,
118) -> Vec<HyperlinkLine> {
119    lines
120        .into_iter()
121        .enumerate()
122        .map(|(index, mut line)| {
123            let prefix = if index == 0 {
124                initial_prefix.clone()
125            } else {
126                subsequent_prefix.clone()
127            };
128            let shift = prefix.content.width();
129            let mut spans = Vec::with_capacity(line.line.spans.len() + 1);
130            spans.push(prefix);
131            spans.extend(line.line.spans);
132            line.line = Line::from(spans).style(line.line.style);
133            for hyperlink in &mut line.hyperlinks {
134                hyperlink.columns = hyperlink.columns.start + shift..hyperlink.columns.end + shift;
135            }
136            line
137        })
138        .collect()
139}
140
141pub fn adaptive_wrap_hyperlink_lines(
142    lines: &[HyperlinkLine],
143    options: RtOptions<'static>,
144) -> Vec<HyperlinkLine> {
145    let mut out = Vec::new();
146    for (index, line) in lines.iter().enumerate() {
147        let options = if index == 0 {
148            options.clone()
149        } else {
150            options
151                .clone()
152                .initial_indent(options.subsequent_indent.clone())
153        };
154        out.extend(remap_wrapped_line(
155            line,
156            adaptive_wrap_line(&line.line, options)
157                .into_iter()
158                .map(|wrapped| line_to_static(&wrapped))
159                .collect(),
160        ));
161    }
162    out
163}
164
165pub fn annotate_web_urls(lines: Vec<Line<'static>>) -> Vec<HyperlinkLine> {
166    lines.into_iter().map(annotate_web_urls_in_line).collect()
167}
168
169pub fn annotate_web_urls_in_line(line: Line<'static>) -> HyperlinkLine {
170    let text = line
171        .spans
172        .iter()
173        .map(|span| span.content.as_ref())
174        .collect::<String>();
175    let mut out = HyperlinkLine::new(line);
176    out.hyperlinks = web_links_in_text(&text);
177    out
178}
179
180/// Re-attach source hyperlink ranges after visible-text wrapping has split a line.
181///
182/// Link text is matched in display order so a URL split across table rows retains the complete
183/// destination on every rendered fragment. Whitespace inserted or removed at line boundaries is
184/// ignored while matching; hyperlink destinations themselves are never reconstructed from output.
185pub fn remap_wrapped_line(
186    source: &HyperlinkLine,
187    wrapped: Vec<Line<'static>>,
188) -> Vec<HyperlinkLine> {
189    let mut out = plain_hyperlink_lines(wrapped);
190    let source_text = line_text(&source.line);
191    let mut source_byte = 0usize;
192    let mut source_column = 0usize;
193    for (index, line) in out.iter_mut().enumerate() {
194        if index > 0 {
195            let trimmed = source_text[source_byte..].trim_start_matches(char::is_whitespace);
196            let skipped = source_text[source_byte..].len() - trimmed.len();
197            source_column += source_text[source_byte..source_byte + skipped].width();
198            source_byte += skipped;
199        }
200
201        let rendered = line_text(&line.line);
202        let remaining = &source_text[source_byte..];
203        let Some(rendered_start) = longest_suffix_matching_prefix(&rendered, remaining) else {
204            continue;
205        };
206        let mapped = &rendered[rendered_start..];
207        let mut output_column = rendered[..rendered_start].width();
208        for ch in mapped.chars() {
209            let width = ch.width().unwrap_or(/*default*/ 0);
210            if let Some(link) = source
211                .hyperlinks
212                .iter()
213                .find(|link| link.columns.contains(&source_column))
214            {
215                push_link_range(
216                    line,
217                    output_column..output_column + width,
218                    &link.destination,
219                );
220            }
221            source_column += width;
222            output_column += width;
223        }
224        source_byte += mapped.len();
225    }
226    out
227}
228
229fn line_text(line: &Line<'_>) -> String {
230    line.spans
231        .iter()
232        .map(|span| span.content.as_ref())
233        .collect()
234}
235
236fn longest_suffix_matching_prefix(rendered: &str, source: &str) -> Option<usize> {
237    rendered
238        .char_indices()
239        .map(|(index, _)| index)
240        .chain(std::iter::once(rendered.len()))
241        .find(|index| source.starts_with(&rendered[*index..]) && *index < rendered.len())
242}
243
244fn push_link_range(line: &mut HyperlinkLine, range: Range<usize>, destination: &str) {
245    if range.is_empty() {
246        return;
247    }
248    if let Some(previous) = line.hyperlinks.last_mut() {
249        if previous.destination == destination && previous.columns.end == range.start {
250            previous.columns.end = range.end;
251            return;
252        }
253    }
254    line.hyperlinks.push(TerminalHyperlink {
255        columns: range,
256        destination: destination.to_string(),
257    });
258}
259
260pub fn web_links_in_text(text: &str) -> Vec<TerminalHyperlink> {
261    let mut links = Vec::new();
262    let mut search_from = 0usize;
263    for raw_token in text.split_ascii_whitespace() {
264        let Some(relative_start) = text[search_from..].find(raw_token) else {
265            continue;
266        };
267        let raw_start = search_from + relative_start;
268        search_from = raw_start + raw_token.len();
269        let trimmed_start = raw_token
270            .find(|ch: char| !is_leading_punctuation(ch))
271            .unwrap_or(raw_token.len());
272        let trimmed_end = trailing_url_end(&raw_token[trimmed_start..]) + trimmed_start;
273        if trimmed_start >= trimmed_end {
274            continue;
275        }
276        let candidate = &raw_token[trimmed_start..trimmed_end];
277        let Some(destination) = web_destination(candidate) else {
278            continue;
279        };
280        let start = text[..raw_start + trimmed_start].width();
281        let end = start + candidate.width();
282        links.push(TerminalHyperlink {
283            columns: start..end,
284            destination,
285        });
286    }
287    links
288}
289
290fn is_leading_punctuation(ch: char) -> bool {
291    matches!(
292        ch,
293        '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | ',' | '.' | ';' | '!' | '\'' | '"'
294    )
295}
296
297fn trailing_url_end(candidate: &str) -> usize {
298    let mut end = candidate.len();
299    while end > 0 {
300        let remaining = &candidate[..end];
301        let Some(ch) = remaining.chars().next_back() else {
302            break;
303        };
304        let trim = matches!(ch, ',' | '.' | ';' | '!' | '\'' | '"')
305            || matches!(ch, ')' | ']' | '}' | '>')
306                && has_unmatched_closing_delimiter(remaining, ch);
307        if !trim {
308            break;
309        }
310        end -= ch.len_utf8();
311    }
312    end
313}
314
315fn has_unmatched_closing_delimiter(candidate: &str, closing: char) -> bool {
316    let opening = match closing {
317        ')' => '(',
318        ']' => '[',
319        '}' => '{',
320        '>' => '<',
321        _ => return false,
322    };
323    candidate.chars().filter(|ch| *ch == closing).count()
324        > candidate.chars().filter(|ch| *ch == opening).count()
325}
326
327pub fn web_destination(destination: &str) -> Option<String> {
328    let safe_destination = destination
329        .chars()
330        .filter(|ch| !ch.is_control())
331        .collect::<String>();
332    let parsed = Url::parse(&safe_destination).ok()?;
333    matches!(parsed.scheme(), "http" | "https")
334        .then(|| parsed.host_str())
335        .flatten()?;
336    Some(safe_destination)
337}
338
339pub fn osc8_hyperlink(destination: &str, text: &str) -> String {
340    let Some(safe_destination) = web_destination(destination) else {
341        return text.to_string();
342    };
343    format!("\x1b]8;;{safe_destination}\x07{text}\x1b]8;;\x07")
344}
345
346pub fn decorate_spans(line: &HyperlinkLine) -> Vec<Span<'static>> {
347    if line.hyperlinks.is_empty() {
348        return line.line.spans.clone();
349    }
350
351    let mut out = Vec::new();
352    let mut column = 0usize;
353    let mut link_index = 0usize;
354    let mut active_link_index = None;
355    let mut active_destination: Option<String> = None;
356    for span in &line.line.spans {
357        for ch in span.content.chars() {
358            let width = ch.width().unwrap_or(/*default*/ 0);
359            while line
360                .hyperlinks
361                .get(link_index)
362                .is_some_and(|link| link.columns.end <= column)
363            {
364                link_index += 1;
365            }
366            let selected_link_index = line
367                .hyperlinks
368                .get(link_index)
369                .and_then(|link| link.columns.contains(&column).then_some(link_index));
370            if active_link_index != selected_link_index {
371                if active_destination.is_some() {
372                    append_to_last_span(&mut out, "\x1b]8;;\x07");
373                }
374                active_destination = selected_link_index
375                    .and_then(|index| web_destination(&line.hyperlinks[index].destination));
376                if let Some(destination) = active_destination.as_ref() {
377                    push_styled_content(
378                        &mut out,
379                        &format!("\x1b]8;;{destination}\x07"),
380                        span.style,
381                    );
382                }
383                active_link_index = selected_link_index;
384            }
385            push_styled_content(&mut out, &ch.to_string(), span.style);
386            column += width;
387        }
388    }
389    if active_destination.is_some() {
390        append_to_last_span(&mut out, "\x1b]8;;\x07");
391    }
392    out
393}
394
395fn push_styled_content(out: &mut Vec<Span<'static>>, content: &str, style: ratatui::style::Style) {
396    if let Some(last) = out.last_mut() {
397        if last.style == style {
398            last.content.to_mut().push_str(content);
399            return;
400        }
401    }
402    out.push(Span::styled(content.to_string(), style));
403}
404
405fn append_to_last_span(out: &mut [Span<'static>], content: &str) {
406    if let Some(last) = out.last_mut() {
407        last.content.to_mut().push_str(content);
408    }
409}
410
411pub fn mark_buffer_hyperlinks(
412    buf: &mut Buffer,
413    area: Rect,
414    lines: &[HyperlinkLine],
415    scroll_rows: usize,
416) {
417    if area.width == 0 {
418        return;
419    }
420    let mut logical_row = 0usize;
421    for line in lines {
422        let paragraph = Paragraph::new(Text::from(line.line.clone())).wrap(Wrap { trim: false });
423        let rendered_height = paragraph.line_count(area.width).max(/*other*/ 1);
424        if line.hyperlinks.is_empty() {
425            logical_row += rendered_height;
426            continue;
427        }
428
429        let layout_area = Rect::new(
430            /*x*/ 0,
431            /*y*/ 0,
432            area.width,
433            u16::try_from(rendered_height).unwrap_or(u16::MAX),
434        );
435        let mut layout = Buffer::empty(layout_area);
436        paragraph.render(layout_area, &mut layout);
437        let rendered_lines = (0..layout_area.height)
438            .map(|row| {
439                let text = (0..layout_area.width)
440                    .filter_map(|column| {
441                        let cell = &layout[(column, row)];
442                        (cell.diff_option != CellDiffOption::Skip).then(|| cell.symbol())
443                    })
444                    .collect::<String>();
445                Line::from(text.trim_end().to_string())
446            })
447            .collect();
448        for (row, rendered) in remap_wrapped_line(line, rendered_lines).iter().enumerate() {
449            for link in &rendered.hyperlinks {
450                for column in link.columns.clone() {
451                    let row = logical_row + row;
452                    if row < scroll_rows || row - scroll_rows >= usize::from(area.height) {
453                        continue;
454                    }
455                    let x = area.x + column as u16;
456                    let y = area.y + (row - scroll_rows) as u16;
457                    let cell = &mut buf[(x, y)];
458                    if cell.diff_option == CellDiffOption::Skip || cell.symbol().trim().is_empty() {
459                        continue;
460                    }
461                    let symbol = osc8_hyperlink(&link.destination, cell.symbol());
462                    cell.set_symbol(&symbol);
463                }
464            }
465        }
466        logical_row += rendered_height;
467    }
468}
469
470pub fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, destination: &str) {
471    mark_matching_cells(buf, area, destination, |cell| {
472        cell.fg == Color::Cyan && cell.modifier.contains(Modifier::UNDERLINED)
473    });
474}
475
476pub fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, destination: &str) {
477    mark_matching_cells(buf, area, destination, |cell| {
478        cell.modifier.contains(Modifier::UNDERLINED)
479    });
480}
481
482fn mark_matching_cells(
483    buf: &mut Buffer,
484    area: Rect,
485    destination: &str,
486    matches: impl Fn(&ratatui::buffer::Cell) -> bool,
487) {
488    if web_destination(destination).is_none() {
489        return;
490    }
491    for position in area.positions() {
492        let cell = &mut buf[position];
493        if cell.diff_option != CellDiffOption::Skip
494            && !cell.symbol().trim().is_empty()
495            && matches(cell)
496        {
497            let symbol = osc8_hyperlink(destination, cell.symbol());
498            cell.set_symbol(&symbol);
499        }
500    }
501}