Skip to main content

limit_tui/components/
chat.rs

1// Chat view component for displaying conversation messages
2
3use 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/// Line type for markdown rendering
17#[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
46/// Parse inline markdown elements and return styled spans
47fn 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        // Handle code inline: `code`
57        if c == '`' && !in_bold && !in_italic {
58            if in_code {
59                // End of code
60                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                // Start of code
66                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        // Handle bold: **text**
76        if c == '*' && chars.peek() == Some(&'*') && !in_code {
77            chars.next(); // consume second *
78            if in_bold {
79                // End of bold
80                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                // Start of bold
86                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        // Handle italic: *text* (single asterisk, not at start/end of word boundary with bold)
96        if c == '*' && !in_code && !in_bold {
97            if in_italic {
98                // End of italic
99                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                // Start of italic
105                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    // Handle remaining text
118    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
138/// Detect line type from content
139fn 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/// Role of a message sender
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum Role {
166    User,
167    Assistant,
168    System,
169}
170
171impl Role {
172    /// Get display name for the role
173    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    /// Get color for the role badge
182    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/// A single chat message
192#[derive(Debug, Clone)]
193pub struct Message {
194    pub role: Role,
195    pub content: String,
196    pub timestamp: String,
197}
198
199impl Message {
200    /// Create a new message
201    pub fn new(role: Role, content: String, timestamp: String) -> Self {
202        Self {
203            role,
204            content,
205            timestamp,
206        }
207    }
208
209    /// Create a user message with current timestamp
210    pub fn user(content: String) -> Self {
211        let timestamp = Self::current_timestamp();
212        Self::new(Role::User, content, timestamp)
213    }
214
215    /// Create an assistant message with current timestamp
216    pub fn assistant(content: String) -> Self {
217        let timestamp = Self::current_timestamp();
218        Self::new(Role::Assistant, content, timestamp)
219    }
220
221    /// Create a system message with current timestamp
222    pub fn system(content: String) -> Self {
223        let timestamp = Self::current_timestamp();
224        Self::new(Role::System, content, timestamp)
225    }
226
227    /// Get current timestamp in local timezone
228    fn current_timestamp() -> String {
229        chrono::Local::now().format("%H:%M").to_string()
230    }
231}
232
233/// Chat view component for displaying conversation messages
234#[derive(Debug, Clone)]
235pub struct ChatView {
236    messages: Vec<Message>,
237    scroll_offset: usize,
238    pinned_to_bottom: bool,
239    /// Cached max scroll offset from last render (used when leaving pinned state)
240    last_max_scroll_offset: Cell<usize>,
241    /// Syntax highlighter for code blocks
242    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    /// Add a message to the chat
264    pub fn add_message(&mut self, message: Message) {
265        self.messages.push(message);
266        // Auto-scroll to bottom on new message
267        self.scroll_to_bottom();
268    }
269
270    /// Append content to the last assistant message, or create a new one if none exists
271    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        // No assistant message to append to, create new
280        self.add_message(Message::assistant(content.to_string()));
281    }
282
283    /// Get the number of messages
284    pub fn message_count(&self) -> usize {
285        self.messages.len()
286    }
287
288    /// Get a reference to the messages
289    pub fn messages(&self) -> &[Message] {
290        &self.messages
291    }
292
293    /// Scroll up by multiple lines (better UX than single line)
294    pub fn scroll_up(&mut self) {
295        const SCROLL_LINES: usize = 5;
296        // When leaving pinned state, sync scroll_offset to actual position
297        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    /// Scroll down by multiple lines
305    pub fn scroll_down(&mut self) {
306        const SCROLL_LINES: usize = 5;
307        // When leaving pinned state, sync scroll_offset to actual position
308        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    /// Scroll up by one page (viewport height)
316    pub fn scroll_page_up(&mut self, viewport_height: u16) {
317        // When leaving pinned state, sync scroll_offset to actual position
318        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    /// Scroll down by one page
327    pub fn scroll_page_down(&mut self, viewport_height: u16) {
328        // When leaving pinned state, sync scroll_offset to actual position
329        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    /// Scroll to the bottom (show newest messages)
338    pub fn scroll_to_bottom(&mut self) {
339        self.pinned_to_bottom = true;
340    }
341
342    /// Scroll to the top (show oldest messages)
343    pub fn scroll_to_top(&mut self) {
344        self.pinned_to_bottom = false;
345        self.scroll_offset = 0;
346    }
347
348    /// Clear all messages
349    pub fn clear(&mut self) {
350        self.messages.clear();
351        self.scroll_offset = 0;
352        self.pinned_to_bottom = true;
353    }
354
355    /// Estimate the number of lines needed to display text with wrapping
356    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            // Split line into words and calculate wrapped lines
372            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                    // First word on line
381                    if word_len > width {
382                        // Very long word - split it
383                        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                    // Word fits on current line
395                    current_line_len += 1 + word_len;
396                } else {
397                    // Need new line
398                    lines += 1;
399                    current_line_len = if word_len > width {
400                        // Very long word - split it
401                        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            // Account for the line itself if we added any content
417            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    /// Process code blocks with syntax highlighting
428    /// Returns a vector of (line, line_type, is_code_block, lang)
429    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                    // End of code block
439                    in_code_block = false;
440                    current_lang = None;
441                } else {
442                    // Start of code block
443                    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    /// Calculate total height needed to display all messages
466    fn calculate_total_height(&self, width: u16) -> usize {
467        let mut total_height = 0;
468
469        for message in &self.messages {
470            // Role badge line: "[USER] HH:MM"
471            total_height += 1;
472
473            // Message content lines (with wrapping)
474            let processed = self.process_code_blocks(&message.content);
475
476            for (line, _line_type, _is_code, _lang) in processed {
477                // Code blocks render line-by-line with height 1
478                // Regular text wraps to estimated height
479                let line_height = if _is_code {
480                    1 // Code blocks: one row per line, no wrapping
481                } else {
482                    Self::estimate_line_count(&line, width as usize)
483                };
484                total_height += line_height;
485            }
486
487            // Empty line between messages
488            total_height += 1;
489        }
490
491        total_height
492    }
493
494    /// Render visible messages based on scroll offset
495    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        // Calculate scroll offset based on pinned state
500        let max_scroll_offset = if total_height > viewport_height {
501            total_height.saturating_sub(viewport_height)
502        } else {
503            0
504        };
505
506        // Cache the max offset for scroll functions to use
507        self.last_max_scroll_offset.set(max_scroll_offset);
508
509        let scroll_offset = if self.pinned_to_bottom {
510            // When pinned to bottom, always show the newest messages
511            max_scroll_offset
512        } else {
513            // User has scrolled - clamp to valid range
514            self.scroll_offset.min(max_scroll_offset)
515        };
516
517        // Content should always start at area.y - pinned_to_bottom only affects scroll_offset
518        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            // Skip if this message is above the viewport
526            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            // Render role badge
545            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            // Render message content with markdown and code highlighting
562            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                    // Code block with syntax highlighting
567                    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                            // Render highlighted lines
573                            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                // Regular text with markdown styling
593                let base_style = line_type.style();
594                let spans = parse_inline_markdown(&line, base_style);
595                let text_line = Line::from(spans);
596
597                // Render the line
598                if global_y >= skip_until && y_offset < area.y + area.height {
599                    // Clamp height to remaining viewport space
600                    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            // Add separator line
615            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        // No border here - let the parent draw_ui handle borders for consistent layout
629        (*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        // Add some messages
731        for i in 0..10 {
732            chat.add_message(Message::user(format!("Message {}", i)));
733        }
734
735        // After adding messages, we're pinned to bottom
736        assert!(chat.pinned_to_bottom);
737
738        // Scroll up should unpin and adjust offset
739        chat.scroll_up();
740        assert!(!chat.pinned_to_bottom);
741        // scroll_offset doesn't change when pinned, but will be used after unpin
742        // The actual visual scroll is calculated in render
743    }
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(); // Start at top with scroll_offset = 0
751
752        // Try to scroll up when at top - saturating_sub should keep it at 0
753        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        // After adding, pinned to bottom
768        assert!(chat.pinned_to_bottom);
769
770        chat.scroll_down();
771        // Scroll down unpins from bottom
772        assert!(!chat.pinned_to_bottom);
773        // scroll_offset increases by SCROLL_LINES (5)
774        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        // scroll_to_bottom sets pinned_to_bottom, not a specific offset
791        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            // After adding a message, should auto-scroll to bottom (pinned)
817        }
818
819        // Auto-scroll sets pinned_to_bottom, not a specific scroll_offset
820        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        // This should not panic
832        chat.render(area, &mut buffer);
833
834        // Check that something was rendered
835        let cell = buffer.cell((0, 0)).unwrap();
836        // Should have at least the border character
837        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        // This should not panic
852        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        // This should not panic
866        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        // Empty chat has 0 height
887        assert_eq!(chat.calculate_total_height(50), 0);
888
889        chat.add_message(Message::user("Hello".to_string()));
890        // 1 role line + 1 content line + 1 separator = 3
891        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        // Short message - single line
899        chat.add_message(Message::user("Hi".to_string()));
900        assert_eq!(chat.calculate_total_height(50), 3);
901
902        // Long message - multiple lines due to wrapping
903        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        // First message: 3 lines
907        // Second message: role line + wrapped content lines + separator
908        let height = chat.calculate_total_height(20);
909        assert!(height > 6); // More than 2 * 3 due to wrapping
910    }
911
912    #[test]
913    fn test_short_content_pinned_to_bottom_should_start_at_top() {
914        // Bug: When content is short and pinned to bottom, it incorrectly anchors to bottom
915        // causing content to scroll up visually when new content is added
916        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        // Render the chat
924        chat.render(area, &mut buffer);
925
926        // Check that content starts at the top of the area (y=0 relative to inner area)
927        // The first line should be the role badge, which should be at y=0 (after border)
928        let cell = buffer.cell((0, 0)).unwrap();
929        // Should not be empty - should have content
930        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        // Bug: When content grows during streaming, it can scroll up unexpectedly
939        let mut chat = ChatView::new();
940
941        // Start with short content
942        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        // Add more content (simulating streaming)
949        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        // The last line should be visible (near bottom of viewport)
955        // Check that content is still visible and not scrolled off-screen
956        // Should have some content (not empty)
957        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        // Bug: When total_height < viewport_height, bottom_padding pushes content down
971        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        // Content should fit without needing padding
979        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        // Content should start at y=0 (relative to inner area after border)
990        // Find the first non-empty, non-border cell
991        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        // Add enough messages to fill more than viewport
1017        for i in 0..10 {
1018            chat.add_message(Message::user(format!("Message {}", i)));
1019        }
1020
1021        // Should be pinned initially
1022        assert!(chat.pinned_to_bottom);
1023
1024        // Scroll up
1025        chat.scroll_up();
1026        assert!(!chat.pinned_to_bottom);
1027
1028        // Scroll back down
1029        chat.scroll_to_bottom();
1030        assert!(chat.pinned_to_bottom);
1031    }
1032
1033    #[test]
1034    fn test_message_growth_maintains_correct_position() {
1035        // Simulate scenario where a message grows (streaming response)
1036        let mut chat = ChatView::new();
1037
1038        // Add initial message
1039        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        // Grow the message
1046        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        // Should still be pinned
1052        assert!(
1053            chat.pinned_to_bottom,
1054            "Should remain pinned after content growth"
1055        );
1056    }
1057}