chabeau 0.7.3

A full-screen terminal chat interface that connects to various AI APIs for real-time conversations
Documentation
use std::collections::VecDeque;

use ratatui::text::Line;

use super::span::SpanKind;
use super::theme::Theme;
use crate::core::message::Message;

/// Policy for how tables should behave when they cannot reasonably fit within the terminal width.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TableOverflowPolicy {
    /// Try to wrap cells according to balanced column widths. Borders must remain intact.
    WrapCells,
}

/// Layout configuration used by the unified layout engine.
#[derive(Clone, Debug)]
pub struct LayoutConfig {
    pub width: Option<usize>,
    pub markdown_enabled: bool,
    pub syntax_enabled: bool,
    pub table_overflow_policy: TableOverflowPolicy,
    pub user_display_name: Option<String>,
}

impl Default for LayoutConfig {
    fn default() -> Self {
        Self {
            width: None,
            markdown_enabled: true,
            syntax_enabled: true,
            table_overflow_policy: TableOverflowPolicy::WrapCells,
            user_display_name: None,
        }
    }
}

/// Mapping for a single message's contribution to the flattened line stream.
#[derive(Clone, Debug, Default)]
pub struct MessageLineSpan {
    pub start: usize,
    pub len: usize,
}

/// Result of a layout pass. Carries the flattened lines along with metadata
/// describing each message's contribution.
#[derive(Clone, Debug, Default)]
pub struct Layout {
    pub lines: Vec<Line<'static>>,
    pub span_metadata: Vec<Vec<SpanKind>>,
    pub message_spans: Vec<MessageLineSpan>,
}

pub struct LayoutEngine;

impl LayoutEngine {
    /// Convenience helper to layout plain-text messages with an explicit width.
    /// This applies width-aware wrapping consistently with the markdown path.
    pub fn layout_plain_text(
        messages: &VecDeque<Message>,
        theme: &Theme,
        width: Option<usize>,
        syntax_enabled: bool,
    ) -> Layout {
        let cfg = LayoutConfig {
            width,
            markdown_enabled: false,
            syntax_enabled,
            table_overflow_policy: TableOverflowPolicy::WrapCells,
            user_display_name: None,
        };
        Self::layout_messages(messages, theme, &cfg)
    }

    /// Perform a layout pass over the messages using the supplied theme and configuration.
    /// This is the single, width-aware pipeline that downstream systems (renderer, scroll math)
    /// should consume. No additional wrapping should be performed after this step.
    pub fn layout_messages(
        messages: &VecDeque<Message>,
        theme: &Theme,
        cfg: &LayoutConfig,
    ) -> Layout {
        let mut lines = Vec::new();
        let mut span_metadata = Vec::new();
        let mut message_spans = Vec::with_capacity(messages.len());
        let mut global_block_index = 0usize;

        for msg in messages {
            let start = lines.len();
            let render_cfg = crate::ui::markdown::MessageRenderConfig::markdown(
                cfg.markdown_enabled,
                cfg.syntax_enabled,
            )
            .with_span_metadata()
            .with_terminal_width(cfg.width, cfg.table_overflow_policy)
            .with_user_display_name(cfg.user_display_name.clone());
            let crate::ui::markdown::RenderedMessageDetails {
                lines: mut msg_lines,
                span_metadata: msg_meta,
            } = crate::ui::markdown::render_message_with_config(msg, theme, render_cfg);
            let mut msg_metadata = msg_meta.unwrap_or_else(|| {
                msg_lines
                    .iter()
                    .map(|line| vec![SpanKind::Text; line.spans.len()])
                    .collect()
            });

            // Renumber code block indices to be globally unique across all messages
            let mut blocks_in_message = std::collections::HashSet::new();
            for line_meta in &msg_metadata {
                for kind in line_meta {
                    if let Some(meta) = kind.code_block_meta() {
                        blocks_in_message.insert(meta.block_index());
                    }
                }
            }
            let block_count = blocks_in_message.len();

            if block_count > 0 {
                // Build a mapping from per-message index to global index
                // Sort local indices to ensure deterministic, sequential global indices
                let mut sorted_indices: Vec<usize> = blocks_in_message.iter().copied().collect();
                sorted_indices.sort_unstable();

                let mut index_map = std::collections::HashMap::new();
                for (i, local_idx) in sorted_indices.iter().enumerate() {
                    index_map.insert(*local_idx, global_block_index + i);
                }

                // Renumber all code block spans
                for line_meta in &mut msg_metadata {
                    for kind in line_meta {
                        if let crate::ui::span::SpanKind::CodeBlock(ref mut meta) = kind {
                            if let Some(&new_idx) = index_map.get(&meta.block_index()) {
                                *meta = crate::ui::span::CodeBlockMeta::new(
                                    meta.language().map(String::from),
                                    new_idx,
                                );
                            }
                        }
                    }
                }

                global_block_index += block_count;
            }

            let len = msg_lines.len();
            span_metadata.extend(msg_metadata);
            lines.append(&mut msg_lines);
            message_spans.push(MessageLineSpan { start, len });
        }

        Layout {
            lines,
            span_metadata,
            message_spans,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{LayoutConfig, LayoutEngine, Theme};
    use crate::core::message::Message;
    #[cfg(test)]
    use crate::core::message::TranscriptRole;
    use std::collections::VecDeque;

    #[test]
    fn markdown_layout_populates_span_metadata() {
        let mut messages = VecDeque::new();
        messages.push_back(Message {
            role: TranscriptRole::Assistant,
            content: "Testing a [link](https://example.com) span.".into(),
        });
        let theme = Theme::dark_default();
        let layout = LayoutEngine::layout_messages(&messages, &theme, &LayoutConfig::default());

        assert_eq!(layout.lines.len(), layout.span_metadata.len());
        let mut saw_link = false;
        for kinds in &layout.span_metadata {
            if kinds.iter().any(|k| k.is_link()) {
                saw_link = true;
                break;
            }
        }
        assert!(saw_link, "expected at least one link span kind");
    }

    #[test]
    fn plain_text_layout_synthesizes_metadata() {
        let mut messages = VecDeque::new();
        messages.push_back(Message {
            role: TranscriptRole::User,
            content: "Hello there".into(),
        });
        let theme = Theme::dark_default();
        let layout = LayoutEngine::layout_plain_text(&messages, &theme, Some(10), false);

        assert_eq!(layout.lines.len(), layout.span_metadata.len());
        let mut saw_prefix = false;
        for kinds in &layout.span_metadata {
            for kind in kinds {
                assert!(kind.is_text() || kind.is_prefix());
                if kind.is_prefix() {
                    saw_prefix = true;
                }
            }
        }
        assert!(saw_prefix, "expected at least one prefix span kind");
    }

    #[test]
    fn layout_lines_can_be_encoded_with_osc_links() {
        let mut messages = VecDeque::new();
        messages.push_back(Message {
            role: TranscriptRole::Assistant,
            content: "[Rust](https://www.rust-lang.org) and [Go](https://go.dev)".into(),
        });
        let theme = Theme::dark_default();
        let layout = LayoutEngine::layout_messages(&messages, &theme, &LayoutConfig::default());
        let encoded = crate::ui::osc::encode_lines_with_links(&layout.lines, &layout.span_metadata);
        let joined = encoded
            .iter()
            .map(|line| line.to_string())
            .collect::<Vec<_>>()
            .join("\n");
        assert!(joined.contains("Rust"));
        assert!(joined.contains("Go"));
        assert!(joined.matches("\x1b]8;;").count() >= 4);
        assert!(joined.matches("\x1b]8;;\x1b\\").count() >= 2);
    }

    #[test]
    fn link_metadata_spans_cover_spaces_within_link_text() {
        let mut messages = VecDeque::new();
        messages.push_back(Message {
            role: TranscriptRole::Assistant,
            content: "[associative trails](https://example.com)".into(),
        });
        let theme = Theme::dark_default();
        let layout = LayoutEngine::layout_messages(&messages, &theme, &LayoutConfig::default());

        let first_line = layout.lines.first().expect("line");
        let first_meta = layout.span_metadata.first().expect("meta");
        assert_eq!(first_line.spans.len(), first_meta.len());
        assert!(first_meta.iter().all(|kind| kind.is_link()));
    }
}