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
17pub 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#[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#[derive(Debug, Clone, PartialEq)]
39pub enum MessageRole {
40 User,
41 Assistant,
42 System,
43}
44
45#[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 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), config: config.clone(),
63 messages: Vec::new(),
64 input_area,
65 scroll_offset: 0,
66 auto_scroll: true,
67 }
68 }
69
70 pub fn add_message(&mut self, message: ChatMessage) {
72 self.messages.push(message);
73
74 if self.messages.len() > self.config.max_messages {
76 self.messages.remove(0);
77 }
78
79 if self.config.auto_scroll && self.auto_scroll {
81 self.scroll_to_bottom();
82 }
83 }
84
85 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 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 Ok(())
107 }
108
109 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 pub fn insert_newline(&mut self) {
117 self.input_area.insert_newline();
118 }
119
120 pub async fn handle_paste(&mut self, data: String) -> Result<()> {
122 self.input_area.insert_str(&data);
123 Ok(())
124 }
125
126 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 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 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 pub fn page_up(&mut self) {
162 let page_size = 10; self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
164 self.auto_scroll = false;
165 }
166
167 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 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 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 pub async fn update(&mut self) -> Result<()> {
193 Ok(())
195 }
196
197 pub fn update_theme(&mut self, theme: &dyn Theme) {
199 }
202
203 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 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 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 let widget = &self.input_area;
236 renderer.render_widget(widget, inner_area);
237 }
238
239 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 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 let mut lines = Vec::new();
257 for message in visible_messages {
258 lines.extend(self.format_message(message));
259 lines.push(Line::raw("")); }
261
262 let paragraph = Paragraph::new(lines)
263 .style(Style::default().bg(self.theme.background()));
264
265 renderer.render_widget(paragraph, area);
266
267 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 }
281 }
282
283 fn format_message<'a>(&self, message: &'a ChatMessage) -> Vec<Line<'a>> {
285 let mut lines = Vec::new();
286
287 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 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 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
337fn 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
345fn 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 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}