stynx-code-tui 0.33.17

Terminal user interface with ratatui for interactive sessions
Documentation
use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Paragraph, Widget, Wrap},
};

use ratatui::layout::Alignment;

use crate::state::ConversationState;
use crate::theme;
use super::markdown::{render_md_line_wrapped, is_table_line, render_table_block};

pub struct MessageList<'a> {
    pub state: &'a mut ConversationState,
    pub spinner_frame: usize,
    pub tool_details: bool,
    pub thinking_active: bool,
}

impl<'a> MessageList<'a> {
    pub fn new(state: &'a mut ConversationState, spinner_frame: usize) -> Self {
        Self { state, spinner_frame, tool_details: true, thinking_active: false }
    }

    pub fn with_tool_details(mut self, on: bool) -> Self {
        self.tool_details = on;
        self
    }

    pub fn with_thinking_active(mut self, on: bool) -> Self {
        self.thinking_active = on;
        self
    }
}

impl<'a> Widget for MessageList<'a> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        // Align the role accent bar (▌) flush to the left edge (main.x), matching
        // the thinking panel's bar and the tools border. Keep a 1-col right margin.
        let area = Rect {
            x: area.x,
            width: area.width.saturating_sub(1),
            y: area.y + 1,
            height: area.height.saturating_sub(1),
        };

        if self.state.messages.is_empty() {
            draw_empty_state(area, buf);
            return;
        }

        let mut lines: Vec<Line<'static>> = Vec::new();

        let bar = "";
        let body_indent = "  ";
        let thinking_active = self.thinking_active;
        let spin = super::spinner::FRAMES[self.spinner_frame % super::spinner::FRAMES.len()];

        for msg in &self.state.messages {
            match msg.role.as_str() {
                "user" => {
                    let start = lines.len();
                    for raw in msg.content.lines() {
                        lines.push(Line::from(Span::styled(
                            format!("{body_indent}{}", raw.trim_end()),
                            Style::default().fg(theme::TEXT()),
                        )));
                    }
                    stamp_role_bar(&mut lines, start, theme::FOAM());
                }
                "error" => {
                    lines.push(Line::from(vec![
                        Span::styled(bar, Style::default().fg(theme::LOVE()).add_modifier(Modifier::BOLD)),
                        Span::styled(" Error", Style::default().fg(theme::LOVE()).add_modifier(Modifier::BOLD)),
                    ]));
                    for raw in msg.content.lines() {
                        lines.push(Line::from(Span::styled(
                            format!("{body_indent}{}", raw.trim_end()),
                            Style::default().fg(theme::LOVE()),
                        )));
                    }
                }
                "system" => {
                    for raw in msg.content.lines() {
                        lines.push(Line::from(vec![
                            Span::styled(bar, Style::default().fg(theme::SUBTLE())),
                            Span::styled(
                                format!(" {}", raw.trim_end()),
                                Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC),
                            ),
                        ]));
                    }
                }
                "done" => {
                    let parts: Vec<&str> = msg.content.split(", ").collect();
                    for (i, part) in parts.iter().enumerate() {
                        let prefix = if i == 0 { "" } else { "    " };
                        let prefix_style = if i == 0 {
                            Style::default().fg(theme::SUCCESS()).add_modifier(Modifier::BOLD)
                        } else {
                            Style::default()
                        };
                        lines.push(Line::from(vec![
                            Span::styled(prefix, prefix_style),
                            Span::styled(
                                part.to_string(),
                                Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC),
                            ),
                        ]));
                    }
                }
                _ => {
                    let asst_start = lines.len();
                    if msg.is_streaming && thinking_active && msg.content.trim().is_empty() {
                        lines.push(Line::from(vec![
                            Span::styled(
                                format!("  {spin} "),
                                Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD),
                            ),
                            Span::styled(
                                "thinking…",
                                Style::default()
                                    .fg(theme::MUTED())
                                    .add_modifier(Modifier::ITALIC),
                            ),
                        ]));
                        lines.push(Line::from(""));
                    }
                    if !msg.thinking.is_empty() && !msg.is_streaming {
                        let lc = msg.thinking.lines().count();
                        lines.push(Line::from(Span::styled(
                            format!("  ◈ thinking · {lc} lines"),
                            Style::default()
                                .fg(theme::MUTED())
                                .add_modifier(Modifier::ITALIC | Modifier::DIM),
                        )));
                        lines.push(Line::from(""));
                    }
                    let content_lines: Vec<&str> = msg.content.lines().collect();
                    let mut in_code = false;
                    let mut in_mermaid = false;
                    let mut prev_blank = false;
                    let mut i = 0;
                    while i < content_lines.len() {
                        let raw = content_lines[i];
                        let trimmed_start = raw.trim_start();
                        let is_blank = raw.trim().is_empty();

                        if is_blank {
                            if !prev_blank { lines.push(Line::from("")); }
                            prev_blank = true;
                            i += 1;
                            continue;
                        }
                        prev_blank = false;

                        if trimmed_start.starts_with("```mermaid") {
                            in_mermaid = true; in_code = true;
                            lines.push(Line::from(Span::styled("  ╭─ mermaid ", Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD))));
                        } else if in_mermaid && trimmed_start.starts_with("```") {
                            in_mermaid = false; in_code = false;
                            lines.push(Line::from(Span::styled("  ╰────────── ", Style::default().fg(theme::IRIS()))));
                        } else if in_mermaid {
                            lines.push(Line::from(vec![
                                Span::styled("", Style::default().fg(theme::IRIS())),
                                Span::styled(raw.trim_end().to_string(), Style::default().fg(theme::GOLD())),
                            ]));
                        } else if trimmed_start.starts_with("```") {
                            if in_code {
                                in_code = false;
                                lines.push(Line::from(Span::styled("  ╰──", Style::default().fg(theme::OVERLAY()))));
                            } else {
                                in_code = true;
                                let lang = trimmed_start.trim_start_matches('`').trim();
                                let label = if lang.is_empty() { "  ╭─ code".to_string() } else { format!("  ╭─ {lang}") };
                                lines.push(Line::from(Span::styled(label, Style::default().fg(theme::OVERLAY()).add_modifier(Modifier::DIM))));
                            }
                        } else if !in_code && is_table_line(raw) {
                            // Gather the whole contiguous table block and render it
                            // aligned (columns padded to a common width).
                            let start = i;
                            while i < content_lines.len() && is_table_line(content_lines[i]) {
                                i += 1;
                            }
                            lines.extend(render_table_block(&content_lines[start..i], area.width as usize));
                            continue;
                        } else {
                            lines.extend(render_md_line_wrapped(raw, in_code, (area.width as usize).saturating_sub(1)));
                        }
                        i += 1;
                    }
                    let _ = in_code;
                    stamp_role_bar(&mut lines, asst_start, theme::IRIS());
                }
            }
            lines.push(Line::from(""));
        }

        lines.push(Line::from(""));
        lines.push(Line::from(""));

        let width = area.width as usize;
        let total_rows: usize = lines
            .iter()
            .map(|l| {
                let w = l.width();
                if w == 0 || width == 0 { 1 } else { (w + width - 1) / width }
            })
            .sum();

        let visible = area.height as usize;
        self.state.total_lines = total_rows;

        // When the conversation doesn't fill the viewport, anchor it to the
        // bottom (growing upward from the input) instead of leaving a large
        // empty gap below the messages.
        if total_rows < visible {
            let pad = visible - total_rows;
            let mut anchored: Vec<Line<'static>> = Vec::with_capacity(pad + lines.len());
            anchored.resize(pad, Line::from(""));
            anchored.extend(lines);
            self.state.scroll_offset = 0;
            Paragraph::new(anchored).wrap(Wrap { trim: false }).render(area, buf);
            return;
        }

        let max_offset = total_rows.saturating_sub(visible);
        let offset = if self.state.auto_scroll {
            self.state.scroll_offset = max_offset;
            max_offset
        } else {
            let o = self.state.scroll_offset.min(max_offset);
            self.state.scroll_offset = o;
            o
        };

        Paragraph::new(lines).scroll((offset as u16, 0)).wrap(Wrap { trim: false }).render(area, buf);
    }
}

/// Stamp a colored role bar (▌) onto a message's first body line, replacing its
/// leading indent. The bar's color is the sole role indicator (foam = user,
/// iris = assistant) — no text label — so messages stay differentiable but clean.
fn stamp_role_bar(lines: &mut [Line<'static>], start: usize, color: Color) {
    let line = match lines.get_mut(start) {
        Some(l) => l,
        None => return,
    };
    if let Some(first) = line.spans.first_mut() {
        let trimmed = first
            .content
            .strip_prefix("  ")
            .or_else(|| first.content.strip_prefix(' '))
            .map(|s| s.to_string());
        if let Some(t) = trimmed {
            first.content = t.into();
        }
    }
    line.spans.insert(
        0,
        Span::styled("", Style::default().fg(color).add_modifier(Modifier::BOLD)),
    );
}

const LOGO_ART: &[&str] = &[
    r" ____   _____ __   __ _   _ __  __",
    r"/ ___| |_   _|\ \ / /| \ | |\ \/ /",
    r"\___ \   | |   \ V / |  \| | \  / ",
    r" ___) |  | |    | |  | |\  | /  \ ",
    r"|____/   |_|    |_|  |_| \_|/_/\_\",
];
const LOGO_SUBTITLE: &str = "c   o   d   e";

const HINTS: &[(&str, &str)] = &[
    ("^P",    "command palette"),
    ("^T",    "focus tools"),
    ("^M",    "switch model"),
    ("/help", "show help"),
];

fn draw_empty_state(area: Rect, buf: &mut Buffer) {
    let mut lines: Vec<Line<'static>> = Vec::new();

    let logo_w = LOGO_ART.iter().map(|s| s.chars().count()).max().unwrap_or(0);
    let pad_to = |s: &str, w: usize| -> String {
        let n = s.chars().count();
        if n >= w { s.to_string() } else {
            let extra = w - n;
            let left = extra / 2;
            let right = extra - left;
            format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
        }
    };

    let top_pad = area.height.saturating_sub(16) / 3;
    for _ in 0..top_pad { lines.push(Line::from("")); }

    for art in LOGO_ART {
        lines.push(Line::from(Span::styled(
            pad_to(art, logo_w),
            Style::default().fg(theme::PRIMARY()).add_modifier(Modifier::BOLD),
        )));
    }
    lines.push(Line::from(Span::styled(
        pad_to(LOGO_SUBTITLE, logo_w),
        Style::default().fg(theme::ACCENT()),
    )));
    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        format!("v{}", env!("CARGO_PKG_VERSION")),
        Style::default().fg(theme::TEXT_MUTED()),
    )));
    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        "────────────────────".to_string(),
        Style::default().fg(theme::BORDER()),
    )));
    lines.push(Line::from(""));

    let key_w = HINTS.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0);
    let label_w = HINTS.iter().map(|(_, l)| l.chars().count()).max().unwrap_or(0);
    for (k, l) in HINTS {
        let key = format!("{:>width$}", k, width = key_w);
        let label = format!("{:<width$}", l, width = label_w);
        lines.push(Line::from(vec![
            Span::styled(key, Style::default().fg(theme::ACCENT()).add_modifier(Modifier::BOLD)),
            Span::raw("   "),
            Span::styled(label, Style::default().fg(theme::TEXT_MUTED())),
        ]));
    }
    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        "type a message to begin…".to_string(),
        Style::default().fg(theme::TEXT_MUTED()).add_modifier(Modifier::ITALIC),
    )));

    Paragraph::new(lines).alignment(Alignment::Center).render(area, buf);
}