chabeau 0.7.1

A full-screen terminal chat interface that connects to various AI APIs for real-time conversations
Documentation
use crate::ui::span::SpanKind;
use ratatui::text::Line;
use std::borrow::Cow;

#[cfg(test)]
mod tests {
    use super::{
        contains_disallowed_control, encode_hyperlink, encode_line_with_links,
        encode_line_with_links_with_underline, encode_lines_with_links,
        encode_lines_with_links_with_underline, OSC_PREFIX, OSC_SUFFIX,
    };
    use crate::ui::span::SpanKind;
    use ratatui::text::{Line, Span};

    #[test]
    fn encode_hyperlink_wraps_text_with_balanced_sequences() {
        let link = encode_hyperlink("Rust", "https://www.rust-lang.org").expect("link");
        let encoded = link.as_encoded_string();
        assert!(encoded.ends_with(OSC_SUFFIX));
        assert_eq!(encoded.matches(OSC_SUFFIX).count(), 1);
        assert!(encoded.contains("Rust"));
    }

    #[test]
    fn encode_hyperlink_rejects_empty_segments() {
        assert!(encode_hyperlink("", "https://example.com").is_none());
        assert!(encode_hyperlink("Example", "").is_none());
    }

    #[test]
    fn encode_hyperlink_rejects_control_bytes() {
        assert!(encode_hyperlink("hi", "bad\u{1b}url").is_none());
        assert!(encode_hyperlink("bad\u{7}text", "https://example.com").is_none());
    }

    #[test]
    fn control_detection_catches_bel_and_escape() {
        assert!(contains_disallowed_control("\u{1b}"));
        assert!(contains_disallowed_control("\u{7}"));
        assert!(contains_disallowed_control("line\n"));
        assert!(contains_disallowed_control("carriage\rreturn"));
        assert!(!contains_disallowed_control("tab\tallowed"));
        assert!(!contains_disallowed_control("normal"));
    }

    #[test]
    fn encode_lines_with_links_wraps_each_link_segment() {
        let lines = vec![Line::from(vec![
            Span::raw("Intro "),
            Span::raw("Rust"),
            Span::raw(" and "),
            Span::raw("Go"),
        ])];
        let metadata = vec![vec![
            SpanKind::Text,
            SpanKind::link("https://www.rust-lang.org"),
            SpanKind::Text,
            SpanKind::link("https://go.dev"),
        ]];

        let encoded = encode_lines_with_links(&lines, &metadata);
        assert_eq!(encoded.len(), 1);
        let line = &encoded[0];
        assert!(line.contains("Intro"));
        assert!(line.contains("Rust"));
        assert!(line.contains("Go"));
        assert!(line.matches(OSC_PREFIX).count() >= 2);
        assert!(line.matches(OSC_SUFFIX).count() >= 2);
    }

    #[test]
    fn encode_line_with_links_returns_plain_text_without_metadata() {
        let line = Line::from(vec![Span::raw("Rust")]);
        let encoded = encode_line_with_links(&line, None);
        assert_eq!(encoded, "Rust");
    }

    #[test]
    fn underline_fallback_wraps_link_text_with_sgr_sequences() {
        let line = Line::from(vec![Span::raw("Rust")]);
        let metadata = vec![SpanKind::link("https://www.rust-lang.org")];
        let encoded = encode_line_with_links_with_underline(&line, Some(&metadata));
        assert!(encoded.contains("\x1b[4m"));
        assert!(encoded.contains("\x1b[24m"));
    }

    #[test]
    fn lines_with_underline_fallback_apply_to_each_link() {
        let lines = vec![Line::from(vec![Span::raw("Rust")])];
        let metadata = vec![vec![SpanKind::link("https://www.rust-lang.org")]];
        let encoded = encode_lines_with_links_with_underline(&lines, &metadata);
        assert_eq!(encoded.len(), 1);
        assert!(encoded[0].contains("\x1b[4m"));
        assert!(encoded[0].contains("\x1b[24m"));
    }
}

const OSC_PREFIX: &str = "\x1b]8;;";
const ST: &str = "\x1b\\";
const OSC_SUFFIX: &str = "\x1b]8;;\x1b\\";
const UNDERLINE_ON: &str = "\x1b[4m";
const UNDERLINE_OFF: &str = "\x1b[24m";

#[derive(Clone, Copy)]
enum LinkRenderMode {
    Plain,
    UnderlineFallback,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct OscHyperlink<'a> {
    prefix: String,
    text: Cow<'a, str>,
    suffix: &'static str,
}

impl<'a> OscHyperlink<'a> {
    fn push_to(&self, buf: &mut String) {
        buf.push_str(&self.prefix);
        buf.push_str(self.text.as_ref());
        buf.push_str(self.suffix);
    }

    #[cfg_attr(not(test), allow(dead_code))]
    fn as_encoded_string(&self) -> String {
        let mut out =
            String::with_capacity(self.prefix.len() + self.text.len() + self.suffix.len());
        self.push_to(&mut out);
        out
    }
}

fn encode_hyperlink<'a>(text: &'a str, href: &str) -> Option<OscHyperlink<'a>> {
    if text.is_empty() || href.is_empty() {
        return None;
    }

    if contains_disallowed_control(text) || contains_disallowed_control(href) {
        return None;
    }

    let mut prefix = String::with_capacity(OSC_PREFIX.len() + href.len() + ST.len());
    prefix.push_str(OSC_PREFIX);
    prefix.push_str(href);
    prefix.push_str(ST);

    Some(OscHyperlink {
        prefix,
        text: Cow::Borrowed(text),
        suffix: OSC_SUFFIX,
    })
}

fn encode_line_with_links_in_mode(
    line: &Line<'_>,
    kinds: Option<&[SpanKind]>,
    mode: LinkRenderMode,
) -> String {
    let mut encoded = String::new();
    let underline = matches!(mode, LinkRenderMode::UnderlineFallback);

    for (idx, span) in line.spans.iter().enumerate() {
        let text = span.content.as_ref();
        let meta = kinds.and_then(|meta| meta.get(idx));
        let maybe_link = meta
            .and_then(|kind| kind.link_meta())
            .and_then(|link_meta| encode_hyperlink(text, link_meta.href()));

        if let Some(link) = maybe_link {
            if underline {
                encoded.push_str(UNDERLINE_ON);
            }
            link.push_to(&mut encoded);
            if underline {
                encoded.push_str(UNDERLINE_OFF);
            }
        } else if underline && meta.is_some_and(SpanKind::is_link) {
            encoded.push_str(UNDERLINE_ON);
            encoded.push_str(text);
            encoded.push_str(UNDERLINE_OFF);
        } else {
            encoded.push_str(text);
        }
    }

    encoded
}

pub fn encode_line_with_links(line: &Line<'_>, kinds: Option<&[SpanKind]>) -> String {
    encode_line_with_links_in_mode(line, kinds, LinkRenderMode::Plain)
}

pub fn encode_line_with_links_with_underline(
    line: &Line<'_>,
    kinds: Option<&[SpanKind]>,
) -> String {
    encode_line_with_links_in_mode(line, kinds, LinkRenderMode::UnderlineFallback)
}

fn encode_lines_with_links_in_mode(
    lines: &[Line<'_>],
    metadata: &[Vec<SpanKind>],
    mode: LinkRenderMode,
) -> Vec<String> {
    lines
        .iter()
        .enumerate()
        .map(|(idx, line)| {
            let kinds = metadata.get(idx).map(|vec| vec.as_slice());
            encode_line_with_links_in_mode(line, kinds, mode)
        })
        .collect()
}

pub fn encode_lines_with_links(lines: &[Line<'_>], metadata: &[Vec<SpanKind>]) -> Vec<String> {
    encode_lines_with_links_in_mode(lines, metadata, LinkRenderMode::Plain)
}

pub fn encode_lines_with_links_with_underline(
    lines: &[Line<'_>],
    metadata: &[Vec<SpanKind>],
) -> Vec<String> {
    encode_lines_with_links_in_mode(lines, metadata, LinkRenderMode::UnderlineFallback)
}

fn contains_disallowed_control(input: &str) -> bool {
    input
        .bytes()
        .any(|b| (b < 0x20 && b != b'\t') || b == b'\x1b')
}