1use std::cell::{Cell, RefCell};
4
5use crate::syntax::SyntaxHighlighter;
6use tracing::{debug, trace};
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 trace!(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 content.is_empty() {
319 trace!("append_to_last_assistant: skipping empty content");
320 return;
321 }
322
323 let last_role = self
324 .messages
325 .last()
326 .map(|m| format!("{:?}", m.role))
327 .unwrap_or_else(|| "None".to_string());
328 trace!(
329 "append_to_last_assistant: content.len()={}, messages.count()={}, last_role={}",
330 content.len(),
331 self.messages.len(),
332 last_role
333 );
334
335 if let Some(last) = self.messages.last_mut() {
336 if matches!(last.role, Role::Assistant) {
337 trace!(
338 "append_to_last_assistant: appending to existing assistant message (content now {} chars)",
339 last.content.len() + content.len()
340 );
341 last.content.push_str(content);
342 self.cache_dirty.set(true); self.scroll_to_bottom();
344 return;
345 }
346 }
347
348 debug!(
350 "append_to_last_assistant: creating NEW assistant message with {} chars",
351 content.len()
352 );
353 self.add_message(Message::assistant(content.to_string()));
354 }
355
356 pub fn start_new_assistant_message(&mut self) {
357 debug!(
358 "start_new_assistant_message: creating fresh assistant message (total messages: {})",
359 self.messages.len()
360 );
361 self.add_message(Message::assistant(String::new()));
362 }
363
364 pub fn message_count(&self) -> usize {
366 self.messages.len()
367 }
368
369 pub fn messages(&self) -> &[Message] {
371 &self.messages
372 }
373
374 pub fn scroll_up(&mut self) {
376 const SCROLL_LINES: usize = 5;
377 if self.pinned_to_bottom {
379 self.scroll_offset = self.last_max_scroll_offset.get();
380 self.pinned_to_bottom = false;
381 }
382 self.scroll_offset = self.scroll_offset.saturating_sub(SCROLL_LINES);
383 self.cache_dirty.set(true);
385 }
386
387 pub fn scroll_down(&mut self) {
389 const SCROLL_LINES: usize = 5;
390 let max_offset = self.last_max_scroll_offset.get();
391
392 if self.pinned_to_bottom {
395 self.scroll_offset = max_offset;
396 self.pinned_to_bottom = false;
397 return;
398 }
399
400 self.scroll_offset = (self.scroll_offset.saturating_add(SCROLL_LINES)).min(max_offset);
402 }
403
404 pub fn scroll_page_up(&mut self, viewport_height: u16) {
406 if self.pinned_to_bottom {
408 self.scroll_offset = self.last_max_scroll_offset.get();
409 self.pinned_to_bottom = false;
410 }
411 let page_size = viewport_height as usize;
412 self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
413 }
414
415 pub fn scroll_page_down(&mut self, viewport_height: u16) {
417 let max_offset = self.last_max_scroll_offset.get();
418
419 if self.pinned_to_bottom {
422 self.scroll_offset = max_offset;
423 self.pinned_to_bottom = false;
424 return;
425 }
426
427 let page_size = viewport_height as usize;
428 self.scroll_offset = (self.scroll_offset.saturating_add(page_size)).min(max_offset);
430 }
431
432 pub fn scroll_to_bottom(&mut self) {
434 self.pinned_to_bottom = true;
435 }
436
437 pub fn scroll_to_top(&mut self) {
439 self.pinned_to_bottom = false;
440 self.scroll_offset = 0;
441 }
442
443 pub fn start_selection(&mut self, message_idx: usize, byte_offset: usize) {
445 self.selection_start = Some((message_idx, byte_offset));
446 self.selection_end = Some((message_idx, byte_offset));
447 }
448
449 pub fn extend_selection(&mut self, message_idx: usize, byte_offset: usize) {
451 if self.selection_start.is_some() {
452 self.selection_end = Some((message_idx, byte_offset));
453 }
454 }
455
456 pub fn clear_selection(&mut self) {
458 self.selection_start = None;
459 self.selection_end = None;
460 }
461
462 pub fn has_selection(&self) -> bool {
464 self.selection_start.is_some() && self.selection_end.is_some()
465 }
466
467 pub fn screen_to_text_pos(&self, col: u16, row: u16) -> Option<(usize, usize)> {
470 trace!(
471 "screen_to_text_pos: col={}, row={}, positions={}",
472 col,
473 row,
474 self.render_positions.borrow().len()
475 );
476 for pos in self.render_positions.borrow().iter() {
477 trace!(
478 " checking pos.screen_row={} vs row={}",
479 pos.screen_row,
480 row
481 );
482 if pos.screen_row == row {
483 let line_len = pos.char_end.saturating_sub(pos.char_start);
486 let char_in_line = (col as usize).min(line_len);
487 trace!(
488 " matched! msg_idx={}, char_offset={}",
489 pos.message_idx,
490 pos.char_start + char_in_line
491 );
492 return Some((pos.message_idx, pos.char_start + char_in_line));
493 }
494 }
495 trace!(" no match found");
496 None
497 }
498
499 pub fn render_position_count(&self) -> usize {
501 self.render_positions.borrow().len()
502 }
503
504 pub fn is_selected(&self, message_idx: usize, char_offset: usize) -> bool {
506 let Some((start_msg, start_offset)) = self.selection_start else {
507 return false;
508 };
509 let Some((end_msg, end_offset)) = self.selection_end else {
510 return false;
511 };
512
513 let (min_msg, min_offset, max_msg, max_offset) =
515 if start_msg < end_msg || (start_msg == end_msg && start_offset <= end_offset) {
516 (start_msg, start_offset, end_msg, end_offset)
517 } else {
518 (end_msg, end_offset, start_msg, start_offset)
519 };
520
521 if message_idx < min_msg || message_idx > max_msg {
523 return false;
524 }
525
526 if message_idx == min_msg && message_idx == max_msg {
527 char_offset >= min_offset && char_offset < max_offset
529 } else if message_idx == min_msg {
530 char_offset >= min_offset
532 } else if message_idx == max_msg {
533 char_offset < max_offset
535 } else {
536 true
538 }
539 }
540
541 fn apply_selection_highlight<'a>(
544 &self,
545 text: &'a str,
546 message_idx: usize,
547 line_char_start: usize,
548 base_style: Style,
549 ) -> Vec<Span<'a>> {
550 let selection_style = Style::default().bg(Color::Blue).fg(Color::White);
551
552 if !self.has_selection() {
554 return vec![Span::styled(text, base_style)];
555 }
556
557 let mut spans = Vec::new();
558 let mut current_start = 0;
559 let mut in_selection = false;
560 let char_positions: Vec<(usize, char)> = text.char_indices().collect();
561
562 for (i, (byte_idx, _)) in char_positions.iter().enumerate() {
563 let global_char = line_char_start + i;
564 let is_sel = self.is_selected(message_idx, global_char);
565
566 if is_sel != in_selection {
567 if i > current_start {
569 let segment_byte_start = char_positions[current_start].0;
570 let segment_byte_end = *byte_idx;
571 let segment = &text[segment_byte_start..segment_byte_end];
572 let style = if in_selection {
573 selection_style
574 } else {
575 base_style
576 };
577 spans.push(Span::styled(segment, style));
578 }
579 current_start = i;
580 in_selection = is_sel;
581 }
582 }
583
584 if current_start < char_positions.len() {
586 let segment_byte_start = char_positions[current_start].0;
587 let segment = &text[segment_byte_start..];
588 let style = if in_selection {
589 selection_style
590 } else {
591 base_style
592 };
593 spans.push(Span::styled(segment, style));
594 }
595
596 if spans.is_empty() {
597 vec![Span::styled(text, base_style)]
598 } else {
599 spans
600 }
601 }
602
603 pub fn get_selected_text(&self) -> Option<String> {
605 let (start_msg, start_offset) = self.selection_start?;
606 let (end_msg, end_offset) = self.selection_end?;
607
608 let (min_msg, min_offset, max_msg, max_offset) =
610 if start_msg < end_msg || (start_msg == end_msg && start_offset <= end_offset) {
611 (start_msg, start_offset, end_msg, end_offset)
612 } else {
613 (end_msg, end_offset, start_msg, start_offset)
614 };
615
616 if min_msg == max_msg {
617 let msg = self.messages.get(min_msg)?;
619 let content = &msg.content;
620 let start_byte = char_offset_to_byte(content, min_offset);
621 let end_byte = char_offset_to_byte(content, max_offset);
622 if start_byte < content.len() && end_byte <= content.len() {
623 Some(content[start_byte..end_byte].to_string())
624 } else {
625 None
626 }
627 } else {
628 let mut result = String::new();
630
631 if let Some(msg) = self.messages.get(min_msg) {
633 let start_byte = char_offset_to_byte(&msg.content, min_offset);
634 if start_byte < msg.content.len() {
635 result.push_str(&msg.content[start_byte..]);
636 }
637 }
638
639 for idx in (min_msg + 1)..max_msg {
641 if let Some(msg) = self.messages.get(idx) {
642 result.push('\n');
643 result.push_str(&msg.content);
644 }
645 }
646
647 if let Some(msg) = self.messages.get(max_msg) {
649 result.push('\n');
650 let end_byte = char_offset_to_byte(&msg.content, max_offset);
651 if end_byte > 0 && end_byte <= msg.content.len() {
652 result.push_str(&msg.content[..end_byte]);
653 }
654 }
655
656 Some(result)
657 }
658 }
659
660 pub fn clear(&mut self) {
662 self.messages.clear();
663 self.scroll_offset = 0;
664 self.pinned_to_bottom = true;
665 self.cache_dirty.set(true);
666 self.hidden_message_count.set(0);
667 }
668
669 fn get_render_window(&self) -> (&[Message], usize) {
672 let total_count = self.messages.len();
673
674 if self.pinned_to_bottom && total_count > RENDER_WINDOW_SIZE {
676 let hidden_count = total_count.saturating_sub(RENDER_WINDOW_SIZE);
677 let window = &self.messages[hidden_count..];
678 self.hidden_message_count.set(hidden_count);
679 (window, hidden_count)
680 } else {
681 self.hidden_message_count.set(0);
682 (&self.messages, 0)
683 }
684 }
685
686 fn estimate_line_count(text: &str, width: usize) -> usize {
688 if width == 0 {
689 return 0;
690 }
691
692 let mut lines = 0;
693 let mut current_line_len = 0;
694
695 for line in text.lines() {
696 if line.is_empty() {
697 lines += 1;
698 current_line_len = 0;
699 continue;
700 }
701
702 let words: Vec<&str> = line.split_whitespace().collect();
704 let mut word_index = 0;
705
706 while word_index < words.len() {
707 let word = words[word_index];
708 let word_len = unicode_width::UnicodeWidthStr::width(word);
709
710 if current_line_len == 0 {
711 if word_len > width {
713 let mut chars_left = word;
715 while !chars_left.is_empty() {
716 let take = chars_left
717 .char_indices()
718 .take_while(|(_, c)| {
719 unicode_width::UnicodeWidthChar::width(*c)
720 .map(|w| w <= width)
721 .unwrap_or(true)
722 })
723 .count();
724 if take == 0 {
725 let next = chars_left
727 .char_indices()
728 .nth(1)
729 .map(|(i, _)| i)
730 .unwrap_or(chars_left.len());
731 lines += 1;
732 chars_left = &chars_left[next..];
733 } else {
734 lines += 1;
735 chars_left = &chars_left[take..];
736 }
737 }
738 current_line_len = 0;
739 } else {
740 current_line_len = word_len;
741 }
742 } else if current_line_len + 1 + word_len <= width {
743 current_line_len += 1 + word_len;
745 } else {
746 lines += 1;
748 current_line_len = if word_len > width {
749 let mut chars_left = word;
751 while !chars_left.is_empty() {
752 let take = chars_left
753 .char_indices()
754 .take_while(|(_, c)| {
755 unicode_width::UnicodeWidthChar::width(*c)
756 .map(|w| w <= width)
757 .unwrap_or(true)
758 })
759 .count();
760 if take == 0 {
761 let next = chars_left
762 .char_indices()
763 .nth(1)
764 .map(|(i, _)| i)
765 .unwrap_or(chars_left.len());
766 lines += 1;
767 chars_left = &chars_left[next..];
768 } else {
769 lines += 1;
770 chars_left = &chars_left[take..];
771 }
772 }
773 0
774 } else {
775 word_len
776 };
777 }
778
779 word_index += 1;
780 }
781
782 if current_line_len > 0 || words.is_empty() {
784 lines += 1;
785 }
786
787 current_line_len = 0;
788 }
789
790 lines.max(1)
791 }
792
793 fn process_code_blocks(&self, content: &str) -> Vec<(String, LineType, bool, Option<String>)> {
796 let mut result = Vec::new();
797 let lines = content.lines().peekable();
798 let mut in_code_block = false;
799 let mut current_lang: Option<String> = None;
800
801 for line in lines {
802 if line.starts_with("```") {
803 if in_code_block {
804 in_code_block = false;
806 current_lang = None;
807 } else {
808 in_code_block = true;
810 current_lang = line
811 .strip_prefix("```")
812 .map(|s| s.trim().to_string())
813 .filter(|s| !s.is_empty());
814 }
815 } else if in_code_block {
816 result.push((
817 line.to_string(),
818 LineType::CodeBlock,
819 true,
820 current_lang.clone(),
821 ));
822 } else {
823 let (line_type, _) = detect_line_type(line);
824 result.push((line.to_string(), line_type, false, None));
825 }
826 }
827
828 result
829 }
830
831 fn calculate_total_height(&self, width: u16) -> usize {
833 if !self.cache_dirty.get() {
835 return self.cached_height.get();
836 }
837
838 let mut total_height = 0;
839
840 for message in &self.messages {
843 total_height += 1;
845
846 let processed = self.process_code_blocks(&message.content);
848
849 for (line, _line_type, _is_code, _lang) in processed {
850 let line_height = if _is_code {
853 1 } else {
855 Self::estimate_line_count(&line, width as usize)
856 };
857 total_height += line_height;
858 }
859
860 total_height += 1;
862 }
863
864 self.cached_height.set(total_height);
866 self.cache_dirty.set(false);
867
868 total_height
869 }
870
871 fn render_to_buffer(&self, area: Rect, buf: &mut Buffer) {
874 if self.cache_dirty.get() {
877 self.render_positions.borrow_mut().clear();
878 }
879
880 let total_height = self.calculate_total_height(area.width);
881 let viewport_height = area.height as usize;
882
883 let max_scroll_offset = if total_height > viewport_height {
885 total_height.saturating_sub(viewport_height)
886 } else {
887 0
888 };
889
890 self.last_max_scroll_offset.set(max_scroll_offset);
892
893 let scroll_offset = if self.pinned_to_bottom {
894 max_scroll_offset
896 } else {
897 self.scroll_offset.min(max_scroll_offset)
899 };
900
901 let (initial_y_offset, skip_until, max_y) =
903 (area.y, scroll_offset, scroll_offset + viewport_height);
904
905 let mut y_offset = initial_y_offset;
906 let mut global_y: usize = 0;
907
908 let (messages_to_render, hidden_count) = self.get_render_window();
910
911 if hidden_count > 0 {
914 for message in &self.messages[..hidden_count] {
917 let role_height = 1;
918 let processed = self.process_code_blocks(&message.content);
919 let content_height: usize = processed
920 .iter()
921 .map(|(line, _, _, _)| Self::estimate_line_count(line, area.width as usize))
922 .sum();
923 let separator_height = 1;
924 global_y += role_height + content_height + separator_height;
925 }
926 }
927 for (local_msg_idx, message) in messages_to_render.iter().enumerate() {
928 let message_idx = hidden_count + local_msg_idx;
929
930 let role_height = 1;
932 let processed = self.process_code_blocks(&message.content);
933 let content_height: usize = processed
934 .iter()
935 .map(|(line, _, _, _)| Self::estimate_line_count(line, area.width as usize))
936 .sum();
937 let separator_height = 1;
938 let message_height = role_height + content_height + separator_height;
939
940 if global_y + message_height <= skip_until {
941 global_y += message_height;
942 continue;
943 }
944
945 if global_y >= max_y {
946 break;
947 }
948
949 if global_y >= skip_until && y_offset < area.y + area.height {
951 let role_text = format!("[{}] {}", message.role.display_name(), message.timestamp);
952 let style = Style::default()
953 .fg(message.role.badge_color())
954 .add_modifier(Modifier::BOLD);
955
956 let line = Line::from(vec![Span::styled(role_text, style)]);
957
958 Paragraph::new(line)
959 .wrap(Wrap { trim: false })
960 .render(Rect::new(area.x, y_offset, area.width, 1), buf);
961
962 y_offset += 1;
963 }
964 global_y += 1;
965
966 let mut char_offset: usize = 0;
969 for (line_idx, (line, line_type, is_code_block, lang)) in processed.iter().enumerate() {
970 let line_height = Self::estimate_line_count(line, area.width as usize);
971 let line_char_count = line.chars().count();
972
973 if global_y >= skip_until && y_offset < area.y + area.height {
977 self.render_positions.borrow_mut().push(RenderPosition {
978 message_idx,
979 line_idx,
980 char_start: char_offset,
981 char_end: char_offset + line_char_count,
982 screen_row: y_offset, });
984 }
985
986 if *is_code_block && global_y >= skip_until {
987 if let Some(ref lang_str) = lang {
989 if let Ok(highlighted_spans) = self
990 .highlighter
991 .highlight_to_spans(&format!("{}\n", line), lang_str)
992 {
993 for highlighted_line in highlighted_spans {
995 if y_offset < area.y + area.height && global_y < max_y {
996 let text = Text::from(Line::from(highlighted_line));
997 Paragraph::new(text)
998 .wrap(Wrap { trim: false })
999 .render(Rect::new(area.x, y_offset, area.width, 1), buf);
1000 y_offset += 1;
1001 }
1002 global_y += 1;
1003
1004 if global_y >= max_y {
1005 break;
1006 }
1007 }
1008 continue;
1009 }
1010 }
1011 }
1012
1013 let base_style = line_type.style();
1015 let spans = if self.has_selection() {
1016 self.apply_selection_highlight(line, message_idx, char_offset, base_style)
1018 } else {
1019 parse_inline_markdown(line, base_style)
1020 };
1021 let text_line = Line::from(spans);
1022
1023 if global_y >= skip_until && y_offset < area.y + area.height {
1025 let render_height =
1027 line_height.min((area.y + area.height - y_offset) as usize) as u16;
1028 Paragraph::new(text_line)
1029 .wrap(Wrap { trim: false })
1030 .render(Rect::new(area.x, y_offset, area.width, render_height), buf);
1031 y_offset += line_height as u16;
1032 }
1033 global_y += line_height;
1034
1035 char_offset += line_char_count + 1; if global_y >= max_y {
1039 break;
1040 }
1041 }
1042
1043 if global_y >= skip_until && global_y < max_y && y_offset < area.y + area.height {
1045 Paragraph::new("─".repeat(area.width as usize).as_str())
1046 .style(Style::default().fg(Color::DarkGray))
1047 .render(Rect::new(area.x, y_offset, area.width, 1), buf);
1048 y_offset += 1;
1049 }
1050 global_y += 1;
1051 }
1052 }
1053}
1054
1055impl ratatui::widgets::Widget for &ChatView {
1056 fn render(self, area: Rect, buf: &mut Buffer) {
1057 (*self).render_to_buffer(area, buf);
1059 }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065
1066 #[test]
1067 fn test_role_display_name() {
1068 assert_eq!(Role::User.display_name(), "USER");
1069 assert_eq!(Role::Assistant.display_name(), "ASSISTANT");
1070 assert_eq!(Role::System.display_name(), "SYSTEM");
1071 }
1072
1073 #[test]
1074 fn test_role_badge_color() {
1075 assert_eq!(Role::User.badge_color(), Color::Blue);
1076 assert_eq!(Role::Assistant.badge_color(), Color::Green);
1077 assert_eq!(Role::System.badge_color(), Color::Yellow);
1078 }
1079
1080 #[test]
1081 fn test_message_new() {
1082 let message = Message::new(Role::User, "Hello, World!".to_string(), "12:34".to_string());
1083
1084 assert_eq!(message.role, Role::User);
1085 assert_eq!(message.content, "Hello, World!");
1086 assert_eq!(message.timestamp, "12:34");
1087 }
1088
1089 #[test]
1090 fn test_message_user() {
1091 let message = Message::user("Test message".to_string());
1092
1093 assert_eq!(message.role, Role::User);
1094 assert_eq!(message.content, "Test message");
1095 assert!(!message.timestamp.is_empty());
1096 }
1097
1098 #[test]
1099 fn test_message_assistant() {
1100 let message = Message::assistant("Response".to_string());
1101
1102 assert_eq!(message.role, Role::Assistant);
1103 assert_eq!(message.content, "Response");
1104 assert!(!message.timestamp.is_empty());
1105 }
1106
1107 #[test]
1108 fn test_message_system() {
1109 let message = Message::system("System notification".to_string());
1110
1111 assert_eq!(message.role, Role::System);
1112 assert_eq!(message.content, "System notification");
1113 assert!(!message.timestamp.is_empty());
1114 }
1115
1116 #[test]
1117 fn test_chat_view_new() {
1118 let chat = ChatView::new();
1119
1120 assert_eq!(chat.message_count(), 0);
1121 assert_eq!(chat.scroll_offset, 0);
1122 assert!(chat.messages().is_empty());
1123 }
1124
1125 #[test]
1126 fn test_chat_view_default() {
1127 let chat = ChatView::default();
1128
1129 assert_eq!(chat.message_count(), 0);
1130 assert_eq!(chat.scroll_offset, 0);
1131 }
1132
1133 #[test]
1134 fn test_chat_view_add_message() {
1135 let mut chat = ChatView::new();
1136
1137 chat.add_message(Message::user("Hello".to_string()));
1138 assert_eq!(chat.message_count(), 1);
1139
1140 chat.add_message(Message::assistant("Hi there!".to_string()));
1141 assert_eq!(chat.message_count(), 2);
1142 }
1143
1144 #[test]
1145 fn test_chat_view_add_multiple_messages() {
1146 let mut chat = ChatView::new();
1147
1148 for i in 0..5 {
1149 chat.add_message(Message::user(format!("Message {}", i)));
1150 }
1151
1152 assert_eq!(chat.message_count(), 5);
1153 }
1154
1155 #[test]
1156 fn test_chat_view_scroll_up() {
1157 let mut chat = ChatView::new();
1158
1159 for i in 0..10 {
1161 chat.add_message(Message::user(format!("Message {}", i)));
1162 }
1163
1164 assert!(chat.pinned_to_bottom);
1166
1167 chat.scroll_up();
1169 assert!(!chat.pinned_to_bottom);
1170 }
1173
1174 #[test]
1175 fn test_chat_view_scroll_up_bounds() {
1176 let mut chat = ChatView::new();
1177
1178 chat.add_message(Message::user("Test".to_string()));
1179 chat.scroll_to_top(); chat.scroll_up();
1183 assert_eq!(chat.scroll_offset, 0);
1184 assert!(!chat.pinned_to_bottom);
1185
1186 chat.scroll_up();
1187 assert_eq!(chat.scroll_offset, 0);
1188 }
1189
1190 #[test]
1191 fn test_chat_view_scroll_down() {
1192 let mut chat = ChatView::new();
1193
1194 chat.add_message(Message::user("Test".to_string()));
1195
1196 assert!(chat.pinned_to_bottom);
1198
1199 chat.scroll_down();
1201 assert!(!chat.pinned_to_bottom); for i in 0..20 {
1206 chat.add_message(Message::user(format!("Message {}", i)));
1207 }
1208
1209 chat.last_max_scroll_offset.set(100); chat.scroll_to_bottom(); assert!(chat.pinned_to_bottom);
1215
1216 chat.scroll_up();
1217 assert!(!chat.pinned_to_bottom);
1218 assert_eq!(chat.scroll_offset, 95);
1220
1221 chat.scroll_down();
1223 assert!(!chat.pinned_to_bottom);
1224 assert_eq!(chat.scroll_offset, 100);
1226
1227 chat.scroll_down();
1229 assert_eq!(chat.scroll_offset, 100); }
1231
1232 #[test]
1233 fn test_chat_view_scroll_to_bottom() {
1234 let mut chat = ChatView::new();
1235
1236 for i in 0..5 {
1237 chat.add_message(Message::user(format!("Message {}", i)));
1238 }
1239
1240 chat.scroll_to_top();
1241 assert_eq!(chat.scroll_offset, 0);
1242 assert!(!chat.pinned_to_bottom);
1243
1244 chat.scroll_to_bottom();
1245 assert!(chat.pinned_to_bottom);
1247 }
1248
1249 #[test]
1250 fn test_chat_view_scroll_to_top() {
1251 let mut chat = ChatView::new();
1252
1253 for i in 0..5 {
1254 chat.add_message(Message::user(format!("Message {}", i)));
1255 }
1256
1257 chat.scroll_to_bottom();
1258 assert!(chat.pinned_to_bottom);
1259
1260 chat.scroll_to_top();
1261 assert_eq!(chat.scroll_offset, 0);
1262 assert!(!chat.pinned_to_bottom);
1263 }
1264
1265 #[test]
1266 fn test_chat_view_auto_scroll() {
1267 let mut chat = ChatView::new();
1268
1269 for i in 0..5 {
1270 chat.add_message(Message::user(format!("Message {}", i)));
1271 }
1273
1274 assert!(chat.pinned_to_bottom);
1276 }
1277
1278 #[test]
1279 fn test_chat_view_render() {
1280 let mut chat = ChatView::new();
1281 chat.add_message(Message::user("Test message".to_string()));
1282
1283 let area = Rect::new(0, 0, 50, 20);
1284 let mut buffer = Buffer::empty(area);
1285
1286 chat.render(area, &mut buffer);
1288
1289 let cell = buffer.cell((0, 0)).unwrap();
1291 assert!(!cell.symbol().is_empty());
1293 }
1294
1295 #[test]
1296 fn test_chat_view_render_multiple_messages() {
1297 let mut chat = ChatView::new();
1298
1299 chat.add_message(Message::user("First message".to_string()));
1300 chat.add_message(Message::assistant("Second message".to_string()));
1301 chat.add_message(Message::system("System message".to_string()));
1302
1303 let area = Rect::new(0, 0, 50, 20);
1304 let mut buffer = Buffer::empty(area);
1305
1306 chat.render(area, &mut buffer);
1308 }
1309
1310 #[test]
1311 fn test_chat_view_render_with_long_message() {
1312 let mut chat = ChatView::new();
1313
1314 let long_message = "This is a very long message that should wrap across multiple lines in the buffer when rendered. ".repeat(5);
1315 chat.add_message(Message::user(long_message));
1316
1317 let area = Rect::new(0, 0, 30, 20);
1318 let mut buffer = Buffer::empty(area);
1319
1320 chat.render(area, &mut buffer);
1322 }
1323
1324 #[test]
1325 fn test_chat_view_messages_ref() {
1326 let mut chat = ChatView::new();
1327
1328 chat.add_message(Message::user("Message 1".to_string()));
1329 chat.add_message(Message::assistant("Message 2".to_string()));
1330
1331 let messages = chat.messages();
1332 assert_eq!(messages.len(), 2);
1333 assert_eq!(messages[0].content, "Message 1");
1334 assert_eq!(messages[1].content, "Message 2");
1335 }
1336
1337 #[test]
1338 fn test_calculate_total_height() {
1339 let mut chat = ChatView::new();
1340
1341 assert_eq!(chat.calculate_total_height(50), 0);
1343
1344 chat.add_message(Message::user("Hello".to_string()));
1345 assert_eq!(chat.calculate_total_height(50), 3);
1347 }
1348
1349 #[test]
1350 fn test_calculate_total_height_with_wrapping() {
1351 let mut chat = ChatView::new();
1352
1353 chat.add_message(Message::user("Hi".to_string()));
1355 assert_eq!(chat.calculate_total_height(50), 3);
1356
1357 let long_msg = "This is a very long message that will definitely wrap onto multiple lines when displayed in a narrow container".to_string();
1359 chat.add_message(Message::assistant(long_msg));
1360
1361 let height = chat.calculate_total_height(20);
1364 assert!(height > 6); }
1366
1367 #[test]
1368 fn test_short_content_pinned_to_bottom_should_start_at_top() {
1369 let mut chat = ChatView::new();
1372
1373 chat.add_message(Message::user("Hello".to_string()));
1374
1375 let area = Rect::new(0, 0, 50, 20);
1376 let mut buffer = Buffer::empty(area);
1377
1378 chat.render(area, &mut buffer);
1380
1381 let cell = buffer.cell((0, 0)).unwrap();
1384 assert!(
1386 !cell.symbol().is_empty(),
1387 "Content should start at top, not be pushed down"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_streaming_content_stays_pinned() {
1393 let mut chat = ChatView::new();
1395
1396 chat.add_message(Message::assistant("Start".to_string()));
1398
1399 let area = Rect::new(0, 0, 50, 20);
1400 let mut buffer1 = Buffer::empty(area);
1401 chat.render(area, &mut buffer1);
1402
1403 chat.append_to_last_assistant(" and continue with more text that is longer");
1405
1406 let mut buffer2 = Buffer::empty(area);
1407 chat.render(area, &mut buffer2);
1408
1409 let has_content_near_bottom = (0u16..20).any(|y| {
1413 let c = buffer2.cell((0, y)).unwrap();
1414 !c.symbol().is_empty() && c.symbol() != "│" && c.symbol() != " "
1415 });
1416
1417 assert!(
1418 has_content_near_bottom,
1419 "Content should remain visible near bottom when pinned"
1420 );
1421 }
1422
1423 #[test]
1424 fn test_content_shorter_than_viewport_no_excess_padding() {
1425 let mut chat = ChatView::new();
1427
1428 chat.add_message(Message::user("Short message".to_string()));
1429
1430 let total_height = chat.calculate_total_height(50);
1431 let viewport_height: u16 = 20;
1432
1433 assert!(
1435 total_height < viewport_height as usize,
1436 "Content should be shorter than viewport"
1437 );
1438
1439 let area = Rect::new(0, 0, 50, viewport_height);
1440 let mut buffer = Buffer::empty(area);
1441
1442 chat.render(area, &mut buffer);
1443
1444 let mut first_content_y: Option<u16> = None;
1447 for y in 0..viewport_height {
1448 let cell = buffer.cell((0, y)).unwrap();
1449 let is_border = matches!(
1450 cell.symbol(),
1451 "─" | "│" | "┌" | "┐" | "└" | "┘" | "├" | "┤" | "┬" | "┴"
1452 );
1453 if !is_border && !cell.symbol().is_empty() {
1454 first_content_y = Some(y);
1455 break;
1456 }
1457 }
1458
1459 let first_content_y = first_content_y.expect("Should find content somewhere");
1460
1461 assert_eq!(
1462 first_content_y, 0,
1463 "Content should start at y=0, not be pushed down by padding"
1464 );
1465 }
1466
1467 #[test]
1468 fn test_pinned_state_after_scrolling() {
1469 let mut chat = ChatView::new();
1470
1471 for i in 0..10 {
1473 chat.add_message(Message::user(format!("Message {}", i)));
1474 }
1475
1476 assert!(chat.pinned_to_bottom);
1478
1479 chat.scroll_up();
1481 assert!(!chat.pinned_to_bottom);
1482
1483 chat.scroll_to_bottom();
1485 assert!(chat.pinned_to_bottom);
1486 }
1487
1488 #[test]
1489 fn test_message_growth_maintains_correct_position() {
1490 let mut chat = ChatView::new();
1492
1493 chat.add_message(Message::assistant("Initial".to_string()));
1495
1496 let area = Rect::new(0, 0, 60, 10);
1497 let mut buffer = Buffer::empty(area);
1498 chat.render(area, &mut buffer);
1499
1500 chat.append_to_last_assistant(" content that gets added");
1502
1503 let mut buffer2 = Buffer::empty(area);
1504 chat.render(area, &mut buffer2);
1505
1506 assert!(
1508 chat.pinned_to_bottom,
1509 "Should remain pinned after content growth"
1510 );
1511 }
1512}