//! Borderless chat panel component //! //! Displays chat messages with proper indentation, timestamps, //! and streaming indicators. Uses whitespace instead of borders. use crate::theme::Theme; use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span, Text}, widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, Frame, }; use std::time::SystemTime; /// Chat message types #[derive(Debug, Clone)] pub enum ChatMessage { User(String), Assistant(String), ToolCall { name: String, args: String }, ToolResult { success: bool, output: String }, System(String), } impl ChatMessage { /// Get a timestamp for when the message was created (for display) pub fn timestamp_display() -> String { let now = SystemTime::now(); let secs = now .duration_since(SystemTime::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let hours = (secs / 3600) % 24; let mins = (secs / 60) % 60; format!("{:02}:{:02}", hours, mins) } } /// Message with metadata for display #[derive(Debug, Clone)] pub struct DisplayMessage { pub message: ChatMessage, pub timestamp: String, pub focused: bool, } impl DisplayMessage { pub fn new(message: ChatMessage) -> Self { Self { message, timestamp: ChatMessage::timestamp_display(), focused: false, } } } /// Borderless chat panel pub struct ChatPanel { messages: Vec, scroll_offset: usize, auto_scroll: bool, total_lines: usize, focused_index: Option, is_streaming: bool, theme: Theme, } impl ChatPanel { /// Create new borderless chat panel pub fn new(theme: Theme) -> Self { Self { messages: Vec::new(), scroll_offset: 0, auto_scroll: true, total_lines: 0, focused_index: None, is_streaming: false, theme, } } /// Add a new message pub fn add_message(&mut self, message: ChatMessage) { self.messages.push(DisplayMessage::new(message)); self.auto_scroll = true; self.is_streaming = false; } /// Append content to the last assistant message, or create a new one pub fn append_to_assistant(&mut self, content: &str) { if let Some(DisplayMessage { message: ChatMessage::Assistant(last_content), .. }) = self.messages.last_mut() { last_content.push_str(content); } else { self.messages.push(DisplayMessage::new(ChatMessage::Assistant( content.to_string(), ))); } self.auto_scroll = true; self.is_streaming = true; } /// Set streaming state pub fn set_streaming(&mut self, streaming: bool) { self.is_streaming = streaming; } /// Scroll up pub fn scroll_up(&mut self, amount: usize) { self.scroll_offset = self.scroll_offset.saturating_sub(amount); self.auto_scroll = false; } /// Scroll down pub fn scroll_down(&mut self, amount: usize) { self.scroll_offset = self.scroll_offset.saturating_add(amount); let near_bottom_threshold = 5; if self.total_lines > 0 { let max_scroll = self.total_lines.saturating_sub(1); if self.scroll_offset.saturating_add(near_bottom_threshold) >= max_scroll { self.auto_scroll = true; } } } /// Scroll to bottom pub fn scroll_to_bottom(&mut self) { self.scroll_offset = self.total_lines.saturating_sub(1); self.auto_scroll = true; } /// Page up pub fn page_up(&mut self, page_size: usize) { self.scroll_up(page_size.saturating_sub(2)); } /// Page down pub fn page_down(&mut self, page_size: usize) { self.scroll_down(page_size.saturating_sub(2)); } /// Focus next message pub fn focus_next(&mut self) { if self.messages.is_empty() { return; } self.focused_index = Some(match self.focused_index { Some(i) if i + 1 < self.messages.len() => i + 1, Some(_) => 0, None => 0, }); } /// Focus previous message pub fn focus_previous(&mut self) { if self.messages.is_empty() { return; } self.focused_index = Some(match self.focused_index { Some(0) => self.messages.len() - 1, Some(i) => i - 1, None => self.messages.len() - 1, }); } /// Clear focus pub fn clear_focus(&mut self) { self.focused_index = None; } /// Get focused message index pub fn focused_index(&self) -> Option { self.focused_index } /// Get focused message pub fn focused_message(&self) -> Option<&ChatMessage> { self.focused_index .and_then(|i| self.messages.get(i)) .map(|m| &m.message) } /// Update scroll position before rendering pub fn update_scroll(&mut self, area: Rect) { self.total_lines = self.count_total_lines(area); if self.auto_scroll { let visible_height = area.height as usize; let max_scroll = self.total_lines.saturating_sub(visible_height); self.scroll_offset = max_scroll; } else { let visible_height = area.height as usize; let max_scroll = self.total_lines.saturating_sub(visible_height); self.scroll_offset = self.scroll_offset.min(max_scroll); } } /// Count total lines for scroll calculation fn count_total_lines(&self, area: Rect) -> usize { let mut line_count = 0; let wrap_width = area.width.saturating_sub(4) as usize; for msg in &self.messages { line_count += match &msg.message { ChatMessage::User(content) => { let wrapped = textwrap::wrap(content, wrap_width); wrapped.len() + 1 // +1 for spacing } ChatMessage::Assistant(content) => { let wrapped = textwrap::wrap(content, wrap_width); wrapped.len() + 1 } ChatMessage::ToolCall { .. } => 2, ChatMessage::ToolResult { .. } => 2, ChatMessage::System(_) => 1, }; } line_count } /// Render the borderless chat panel pub fn render(&self, frame: &mut Frame, area: Rect) { let mut text_lines = Vec::new(); let wrap_width = area.width.saturating_sub(4) as usize; let symbols = &self.theme.symbols; for (idx, display_msg) in self.messages.iter().enumerate() { let is_focused = self.focused_index == Some(idx); let is_last = idx == self.messages.len() - 1; match &display_msg.message { ChatMessage::User(content) => { // User message: bright, with prefix let mut role_spans = vec![ Span::styled(" ", Style::default()), Span::styled( format!("{} You", symbols.user_prefix), self.theme.user_message, ), ]; // Timestamp right-aligned (we'll simplify for now) role_spans.push(Span::styled( format!(" {}", display_msg.timestamp), self.theme.timestamp, )); text_lines.push(Line::from(role_spans)); // Message content with 2-space indent let wrapped = textwrap::wrap(content, wrap_width); for line in wrapped { let style = if is_focused { self.theme.user_message.add_modifier(Modifier::REVERSED) } else { self.theme.user_message.remove_modifier(Modifier::BOLD) }; text_lines.push(Line::from(Span::styled( format!(" {}", line), style, ))); } // Focus hints if is_focused { text_lines.push(Line::from(Span::styled( " [y]copy [e]edit [r]retry", self.theme.status_dim, ))); } text_lines.push(Line::from("")); } ChatMessage::Assistant(content) => { // Assistant message: accent color let mut role_spans = vec![Span::styled(" ", Style::default())]; // Streaming indicator if is_last && self.is_streaming { role_spans.push(Span::styled( format!("{} ", symbols.streaming), Style::default().fg(self.theme.palette.success), )); } role_spans.push(Span::styled( format!("{} Assistant", symbols.assistant_prefix), self.theme.assistant_message.add_modifier(Modifier::BOLD), )); role_spans.push(Span::styled( format!(" {}", display_msg.timestamp), self.theme.timestamp, )); text_lines.push(Line::from(role_spans)); // Content let wrapped = textwrap::wrap(content, wrap_width); for line in wrapped { let style = if is_focused { self.theme.assistant_message.add_modifier(Modifier::REVERSED) } else { self.theme.assistant_message }; text_lines.push(Line::from(Span::styled( format!(" {}", line), style, ))); } // Focus hints if is_focused { text_lines.push(Line::from(Span::styled( " [y]copy [r]retry", self.theme.status_dim, ))); } text_lines.push(Line::from("")); } ChatMessage::ToolCall { name, args } => { text_lines.push(Line::from(vec![ Span::styled(" ", Style::default()), Span::styled( format!("{} ", symbols.tool_prefix), self.theme.tool_call, ), Span::styled(format!("{} ", name), self.theme.tool_call), Span::styled( truncate_str(args, 60), self.theme.tool_call.add_modifier(Modifier::DIM), ), ])); text_lines.push(Line::from("")); } ChatMessage::ToolResult { success, output } => { let style = if *success { self.theme.tool_result_success } else { self.theme.tool_result_error }; let icon = if *success { symbols.check } else { symbols.cross }; text_lines.push(Line::from(vec![ Span::styled(format!(" {} ", icon), style), Span::styled( truncate_str(output, 100), style.add_modifier(Modifier::DIM), ), ])); text_lines.push(Line::from("")); } ChatMessage::System(content) => { text_lines.push(Line::from(vec![ Span::styled(" ", Style::default()), Span::styled( format!("{} ", symbols.system_prefix), self.theme.system_message, ), Span::styled(content.to_string(), self.theme.system_message), ])); } } } let text = Text::from(text_lines); let paragraph = Paragraph::new(text).scroll((self.scroll_offset as u16, 0)); frame.render_widget(paragraph, area); // Render scrollbar if needed if self.total_lines > area.height as usize { let scrollbar = Scrollbar::default() .orientation(ScrollbarOrientation::VerticalRight) .begin_symbol(None) .end_symbol(None) .track_symbol(Some(" ")) .thumb_symbol("│") .style(self.theme.status_dim); let mut scrollbar_state = ScrollbarState::default() .content_length(self.total_lines) .position(self.scroll_offset); frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state); } } /// Get messages pub fn messages(&self) -> &[DisplayMessage] { &self.messages } /// Clear all messages pub fn clear(&mut self) { self.messages.clear(); self.scroll_offset = 0; self.focused_index = None; } /// Update theme pub fn set_theme(&mut self, theme: Theme) { self.theme = theme; } } /// Truncate a string to max length with ellipsis fn truncate_str(s: &str, max_len: usize) -> String { if s.len() <= max_len { s.to_string() } else { format!("{}...", &s[..max_len.saturating_sub(3)]) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_chat_panel_add_message() { let theme = Theme::default(); let mut panel = ChatPanel::new(theme); panel.add_message(ChatMessage::User("Hello".to_string())); panel.add_message(ChatMessage::Assistant("Hi there!".to_string())); assert_eq!(panel.messages().len(), 2); } #[test] fn test_append_to_assistant() { let theme = Theme::default(); let mut panel = ChatPanel::new(theme); panel.append_to_assistant("Hello"); panel.append_to_assistant(" world"); assert_eq!(panel.messages().len(), 1); if let ChatMessage::Assistant(content) = &panel.messages()[0].message { assert_eq!(content, "Hello world"); } } #[test] fn test_focus_navigation() { let theme = Theme::default(); let mut panel = ChatPanel::new(theme); panel.add_message(ChatMessage::User("1".to_string())); panel.add_message(ChatMessage::User("2".to_string())); panel.add_message(ChatMessage::User("3".to_string())); assert_eq!(panel.focused_index(), None); panel.focus_next(); assert_eq!(panel.focused_index(), Some(0)); panel.focus_next(); assert_eq!(panel.focused_index(), Some(1)); panel.focus_previous(); assert_eq!(panel.focused_index(), Some(0)); } }