#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] //! Chat transcript panel rendering — message cards with role badges. //! //! Flow: `draw_chat` turns `state.transcript_cache.messages` into a //! visually rich transcript where each message is rendered as a "card" //! with a role-colored left accent bar, a role badge pill, timestamp, //! and markdown body. A streaming spinner line is appended when a turn //! is in flight. The combined line list is sliced to the visible scroll //! window before rendering. //! //! Design: messages are visually separated with vertical spacing, role //! badges are colored pills on the left, and each message has a thin //! role-colored border on its left side for quick visual scanning. use ratatui::layout::Rect; use ratatui::style::{Style, Modifier}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; use super::theme::Theme; /// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries. fn split_spans_into_lines(spans: Vec>) -> Vec> { let mut lines = Vec::new(); let mut current_spans = Vec::new(); for span in spans { let text = span.content.as_ref(); let mut parts = text.split('\n').peekable(); while let Some(part) = parts.next() { if !part.is_empty() { current_spans.push(Span::styled(part.to_string(), span.style)); } if parts.peek().is_some() { lines.push(Line::from(std::mem::take(&mut current_spans))); } } } if !current_spans.is_empty() { lines.push(Line::from(current_spans)); } if lines.is_empty() { lines.push(Line::from(vec![])); } lines } fn role_badge(role: &crate::dto::chat::message::Role) -> &'static str { match role { crate::dto::chat::message::Role::User => " YOU ", crate::dto::chat::message::Role::Assistant => " AI ", crate::dto::chat::message::Role::System => " SYS ", crate::dto::chat::message::Role::Tool => " TOOL ", } } fn role_accent_color(role: &crate::dto::chat::message::Role) -> Color { match role { crate::dto::chat::message::Role::User => Theme::ROLE_USER, crate::dto::chat::message::Role::Assistant => Theme::ROLE_ASSISTANT, crate::dto::chat::message::Role::System => Theme::ROLE_SYSTEM, crate::dto::chat::message::Role::Tool => Theme::ROLE_TOOL, } } fn role_label(role: &crate::dto::chat::message::Role) -> &'static str { match role { crate::dto::chat::message::Role::User => "You", crate::dto::chat::message::Role::Assistant => "Assistant", crate::dto::chat::message::Role::System => "System", crate::dto::chat::message::Role::Tool => "Tool Call", } } fn format_timestamp(ts: i64) -> String { if ts <= 0 { return String::new(); } let secs = ts / 1000; let mins = (secs / 60) % 60; let hrs = (secs / 3600) % 24; format!("{hrs:02}:{mins:02}") } /// Render the scrollable chat transcript panel with message card styling. #[allow(clippy::too_many_lines)] pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { let messages = &state.transcript_cache.messages; let scroll_offset = state.scroll.offset; let max_visible = (area.height as usize).saturating_sub(3); let mut display_lines: Vec = Vec::new(); // ── Header ─────────────────────────────────────────────────────────── let title = if messages.is_empty() { String::from(" Chat ") } else { format!(" Chat [{} msgs]", messages.len()) }; // ── Render messages as cards ───────────────────────────────────────── for msg in messages { let accent = role_accent_color(&msg.role); let badge = role_badge(&msg.role); let label = role_label(&msg.role); let ts_str = format_timestamp(msg.timestamp); // ── Role header line ───────────────────────────────────────────── // Left accent bar + badge pill + role name + timestamp let header = Line::from(vec![ // Thin accent bar on the left Span::styled( "▎", Style::default().fg(accent), ), // Role badge pill Span::styled( badge, Style::default() .fg(Theme::BG) .bg(accent) .add_modifier(Modifier::BOLD), ), // Role name Span::styled( format!(" {label}"), Style::default().fg(accent).add_modifier(Modifier::BOLD), ), // Timestamp Span::styled( if ts_str.is_empty() { String::new() } else { format!(" {ts_str}") }, Style::default().fg(Theme::TEXT_DIM), ), ]); display_lines.push(header); // ── Message content ────────────────────────────────────────────── let is_last = std::ptr::eq(msg, messages.last().unwrap()); let content_str = if msg.content.trim().is_empty() { if is_last && state.turn_in_flight() { "(streaming...)".to_string() } else { "(tool execution)".to_string() } } else { msg.content.clone() }; // Render markdown content with accent-colored prefix let mut content_spans = vec![ Span::styled(" ", Style::default().fg(accent)), ]; content_spans.extend(super::markdown::render_markdown(&content_str, area.width)); let message_lines = split_spans_into_lines(content_spans); for line in message_lines { display_lines.push(line); } // ── Message separator ──────────────────────────────────────────── display_lines.push(Line::from(Span::raw(""))); } // ── Streaming indicator ────────────────────────────────────────────── if state.turn_in_flight() { let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len(); let spinner = spinner_frames[frame_idx]; display_lines.push(Line::from(vec![ Span::styled( "▎", Style::default().fg(Theme::ROLE_ASSISTANT), ), Span::styled( " AI ", Style::default() .fg(Theme::BG) .bg(Theme::ROLE_ASSISTANT) .add_modifier(Modifier::BOLD), ), Span::styled( format!(" {spinner} "), Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), ), Span::styled( "Generating...", Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), ), ])); display_lines.push(Line::from(Span::raw(""))); } // ── Scrolling ──────────────────────────────────────────────────────── let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) .title(Span::styled( title, Style::default().fg(Theme::TEXT_MUTED), )); let total = display_lines.len(); let max_offset = total.saturating_sub(max_visible); let offset = scroll_offset.min(max_offset); let end_idx = total.saturating_sub(offset); let start_idx = end_idx.saturating_sub(max_visible); let visible: Vec = if start_idx < end_idx && start_idx < total { display_lines[start_idx..end_idx].to_vec() } else { display_lines[total.saturating_sub(max_visible)..total].to_vec() }; // ── Scroll position indicator ──────────────────────────────────────── // Show a small percentage indicator in the title if scrolled let scroll_pct = if total > max_visible { ((offset as f64 / max_offset as f64) * 100.0) as u8 } else { 0 }; let block = if scroll_pct > 0 { let scroll_title = format!(" Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct); Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) .title(Span::styled( scroll_title, Style::default().fg(Theme::TEXT_MUTED), )) } else { block }; let paragraph = Paragraph::new(visible) .block(block) .style(Style::default().bg(Theme::BG)) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } // Need to import Color for role_accent_color use ratatui::style::Color;