1use std::cell::{Cell, RefCell};
4
5use crate::syntax::SyntaxHighlighter;
6use tracing::debug;
7
8use ratatui::{
9 buffer::Buffer,
10 layout::Rect,
11 prelude::Widget,
12 style::{Color, Modifier, Style},
13 text::{Line, Span, Text},
14 widgets::{Paragraph, Wrap},
15};
16
17const RENDER_WINDOW_SIZE: usize = 50;
19
20fn char_offset_to_byte(text: &str, char_offset: usize) -> usize {
22 text.char_indices()
23 .nth(char_offset)
24 .map(|(i, _)| i)
25 .unwrap_or(text.len())
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum LineType {
31 Normal,
32 Header1,
33 Header2,
34 Header3,
35 ListItem,
36 CodeBlock,
37}
38
39impl LineType {
40 fn style(&self) -> Style {
41 match self {
42 LineType::Header1 => Style::default()
43 .fg(Color::Cyan)
44 .add_modifier(Modifier::BOLD),
45 LineType::Header2 => Style::default()
46 .fg(Color::Yellow)
47 .add_modifier(Modifier::BOLD),
48 LineType::Header3 => Style::default()
49 .fg(Color::Green)
50 .add_modifier(Modifier::BOLD),
51 LineType::ListItem => Style::default().fg(Color::White),
52 LineType::CodeBlock => Style::default().fg(Color::Gray),
53 LineType::Normal => Style::default(),
54 }
55 }
56}
57
58fn parse_inline_markdown(text: &str, base_style: Style) -> Vec<Span<'_>> {
60 let mut spans = Vec::new();
61 let mut chars = text.chars().peekable();
62 let mut current = String::new();
63 let mut in_bold = false;
64 let mut in_italic = false;
65 let mut in_code = false;
66
67 while let Some(c) = chars.next() {
68 if c == '`' && !in_bold && !in_italic {
70 if in_code {
71 let style = Style::default().fg(Color::Yellow);
73 spans.push(Span::styled(current.clone(), style));
74 current.clear();
75 in_code = false;
76 } else {
77 if !current.is_empty() {
79 spans.push(Span::styled(current.clone(), base_style));
80 current.clear();
81 }
82 in_code = true;
83 }
84 continue;
85 }
86
87 if c == '*' && chars.peek() == Some(&'*') && !in_code {
89 chars.next(); if in_bold {
91 let style = base_style.add_modifier(Modifier::BOLD);
93 spans.push(Span::styled(current.clone(), style));
94 current.clear();
95 in_bold = false;
96 } else {
97 if !current.is_empty() {
99 spans.push(Span::styled(current.clone(), base_style));
100 current.clear();
101 }
102 in_bold = true;
103 }
104 continue;
105 }
106
107 if c == '*' && !in_code && !in_bold {
109 if in_italic {
110 let style = base_style.add_modifier(Modifier::ITALIC);
112 spans.push(Span::styled(current.clone(), style));
113 current.clear();
114 in_italic = false;
115 } else {
116 if !current.is_empty() {
118 spans.push(Span::styled(current.clone(), base_style));
119 current.clear();
120 }
121 in_italic = true;
122 }
123 continue;
124 }
125
126 current.push(c);
127 }
128
129 if !current.is_empty() {
131 let style = if in_code {
132 Style::default().fg(Color::Yellow)
133 } else if in_bold {
134 base_style.add_modifier(Modifier::BOLD)
135 } else if in_italic {
136 base_style.add_modifier(Modifier::ITALIC)
137 } else {
138 base_style
139 };
140 spans.push(Span::styled(current, style));
141 }
142
143 if spans.is_empty() {
144 spans.push(Span::styled(text, base_style));
145 }
146
147 spans
148}
149
150fn detect_line_type(line: &str) -> (LineType, &str) {
152 let trimmed = line.trim_start();
153 if trimmed.starts_with("### ") {
154 (
155 LineType::Header3,
156 trimmed.strip_prefix("### ").unwrap_or(trimmed),
157 )
158 } else if trimmed.starts_with("## ") {
159 (
160 LineType::Header2,
161 trimmed.strip_prefix("## ").unwrap_or(trimmed),
162 )
163 } else if trimmed.starts_with("# ") {
164 (
165 LineType::Header1,
166 trimmed.strip_prefix("# ").unwrap_or(trimmed),
167 )
168 } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
169 (LineType::ListItem, line)
170 } else {
171 (LineType::Normal, line)
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum Role {
178 User,
179 Assistant,
180 System,
181}
182
183impl Role {
184 pub fn display_name(&self) -> &str {
186 match self {
187 Role::User => "USER",
188 Role::Assistant => "ASSISTANT",
189 Role::System => "SYSTEM",
190 }
191 }
192
193 pub fn badge_color(&self) -> Color {
195 match self {
196 Role::User => Color::Blue,
197 Role::Assistant => Color::Green,
198 Role::System => Color::Yellow,
199 }
200 }
201}
202
203#[derive(Debug, Clone)]
205pub struct Message {
206 pub role: Role,
207 pub content: String,
208 pub timestamp: String,
209}
210
211impl Message {
212 pub fn new(role: Role, content: String, timestamp: String) -> Self {
214 Self {
215 role,
216 content,
217 timestamp,
218 }
219 }
220
221 pub fn user(content: String) -> Self {
223 let timestamp = Self::current_timestamp();
224 Self::new(Role::User, content, timestamp)
225 }
226
227 pub fn assistant(content: String) -> Self {
229 let timestamp = Self::current_timestamp();
230 Self::new(Role::Assistant, content, timestamp)
231 }
232
233 pub fn system(content: String) -> Self {
235 let timestamp = Self::current_timestamp();
236 Self::new(Role::System, content, timestamp)
237 }
238
239 fn current_timestamp() -> String {
241 chrono::Local::now().format("%H:%M").to_string()
242 }
243}
244
245#[derive(Debug, Clone, Copy)]
247pub struct RenderPosition {
248 pub message_idx: usize,
250 pub line_idx: usize,
252 pub char_start: usize,
254 pub char_end: usize,
256 pub screen_row: u16,
258}
259
260#[derive(Debug, Clone)]
262pub struct ChatView {
263 messages: Vec<Message>,
264 scroll_offset: usize,
265 pinned_to_bottom: bool,
266 last_max_scroll_offset: Cell<usize>,
268 highlighter: SyntaxHighlighter,
270 cached_height: Cell<usize>,
272 cache_dirty: Cell<bool>,
274 hidden_message_count: Cell<usize>,
276 selection_start: Option<(usize, usize)>,
278 selection_end: Option<(usize, usize)>,
279 render_positions: RefCell<Vec<RenderPosition>>,
281}
282
283impl Default for ChatView {
284 fn default() -> Self {
285 Self::new()
286 }
287}
288
289impl ChatView {
290 pub fn new() -> Self {
291 debug!(component = %"ChatView", "Component created");
292 Self {
293 messages: Vec::new(),
294 scroll_offset: 0,
295 pinned_to_bottom: true,
296 last_max_scroll_offset: Cell::new(0),
297 highlighter: SyntaxHighlighter::new().expect("Failed to initialize syntax highlighter"),
298 cache_dirty: Cell::new(true),
299 cached_height: Cell::new(0),
300 hidden_message_count: Cell::new(0),
301 selection_start: None,
302 selection_end: None,
303 render_positions: RefCell::new(Vec::new()),
304 }
305 }
306
307 pub fn add_message(&mut self, message: Message) {
309 self.messages.push(message);
310 self.scroll_to_bottom();
312 }
313
314 pub fn append_to_last_assistant(&mut self, content: &str) {
316 if let Some(last) = self.messages.last_mut() {
317 if matches!(last.role, Role::Assistant) {
318 last.content.push_str(content);
319 self.scroll_to_bottom();
320 return;
321 }
322 }
323 self.add_message(Message::assistant(content.to_string()));
325 }
326
327 pub fn message_count(&self) -> usize {
329 self.messages.len()
330 }
331
332 pub fn messages(&self) -> &[Message] {
334 &self.messages
335 }
336
337 pub fn scroll_up(&mut self) {
339 const SCROLL_LINES: usize = 5;
340 if self.pinned_to_bottom {
342 self.scroll_offset = self.last_max_scroll_offset.get();
343 }
344 self.pinned_to_bottom = false;
345 self.scroll_offset = self.scroll_offset.saturating_sub(SCROLL_LINES);
346 self.cache_dirty.set(true);
348 }
349
350 pub fn scroll_down(&mut self) {
352 const SCROLL_LINES: usize = 5;
353 if self.pinned_to_bottom {
355 self.scroll_offset = self.last_max_scroll_offset.get();
356 }
357 self.pinned_to_bottom = false;
358 self.scroll_offset = self.scroll_offset.saturating_add(SCROLL_LINES);
359 }
360
361 pub fn scroll_page_up(&mut self, viewport_height: u16) {
363 if self.pinned_to_bottom {
365 self.scroll_offset = self.last_max_scroll_offset.get();
366 }
367 self.pinned_to_bottom = false;
368 let page_size = viewport_height as usize;
369 self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
370 }
371
372 pub fn scroll_page_down(&mut self, viewport_height: u16) {
374 if self.pinned_to_bottom {
376 self.scroll_offset = self.last_max_scroll_offset.get();
377 }
378 self.pinned_to_bottom = false;
379 let page_size = viewport_height as usize;
380 self.scroll_offset = self.scroll_offset.saturating_add(page_size);
381 }
382
383 pub fn scroll_to_bottom(&mut self) {
385 self.pinned_to_bottom = true;
386 }
387
388 pub fn scroll_to_top(&mut self) {
390 self.pinned_to_bottom = false;
391 self.scroll_offset = 0;
392 }
393
394 pub fn start_selection(&mut self, message_idx: usize, byte_offset: usize) {
396 self.selection_start = Some((message_idx, byte_offset));
397 self.selection_end = Some((message_idx, byte_offset));
398 }
399
400 pub fn extend_selection(&mut self, message_idx: usize, byte_offset: usize) {
402 if self.selection_start.is_some() {
403 self.selection_end = Some((message_idx, byte_offset));
404 }
405 }
406
407 pub fn clear_selection(&mut self) {
409 self.selection_start = None;
410 self.selection_end = None;
411 }
412
413 pub fn has_selection(&self) -> bool {
415 self.selection_start.is_some() && self.selection_end.is_some()
416 }
417
418 pub fn screen_to_text_pos(&self, col: u16, row: u16) -> Option<(usize, usize)> {
421 for pos in self.render_positions.borrow().iter() {
422 if pos.screen_row == row {
423 let line_len = pos.char_end.saturating_sub(pos.char_start);
426 let char_in_line = (col as usize).min(line_len);
427 return Some((pos.message_idx, pos.char_start + char_in_line));
428 }
429 }
430 None
431 }
432
433 pub fn is_selected(&self, message_idx: usize, char_offset: usize) -> bool {
435 let Some((start_msg, start_offset)) = self.selection_start else {
436 return false;
437 };
438 let Some((end_msg, end_offset)) = self.selection_end else {
439 return false;
440 };
441
442 let (min_msg, min_offset, max_msg, max_offset) =
444 if start_msg < end_msg || (start_msg == end_msg && start_offset <= end_offset) {
445 (start_msg, start_offset, end_msg, end_offset)
446 } else {
447 (end_msg, end_offset, start_msg, start_offset)
448 };
449
450 if message_idx < min_msg || message_idx > max_msg {
452 return false;
453 }
454
455 if message_idx == min_msg && message_idx == max_msg {
456 char_offset >= min_offset && char_offset < max_offset
458 } else if message_idx == min_msg {
459 char_offset >= min_offset
461 } else if message_idx == max_msg {
462 char_offset < max_offset
464 } else {
465 true
467 }
468 }
469
470 fn apply_selection_highlight<'a>(
473 &self,
474 text: &'a str,
475 message_idx: usize,
476 line_char_start: usize,
477 base_style: Style,
478 ) -> Vec<Span<'a>> {
479 let selection_style = Style::default().bg(Color::Blue).fg(Color::White);
480
481 if !self.has_selection() {
483 return vec![Span::styled(text, base_style)];
484 }
485
486 let mut spans = Vec::new();
487 let mut current_start = 0;
488 let mut in_selection = false;
489 let char_positions: Vec<(usize, char)> = text.char_indices().collect();
490
491 for (i, (byte_idx, _)) in char_positions.iter().enumerate() {
492 let global_char = line_char_start + i;
493 let is_sel = self.is_selected(message_idx, global_char);
494
495 if is_sel != in_selection {
496 if i > current_start {
498 let segment_byte_start = char_positions[current_start].0;
499 let segment_byte_end = *byte_idx;
500 let segment = &text[segment_byte_start..segment_byte_end];
501 let style = if in_selection {
502 selection_style
503 } else {
504 base_style
505 };
506 spans.push(Span::styled(segment, style));
507 }
508 current_start = i;
509 in_selection = is_sel;
510 }
511 }
512
513 if current_start < char_positions.len() {
515 let segment_byte_start = char_positions[current_start].0;
516 let segment = &text[segment_byte_start..];
517 let style = if in_selection {
518 selection_style
519 } else {
520 base_style
521 };
522 spans.push(Span::styled(segment, style));
523 }
524
525 if spans.is_empty() {
526 vec![Span::styled(text, base_style)]
527 } else {
528 spans
529 }
530 }
531
532 pub fn get_selected_text(&self) -> Option<String> {
534 let (start_msg, start_offset) = self.selection_start?;
535 let (end_msg, end_offset) = self.selection_end?;
536
537 let (min_msg, min_offset, max_msg, max_offset) =
539 if start_msg < end_msg || (start_msg == end_msg && start_offset <= end_offset) {
540 (start_msg, start_offset, end_msg, end_offset)
541 } else {
542 (end_msg, end_offset, start_msg, start_offset)
543 };
544
545 if min_msg == max_msg {
546 let msg = self.messages.get(min_msg)?;
548 let content = &msg.content;
549 let start_byte = char_offset_to_byte(content, min_offset);
550 let end_byte = char_offset_to_byte(content, max_offset);
551 if start_byte < content.len() && end_byte <= content.len() {
552 Some(content[start_byte..end_byte].to_string())
553 } else {
554 None
555 }
556 } else {
557 let mut result = String::new();
559
560 if let Some(msg) = self.messages.get(min_msg) {
562 let start_byte = char_offset_to_byte(&msg.content, min_offset);
563 if start_byte < msg.content.len() {
564 result.push_str(&msg.content[start_byte..]);
565 }
566 }
567
568 for idx in (min_msg + 1)..max_msg {
570 if let Some(msg) = self.messages.get(idx) {
571 result.push('\n');
572 result.push_str(&msg.content);
573 }
574 }
575
576 if let Some(msg) = self.messages.get(max_msg) {
578 result.push('\n');
579 let end_byte = char_offset_to_byte(&msg.content, max_offset);
580 if end_byte > 0 && end_byte <= msg.content.len() {
581 result.push_str(&msg.content[..end_byte]);
582 }
583 }
584
585 Some(result)
586 }
587 }
588
589 pub fn clear(&mut self) {
591 self.messages.clear();
592 self.scroll_offset = 0;
593 self.pinned_to_bottom = true;
594 self.cache_dirty.set(true);
595 self.hidden_message_count.set(0);
596 }
597
598 fn get_render_window(&self) -> (&[Message], usize) {
601 let total_count = self.messages.len();
602
603 if self.pinned_to_bottom && total_count > RENDER_WINDOW_SIZE {
605 let hidden_count = total_count.saturating_sub(RENDER_WINDOW_SIZE);
606 let window = &self.messages[hidden_count..];
607 self.hidden_message_count.set(hidden_count);
608 (window, hidden_count)
609 } else {
610 self.hidden_message_count.set(0);
611 (&self.messages, 0)
612 }
613 }
614
615 fn estimate_line_count(text: &str, width: usize) -> usize {
617 if width == 0 {
618 return 0;
619 }
620
621 let mut lines = 0;
622 let mut current_line_len = 0;
623
624 for line in text.lines() {
625 if line.is_empty() {
626 lines += 1;
627 current_line_len = 0;
628 continue;
629 }
630
631 let words: Vec<&str> = line.split_whitespace().collect();
633 let mut word_index = 0;
634
635 while word_index < words.len() {
636 let word = words[word_index];
637 let word_len = word.len();
638
639 if current_line_len == 0 {
640 if word_len > width {
642 let mut chars_left = word;
644 while !chars_left.is_empty() {
645 let take = chars_left.len().min(width);
646 lines += 1;
647 chars_left = &chars_left[take..];
648 }
649 current_line_len = 0;
650 } else {
651 current_line_len = word_len;
652 }
653 } else if current_line_len + 1 + word_len <= width {
654 current_line_len += 1 + word_len;
656 } else {
657 lines += 1;
659 current_line_len = if word_len > width {
660 let mut chars_left = word;
662 while !chars_left.is_empty() {
663 let take = chars_left.len().min(width);
664 lines += 1;
665 chars_left = &chars_left[take..];
666 }
667 0
668 } else {
669 word_len
670 };
671 }
672
673 word_index += 1;
674 }
675
676 if current_line_len > 0 || words.is_empty() {
678 lines += 1;
679 }
680
681 current_line_len = 0;
682 }
683
684 lines.max(1)
685 }
686
687 fn process_code_blocks(&self, content: &str) -> Vec<(String, LineType, bool, Option<String>)> {
690 let mut result = Vec::new();
691 let lines = content.lines().peekable();
692 let mut in_code_block = false;
693 let mut current_lang: Option<String> = None;
694
695 for line in lines {
696 if line.starts_with("```") {
697 if in_code_block {
698 in_code_block = false;
700 current_lang = None;
701 } else {
702 in_code_block = true;
704 current_lang = line
705 .strip_prefix("```")
706 .map(|s| s.trim().to_string())
707 .filter(|s| !s.is_empty());
708 }
709 } else if in_code_block {
710 result.push((
711 line.to_string(),
712 LineType::CodeBlock,
713 true,
714 current_lang.clone(),
715 ));
716 } else {
717 let (line_type, _) = detect_line_type(line);
718 result.push((line.to_string(), line_type, false, None));
719 }
720 }
721
722 result
723 }
724
725 fn calculate_total_height(&self, width: u16) -> usize {
727 if !self.cache_dirty.get() {
729 return self.cached_height.get();
730 }
731
732 let mut total_height = 0;
733
734 for message in &self.messages {
737 total_height += 1;
739
740 let processed = self.process_code_blocks(&message.content);
742
743 for (line, _line_type, _is_code, _lang) in processed {
744 let line_height = if _is_code {
747 1 } else {
749 Self::estimate_line_count(&line, width as usize)
750 };
751 total_height += line_height;
752 }
753
754 total_height += 1;
756 }
757
758 self.cached_height.set(total_height);
760 self.cache_dirty.set(false);
761
762 total_height
763 }
764
765 fn render_to_buffer(&self, area: Rect, buf: &mut Buffer) {
768 self.render_positions.borrow_mut().clear();
770
771 let total_height = self.calculate_total_height(area.width);
772 let viewport_height = area.height as usize;
773
774 let max_scroll_offset = if total_height > viewport_height {
776 total_height.saturating_sub(viewport_height)
777 } else {
778 0
779 };
780
781 self.last_max_scroll_offset.set(max_scroll_offset);
783
784 let scroll_offset = if self.pinned_to_bottom {
785 max_scroll_offset
787 } else {
788 self.scroll_offset.min(max_scroll_offset)
790 };
791
792 let (initial_y_offset, skip_until, max_y) =
794 (area.y, scroll_offset, scroll_offset + viewport_height);
795
796 let mut y_offset = initial_y_offset;
797 let mut global_y: usize = 0;
798
799 let (messages_to_render, hidden_count) = self.get_render_window();
801
802 if hidden_count > 0 {
805 for message in &self.messages[..hidden_count] {
808 let role_height = 1;
809 let processed = self.process_code_blocks(&message.content);
810 let content_height: usize = processed
811 .iter()
812 .map(|(line, _, _, _)| Self::estimate_line_count(line, area.width as usize))
813 .sum();
814 let separator_height = 1;
815 global_y += role_height + content_height + separator_height;
816 }
817 }
818 for (local_msg_idx, message) in messages_to_render.iter().enumerate() {
819 let message_idx = hidden_count + local_msg_idx;
820
821 let role_height = 1;
823 let processed = self.process_code_blocks(&message.content);
824 let content_height: usize = processed
825 .iter()
826 .map(|(line, _, _, _)| Self::estimate_line_count(line, area.width as usize))
827 .sum();
828 let separator_height = 1;
829 let message_height = role_height + content_height + separator_height;
830
831 if global_y + message_height <= skip_until {
832 global_y += message_height;
833 continue;
834 }
835
836 if global_y >= max_y {
837 break;
838 }
839
840 if global_y >= skip_until && y_offset < area.y + area.height {
842 let role_text = format!("[{}] {}", message.role.display_name(), message.timestamp);
843 let style = Style::default()
844 .fg(message.role.badge_color())
845 .add_modifier(Modifier::BOLD);
846
847 let line = Line::from(vec![Span::styled(role_text, style)]);
848
849 Paragraph::new(line)
850 .wrap(Wrap { trim: false })
851 .render(Rect::new(area.x, y_offset, area.width, 1), buf);
852
853 y_offset += 1;
854 }
855 global_y += 1;
856
857 let mut char_offset: usize = 0;
860 for (line_idx, (line, line_type, is_code_block, lang)) in processed.iter().enumerate() {
861 let line_height = Self::estimate_line_count(line, area.width as usize);
862 let line_char_count = line.chars().count();
863
864 if global_y >= skip_until && y_offset < area.y + area.height {
866 self.render_positions.borrow_mut().push(RenderPosition {
867 message_idx,
868 line_idx,
869 char_start: char_offset,
870 char_end: char_offset + line_char_count,
871 screen_row: y_offset,
872 });
873 }
874
875 if *is_code_block && global_y >= skip_until {
876 if let Some(ref lang_str) = lang {
878 if let Ok(highlighted_spans) = self
879 .highlighter
880 .highlight_to_spans(&format!("{}\n", line), lang_str)
881 {
882 for highlighted_line in highlighted_spans {
884 if y_offset < area.y + area.height && global_y < max_y {
885 let text = Text::from(Line::from(highlighted_line));
886 Paragraph::new(text)
887 .wrap(Wrap { trim: false })
888 .render(Rect::new(area.x, y_offset, area.width, 1), buf);
889 y_offset += 1;
890 }
891 global_y += 1;
892
893 if global_y >= max_y {
894 break;
895 }
896 }
897 continue;
898 }
899 }
900 }
901
902 let base_style = line_type.style();
904 let spans = if self.has_selection() {
905 self.apply_selection_highlight(line, message_idx, char_offset, base_style)
907 } else {
908 parse_inline_markdown(line, base_style)
909 };
910 let text_line = Line::from(spans);
911
912 if global_y >= skip_until && y_offset < area.y + area.height {
914 let render_height =
916 line_height.min((area.y + area.height - y_offset) as usize) as u16;
917 Paragraph::new(text_line)
918 .wrap(Wrap { trim: false })
919 .render(Rect::new(area.x, y_offset, area.width, render_height), buf);
920 y_offset += line_height as u16;
921 }
922 global_y += line_height;
923
924 char_offset += line_char_count + 1; if global_y >= max_y {
928 break;
929 }
930 }
931
932 if global_y >= skip_until && global_y < max_y && y_offset < area.y + area.height {
934 Paragraph::new("─".repeat(area.width as usize).as_str())
935 .style(Style::default().fg(Color::DarkGray))
936 .render(Rect::new(area.x, y_offset, area.width, 1), buf);
937 y_offset += 1;
938 }
939 global_y += 1;
940 }
941 }
942}
943
944impl ratatui::widgets::Widget for &ChatView {
945 fn render(self, area: Rect, buf: &mut Buffer) {
946 (*self).render_to_buffer(area, buf);
948 }
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954
955 #[test]
956 fn test_role_display_name() {
957 assert_eq!(Role::User.display_name(), "USER");
958 assert_eq!(Role::Assistant.display_name(), "ASSISTANT");
959 assert_eq!(Role::System.display_name(), "SYSTEM");
960 }
961
962 #[test]
963 fn test_role_badge_color() {
964 assert_eq!(Role::User.badge_color(), Color::Blue);
965 assert_eq!(Role::Assistant.badge_color(), Color::Green);
966 assert_eq!(Role::System.badge_color(), Color::Yellow);
967 }
968
969 #[test]
970 fn test_message_new() {
971 let message = Message::new(Role::User, "Hello, World!".to_string(), "12:34".to_string());
972
973 assert_eq!(message.role, Role::User);
974 assert_eq!(message.content, "Hello, World!");
975 assert_eq!(message.timestamp, "12:34");
976 }
977
978 #[test]
979 fn test_message_user() {
980 let message = Message::user("Test message".to_string());
981
982 assert_eq!(message.role, Role::User);
983 assert_eq!(message.content, "Test message");
984 assert!(!message.timestamp.is_empty());
985 }
986
987 #[test]
988 fn test_message_assistant() {
989 let message = Message::assistant("Response".to_string());
990
991 assert_eq!(message.role, Role::Assistant);
992 assert_eq!(message.content, "Response");
993 assert!(!message.timestamp.is_empty());
994 }
995
996 #[test]
997 fn test_message_system() {
998 let message = Message::system("System notification".to_string());
999
1000 assert_eq!(message.role, Role::System);
1001 assert_eq!(message.content, "System notification");
1002 assert!(!message.timestamp.is_empty());
1003 }
1004
1005 #[test]
1006 fn test_chat_view_new() {
1007 let chat = ChatView::new();
1008
1009 assert_eq!(chat.message_count(), 0);
1010 assert_eq!(chat.scroll_offset, 0);
1011 assert!(chat.messages().is_empty());
1012 }
1013
1014 #[test]
1015 fn test_chat_view_default() {
1016 let chat = ChatView::default();
1017
1018 assert_eq!(chat.message_count(), 0);
1019 assert_eq!(chat.scroll_offset, 0);
1020 }
1021
1022 #[test]
1023 fn test_chat_view_add_message() {
1024 let mut chat = ChatView::new();
1025
1026 chat.add_message(Message::user("Hello".to_string()));
1027 assert_eq!(chat.message_count(), 1);
1028
1029 chat.add_message(Message::assistant("Hi there!".to_string()));
1030 assert_eq!(chat.message_count(), 2);
1031 }
1032
1033 #[test]
1034 fn test_chat_view_add_multiple_messages() {
1035 let mut chat = ChatView::new();
1036
1037 for i in 0..5 {
1038 chat.add_message(Message::user(format!("Message {}", i)));
1039 }
1040
1041 assert_eq!(chat.message_count(), 5);
1042 }
1043
1044 #[test]
1045 fn test_chat_view_scroll_up() {
1046 let mut chat = ChatView::new();
1047
1048 for i in 0..10 {
1050 chat.add_message(Message::user(format!("Message {}", i)));
1051 }
1052
1053 assert!(chat.pinned_to_bottom);
1055
1056 chat.scroll_up();
1058 assert!(!chat.pinned_to_bottom);
1059 }
1062
1063 #[test]
1064 fn test_chat_view_scroll_up_bounds() {
1065 let mut chat = ChatView::new();
1066
1067 chat.add_message(Message::user("Test".to_string()));
1068 chat.scroll_to_top(); chat.scroll_up();
1072 assert_eq!(chat.scroll_offset, 0);
1073 assert!(!chat.pinned_to_bottom);
1074
1075 chat.scroll_up();
1076 assert_eq!(chat.scroll_offset, 0);
1077 }
1078
1079 #[test]
1080 fn test_chat_view_scroll_down() {
1081 let mut chat = ChatView::new();
1082
1083 chat.add_message(Message::user("Test".to_string()));
1084
1085 assert!(chat.pinned_to_bottom);
1087
1088 chat.scroll_down();
1089 assert!(!chat.pinned_to_bottom);
1091 assert_eq!(chat.scroll_offset, 5);
1093 }
1094
1095 #[test]
1096 fn test_chat_view_scroll_to_bottom() {
1097 let mut chat = ChatView::new();
1098
1099 for i in 0..5 {
1100 chat.add_message(Message::user(format!("Message {}", i)));
1101 }
1102
1103 chat.scroll_to_top();
1104 assert_eq!(chat.scroll_offset, 0);
1105 assert!(!chat.pinned_to_bottom);
1106
1107 chat.scroll_to_bottom();
1108 assert!(chat.pinned_to_bottom);
1110 }
1111
1112 #[test]
1113 fn test_chat_view_scroll_to_top() {
1114 let mut chat = ChatView::new();
1115
1116 for i in 0..5 {
1117 chat.add_message(Message::user(format!("Message {}", i)));
1118 }
1119
1120 chat.scroll_to_bottom();
1121 assert!(chat.pinned_to_bottom);
1122
1123 chat.scroll_to_top();
1124 assert_eq!(chat.scroll_offset, 0);
1125 assert!(!chat.pinned_to_bottom);
1126 }
1127
1128 #[test]
1129 fn test_chat_view_auto_scroll() {
1130 let mut chat = ChatView::new();
1131
1132 for i in 0..5 {
1133 chat.add_message(Message::user(format!("Message {}", i)));
1134 }
1136
1137 assert!(chat.pinned_to_bottom);
1139 }
1140
1141 #[test]
1142 fn test_chat_view_render() {
1143 let mut chat = ChatView::new();
1144 chat.add_message(Message::user("Test message".to_string()));
1145
1146 let area = Rect::new(0, 0, 50, 20);
1147 let mut buffer = Buffer::empty(area);
1148
1149 chat.render(area, &mut buffer);
1151
1152 let cell = buffer.cell((0, 0)).unwrap();
1154 assert!(!cell.symbol().is_empty());
1156 }
1157
1158 #[test]
1159 fn test_chat_view_render_multiple_messages() {
1160 let mut chat = ChatView::new();
1161
1162 chat.add_message(Message::user("First message".to_string()));
1163 chat.add_message(Message::assistant("Second message".to_string()));
1164 chat.add_message(Message::system("System message".to_string()));
1165
1166 let area = Rect::new(0, 0, 50, 20);
1167 let mut buffer = Buffer::empty(area);
1168
1169 chat.render(area, &mut buffer);
1171 }
1172
1173 #[test]
1174 fn test_chat_view_render_with_long_message() {
1175 let mut chat = ChatView::new();
1176
1177 let long_message = "This is a very long message that should wrap across multiple lines in the buffer when rendered. ".repeat(5);
1178 chat.add_message(Message::user(long_message));
1179
1180 let area = Rect::new(0, 0, 30, 20);
1181 let mut buffer = Buffer::empty(area);
1182
1183 chat.render(area, &mut buffer);
1185 }
1186
1187 #[test]
1188 fn test_chat_view_messages_ref() {
1189 let mut chat = ChatView::new();
1190
1191 chat.add_message(Message::user("Message 1".to_string()));
1192 chat.add_message(Message::assistant("Message 2".to_string()));
1193
1194 let messages = chat.messages();
1195 assert_eq!(messages.len(), 2);
1196 assert_eq!(messages[0].content, "Message 1");
1197 assert_eq!(messages[1].content, "Message 2");
1198 }
1199
1200 #[test]
1201 fn test_calculate_total_height() {
1202 let mut chat = ChatView::new();
1203
1204 assert_eq!(chat.calculate_total_height(50), 0);
1206
1207 chat.add_message(Message::user("Hello".to_string()));
1208 assert_eq!(chat.calculate_total_height(50), 3);
1210 }
1211
1212 #[test]
1213 fn test_calculate_total_height_with_wrapping() {
1214 let mut chat = ChatView::new();
1215
1216 chat.add_message(Message::user("Hi".to_string()));
1218 assert_eq!(chat.calculate_total_height(50), 3);
1219
1220 let long_msg = "This is a very long message that will definitely wrap onto multiple lines when displayed in a narrow container".to_string();
1222 chat.add_message(Message::assistant(long_msg));
1223
1224 let height = chat.calculate_total_height(20);
1227 assert!(height > 6); }
1229
1230 #[test]
1231 fn test_short_content_pinned_to_bottom_should_start_at_top() {
1232 let mut chat = ChatView::new();
1235
1236 chat.add_message(Message::user("Hello".to_string()));
1237
1238 let area = Rect::new(0, 0, 50, 20);
1239 let mut buffer = Buffer::empty(area);
1240
1241 chat.render(area, &mut buffer);
1243
1244 let cell = buffer.cell((0, 0)).unwrap();
1247 assert!(
1249 !cell.symbol().is_empty(),
1250 "Content should start at top, not be pushed down"
1251 );
1252 }
1253
1254 #[test]
1255 fn test_streaming_content_stays_pinned() {
1256 let mut chat = ChatView::new();
1258
1259 chat.add_message(Message::assistant("Start".to_string()));
1261
1262 let area = Rect::new(0, 0, 50, 20);
1263 let mut buffer1 = Buffer::empty(area);
1264 chat.render(area, &mut buffer1);
1265
1266 chat.append_to_last_assistant(" and continue with more text that is longer");
1268
1269 let mut buffer2 = Buffer::empty(area);
1270 chat.render(area, &mut buffer2);
1271
1272 let has_content_near_bottom = (0u16..20).any(|y| {
1276 let c = buffer2.cell((0, y)).unwrap();
1277 !c.symbol().is_empty() && c.symbol() != "│" && c.symbol() != " "
1278 });
1279
1280 assert!(
1281 has_content_near_bottom,
1282 "Content should remain visible near bottom when pinned"
1283 );
1284 }
1285
1286 #[test]
1287 fn test_content_shorter_than_viewport_no_excess_padding() {
1288 let mut chat = ChatView::new();
1290
1291 chat.add_message(Message::user("Short message".to_string()));
1292
1293 let total_height = chat.calculate_total_height(50);
1294 let viewport_height: u16 = 20;
1295
1296 assert!(
1298 total_height < viewport_height as usize,
1299 "Content should be shorter than viewport"
1300 );
1301
1302 let area = Rect::new(0, 0, 50, viewport_height);
1303 let mut buffer = Buffer::empty(area);
1304
1305 chat.render(area, &mut buffer);
1306
1307 let mut first_content_y: Option<u16> = None;
1310 for y in 0..viewport_height {
1311 let cell = buffer.cell((0, y)).unwrap();
1312 let is_border = matches!(
1313 cell.symbol(),
1314 "─" | "│" | "┌" | "┐" | "└" | "┘" | "├" | "┤" | "┬" | "┴"
1315 );
1316 if !is_border && !cell.symbol().is_empty() {
1317 first_content_y = Some(y);
1318 break;
1319 }
1320 }
1321
1322 let first_content_y = first_content_y.expect("Should find content somewhere");
1323
1324 assert_eq!(
1325 first_content_y, 0,
1326 "Content should start at y=0, not be pushed down by padding"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_pinned_state_after_scrolling() {
1332 let mut chat = ChatView::new();
1333
1334 for i in 0..10 {
1336 chat.add_message(Message::user(format!("Message {}", i)));
1337 }
1338
1339 assert!(chat.pinned_to_bottom);
1341
1342 chat.scroll_up();
1344 assert!(!chat.pinned_to_bottom);
1345
1346 chat.scroll_to_bottom();
1348 assert!(chat.pinned_to_bottom);
1349 }
1350
1351 #[test]
1352 fn test_message_growth_maintains_correct_position() {
1353 let mut chat = ChatView::new();
1355
1356 chat.add_message(Message::assistant("Initial".to_string()));
1358
1359 let area = Rect::new(0, 0, 60, 10);
1360 let mut buffer = Buffer::empty(area);
1361 chat.render(area, &mut buffer);
1362
1363 chat.append_to_last_assistant(" content that gets added");
1365
1366 let mut buffer2 = Buffer::empty(area);
1367 chat.render(area, &mut buffer2);
1368
1369 assert!(
1371 chat.pinned_to_bottom,
1372 "Should remain pinned after content growth"
1373 );
1374 }
1375}