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
346#[cfg(test)]
347pub fn strip_osc8(text: &str) -> String {
348    let bytes = text.as_bytes();
349    let mut stripped = String::with_capacity(text.len());
350    let mut index = 0usize;
351
352    while index < bytes.len() {
353        if bytes[index..].starts_with(b"\x1b]8;;") {
354            index += 5;
355            while index < bytes.len() {
356                if bytes[index] == b'\x07' {
357                    index += 1;
358                    break;
359                }
360                if index + 1 < bytes.len() && bytes[index] == b'\x1b' && bytes[index + 1] == b'\\' {
361                    index += 2;
362                    break;
363                }
364                index += 1;
365            }
366            continue;
367        }
368        let ch = text[index..]
369            .chars()
370            .next()
371            .expect("current byte index starts a character");
372        stripped.push(ch);
373        index += ch.len_utf8();
374    }
375
376    stripped
377}
378
379pub fn decorate_spans(line: &HyperlinkLine) -> Vec<Span<'static>> {
380    if line.hyperlinks.is_empty() {
381        return line.line.spans.clone();
382    }
383
384    let mut out = Vec::new();
385    let mut column = 0usize;
386    let mut link_index = 0usize;
387    let mut active_link_index = None;
388    let mut active_destination: Option<String> = None;
389    for span in &line.line.spans {
390        for ch in span.content.chars() {
391            let width = ch.width().unwrap_or(/*default*/ 0);
392            while line
393                .hyperlinks
394                .get(link_index)
395                .is_some_and(|link| link.columns.end <= column)
396            {
397                link_index += 1;
398            }
399            let selected_link_index = line
400                .hyperlinks
401                .get(link_index)
402                .and_then(|link| link.columns.contains(&column).then_some(link_index));
403            if active_link_index != selected_link_index {
404                if active_destination.is_some() {
405                    append_to_last_span(&mut out, "\x1b]8;;\x07");
406                }
407                active_destination = selected_link_index
408                    .and_then(|index| web_destination(&line.hyperlinks[index].destination));
409                if let Some(destination) = active_destination.as_ref() {
410                    push_styled_content(
411                        &mut out,
412                        &format!("\x1b]8;;{destination}\x07"),
413                        span.style,
414                    );
415                }
416                active_link_index = selected_link_index;
417            }
418            push_styled_content(&mut out, &ch.to_string(), span.style);
419            column += width;
420        }
421    }
422    if active_destination.is_some() {
423        append_to_last_span(&mut out, "\x1b]8;;\x07");
424    }
425    out
426}
427
428fn push_styled_content(out: &mut Vec<Span<'static>>, content: &str, style: ratatui::style::Style) {
429    if let Some(last) = out.last_mut() {
430        if last.style == style {
431            last.content.to_mut().push_str(content);
432            return;
433        }
434    }
435    out.push(Span::styled(content.to_string(), style));
436}
437
438fn append_to_last_span(out: &mut [Span<'static>], content: &str) {
439    if let Some(last) = out.last_mut() {
440        last.content.to_mut().push_str(content);
441    }
442}
443
444pub fn mark_buffer_hyperlinks(
445    buf: &mut Buffer,
446    area: Rect,
447    lines: &[HyperlinkLine],
448    scroll_rows: usize,
449) {
450    if area.width == 0 {
451        return;
452    }
453    let mut logical_row = 0usize;
454    for line in lines {
455        let paragraph = Paragraph::new(Text::from(line.line.clone())).wrap(Wrap { trim: false });
456        let rendered_height = paragraph.line_count(area.width).max(/*other*/ 1);
457        if line.hyperlinks.is_empty() {
458            logical_row += rendered_height;
459            continue;
460        }
461
462        let layout_area = Rect::new(
463            /*x*/ 0,
464            /*y*/ 0,
465            area.width,
466            u16::try_from(rendered_height).unwrap_or(u16::MAX),
467        );
468        let mut layout = Buffer::empty(layout_area);
469        paragraph.render(layout_area, &mut layout);
470        let rendered_lines = (0..layout_area.height)
471            .map(|row| {
472                let text = (0..layout_area.width)
473                    .filter_map(|column| {
474                        let cell = &layout[(column, row)];
475                        (cell.diff_option != CellDiffOption::Skip).then(|| cell.symbol())
476                    })
477                    .collect::<String>();
478                Line::from(text.trim_end().to_string())
479            })
480            .collect();
481        for (row, rendered) in remap_wrapped_line(line, rendered_lines).iter().enumerate() {
482            for link in &rendered.hyperlinks {
483                for column in link.columns.clone() {
484                    let row = logical_row + row;
485                    if row < scroll_rows || row - scroll_rows >= usize::from(area.height) {
486                        continue;
487                    }
488                    let x = area.x + column as u16;
489                    let y = area.y + (row - scroll_rows) as u16;
490                    let cell = &mut buf[(x, y)];
491                    if cell.diff_option == CellDiffOption::Skip || cell.symbol().trim().is_empty() {
492                        continue;
493                    }
494                    let symbol = osc8_hyperlink(&link.destination, cell.symbol());
495                    cell.set_symbol(&symbol);
496                }
497            }
498        }
499        logical_row += rendered_height;
500    }
501}
502
503pub fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, destination: &str) {
504    mark_matching_cells(buf, area, destination, |cell| {
505        cell.fg == Color::Cyan && cell.modifier.contains(Modifier::UNDERLINED)
506    });
507}
508
509pub fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, destination: &str) {
510    mark_matching_cells(buf, area, destination, |cell| {
511        cell.modifier.contains(Modifier::UNDERLINED)
512    });
513}
514
515fn mark_matching_cells(
516    buf: &mut Buffer,
517    area: Rect,
518    destination: &str,
519    matches: impl Fn(&ratatui::buffer::Cell) -> bool,
520) {
521    if web_destination(destination).is_none() {
522        return;
523    }
524    for position in area.positions() {
525        let cell = &mut buf[position];
526        if cell.diff_option != CellDiffOption::Skip
527            && !cell.symbol().trim().is_empty()
528            && matches(cell)
529        {
530            let symbol = osc8_hyperlink(destination, cell.symbol());
531            cell.set_symbol(&symbol);
532        }
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use pretty_assertions::assert_eq;
540
541    #[test]
542    fn only_web_destinations_receive_osc8() {
543        assert!(osc8_hyperlink("https://example.com/a", "a").contains("\x1b]8;;"));
544        assert_eq!(osc8_hyperlink("mailto:a@example.com", "a"), "a");
545        assert_eq!(
546            osc8_hyperlink("https://example.com/\u{7}safe", "a"),
547            "\x1b]8;;https://example.com/safe\x07a\x1b]8;;\x07"
548        );
549        assert_eq!(
550            strip_osc8(&osc8_hyperlink("https://example.com/a", "visible")),
551            "visible"
552        );
553    }
554
555    #[test]
556    fn discovers_punctuated_web_url_columns() {
557        assert_eq!(
558            web_links_in_text("See (https://example.com/a)."),
559            vec![TerminalHyperlink {
560                columns: 5..26,
561                destination: "https://example.com/a".to_string(),
562            }]
563        );
564    }
565
566    #[test]
567    fn preserves_balanced_parentheses_in_bare_web_urls() {
568        let destination = "https://en.wikipedia.org/wiki/Function_(mathematics)";
569        assert_eq!(
570            web_links_in_text(&format!("See ({destination}).")),
571            vec![TerminalHyperlink {
572                columns: 5..5 + destination.width(),
573                destination: destination.to_string(),
574            }]
575        );
576    }
577
578    #[test]
579    fn decorates_a_contiguous_web_link_with_one_osc8_pair() {
580        let destination = "https://example.com/a/very/long/path";
581        let line = HyperlinkLine {
582            line: Line::from(destination),
583            hyperlinks: vec![TerminalHyperlink {
584                columns: 0..destination.width(),
585                destination: destination.to_string(),
586            }],
587        };
588
589        assert_eq!(
590            decorate_spans(&line),
591            vec![Span::from(osc8_hyperlink(destination, destination))]
592        );
593        assert_eq!(
594            decorate_spans(&HyperlinkLine::new(Line::from("not linked"))),
595            vec![Span::from("not linked")]
596        );
597    }
598
599    #[test]
600    fn wrapping_maps_repeated_link_labels_by_source_position() {
601        let mut source = HyperlinkLine::new(Line::from("here here"));
602        source.hyperlinks.push(TerminalHyperlink {
603            columns: 5..9,
604            destination: "https://example.com".to_string(),
605        });
606
607        let wrapped = remap_wrapped_line(&source, vec![Line::from("here here")]);
608
609        assert_eq!(
610            wrapped[0].hyperlinks,
611            vec![TerminalHyperlink {
612                columns: 5..9,
613                destination: "https://example.com".to_string(),
614            }]
615        );
616    }
617
618    #[test]
619    fn buffer_hyperlinks_follow_word_wrapping() {
620        let destination = "https://example.com/path";
621        let mut line = HyperlinkLine::new(Line::from(format!("See {destination} now")));
622        line.hyperlinks.push(TerminalHyperlink {
623            columns: 4..4 + destination.width(),
624            destination: destination.to_string(),
625        });
626        let area = Rect::new(
627            /*x*/ 0, /*y*/ 0, /*width*/ 18, /*height*/ 4,
628        );
629        let mut buf = Buffer::empty(area);
630
631        Paragraph::new(Text::from(line.line.clone()))
632            .wrap(Wrap { trim: false })
633            .render(area, &mut buf);
634        mark_buffer_hyperlinks(&mut buf, area, &[line], /*scroll_rows*/ 0);
635
636        let linked_text = area
637            .positions()
638            .filter_map(|position| {
639                let symbol = buf[position].symbol();
640                symbol
641                    .contains(&format!("\x1b]8;;{destination}\x07"))
642                    .then(|| strip_osc8(symbol))
643            })
644            .collect::<String>();
645        assert_eq!(linked_text, destination);
646    }
647}