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; fn _role_name(role: &crate::dto::chat::message::Role) -> &'static str { match role { crate::dto::chat::message::Role::User => "User", crate::dto::chat::message::Role::Assistant => "Assistant", crate::dto::chat::message::Role::System => "System", crate::dto::chat::message::Role::Tool => "Tool", } } 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 format_timestamp(ts: i64) -> String { if ts <= 0 { return "".to_string(); } let secs = ts / 1000; let mins = (secs / 60) % 60; let hrs = (secs / 3600) % 24; format!("{:02}:{:02}", hrs, mins) } 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(); let _total_msgs = messages.len(); for msg in messages.iter() { let role_color = match msg.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, }; let prefix = match msg.role { crate::dto::chat::message::Role::User => ">", crate::dto::chat::message::Role::Assistant => "•", crate::dto::chat::message::Role::System => "#", crate::dto::chat::message::Role::Tool => "→", }; let ts_str = format_timestamp(msg.timestamp); let badge = role_badge(&msg.role); let time_display = if ts_str.is_empty() { String::new() } else { format!(" [{}]", ts_str) }; let header = Line::from(vec![ Span::styled( format!(" {} ", badge), Style::default().fg(Theme::BG).bg(role_color).add_modifier(Modifier::BOLD), ), Span::styled( time_display, Style::default().fg(Theme::DIM), ), ]); let content_str = if msg.content.is_empty() { "(streaming...)".to_string() } else { msg.content.clone() }; let mut content_spans = vec![Span::styled(format!("{} ", prefix), Style::default().fg(role_color))]; content_spans.extend(super::markdown::render_markdown(&content_str, area.width)); let content_line = Line::from(content_spans); display_lines.push(header); display_lines.push(content_line); display_lines.push(Line::from(Span::raw(""))); } if state.turn_in_flight() { let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frame = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()]; display_lines.push(Line::from(vec![ Span::styled(" AI ", Style::default().fg(Theme::BG).bg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD)), Span::styled(format!(" {} Generating...", frame), Style::default().fg(Theme::DIM)), ])); display_lines.push(Line::from(Span::raw(""))); } let mut title = String::from(" Chat "); if !messages.is_empty() { title.push_str(&format!("[{} msgs]", messages.len())); } let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) .title(title); 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() }; let paragraph = Paragraph::new(visible) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); }