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.cache_dirty.set(true); self.scroll_to_bottom();
313 }
314
315 pub fn append_to_last_assistant(&mut self, content: &str) {
317 if let Some(last) = self.messages.last_mut() {
318 if matches!(last.role, Role::Assistant) {
319 last.content.push_str(content);
320 self.cache_dirty.set(true); self.scroll_to_bottom();
322 return;
323 }
324 }
325 self.add_message(Message::assistant(content.to_string()));
327 }
328
329 pub fn message_count(&self) -> usize {
331 self.messages.len()
332 }
333
334 pub fn messages(&self) -> &[Message] {
336 &self.messages
337 }
338
339 pub fn scroll_up(&mut self) {
341 const SCROLL_LINES: usize = 5;
342 if self.pinned_to_bottom {
344 self.scroll_offset = self.last_max_scroll_offset.get();
345 self.pinned_to_bottom = false;
346 }
347 self.scroll_offset = self.scroll_offset.saturating_sub(SCROLL_LINES);
348 self.cache_dirty.set(true);
350 }
351
352 pub fn scroll_down(&mut self) {
354 const SCROLL_LINES: usize = 5;
355 let max_offset = self.last_max_scroll_offset.get();
356
357 if self.pinned_to_bottom {
360 self.scroll_offset = max_offset;
361 self.pinned_to_bottom = false;
362 return;
363 }
364
365 self.scroll_offset = (self.scroll_offset.saturating_add(SCROLL_LINES)).min(max_offset);
367 }
368
369 pub fn scroll_page_up(&mut self, viewport_height: u16) {
371 if self.pinned_to_bottom {
373 self.scroll_offset = self.last_max_scroll_offset.get();
374 self.pinned_to_bottom = false;
375 }
376 let page_size = viewport_height as usize;
377 self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
378 }
379
380 pub fn scroll_page_down(&mut self, viewport_height: u16) {
382 let max_offset = self.last_max_scroll_offset.get();
383
384 if self.pinned_to_bottom {
387 self.scroll_offset = max_offset;
388 self.pinned_to_bottom = false;
389 return;
390 }
391
392 let page_size = viewport_height as usize;
393 self.scroll_offset = (self.scroll_offset.saturating_add(page_size)).min(max_offset);
395 }
396
397 pub fn scroll_to_bottom(&mut self) {
399 self.pinned_to_bottom = true;
400 }
401
402 pub fn scroll_to_top(&mut self) {
404 self.pinned_to_bottom = false;
405 self.scroll_offset = 0;
406 }
407
408 pub fn start_selection(&mut self, message_idx: usize, byte_offset: usize) {
410 self.selection_start = Some((message_idx, byte_offset));
411 self.selection_end = Some((message_idx, byte_offset));
412 }
413
414 pub fn extend_selection(&mut self, message_idx: usize, byte_offset: usize) {
416 if self.selection_start.is_some() {
417 self.selection_end = Some((message_idx, byte_offset));
418 }
419 }
420
421 pub fn clear_selection(&mut self) {
423 self.selection_start = None;
424 self.selection_end = None;
425 }
426
427 pub fn has_selection(&self) -> bool {
429 self.selection_start.is_some() && self.selection_end.is_some()
430 }
431
432 pub fn screen_to_text_pos(&self, col: u16, row: u16) -> Option<(usize, usize)> {
435 debug!(
436 "screen_to_text_pos: col={}, row={}, positions={}",
437 col,
438 row,
439 self.render_positions.borrow().len()
440 );
441 for pos in self.render_positions.borrow().iter() {
442 debug!(
443 " checking pos.screen_row={} vs row={}",
444 pos.screen_row, row
445 );
446 if pos.screen_row == row {
447 let line_len = pos.char_end.saturating_sub(pos.char_start);
450 let char_in_line = (col as usize).min(line_len);
451 debug!(
452 " matched! msg_idx={}, char_offset={}",
453 pos.message_idx,
454 pos.char_start + char_in_line
455 );
456 return Some((pos.message_idx, pos.char_start + char_in_line));
457 }
458 }
459 debug!(" no match found");
460 None
461 }
462
463 pub fn render_position_count(&self) -> usize {
465 self.render_positions.borrow().len()
466 }
467
468 pub fn is_selected(&self, message_idx: usize, char_offset: usize) -> bool {
470 let Some((start_msg, start_offset)) = self.selection_start else {
471 return false;
472 };
473 let Some((end_msg, end_offset)) = self.selection_end else {
474 return false;
475 };
476
477 let (min_msg, min_offset, max_msg, max_offset) =
479 if start_msg < end_msg || (start_msg == end_msg && start_offset <= end_offset) {
480 (start_msg, start_offset, end_msg, end_offset)
481 } else {
482 (end_msg, end_offset, start_msg, start_offset)
483 };
484
485 if message_idx < min_msg || message_idx > max_msg {
487 return false;
488 }
489
490 if message_idx == min_msg && message_idx == max_msg {
491 char_offset >= min_offset && char_offset < max_offset
493 } else if message_idx == min_msg {
494 char_offset >= min_offset
496 } else if message_idx == max_msg {
497 char_offset < max_offset
499 } else {
500 true
502 }
503 }
504
505 fn apply_selection_highlight<'a>(
508 &self,
509 text: &'a str,
510 message_idx: usize,
511 line_char_start: usize,
512 base_style: Style,
513 ) -> Vec<Span<'a>> {
514 let selection_style = Style::default().bg(Color::Blue).fg(Color::White);
515
516 if !self.has_selection() {
518 return vec![Span::styled(text, base_style)];
519 }
520
521 let mut spans = Vec::new();
522 let mut current_start = 0;
523 let mut in_selection = false;
524 let char_positions: Vec<(usize, char)> = text.char_indices().collect();
525
526 for (i, (byte_idx, _)) in char_positions.iter().enumerate() {
527 let global_char = line_char_start + i;
528 let is_sel = self.is_selected(message_idx, global_char);
529
530 if is_sel != in_selection {
531 if i > current_start {
533 let segment_byte_start = char_positions[current_start].0;
534 let segment_byte_end = *byte_idx;
535 let segment = &text[segment_byte_start..segment_byte_end];
536 let style = if in_selection {
537 selection_style
538 } else {
539 base_style
540 };
541 spans.push(Span::styled(segment, style));
542 }
543 current_start = i;
544 in_selection = is_sel;
545 }
546 }
547
548 if current_start < char_positions.len() {
550 let segment_byte_start = char_positions[current_start].0;
551 let segment = &text[segment_byte_start..];
552 let style = if in_selection {
553 selection_style
554 } else {
555 base_style
556 };
557 spans.push(Span::styled(segment, style));
558 }
559
560 if spans.is_empty() {
561 vec![Span::styled(text, base_style)]
562 } else {
563 spans
564 }
565 }
566
567 pub fn get_selected_text(&self) -> Option<String> {
569 let (start_msg, start_offset) = self.selection_start?;
570 let (end_msg, end_offset) = self.selection_end?;
571
572 let (min_msg, min_offset, max_msg, max_offset) =
574 if start_msg < end_msg || (start_msg == end_msg && start_offset <= end_offset) {
575 (start_msg, start_offset, end_msg, end_offset)
576 } else {
577 (end_msg, end_offset, start_msg, start_offset)
578 };
579
580 if min_msg == max_msg {
581 let msg = self.messages.get(min_msg)?;
583 let content = &msg.content;
584 let start_byte = char_offset_to_byte(content, min_offset);
585 let end_byte = char_offset_to_byte(content, max_offset);
586 if start_byte < content.len() && end_byte <= content.len() {
587 Some(content[start_byte..end_byte].to_string())
588 } else {
589 None
590 }
591 } else {
592 let mut result = String::new();
594
595 if let Some(msg) = self.messages.get(min_msg) {
597 let start_byte = char_offset_to_byte(&msg.content, min_offset);
598 if start_byte < msg.content.len() {
599 result.push_str(&msg.content[start_byte..]);
600 }
601 }
602
603 for idx in (min_msg + 1)..max_msg {
605 if let Some(msg) = self.messages.get(idx) {
606 result.push('\n');
607 result.push_str(&msg.content);
608 }
609 }
610
611 if let Some(msg) = self.messages.get(max_msg) {
613 result.push('\n');
614 let end_byte = char_offset_to_byte(&msg.content, max_offset);
615 if end_byte > 0 && end_byte <= msg.content.len() {
616 result.push_str(&msg.content[..end_byte]);
617 }
618 }
619
620 Some(result)
621 }
622 }
623
624 pub fn clear(&mut self) {
626 self.messages.clear();
627 self.scroll_offset = 0;
628 self.pinned_to_bottom = true;
629 self.cache_dirty.set(true);
630 self.hidden_message_count.set(0);
631 }
632
633 fn get_render_window(&self) -> (&[Message], usize) {
636 let total_count = self.messages.len();
637
638 if self.pinned_to_bottom && total_count > RENDER_WINDOW_SIZE {
640 let hidden_count = total_count.saturating_sub(RENDER_WINDOW_SIZE);
641 let window = &self.messages[hidden_count..];
642 self.hidden_message_count.set(hidden_count);
643 (window, hidden_count)
644 } else {
645 self.hidden_message_count.set(0);
646 (&self.messages, 0)
647 }
648 }
649
650 fn estimate_line_count(text: &str, width: usize) -> usize {
652 if width == 0 {
653 return 0;
654 }
655
656 let mut lines = 0;
657 let mut current_line_len = 0;
658
659 for line in text.lines() {
660 if line.is_empty() {
661 lines += 1;
662 current_line_len = 0;
663 continue;
664 }
665
666 let words: Vec<&str> = line.split_whitespace().collect();
668 let mut word_index = 0;
669
670 while word_index < words.len() {
671 let word = words[word_index];
672 let word_len = word.len();
673
674 if current_line_len == 0 {
675 if word_len > width {
677 let mut chars_left = word;
679 while !chars_left.is_empty() {
680 let take = chars_left.len().min(width);
681 lines += 1;
682 chars_left = &chars_left[take..];
683 }
684 current_line_len = 0;
685 } else {
686 current_line_len = word_len;
687 }
688 } else if current_line_len + 1 + word_len <= width {
689 current_line_len += 1 + word_len;
691 } else {
692 lines += 1;
694 current_line_len = if word_len > width {
695 let mut chars_left = word;
697 while !chars_left.is_empty() {
698 let take = chars_left.len().min(width);
699 lines += 1;
700 chars_left = &chars_left[take..];
701 }
702 0
703 } else {
704 word_len
705 };
706 }
707
708 word_index += 1;
709 }
710
711 if current_line_len > 0 || words.is_empty() {
713 lines += 1;
714 }
715
716 current_line_len = 0;
717 }
718
719 lines.max(1)
720 }
721
722 fn process_code_blocks(&self, content: &str) -> Vec<(String, LineType, bool, Option<String>)> {
725 let mut result = Vec::new();
726 let lines = content.lines().peekable();
727 let mut in_code_block = false;
728 let mut current_lang: Option<String> = None;
729
730 for line in lines {
731 if line.starts_with("```") {
732 if in_code_block {
733 in_code_block = false;
735 current_lang = None;
736 } else {
737 in_code_block = true;
739 current_lang = line
740 .strip_prefix("```")
741 .map(|s| s.trim().to_string())
742 .filter(|s| !s.is_empty());
743 }
744 } else if in_code_block {
745 result.push((
746 line.to_string(),
747 LineType::CodeBlock,
748 true,
749 current_lang.clone(),
750 ));
751 } else {
752 let (line_type, _) = detect_line_type(line);
753 result.push((line.to_string(), line_type, false, None));
754 }
755 }
756
757 result
758 }
759
760 fn calculate_total_height(&self, width: u16) -> usize {
762 if !self.cache_dirty.get() {
764 return self.cached_height.get();
765 }
766
767 let mut total_height = 0;
768
769 for message in &self.messages {
772 total_height += 1;
774
775 let processed = self.process_code_blocks(&message.content);
777
778 for (line, _line_type, _is_code, _lang) in processed {
779 let line_height = if _is_code {
782 1 } else {
784 Self::estimate_line_count(&line, width as usize)
785 };
786 total_height += line_height;
787 }
788
789 total_height += 1;
791 }
792
793 self.cached_height.set(total_height);
795 self.cache_dirty.set(false);
796
797 total_height
798 }
799
800 fn render_to_buffer(&self, area: Rect, buf: &mut Buffer) {
803 if self.cache_dirty.get() {
806 self.render_positions.borrow_mut().clear();
807 }
808
809 let total_height = self.calculate_total_height(area.width);
810 let viewport_height = area.height as usize;
811
812 let max_scroll_offset = if total_height > viewport_height {
814 total_height.saturating_sub(viewport_height)
815 } else {
816 0
817 };
818
819 self.last_max_scroll_offset.set(max_scroll_offset);
821
822 let scroll_offset = if self.pinned_to_bottom {
823 max_scroll_offset
825 } else {
826 self.scroll_offset.min(max_scroll_offset)
828 };
829
830 let (initial_y_offset, skip_until, max_y) =
832 (area.y, scroll_offset, scroll_offset + viewport_height);
833
834 let mut y_offset = initial_y_offset;
835 let mut global_y: usize = 0;
836
837 let (messages_to_render, hidden_count) = self.get_render_window();
839
840 if hidden_count > 0 {
843 for message in &self.messages[..hidden_count] {
846 let role_height = 1;
847 let processed = self.process_code_blocks(&message.content);
848 let content_height: usize = processed
849 .iter()
850 .map(|(line, _, _, _)| Self::estimate_line_count(line, area.width as usize))
851 .sum();
852 let separator_height = 1;
853 global_y += role_height + content_height + separator_height;
854 }
855 }
856 for (local_msg_idx, message) in messages_to_render.iter().enumerate() {
857 let message_idx = hidden_count + local_msg_idx;
858
859 let role_height = 1;
861 let processed = self.process_code_blocks(&message.content);
862 let content_height: usize = processed
863 .iter()
864 .map(|(line, _, _, _)| Self::estimate_line_count(line, area.width as usize))
865 .sum();
866 let separator_height = 1;
867 let message_height = role_height + content_height + separator_height;
868
869 if global_y + message_height <= skip_until {
870 global_y += message_height;
871 continue;
872 }
873
874 if global_y >= max_y {
875 break;
876 }
877
878 if global_y >= skip_until && y_offset < area.y + area.height {
880 let role_text = format!("[{}] {}", message.role.display_name(), message.timestamp);
881 let style = Style::default()
882 .fg(message.role.badge_color())
883 .add_modifier(Modifier::BOLD);
884
885 let line = Line::from(vec![Span::styled(role_text, style)]);
886
887 Paragraph::new(line)
888 .wrap(Wrap { trim: false })
889 .render(Rect::new(area.x, y_offset, area.width, 1), buf);
890
891 y_offset += 1;
892 }
893 global_y += 1;
894
895 let mut char_offset: usize = 0;
898 for (line_idx, (line, line_type, is_code_block, lang)) in processed.iter().enumerate() {
899 let line_height = Self::estimate_line_count(line, area.width as usize);
900 let line_char_count = line.chars().count();
901
902 if global_y >= skip_until && y_offset < area.y + area.height {
906 self.render_positions.borrow_mut().push(RenderPosition {
907 message_idx,
908 line_idx,
909 char_start: char_offset,
910 char_end: char_offset + line_char_count,
911 screen_row: y_offset, });
913 }
914
915 if *is_code_block && global_y >= skip_until {
916 if let Some(ref lang_str) = lang {
918 if let Ok(highlighted_spans) = self
919 .highlighter
920 .highlight_to_spans(&format!("{}\n", line), lang_str)
921 {
922 for highlighted_line in highlighted_spans {
924 if y_offset < area.y + area.height && global_y < max_y {
925 let text = Text::from(Line::from(highlighted_line));
926 Paragraph::new(text)
927 .wrap(Wrap { trim: false })
928 .render(Rect::new(area.x, y_offset, area.width, 1), buf);
929 y_offset += 1;
930 }
931 global_y += 1;
932
933 if global_y >= max_y {
934 break;
935 }
936 }
937 continue;
938 }
939 }
940 }
941
942 let base_style = line_type.style();
944 let spans = if self.has_selection() {
945 self.apply_selection_highlight(line, message_idx, char_offset, base_style)
947 } else {
948 parse_inline_markdown(line, base_style)
949 };
950 let text_line = Line::from(spans);
951
952 if global_y >= skip_until && y_offset < area.y + area.height {
954 let render_height =
956 line_height.min((area.y + area.height - y_offset) as usize) as u16;
957 Paragraph::new(text_line)
958 .wrap(Wrap { trim: false })
959 .render(Rect::new(area.x, y_offset, area.width, render_height), buf);
960 y_offset += line_height as u16;
961 }
962 global_y += line_height;
963
964 char_offset += line_char_count + 1; if global_y >= max_y {
968 break;
969 }
970 }
971
972 if global_y >= skip_until && global_y < max_y && y_offset < area.y + area.height {
974 Paragraph::new("─".repeat(area.width as usize).as_str())
975 .style(Style::default().fg(Color::DarkGray))
976 .render(Rect::new(area.x, y_offset, area.width, 1), buf);
977 y_offset += 1;
978 }
979 global_y += 1;
980 }
981 }
982}
983
984impl ratatui::widgets::Widget for &ChatView {
985 fn render(self, area: Rect, buf: &mut Buffer) {
986 (*self).render_to_buffer(area, buf);
988 }
989}
990
991#[cfg(test)]
992mod tests {
993 use super::*;
994
995 #[test]
996 fn test_role_display_name() {
997 assert_eq!(Role::User.display_name(), "USER");
998 assert_eq!(Role::Assistant.display_name(), "ASSISTANT");
999 assert_eq!(Role::System.display_name(), "SYSTEM");
1000 }
1001
1002 #[test]
1003 fn test_role_badge_color() {
1004 assert_eq!(Role::User.badge_color(), Color::Blue);
1005 assert_eq!(Role::Assistant.badge_color(), Color::Green);
1006 assert_eq!(Role::System.badge_color(), Color::Yellow);
1007 }
1008
1009 #[test]
1010 fn test_message_new() {
1011 let message = Message::new(Role::User, "Hello, World!".to_string(), "12:34".to_string());
1012
1013 assert_eq!(message.role, Role::User);
1014 assert_eq!(message.content, "Hello, World!");
1015 assert_eq!(message.timestamp, "12:34");
1016 }
1017
1018 #[test]
1019 fn test_message_user() {
1020 let message = Message::user("Test message".to_string());
1021
1022 assert_eq!(message.role, Role::User);
1023 assert_eq!(message.content, "Test message");
1024 assert!(!message.timestamp.is_empty());
1025 }
1026
1027 #[test]
1028 fn test_message_assistant() {
1029 let message = Message::assistant("Response".to_string());
1030
1031 assert_eq!(message.role, Role::Assistant);
1032 assert_eq!(message.content, "Response");
1033 assert!(!message.timestamp.is_empty());
1034 }
1035
1036 #[test]
1037 fn test_message_system() {
1038 let message = Message::system("System notification".to_string());
1039
1040 assert_eq!(message.role, Role::System);
1041 assert_eq!(message.content, "System notification");
1042 assert!(!message.timestamp.is_empty());
1043 }
1044
1045 #[test]
1046 fn test_chat_view_new() {
1047 let chat = ChatView::new();
1048
1049 assert_eq!(chat.message_count(), 0);
1050 assert_eq!(chat.scroll_offset, 0);
1051 assert!(chat.messages().is_empty());
1052 }
1053
1054 #[test]
1055 fn test_chat_view_default() {
1056 let chat = ChatView::default();
1057
1058 assert_eq!(chat.message_count(), 0);
1059 assert_eq!(chat.scroll_offset, 0);
1060 }
1061
1062 #[test]
1063 fn test_chat_view_add_message() {
1064 let mut chat = ChatView::new();
1065
1066 chat.add_message(Message::user("Hello".to_string()));
1067 assert_eq!(chat.message_count(), 1);
1068
1069 chat.add_message(Message::assistant("Hi there!".to_string()));
1070 assert_eq!(chat.message_count(), 2);
1071 }
1072
1073 #[test]
1074 fn test_chat_view_add_multiple_messages() {
1075 let mut chat = ChatView::new();
1076
1077 for i in 0..5 {
1078 chat.add_message(Message::user(format!("Message {}", i)));
1079 }
1080
1081 assert_eq!(chat.message_count(), 5);
1082 }
1083
1084 #[test]
1085 fn test_chat_view_scroll_up() {
1086 let mut chat = ChatView::new();
1087
1088 for i in 0..10 {
1090 chat.add_message(Message::user(format!("Message {}", i)));
1091 }
1092
1093 assert!(chat.pinned_to_bottom);
1095
1096 chat.scroll_up();
1098 assert!(!chat.pinned_to_bottom);
1099 }
1102
1103 #[test]
1104 fn test_chat_view_scroll_up_bounds() {
1105 let mut chat = ChatView::new();
1106
1107 chat.add_message(Message::user("Test".to_string()));
1108 chat.scroll_to_top(); chat.scroll_up();
1112 assert_eq!(chat.scroll_offset, 0);
1113 assert!(!chat.pinned_to_bottom);
1114
1115 chat.scroll_up();
1116 assert_eq!(chat.scroll_offset, 0);
1117 }
1118
1119 #[test]
1120 fn test_chat_view_scroll_down() {
1121 let mut chat = ChatView::new();
1122
1123 chat.add_message(Message::user("Test".to_string()));
1124
1125 assert!(chat.pinned_to_bottom);
1127
1128 chat.scroll_down();
1130 assert!(!chat.pinned_to_bottom); for i in 0..20 {
1135 chat.add_message(Message::user(format!("Message {}", i)));
1136 }
1137
1138 chat.last_max_scroll_offset.set(100); chat.scroll_to_bottom(); assert!(chat.pinned_to_bottom);
1144
1145 chat.scroll_up();
1146 assert!(!chat.pinned_to_bottom);
1147 assert_eq!(chat.scroll_offset, 95);
1149
1150 chat.scroll_down();
1152 assert!(!chat.pinned_to_bottom);
1153 assert_eq!(chat.scroll_offset, 100);
1155
1156 chat.scroll_down();
1158 assert_eq!(chat.scroll_offset, 100); }
1160
1161 #[test]
1162 fn test_chat_view_scroll_to_bottom() {
1163 let mut chat = ChatView::new();
1164
1165 for i in 0..5 {
1166 chat.add_message(Message::user(format!("Message {}", i)));
1167 }
1168
1169 chat.scroll_to_top();
1170 assert_eq!(chat.scroll_offset, 0);
1171 assert!(!chat.pinned_to_bottom);
1172
1173 chat.scroll_to_bottom();
1174 assert!(chat.pinned_to_bottom);
1176 }
1177
1178 #[test]
1179 fn test_chat_view_scroll_to_top() {
1180 let mut chat = ChatView::new();
1181
1182 for i in 0..5 {
1183 chat.add_message(Message::user(format!("Message {}", i)));
1184 }
1185
1186 chat.scroll_to_bottom();
1187 assert!(chat.pinned_to_bottom);
1188
1189 chat.scroll_to_top();
1190 assert_eq!(chat.scroll_offset, 0);
1191 assert!(!chat.pinned_to_bottom);
1192 }
1193
1194 #[test]
1195 fn test_chat_view_auto_scroll() {
1196 let mut chat = ChatView::new();
1197
1198 for i in 0..5 {
1199 chat.add_message(Message::user(format!("Message {}", i)));
1200 }
1202
1203 assert!(chat.pinned_to_bottom);
1205 }
1206
1207 #[test]
1208 fn test_chat_view_render() {
1209 let mut chat = ChatView::new();
1210 chat.add_message(Message::user("Test message".to_string()));
1211
1212 let area = Rect::new(0, 0, 50, 20);
1213 let mut buffer = Buffer::empty(area);
1214
1215 chat.render(area, &mut buffer);
1217
1218 let cell = buffer.cell((0, 0)).unwrap();
1220 assert!(!cell.symbol().is_empty());
1222 }
1223
1224 #[test]
1225 fn test_chat_view_render_multiple_messages() {
1226 let mut chat = ChatView::new();
1227
1228 chat.add_message(Message::user("First message".to_string()));
1229 chat.add_message(Message::assistant("Second message".to_string()));
1230 chat.add_message(Message::system("System message".to_string()));
1231
1232 let area = Rect::new(0, 0, 50, 20);
1233 let mut buffer = Buffer::empty(area);
1234
1235 chat.render(area, &mut buffer);
1237 }
1238
1239 #[test]
1240 fn test_chat_view_render_with_long_message() {
1241 let mut chat = ChatView::new();
1242
1243 let long_message = "This is a very long message that should wrap across multiple lines in the buffer when rendered. ".repeat(5);
1244 chat.add_message(Message::user(long_message));
1245
1246 let area = Rect::new(0, 0, 30, 20);
1247 let mut buffer = Buffer::empty(area);
1248
1249 chat.render(area, &mut buffer);
1251 }
1252
1253 #[test]
1254 fn test_chat_view_messages_ref() {
1255 let mut chat = ChatView::new();
1256
1257 chat.add_message(Message::user("Message 1".to_string()));
1258 chat.add_message(Message::assistant("Message 2".to_string()));
1259
1260 let messages = chat.messages();
1261 assert_eq!(messages.len(), 2);
1262 assert_eq!(messages[0].content, "Message 1");
1263 assert_eq!(messages[1].content, "Message 2");
1264 }
1265
1266 #[test]
1267 fn test_calculate_total_height() {
1268 let mut chat = ChatView::new();
1269
1270 assert_eq!(chat.calculate_total_height(50), 0);
1272
1273 chat.add_message(Message::user("Hello".to_string()));
1274 assert_eq!(chat.calculate_total_height(50), 3);
1276 }
1277
1278 #[test]
1279 fn test_calculate_total_height_with_wrapping() {
1280 let mut chat = ChatView::new();
1281
1282 chat.add_message(Message::user("Hi".to_string()));
1284 assert_eq!(chat.calculate_total_height(50), 3);
1285
1286 let long_msg = "This is a very long message that will definitely wrap onto multiple lines when displayed in a narrow container".to_string();
1288 chat.add_message(Message::assistant(long_msg));
1289
1290 let height = chat.calculate_total_height(20);
1293 assert!(height > 6); }
1295
1296 #[test]
1297 fn test_short_content_pinned_to_bottom_should_start_at_top() {
1298 let mut chat = ChatView::new();
1301
1302 chat.add_message(Message::user("Hello".to_string()));
1303
1304 let area = Rect::new(0, 0, 50, 20);
1305 let mut buffer = Buffer::empty(area);
1306
1307 chat.render(area, &mut buffer);
1309
1310 let cell = buffer.cell((0, 0)).unwrap();
1313 assert!(
1315 !cell.symbol().is_empty(),
1316 "Content should start at top, not be pushed down"
1317 );
1318 }
1319
1320 #[test]
1321 fn test_streaming_content_stays_pinned() {
1322 let mut chat = ChatView::new();
1324
1325 chat.add_message(Message::assistant("Start".to_string()));
1327
1328 let area = Rect::new(0, 0, 50, 20);
1329 let mut buffer1 = Buffer::empty(area);
1330 chat.render(area, &mut buffer1);
1331
1332 chat.append_to_last_assistant(" and continue with more text that is longer");
1334
1335 let mut buffer2 = Buffer::empty(area);
1336 chat.render(area, &mut buffer2);
1337
1338 let has_content_near_bottom = (0u16..20).any(|y| {
1342 let c = buffer2.cell((0, y)).unwrap();
1343 !c.symbol().is_empty() && c.symbol() != "│" && c.symbol() != " "
1344 });
1345
1346 assert!(
1347 has_content_near_bottom,
1348 "Content should remain visible near bottom when pinned"
1349 );
1350 }
1351
1352 #[test]
1353 fn test_content_shorter_than_viewport_no_excess_padding() {
1354 let mut chat = ChatView::new();
1356
1357 chat.add_message(Message::user("Short message".to_string()));
1358
1359 let total_height = chat.calculate_total_height(50);
1360 let viewport_height: u16 = 20;
1361
1362 assert!(
1364 total_height < viewport_height as usize,
1365 "Content should be shorter than viewport"
1366 );
1367
1368 let area = Rect::new(0, 0, 50, viewport_height);
1369 let mut buffer = Buffer::empty(area);
1370
1371 chat.render(area, &mut buffer);
1372
1373 let mut first_content_y: Option<u16> = None;
1376 for y in 0..viewport_height {
1377 let cell = buffer.cell((0, y)).unwrap();
1378 let is_border = matches!(
1379 cell.symbol(),
1380 "─" | "│" | "┌" | "┐" | "└" | "┘" | "├" | "┤" | "┬" | "┴"
1381 );
1382 if !is_border && !cell.symbol().is_empty() {
1383 first_content_y = Some(y);
1384 break;
1385 }
1386 }
1387
1388 let first_content_y = first_content_y.expect("Should find content somewhere");
1389
1390 assert_eq!(
1391 first_content_y, 0,
1392 "Content should start at y=0, not be pushed down by padding"
1393 );
1394 }
1395
1396 #[test]
1397 fn test_pinned_state_after_scrolling() {
1398 let mut chat = ChatView::new();
1399
1400 for i in 0..10 {
1402 chat.add_message(Message::user(format!("Message {}", i)));
1403 }
1404
1405 assert!(chat.pinned_to_bottom);
1407
1408 chat.scroll_up();
1410 assert!(!chat.pinned_to_bottom);
1411
1412 chat.scroll_to_bottom();
1414 assert!(chat.pinned_to_bottom);
1415 }
1416
1417 #[test]
1418 fn test_message_growth_maintains_correct_position() {
1419 let mut chat = ChatView::new();
1421
1422 chat.add_message(Message::assistant("Initial".to_string()));
1424
1425 let area = Rect::new(0, 0, 60, 10);
1426 let mut buffer = Buffer::empty(area);
1427 chat.render(area, &mut buffer);
1428
1429 chat.append_to_last_assistant(" content that gets added");
1431
1432 let mut buffer2 = Buffer::empty(area);
1433 chat.render(area, &mut buffer2);
1434
1435 assert!(
1437 chat.pinned_to_bottom,
1438 "Should remain pinned after content growth"
1439 );
1440 }
1441}