code_mesh_tui/
chat.rs

1use ratatui::{
2    layout::Rect,
3    widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
4    style::Style,
5    text::{Line, Span},
6};
7use anyhow::Result;
8use crossterm::event::{KeyCode, KeyEvent};
9use tui_textarea::TextArea;
10
11use crate::{
12    config::ChatConfig,
13    renderer::Renderer,
14    theme::Theme,
15};
16
17/// Chat component for interactive messaging
18pub struct ChatComponent {
19    theme: Box<dyn Theme + Send + Sync>,
20    config: ChatConfig,
21    messages: Vec<ChatMessage>,
22    input_area: TextArea<'static>,
23    scroll_offset: usize,
24    auto_scroll: bool,
25}
26
27/// A chat message
28#[derive(Debug, Clone)]
29pub struct ChatMessage {
30    pub id: String,
31    pub role: MessageRole,
32    pub content: String,
33    pub timestamp: std::time::SystemTime,
34    pub attachments: Vec<MessageAttachment>,
35}
36
37/// Message role enumeration
38#[derive(Debug, Clone, PartialEq)]
39pub enum MessageRole {
40    User,
41    Assistant,
42    System,
43}
44
45/// Message attachment
46#[derive(Debug, Clone)]
47pub struct MessageAttachment {
48    pub name: String,
49    pub path: String,
50    pub mime_type: String,
51    pub size: u64,
52}
53
54impl ChatComponent {
55    /// Create a new chat component
56    pub fn new(config: &ChatConfig, theme: &dyn Theme) -> Self {
57        let mut input_area = TextArea::default();
58        input_area.set_placeholder_text("Type your message...");
59        
60        Self {
61            theme: Box::new(crate::theme::DefaultTheme), // Temporary
62            config: config.clone(),
63            messages: Vec::new(),
64            input_area,
65            scroll_offset: 0,
66            auto_scroll: true,
67        }
68    }
69    
70    /// Add a new message
71    pub fn add_message(&mut self, message: ChatMessage) {
72        self.messages.push(message);
73        
74        // Limit message history
75        if self.messages.len() > self.config.max_messages {
76            self.messages.remove(0);
77        }
78        
79        // Auto-scroll to bottom if enabled
80        if self.config.auto_scroll && self.auto_scroll {
81            self.scroll_to_bottom();
82        }
83    }
84    
85    /// Send the current message
86    pub async fn send_message(&mut self) -> Result<()> {
87        let content = self.input_area.lines().join("\n");
88        if content.trim().is_empty() {
89            return Ok(());
90        }
91        
92        // Create user message
93        let message = ChatMessage {
94            id: uuid::Uuid::new_v4().to_string(),
95            role: MessageRole::User,
96            content: content.clone(),
97            timestamp: std::time::SystemTime::now(),
98            attachments: Vec::new(),
99        };
100        
101        self.add_message(message);
102        self.clear_input();
103        
104        // TODO: Send to LLM service and handle response
105        
106        Ok(())
107    }
108    
109    /// Clear the input area
110    pub fn clear_input(&mut self) {
111        self.input_area = TextArea::default();
112        self.input_area.set_placeholder_text("Type your message...");
113    }
114    
115    /// Insert a newline in the input
116    pub fn insert_newline(&mut self) {
117        self.input_area.insert_newline();
118    }
119    
120    /// Handle paste event
121    pub async fn handle_paste(&mut self, data: String) -> Result<()> {
122        self.input_area.insert_str(&data);
123        Ok(())
124    }
125    
126    /// Handle key events
127    pub async fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
128        match key.code {
129            KeyCode::Enter if key.modifiers.is_empty() => {
130                self.send_message().await?;
131            }
132            KeyCode::Enter if key.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) => {
133                self.insert_newline();
134            }
135            _ => {
136                self.input_area.input(key);
137            }
138        }
139        Ok(())
140    }
141    
142    /// Scroll up in the message history
143    pub fn scroll_up(&mut self) {
144        if self.scroll_offset > 0 {
145            self.scroll_offset -= 1;
146            self.auto_scroll = false;
147        }
148    }
149    
150    /// Scroll down in the message history
151    pub fn scroll_down(&mut self) {
152        let max_offset = self.messages.len().saturating_sub(1);
153        if self.scroll_offset < max_offset {
154            self.scroll_offset += 1;
155        } else {
156            self.auto_scroll = true;
157        }
158    }
159    
160    /// Page up in the message history
161    pub fn page_up(&mut self) {
162        let page_size = 10; // Could be based on visible area
163        self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
164        self.auto_scroll = false;
165    }
166    
167    /// Page down in the message history
168    pub fn page_down(&mut self) {
169        let page_size = 10;
170        let max_offset = self.messages.len().saturating_sub(1);
171        self.scroll_offset = (self.scroll_offset + page_size).min(max_offset);
172        if self.scroll_offset >= max_offset {
173            self.auto_scroll = true;
174        }
175    }
176    
177    /// Scroll to the bottom of the message history
178    pub fn scroll_to_bottom(&mut self) {
179        self.scroll_offset = self.messages.len().saturating_sub(1);
180        self.auto_scroll = true;
181    }
182    
183    /// Clear all messages
184    pub async fn clear(&mut self) -> Result<()> {
185        self.messages.clear();
186        self.scroll_offset = 0;
187        self.auto_scroll = true;
188        Ok(())
189    }
190    
191    /// Update the component
192    pub async fn update(&mut self) -> Result<()> {
193        // Handle any periodic updates
194        Ok(())
195    }
196    
197    /// Update theme
198    pub fn update_theme(&mut self, theme: &dyn Theme) {
199        // In a real implementation, we'd clone or recreate the theme
200        // For now, this is a placeholder
201    }
202    
203    /// Render the main chat area (messages)
204    pub fn render(&mut self, renderer: &Renderer, area: Rect) {
205        let block = Block::default()
206            .title("Chat")
207            .borders(Borders::ALL)
208            .border_style(Style::default().fg(self.theme.border()));
209        
210        renderer.render_widget(block.clone(), area);
211        
212        let inner_area = block.inner(area);
213        self.render_messages(renderer, inner_area);
214    }
215    
216    /// Render the input area
217    pub fn render_input(&mut self, renderer: &Renderer, area: Rect) {
218        let block = Block::default()
219            .title("Message")
220            .borders(Borders::ALL)
221            .border_style(Style::default().fg(self.theme.border_active()));
222        
223        renderer.render_widget(block.clone(), area);
224        
225        let inner_area = block.inner(area);
226        
227        // Apply theme to input area
228        let mut input_style = self.input_area.style();
229        input_style = input_style
230            .fg(self.theme.text())
231            .bg(self.theme.background_element());
232        self.input_area.set_style(input_style);
233        
234        // Render the text area
235        let widget = &self.input_area;
236        renderer.render_widget(widget, inner_area);
237    }
238    
239    /// Render the message list
240    fn render_messages(&self, renderer: &Renderer, area: Rect) {
241        if self.messages.is_empty() {
242            let empty_msg = Paragraph::new("No messages yet. Start a conversation!")
243                .style(Style::default().fg(self.theme.text_muted()));
244            renderer.render_widget(empty_msg, area);
245            return;
246        }
247        
248        // Calculate visible messages based on area height and scroll offset
249        let visible_height = area.height as usize;
250        let start_index = self.scroll_offset;
251        let end_index = (start_index + visible_height).min(self.messages.len());
252        
253        let visible_messages = &self.messages[start_index..end_index];
254        
255        // Convert messages to renderable lines
256        let mut lines = Vec::new();
257        for message in visible_messages {
258            lines.extend(self.format_message(message));
259            lines.push(Line::raw("")); // Empty line between messages
260        }
261        
262        let paragraph = Paragraph::new(lines)
263            .style(Style::default().bg(self.theme.background()));
264        
265        renderer.render_widget(paragraph, area);
266        
267        // Render scrollbar if needed
268        if self.messages.len() > visible_height {
269            let scrollbar = Scrollbar::default()
270                .orientation(ScrollbarOrientation::VerticalRight)
271                .begin_symbol(Some("↑"))
272                .end_symbol(Some("↓"));
273            
274            let mut scrollbar_state = ScrollbarState::default()
275                .content_length(self.messages.len())
276                .position(self.scroll_offset);
277            
278            // Note: scrollbar rendering would need proper state management
279            // renderer.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
280        }
281    }
282    
283    /// Format a message for display
284    fn format_message<'a>(&self, message: &'a ChatMessage) -> Vec<Line<'a>> {
285        let mut lines = Vec::new();
286        
287        // Message header with role and timestamp
288        let timestamp = message.timestamp
289            .duration_since(std::time::UNIX_EPOCH)
290            .unwrap_or_default()
291            .as_secs();
292        
293        let time_str = format_timestamp(timestamp);
294        
295        let (role_text, role_style) = match message.role {
296            MessageRole::User => ("You", Style::default().fg(self.theme.primary())),
297            MessageRole::Assistant => ("Assistant", Style::default().fg(self.theme.secondary())),
298            MessageRole::System => ("System", Style::default().fg(self.theme.accent())),
299        };
300        
301        let header = Line::from(vec![
302            Span::styled(role_text, role_style),
303            Span::raw(" • "),
304            Span::styled(time_str, Style::default().fg(self.theme.text_muted())),
305        ]);
306        
307        lines.push(header);
308        
309        // Message content
310        let content_lines: Vec<&str> = message.content.lines().collect();
311        for line in content_lines {
312            lines.push(Line::from(Span::styled(
313                line,
314                Style::default().fg(self.theme.text()),
315            )));
316        }
317        
318        // Attachments
319        for attachment in &message.attachments {
320            lines.push(Line::from(vec![
321                Span::raw("📎 "),
322                Span::styled(
323                    &attachment.name,
324                    Style::default().fg(self.theme.accent()),
325                ),
326                Span::styled(
327                    format!(" ({})", format_file_size(attachment.size)),
328                    Style::default().fg(self.theme.text_muted()),
329                ),
330            ]));
331        }
332        
333        lines
334    }
335}
336
337/// Format a timestamp for display
338fn format_timestamp(timestamp: u64) -> String {
339    match chrono::NaiveDateTime::from_timestamp_opt(timestamp as i64, 0) {
340        Some(dt) => dt.format("%H:%M:%S").to_string(),
341        None => "??:??:??".to_string(),
342    }
343}
344
345/// Format file size for display
346fn format_file_size(size: u64) -> String {
347    const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
348    let mut size = size as f64;
349    let mut unit_index = 0;
350    
351    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
352        size /= 1024.0;
353        unit_index += 1;
354    }
355    
356    if unit_index == 0 {
357        format!("{} {}", size as u64, UNITS[unit_index])
358    } else {
359        format!("{:.1} {}", size, UNITS[unit_index])
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::config::ChatConfig;
367    
368    #[test]
369    fn test_format_file_size() {
370        assert_eq!(format_file_size(500), "500 B");
371        assert_eq!(format_file_size(1536), "1.5 KB");
372        assert_eq!(format_file_size(1048576), "1.0 MB");
373    }
374    
375    #[test]
376    fn test_message_management() {
377        let config = ChatConfig::default();
378        let theme = crate::theme::DefaultTheme;
379        let mut chat = ChatComponent::new(&config, &theme);
380        
381        // Add a message
382        let message = ChatMessage {
383            id: "test".to_string(),
384            role: MessageRole::User,
385            content: "Hello".to_string(),
386            timestamp: std::time::SystemTime::now(),
387            attachments: Vec::new(),
388        };
389        
390        chat.add_message(message);
391        assert_eq!(chat.messages.len(), 1);
392    }
393}