1use std::cell::Cell;
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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18enum LineType {
19 Normal,
20 Header1,
21 Header2,
22 Header3,
23 ListItem,
24 CodeBlock,
25}
26
27impl LineType {
28 fn style(&self) -> Style {
29 match self {
30 LineType::Header1 => Style::default()
31 .fg(Color::Cyan)
32 .add_modifier(Modifier::BOLD),
33 LineType::Header2 => Style::default()
34 .fg(Color::Yellow)
35 .add_modifier(Modifier::BOLD),
36 LineType::Header3 => Style::default()
37 .fg(Color::Green)
38 .add_modifier(Modifier::BOLD),
39 LineType::ListItem => Style::default().fg(Color::White),
40 LineType::CodeBlock => Style::default().fg(Color::Gray),
41 LineType::Normal => Style::default(),
42 }
43 }
44}
45
46fn parse_inline_markdown(text: &str, base_style: Style) -> Vec<Span<'_>> {
48 let mut spans = Vec::new();
49 let mut chars = text.chars().peekable();
50 let mut current = String::new();
51 let mut in_bold = false;
52 let mut in_italic = false;
53 let mut in_code = false;
54
55 while let Some(c) = chars.next() {
56 if c == '`' && !in_bold && !in_italic {
58 if in_code {
59 let style = Style::default().fg(Color::Yellow);
61 spans.push(Span::styled(current.clone(), style));
62 current.clear();
63 in_code = false;
64 } else {
65 if !current.is_empty() {
67 spans.push(Span::styled(current.clone(), base_style));
68 current.clear();
69 }
70 in_code = true;
71 }
72 continue;
73 }
74
75 if c == '*' && chars.peek() == Some(&'*') && !in_code {
77 chars.next(); if in_bold {
79 let style = base_style.add_modifier(Modifier::BOLD);
81 spans.push(Span::styled(current.clone(), style));
82 current.clear();
83 in_bold = false;
84 } else {
85 if !current.is_empty() {
87 spans.push(Span::styled(current.clone(), base_style));
88 current.clear();
89 }
90 in_bold = true;
91 }
92 continue;
93 }
94
95 if c == '*' && !in_code && !in_bold {
97 if in_italic {
98 let style = base_style.add_modifier(Modifier::ITALIC);
100 spans.push(Span::styled(current.clone(), style));
101 current.clear();
102 in_italic = false;
103 } else {
104 if !current.is_empty() {
106 spans.push(Span::styled(current.clone(), base_style));
107 current.clear();
108 }
109 in_italic = true;
110 }
111 continue;
112 }
113
114 current.push(c);
115 }
116
117 if !current.is_empty() {
119 let style = if in_code {
120 Style::default().fg(Color::Yellow)
121 } else if in_bold {
122 base_style.add_modifier(Modifier::BOLD)
123 } else if in_italic {
124 base_style.add_modifier(Modifier::ITALIC)
125 } else {
126 base_style
127 };
128 spans.push(Span::styled(current, style));
129 }
130
131 if spans.is_empty() {
132 spans.push(Span::styled(text, base_style));
133 }
134
135 spans
136}
137
138fn detect_line_type(line: &str) -> (LineType, &str) {
140 let trimmed = line.trim_start();
141 if trimmed.starts_with("### ") {
142 (
143 LineType::Header3,
144 trimmed.strip_prefix("### ").unwrap_or(trimmed),
145 )
146 } else if trimmed.starts_with("## ") {
147 (
148 LineType::Header2,
149 trimmed.strip_prefix("## ").unwrap_or(trimmed),
150 )
151 } else if trimmed.starts_with("# ") {
152 (
153 LineType::Header1,
154 trimmed.strip_prefix("# ").unwrap_or(trimmed),
155 )
156 } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
157 (LineType::ListItem, line)
158 } else {
159 (LineType::Normal, line)
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum Role {
166 User,
167 Assistant,
168 System,
169}
170
171impl Role {
172 pub fn display_name(&self) -> &str {
174 match self {
175 Role::User => "USER",
176 Role::Assistant => "ASSISTANT",
177 Role::System => "SYSTEM",
178 }
179 }
180
181 pub fn badge_color(&self) -> Color {
183 match self {
184 Role::User => Color::Blue,
185 Role::Assistant => Color::Green,
186 Role::System => Color::Yellow,
187 }
188 }
189}
190
191#[derive(Debug, Clone)]
193pub struct Message {
194 pub role: Role,
195 pub content: String,
196 pub timestamp: String,
197}
198
199impl Message {
200 pub fn new(role: Role, content: String, timestamp: String) -> Self {
202 Self {
203 role,
204 content,
205 timestamp,
206 }
207 }
208
209 pub fn user(content: String) -> Self {
211 let timestamp = Self::current_timestamp();
212 Self::new(Role::User, content, timestamp)
213 }
214
215 pub fn assistant(content: String) -> Self {
217 let timestamp = Self::current_timestamp();
218 Self::new(Role::Assistant, content, timestamp)
219 }
220
221 pub fn system(content: String) -> Self {
223 let timestamp = Self::current_timestamp();
224 Self::new(Role::System, content, timestamp)
225 }
226
227 fn current_timestamp() -> String {
229 chrono::Local::now().format("%H:%M").to_string()
230 }
231}
232
233#[derive(Debug, Clone)]
235pub struct ChatView {
236 messages: Vec<Message>,
237 scroll_offset: usize,
238 pinned_to_bottom: bool,
239 last_max_scroll_offset: Cell<usize>,
241 highlighter: SyntaxHighlighter,
243}
244
245impl Default for ChatView {
246 fn default() -> Self {
247 Self::new()
248 }
249}
250
251impl ChatView {
252 pub fn new() -> Self {
253 debug!(component = %"ChatView", "Component created");
254 Self {
255 messages: Vec::new(),
256 scroll_offset: 0,
257 pinned_to_bottom: true,
258 last_max_scroll_offset: Cell::new(0),
259 highlighter: SyntaxHighlighter::new().expect("Failed to initialize syntax highlighter"),
260 }
261 }
262
263 pub fn add_message(&mut self, message: Message) {
265 self.messages.push(message);
266 self.scroll_to_bottom();
268 }
269
270 pub fn append_to_last_assistant(&mut self, content: &str) {
272 if let Some(last) = self.messages.last_mut() {
273 if matches!(last.role, Role::Assistant) {
274 last.content.push_str(content);
275 self.scroll_to_bottom();
276 return;
277 }
278 }
279 self.add_message(Message::assistant(content.to_string()));
281 }
282
283 pub fn message_count(&self) -> usize {
285 self.messages.len()
286 }
287
288 pub fn messages(&self) -> &[Message] {
290 &self.messages
291 }
292
293 pub fn scroll_up(&mut self) {
295 const SCROLL_LINES: usize = 5;
296 if self.pinned_to_bottom {
298 self.scroll_offset = self.last_max_scroll_offset.get();
299 }
300 self.pinned_to_bottom = false;
301 self.scroll_offset = self.scroll_offset.saturating_sub(SCROLL_LINES);
302 }
303
304 pub fn scroll_down(&mut self) {
306 const SCROLL_LINES: usize = 5;
307 if self.pinned_to_bottom {
309 self.scroll_offset = self.last_max_scroll_offset.get();
310 }
311 self.pinned_to_bottom = false;
312 self.scroll_offset = self.scroll_offset.saturating_add(SCROLL_LINES);
313 }
314
315 pub fn scroll_page_up(&mut self, viewport_height: u16) {
317 if self.pinned_to_bottom {
319 self.scroll_offset = self.last_max_scroll_offset.get();
320 }
321 self.pinned_to_bottom = false;
322 let page_size = viewport_height as usize;
323 self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
324 }
325
326 pub fn scroll_page_down(&mut self, viewport_height: u16) {
328 if self.pinned_to_bottom {
330 self.scroll_offset = self.last_max_scroll_offset.get();
331 }
332 self.pinned_to_bottom = false;
333 let page_size = viewport_height as usize;
334 self.scroll_offset = self.scroll_offset.saturating_add(page_size);
335 }
336
337 pub fn scroll_to_bottom(&mut self) {
339 self.pinned_to_bottom = true;
340 }
341
342 pub fn scroll_to_top(&mut self) {
344 self.pinned_to_bottom = false;
345 self.scroll_offset = 0;
346 }
347
348 pub fn clear(&mut self) {
350 self.messages.clear();
351 self.scroll_offset = 0;
352 self.pinned_to_bottom = true;
353 }
354
355 fn estimate_line_count(text: &str, width: usize) -> usize {
357 if width == 0 {
358 return 0;
359 }
360
361 let mut lines = 0;
362 let mut current_line_len = 0;
363
364 for line in text.lines() {
365 if line.is_empty() {
366 lines += 1;
367 current_line_len = 0;
368 continue;
369 }
370
371 let words: Vec<&str> = line.split_whitespace().collect();
373 let mut word_index = 0;
374
375 while word_index < words.len() {
376 let word = words[word_index];
377 let word_len = word.len();
378
379 if current_line_len == 0 {
380 if word_len > width {
382 let mut chars_left = word;
384 while !chars_left.is_empty() {
385 let take = chars_left.len().min(width);
386 lines += 1;
387 chars_left = &chars_left[take..];
388 }
389 current_line_len = 0;
390 } else {
391 current_line_len = word_len;
392 }
393 } else if current_line_len + 1 + word_len <= width {
394 current_line_len += 1 + word_len;
396 } else {
397 lines += 1;
399 current_line_len = if word_len > width {
400 let mut chars_left = word;
402 while !chars_left.is_empty() {
403 let take = chars_left.len().min(width);
404 lines += 1;
405 chars_left = &chars_left[take..];
406 }
407 0
408 } else {
409 word_len
410 };
411 }
412
413 word_index += 1;
414 }
415
416 if current_line_len > 0 || words.is_empty() {
418 lines += 1;
419 }
420
421 current_line_len = 0;
422 }
423
424 lines.max(1)
425 }
426
427 fn process_code_blocks(&self, content: &str) -> Vec<(String, LineType, bool, Option<String>)> {
430 let mut result = Vec::new();
431 let lines = content.lines().peekable();
432 let mut in_code_block = false;
433 let mut current_lang: Option<String> = None;
434
435 for line in lines {
436 if line.starts_with("```") {
437 if in_code_block {
438 in_code_block = false;
440 current_lang = None;
441 } else {
442 in_code_block = true;
444 current_lang = line
445 .strip_prefix("```")
446 .map(|s| s.trim().to_string())
447 .filter(|s| !s.is_empty());
448 }
449 } else if in_code_block {
450 result.push((
451 line.to_string(),
452 LineType::CodeBlock,
453 true,
454 current_lang.clone(),
455 ));
456 } else {
457 let (line_type, _) = detect_line_type(line);
458 result.push((line.to_string(), line_type, false, None));
459 }
460 }
461
462 result
463 }
464
465 fn calculate_total_height(&self, width: u16) -> usize {
467 let mut total_height = 0;
468
469 for message in &self.messages {
470 total_height += 1;
472
473 let processed = self.process_code_blocks(&message.content);
475
476 for (line, _line_type, _is_code, _lang) in processed {
477 let line_height = if _is_code {
480 1 } else {
482 Self::estimate_line_count(&line, width as usize)
483 };
484 total_height += line_height;
485 }
486
487 total_height += 1;
489 }
490
491 total_height
492 }
493
494 fn render_to_buffer(&self, area: Rect, buf: &mut Buffer) {
496 let total_height = self.calculate_total_height(area.width);
497 let viewport_height = area.height as usize;
498
499 let max_scroll_offset = if total_height > viewport_height {
501 total_height.saturating_sub(viewport_height)
502 } else {
503 0
504 };
505
506 self.last_max_scroll_offset.set(max_scroll_offset);
508
509 let scroll_offset = if self.pinned_to_bottom {
510 max_scroll_offset
512 } else {
513 self.scroll_offset.min(max_scroll_offset)
515 };
516
517 let (initial_y_offset, skip_until, max_y) =
519 (area.y, scroll_offset, scroll_offset + viewport_height);
520
521 let mut y_offset = initial_y_offset;
522 let mut global_y: usize = 0;
523
524 for message in &self.messages {
525 let role_height = 1;
527 let processed = self.process_code_blocks(&message.content);
528 let content_height: usize = processed
529 .iter()
530 .map(|(line, _, _, _)| Self::estimate_line_count(line, area.width as usize))
531 .sum();
532 let separator_height = 1;
533 let message_height = role_height + content_height + separator_height;
534
535 if global_y + message_height <= skip_until {
536 global_y += message_height;
537 continue;
538 }
539
540 if global_y >= max_y {
541 break;
542 }
543
544 if global_y >= skip_until && y_offset < area.y + area.height {
546 let role_text = format!("[{}] {}", message.role.display_name(), message.timestamp);
547 let style = Style::default()
548 .fg(message.role.badge_color())
549 .add_modifier(Modifier::BOLD);
550
551 let line = Line::from(vec![Span::styled(role_text, style)]);
552
553 Paragraph::new(line)
554 .wrap(Wrap { trim: false })
555 .render(Rect::new(area.x, y_offset, area.width, 1), buf);
556
557 y_offset += 1;
558 }
559 global_y += 1;
560
561 for (line, line_type, is_code_block, lang) in processed {
563 let line_height = Self::estimate_line_count(&line, area.width as usize);
564
565 if is_code_block && global_y >= skip_until {
566 if let Some(ref lang_str) = lang {
568 if let Ok(highlighted_spans) = self
569 .highlighter
570 .highlight_to_spans(&format!("{}\n", line), lang_str)
571 {
572 for highlighted_line in highlighted_spans {
574 if y_offset < area.y + area.height && global_y < max_y {
575 let text = Text::from(Line::from(highlighted_line));
576 Paragraph::new(text)
577 .wrap(Wrap { trim: false })
578 .render(Rect::new(area.x, y_offset, area.width, 1), buf);
579 y_offset += 1;
580 }
581 global_y += 1;
582
583 if global_y >= max_y {
584 break;
585 }
586 }
587 continue;
588 }
589 }
590 }
591
592 let base_style = line_type.style();
594 let spans = parse_inline_markdown(&line, base_style);
595 let text_line = Line::from(spans);
596
597 if global_y >= skip_until && y_offset < area.y + area.height {
599 let render_height =
601 line_height.min((area.y + area.height - y_offset) as usize) as u16;
602 Paragraph::new(text_line)
603 .wrap(Wrap { trim: false })
604 .render(Rect::new(area.x, y_offset, area.width, render_height), buf);
605 y_offset += line_height as u16;
606 }
607 global_y += line_height;
608
609 if global_y >= max_y {
610 break;
611 }
612 }
613
614 if global_y >= skip_until && global_y < max_y && y_offset < area.y + area.height {
616 Paragraph::new("─".repeat(area.width as usize).as_str())
617 .style(Style::default().fg(Color::DarkGray))
618 .render(Rect::new(area.x, y_offset, area.width, 1), buf);
619 y_offset += 1;
620 }
621 global_y += 1;
622 }
623 }
624}
625
626impl ratatui::widgets::Widget for &ChatView {
627 fn render(self, area: Rect, buf: &mut Buffer) {
628 (*self).render_to_buffer(area, buf);
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 #[test]
638 fn test_role_display_name() {
639 assert_eq!(Role::User.display_name(), "USER");
640 assert_eq!(Role::Assistant.display_name(), "ASSISTANT");
641 assert_eq!(Role::System.display_name(), "SYSTEM");
642 }
643
644 #[test]
645 fn test_role_badge_color() {
646 assert_eq!(Role::User.badge_color(), Color::Blue);
647 assert_eq!(Role::Assistant.badge_color(), Color::Green);
648 assert_eq!(Role::System.badge_color(), Color::Yellow);
649 }
650
651 #[test]
652 fn test_message_new() {
653 let message = Message::new(Role::User, "Hello, World!".to_string(), "12:34".to_string());
654
655 assert_eq!(message.role, Role::User);
656 assert_eq!(message.content, "Hello, World!");
657 assert_eq!(message.timestamp, "12:34");
658 }
659
660 #[test]
661 fn test_message_user() {
662 let message = Message::user("Test message".to_string());
663
664 assert_eq!(message.role, Role::User);
665 assert_eq!(message.content, "Test message");
666 assert!(!message.timestamp.is_empty());
667 }
668
669 #[test]
670 fn test_message_assistant() {
671 let message = Message::assistant("Response".to_string());
672
673 assert_eq!(message.role, Role::Assistant);
674 assert_eq!(message.content, "Response");
675 assert!(!message.timestamp.is_empty());
676 }
677
678 #[test]
679 fn test_message_system() {
680 let message = Message::system("System notification".to_string());
681
682 assert_eq!(message.role, Role::System);
683 assert_eq!(message.content, "System notification");
684 assert!(!message.timestamp.is_empty());
685 }
686
687 #[test]
688 fn test_chat_view_new() {
689 let chat = ChatView::new();
690
691 assert_eq!(chat.message_count(), 0);
692 assert_eq!(chat.scroll_offset, 0);
693 assert!(chat.messages().is_empty());
694 }
695
696 #[test]
697 fn test_chat_view_default() {
698 let chat = ChatView::default();
699
700 assert_eq!(chat.message_count(), 0);
701 assert_eq!(chat.scroll_offset, 0);
702 }
703
704 #[test]
705 fn test_chat_view_add_message() {
706 let mut chat = ChatView::new();
707
708 chat.add_message(Message::user("Hello".to_string()));
709 assert_eq!(chat.message_count(), 1);
710
711 chat.add_message(Message::assistant("Hi there!".to_string()));
712 assert_eq!(chat.message_count(), 2);
713 }
714
715 #[test]
716 fn test_chat_view_add_multiple_messages() {
717 let mut chat = ChatView::new();
718
719 for i in 0..5 {
720 chat.add_message(Message::user(format!("Message {}", i)));
721 }
722
723 assert_eq!(chat.message_count(), 5);
724 }
725
726 #[test]
727 fn test_chat_view_scroll_up() {
728 let mut chat = ChatView::new();
729
730 for i in 0..10 {
732 chat.add_message(Message::user(format!("Message {}", i)));
733 }
734
735 assert!(chat.pinned_to_bottom);
737
738 chat.scroll_up();
740 assert!(!chat.pinned_to_bottom);
741 }
744
745 #[test]
746 fn test_chat_view_scroll_up_bounds() {
747 let mut chat = ChatView::new();
748
749 chat.add_message(Message::user("Test".to_string()));
750 chat.scroll_to_top(); chat.scroll_up();
754 assert_eq!(chat.scroll_offset, 0);
755 assert!(!chat.pinned_to_bottom);
756
757 chat.scroll_up();
758 assert_eq!(chat.scroll_offset, 0);
759 }
760
761 #[test]
762 fn test_chat_view_scroll_down() {
763 let mut chat = ChatView::new();
764
765 chat.add_message(Message::user("Test".to_string()));
766
767 assert!(chat.pinned_to_bottom);
769
770 chat.scroll_down();
771 assert!(!chat.pinned_to_bottom);
773 assert_eq!(chat.scroll_offset, 5);
775 }
776
777 #[test]
778 fn test_chat_view_scroll_to_bottom() {
779 let mut chat = ChatView::new();
780
781 for i in 0..5 {
782 chat.add_message(Message::user(format!("Message {}", i)));
783 }
784
785 chat.scroll_to_top();
786 assert_eq!(chat.scroll_offset, 0);
787 assert!(!chat.pinned_to_bottom);
788
789 chat.scroll_to_bottom();
790 assert!(chat.pinned_to_bottom);
792 }
793
794 #[test]
795 fn test_chat_view_scroll_to_top() {
796 let mut chat = ChatView::new();
797
798 for i in 0..5 {
799 chat.add_message(Message::user(format!("Message {}", i)));
800 }
801
802 chat.scroll_to_bottom();
803 assert!(chat.pinned_to_bottom);
804
805 chat.scroll_to_top();
806 assert_eq!(chat.scroll_offset, 0);
807 assert!(!chat.pinned_to_bottom);
808 }
809
810 #[test]
811 fn test_chat_view_auto_scroll() {
812 let mut chat = ChatView::new();
813
814 for i in 0..5 {
815 chat.add_message(Message::user(format!("Message {}", i)));
816 }
818
819 assert!(chat.pinned_to_bottom);
821 }
822
823 #[test]
824 fn test_chat_view_render() {
825 let mut chat = ChatView::new();
826 chat.add_message(Message::user("Test message".to_string()));
827
828 let area = Rect::new(0, 0, 50, 20);
829 let mut buffer = Buffer::empty(area);
830
831 chat.render(area, &mut buffer);
833
834 let cell = buffer.cell((0, 0)).unwrap();
836 assert!(!cell.symbol().is_empty());
838 }
839
840 #[test]
841 fn test_chat_view_render_multiple_messages() {
842 let mut chat = ChatView::new();
843
844 chat.add_message(Message::user("First message".to_string()));
845 chat.add_message(Message::assistant("Second message".to_string()));
846 chat.add_message(Message::system("System message".to_string()));
847
848 let area = Rect::new(0, 0, 50, 20);
849 let mut buffer = Buffer::empty(area);
850
851 chat.render(area, &mut buffer);
853 }
854
855 #[test]
856 fn test_chat_view_render_with_long_message() {
857 let mut chat = ChatView::new();
858
859 let long_message = "This is a very long message that should wrap across multiple lines in the buffer when rendered. ".repeat(5);
860 chat.add_message(Message::user(long_message));
861
862 let area = Rect::new(0, 0, 30, 20);
863 let mut buffer = Buffer::empty(area);
864
865 chat.render(area, &mut buffer);
867 }
868
869 #[test]
870 fn test_chat_view_messages_ref() {
871 let mut chat = ChatView::new();
872
873 chat.add_message(Message::user("Message 1".to_string()));
874 chat.add_message(Message::assistant("Message 2".to_string()));
875
876 let messages = chat.messages();
877 assert_eq!(messages.len(), 2);
878 assert_eq!(messages[0].content, "Message 1");
879 assert_eq!(messages[1].content, "Message 2");
880 }
881
882 #[test]
883 fn test_calculate_total_height() {
884 let mut chat = ChatView::new();
885
886 assert_eq!(chat.calculate_total_height(50), 0);
888
889 chat.add_message(Message::user("Hello".to_string()));
890 assert_eq!(chat.calculate_total_height(50), 3);
892 }
893
894 #[test]
895 fn test_calculate_total_height_with_wrapping() {
896 let mut chat = ChatView::new();
897
898 chat.add_message(Message::user("Hi".to_string()));
900 assert_eq!(chat.calculate_total_height(50), 3);
901
902 let long_msg = "This is a very long message that will definitely wrap onto multiple lines when displayed in a narrow container".to_string();
904 chat.add_message(Message::assistant(long_msg));
905
906 let height = chat.calculate_total_height(20);
909 assert!(height > 6); }
911
912 #[test]
913 fn test_short_content_pinned_to_bottom_should_start_at_top() {
914 let mut chat = ChatView::new();
917
918 chat.add_message(Message::user("Hello".to_string()));
919
920 let area = Rect::new(0, 0, 50, 20);
921 let mut buffer = Buffer::empty(area);
922
923 chat.render(area, &mut buffer);
925
926 let cell = buffer.cell((0, 0)).unwrap();
929 assert!(
931 !cell.symbol().is_empty(),
932 "Content should start at top, not be pushed down"
933 );
934 }
935
936 #[test]
937 fn test_streaming_content_stays_pinned() {
938 let mut chat = ChatView::new();
940
941 chat.add_message(Message::assistant("Start".to_string()));
943
944 let area = Rect::new(0, 0, 50, 20);
945 let mut buffer1 = Buffer::empty(area);
946 chat.render(area, &mut buffer1);
947
948 chat.append_to_last_assistant(" and continue with more text that is longer");
950
951 let mut buffer2 = Buffer::empty(area);
952 chat.render(area, &mut buffer2);
953
954 let has_content_near_bottom = (0u16..20).any(|y| {
958 let c = buffer2.cell((0, y)).unwrap();
959 !c.symbol().is_empty() && c.symbol() != "│" && c.symbol() != " "
960 });
961
962 assert!(
963 has_content_near_bottom,
964 "Content should remain visible near bottom when pinned"
965 );
966 }
967
968 #[test]
969 fn test_content_shorter_than_viewport_no_excess_padding() {
970 let mut chat = ChatView::new();
972
973 chat.add_message(Message::user("Short message".to_string()));
974
975 let total_height = chat.calculate_total_height(50);
976 let viewport_height: u16 = 20;
977
978 assert!(
980 total_height < viewport_height as usize,
981 "Content should be shorter than viewport"
982 );
983
984 let area = Rect::new(0, 0, 50, viewport_height);
985 let mut buffer = Buffer::empty(area);
986
987 chat.render(area, &mut buffer);
988
989 let mut first_content_y: Option<u16> = None;
992 for y in 0..viewport_height {
993 let cell = buffer.cell((0, y)).unwrap();
994 let is_border = matches!(
995 cell.symbol(),
996 "─" | "│" | "┌" | "┐" | "└" | "┘" | "├" | "┤" | "┬" | "┴"
997 );
998 if !is_border && !cell.symbol().is_empty() {
999 first_content_y = Some(y);
1000 break;
1001 }
1002 }
1003
1004 let first_content_y = first_content_y.expect("Should find content somewhere");
1005
1006 assert_eq!(
1007 first_content_y, 0,
1008 "Content should start at y=0, not be pushed down by padding"
1009 );
1010 }
1011
1012 #[test]
1013 fn test_pinned_state_after_scrolling() {
1014 let mut chat = ChatView::new();
1015
1016 for i in 0..10 {
1018 chat.add_message(Message::user(format!("Message {}", i)));
1019 }
1020
1021 assert!(chat.pinned_to_bottom);
1023
1024 chat.scroll_up();
1026 assert!(!chat.pinned_to_bottom);
1027
1028 chat.scroll_to_bottom();
1030 assert!(chat.pinned_to_bottom);
1031 }
1032
1033 #[test]
1034 fn test_message_growth_maintains_correct_position() {
1035 let mut chat = ChatView::new();
1037
1038 chat.add_message(Message::assistant("Initial".to_string()));
1040
1041 let area = Rect::new(0, 0, 60, 10);
1042 let mut buffer = Buffer::empty(area);
1043 chat.render(area, &mut buffer);
1044
1045 chat.append_to_last_assistant(" content that gets added");
1047
1048 let mut buffer2 = Buffer::empty(area);
1049 chat.render(area, &mut buffer2);
1050
1051 assert!(
1053 chat.pinned_to_bottom,
1054 "Should remain pinned after content growth"
1055 );
1056 }
1057}