Skip to main content

limit_tui/components/
chat.rs

1// Chat view component for displaying conversation messages
2
3use 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
17/// Maximum number of messages to render at once (sliding window)
18const RENDER_WINDOW_SIZE: usize = 50;
19
20/// Convert character offset to byte offset for UTF-8 safe slicing
21fn 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/// Line type for markdown rendering
29#[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
58/// Parse inline markdown elements and return styled spans
59fn 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        // Handle code inline: `code`
69        if c == '`' && !in_bold && !in_italic {
70            if in_code {
71                // End of code
72                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                // Start of code
78                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        // Handle bold: **text**
88        if c == '*' && chars.peek() == Some(&'*') && !in_code {
89            chars.next(); // consume second *
90            if in_bold {
91                // End of bold
92                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                // Start of bold
98                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        // Handle italic: *text* (single asterisk, not at start/end of word boundary with bold)
108        if c == '*' && !in_code && !in_bold {
109            if in_italic {
110                // End of italic
111                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                // Start of italic
117                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    // Handle remaining text
130    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
150/// Detect line type from content
151fn 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/// Role of a message sender
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum Role {
178    User,
179    Assistant,
180    System,
181}
182
183impl Role {
184    /// Get display name for the role
185    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    /// Get color for the role badge
194    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/// A single chat message
204#[derive(Debug, Clone)]
205pub struct Message {
206    pub role: Role,
207    pub content: String,
208    pub timestamp: String,
209}
210
211impl Message {
212    /// Create a new message
213    pub fn new(role: Role, content: String, timestamp: String) -> Self {
214        Self {
215            role,
216            content,
217            timestamp,
218        }
219    }
220
221    /// Create a user message with current timestamp
222    pub fn user(content: String) -> Self {
223        let timestamp = Self::current_timestamp();
224        Self::new(Role::User, content, timestamp)
225    }
226
227    /// Create an assistant message with current timestamp
228    pub fn assistant(content: String) -> Self {
229        let timestamp = Self::current_timestamp();
230        Self::new(Role::Assistant, content, timestamp)
231    }
232
233    /// Create a system message with current timestamp
234    pub fn system(content: String) -> Self {
235        let timestamp = Self::current_timestamp();
236        Self::new(Role::System, content, timestamp)
237    }
238
239    /// Get current timestamp in local timezone
240    fn current_timestamp() -> String {
241        chrono::Local::now().format("%H:%M").to_string()
242    }
243}
244
245/// Position metadata for mapping screen coordinates to text positions
246#[derive(Debug, Clone, Copy)]
247pub struct RenderPosition {
248    /// Index of the message in the messages vector
249    pub message_idx: usize,
250    /// Index of the line within the message content
251    pub line_idx: usize,
252    /// Character offset where this screen line starts
253    pub char_start: usize,
254    /// Character offset where this screen line ends
255    pub char_end: usize,
256    /// Absolute screen Y coordinate
257    pub screen_row: u16,
258}
259
260/// Chat view component for displaying conversation messages
261#[derive(Debug, Clone)]
262pub struct ChatView {
263    messages: Vec<Message>,
264    scroll_offset: usize,
265    pinned_to_bottom: bool,
266    /// Cached max scroll offset from last render (used when leaving pinned state)
267    last_max_scroll_offset: Cell<usize>,
268    /// Syntax highlighter for code blocks
269    highlighter: SyntaxHighlighter,
270    /// Cached height for render performance
271    cached_height: Cell<usize>,
272    /// Cache dirty flag - set to true when content changes
273    cache_dirty: Cell<bool>,
274    /// Number of hidden messages when using sliding window
275    hidden_message_count: Cell<usize>,
276    /// Text selection state: (message_idx, char_offset)
277    selection_start: Option<(usize, usize)>,
278    selection_end: Option<(usize, usize)>,
279    /// Render position metadata for mouse-to-text mapping
280    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    /// Add a message to the chat
308    pub fn add_message(&mut self, message: Message) {
309        self.messages.push(message);
310        // Auto-scroll to bottom on new message
311        self.scroll_to_bottom();
312    }
313
314    /// Append content to the last assistant message, or create a new one if none exists
315    pub fn append_to_last_assistant(&mut self, content: &str) {
316        if let Some(last) = self.messages.last_mut() {
317            if matches!(last.role, Role::Assistant) {
318                last.content.push_str(content);
319                self.scroll_to_bottom();
320                return;
321            }
322        }
323        // No assistant message to append to, create new
324        self.add_message(Message::assistant(content.to_string()));
325    }
326
327    /// Get the number of messages
328    pub fn message_count(&self) -> usize {
329        self.messages.len()
330    }
331
332    /// Get a reference to the messages
333    pub fn messages(&self) -> &[Message] {
334        &self.messages
335    }
336
337    /// Scroll up by multiple lines (better UX than single line)
338    pub fn scroll_up(&mut self) {
339        const SCROLL_LINES: usize = 5;
340        // When leaving pinned state, sync scroll_offset to actual position
341        if self.pinned_to_bottom {
342            self.scroll_offset = self.last_max_scroll_offset.get();
343        }
344        self.pinned_to_bottom = false;
345        self.scroll_offset = self.scroll_offset.saturating_sub(SCROLL_LINES);
346        // Invalidate cache since window size changed
347        self.cache_dirty.set(true);
348    }
349
350    /// Scroll down by multiple lines
351    pub fn scroll_down(&mut self) {
352        const SCROLL_LINES: usize = 5;
353        // When leaving pinned state, sync scroll_offset to actual position
354        if self.pinned_to_bottom {
355            self.scroll_offset = self.last_max_scroll_offset.get();
356        }
357        self.pinned_to_bottom = false;
358        self.scroll_offset = self.scroll_offset.saturating_add(SCROLL_LINES);
359    }
360
361    /// Scroll up by one page (viewport height)
362    pub fn scroll_page_up(&mut self, viewport_height: u16) {
363        // When leaving pinned state, sync scroll_offset to actual position
364        if self.pinned_to_bottom {
365            self.scroll_offset = self.last_max_scroll_offset.get();
366        }
367        self.pinned_to_bottom = false;
368        let page_size = viewport_height as usize;
369        self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
370    }
371
372    /// Scroll down by one page
373    pub fn scroll_page_down(&mut self, viewport_height: u16) {
374        // When leaving pinned state, sync scroll_offset to actual position
375        if self.pinned_to_bottom {
376            self.scroll_offset = self.last_max_scroll_offset.get();
377        }
378        self.pinned_to_bottom = false;
379        let page_size = viewport_height as usize;
380        self.scroll_offset = self.scroll_offset.saturating_add(page_size);
381    }
382
383    /// Scroll to the bottom (show newest messages)
384    pub fn scroll_to_bottom(&mut self) {
385        self.pinned_to_bottom = true;
386    }
387
388    /// Scroll to the top (show oldest messages)
389    pub fn scroll_to_top(&mut self) {
390        self.pinned_to_bottom = false;
391        self.scroll_offset = 0;
392    }
393
394    /// Start text selection at position
395    pub fn start_selection(&mut self, message_idx: usize, byte_offset: usize) {
396        self.selection_start = Some((message_idx, byte_offset));
397        self.selection_end = Some((message_idx, byte_offset));
398    }
399
400    /// Extend selection to position
401    pub fn extend_selection(&mut self, message_idx: usize, byte_offset: usize) {
402        if self.selection_start.is_some() {
403            self.selection_end = Some((message_idx, byte_offset));
404        }
405    }
406
407    /// Clear text selection
408    pub fn clear_selection(&mut self) {
409        self.selection_start = None;
410        self.selection_end = None;
411    }
412
413    /// Check if there is an active selection
414    pub fn has_selection(&self) -> bool {
415        self.selection_start.is_some() && self.selection_end.is_some()
416    }
417
418    /// Map screen coordinates to text position for mouse selection
419    /// Returns (message_idx, char_offset) if a valid position is found
420    pub fn screen_to_text_pos(&self, col: u16, row: u16) -> Option<(usize, usize)> {
421        for pos in self.render_positions.borrow().iter() {
422            if pos.screen_row == row {
423                // Calculate character offset within the line based on column
424                // (assumes monospace font - accurate for terminal)
425                let line_len = pos.char_end.saturating_sub(pos.char_start);
426                let char_in_line = (col as usize).min(line_len);
427                return Some((pos.message_idx, pos.char_start + char_in_line));
428            }
429        }
430        None
431    }
432
433    /// Check if a byte position is within the current selection
434    pub fn is_selected(&self, message_idx: usize, char_offset: usize) -> bool {
435        let Some((start_msg, start_offset)) = self.selection_start else {
436            return false;
437        };
438        let Some((end_msg, end_offset)) = self.selection_end else {
439            return false;
440        };
441
442        // Normalize order
443        let (min_msg, min_offset, max_msg, max_offset) =
444            if start_msg < end_msg || (start_msg == end_msg && start_offset <= end_offset) {
445                (start_msg, start_offset, end_msg, end_offset)
446            } else {
447                (end_msg, end_offset, start_msg, start_offset)
448            };
449
450        // Check if position is in selection range
451        if message_idx < min_msg || message_idx > max_msg {
452            return false;
453        }
454
455        if message_idx == min_msg && message_idx == max_msg {
456            // Same message: check offset range
457            char_offset >= min_offset && char_offset < max_offset
458        } else if message_idx == min_msg {
459            // First message: offset >= min_offset
460            char_offset >= min_offset
461        } else if message_idx == max_msg {
462            // Last message: offset < max_offset
463            char_offset < max_offset
464        } else {
465            // Middle message: fully selected
466            true
467        }
468    }
469
470    /// Apply selection highlighting to text spans
471    /// Takes a line of text and returns styled spans with selection highlighted
472    fn apply_selection_highlight<'a>(
473        &self,
474        text: &'a str,
475        message_idx: usize,
476        line_char_start: usize,
477        base_style: Style,
478    ) -> Vec<Span<'a>> {
479        let selection_style = Style::default().bg(Color::Blue).fg(Color::White);
480
481        // If no selection, just return styled text
482        if !self.has_selection() {
483            return vec![Span::styled(text, base_style)];
484        }
485
486        let mut spans = Vec::new();
487        let mut current_start = 0;
488        let mut in_selection = false;
489        let char_positions: Vec<(usize, char)> = text.char_indices().collect();
490
491        for (i, (byte_idx, _)) in char_positions.iter().enumerate() {
492            let global_char = line_char_start + i;
493            let is_sel = self.is_selected(message_idx, global_char);
494
495            if is_sel != in_selection {
496                // Transition point - push current segment
497                if i > current_start {
498                    let segment_byte_start = char_positions[current_start].0;
499                    let segment_byte_end = *byte_idx;
500                    let segment = &text[segment_byte_start..segment_byte_end];
501                    let style = if in_selection {
502                        selection_style
503                    } else {
504                        base_style
505                    };
506                    spans.push(Span::styled(segment, style));
507                }
508                current_start = i;
509                in_selection = is_sel;
510            }
511        }
512
513        // Push final segment
514        if current_start < char_positions.len() {
515            let segment_byte_start = char_positions[current_start].0;
516            let segment = &text[segment_byte_start..];
517            let style = if in_selection {
518                selection_style
519            } else {
520                base_style
521            };
522            spans.push(Span::styled(segment, style));
523        }
524
525        if spans.is_empty() {
526            vec![Span::styled(text, base_style)]
527        } else {
528            spans
529        }
530    }
531
532    /// Get selected text (character-precise)
533    pub fn get_selected_text(&self) -> Option<String> {
534        let (start_msg, start_offset) = self.selection_start?;
535        let (end_msg, end_offset) = self.selection_end?;
536
537        // Normalize order
538        let (min_msg, min_offset, max_msg, max_offset) =
539            if start_msg < end_msg || (start_msg == end_msg && start_offset <= end_offset) {
540                (start_msg, start_offset, end_msg, end_offset)
541            } else {
542                (end_msg, end_offset, start_msg, start_offset)
543            };
544
545        if min_msg == max_msg {
546            // Single message: extract substring using character indices
547            let msg = self.messages.get(min_msg)?;
548            let content = &msg.content;
549            let start_byte = char_offset_to_byte(content, min_offset);
550            let end_byte = char_offset_to_byte(content, max_offset);
551            if start_byte < content.len() && end_byte <= content.len() {
552                Some(content[start_byte..end_byte].to_string())
553            } else {
554                None
555            }
556        } else {
557            // Multiple messages: collect parts
558            let mut result = String::new();
559
560            // First message: from offset to end
561            if let Some(msg) = self.messages.get(min_msg) {
562                let start_byte = char_offset_to_byte(&msg.content, min_offset);
563                if start_byte < msg.content.len() {
564                    result.push_str(&msg.content[start_byte..]);
565                }
566            }
567
568            // Middle messages: full content
569            for idx in (min_msg + 1)..max_msg {
570                if let Some(msg) = self.messages.get(idx) {
571                    result.push('\n');
572                    result.push_str(&msg.content);
573                }
574            }
575
576            // Last message: from start to offset
577            if let Some(msg) = self.messages.get(max_msg) {
578                result.push('\n');
579                let end_byte = char_offset_to_byte(&msg.content, max_offset);
580                if end_byte > 0 && end_byte <= msg.content.len() {
581                    result.push_str(&msg.content[..end_byte]);
582                }
583            }
584
585            Some(result)
586        }
587    }
588
589    /// Clear all messages
590    pub fn clear(&mut self) {
591        self.messages.clear();
592        self.scroll_offset = 0;
593        self.pinned_to_bottom = true;
594        self.cache_dirty.set(true);
595        self.hidden_message_count.set(0);
596    }
597
598    /// Get the render window (sliding window for large sessions)
599    /// Returns a slice of messages to render and the count of hidden messages
600    fn get_render_window(&self) -> (&[Message], usize) {
601        let total_count = self.messages.len();
602
603        // Use sliding window only when pinned to bottom and there are more than RENDER_WINDOW_SIZE messages
604        if self.pinned_to_bottom && total_count > RENDER_WINDOW_SIZE {
605            let hidden_count = total_count.saturating_sub(RENDER_WINDOW_SIZE);
606            let window = &self.messages[hidden_count..];
607            self.hidden_message_count.set(hidden_count);
608            (window, hidden_count)
609        } else {
610            self.hidden_message_count.set(0);
611            (&self.messages, 0)
612        }
613    }
614
615    /// Estimate the number of lines needed to display text with wrapping
616    fn estimate_line_count(text: &str, width: usize) -> usize {
617        if width == 0 {
618            return 0;
619        }
620
621        let mut lines = 0;
622        let mut current_line_len = 0;
623
624        for line in text.lines() {
625            if line.is_empty() {
626                lines += 1;
627                current_line_len = 0;
628                continue;
629            }
630
631            // Split line into words and calculate wrapped lines
632            let words: Vec<&str> = line.split_whitespace().collect();
633            let mut word_index = 0;
634
635            while word_index < words.len() {
636                let word = words[word_index];
637                let word_len = word.len();
638
639                if current_line_len == 0 {
640                    // First word on line
641                    if word_len > width {
642                        // Very long word - split it
643                        let mut chars_left = word;
644                        while !chars_left.is_empty() {
645                            let take = chars_left.len().min(width);
646                            lines += 1;
647                            chars_left = &chars_left[take..];
648                        }
649                        current_line_len = 0;
650                    } else {
651                        current_line_len = word_len;
652                    }
653                } else if current_line_len + 1 + word_len <= width {
654                    // Word fits on current line
655                    current_line_len += 1 + word_len;
656                } else {
657                    // Need new line
658                    lines += 1;
659                    current_line_len = if word_len > width {
660                        // Very long word - split it
661                        let mut chars_left = word;
662                        while !chars_left.is_empty() {
663                            let take = chars_left.len().min(width);
664                            lines += 1;
665                            chars_left = &chars_left[take..];
666                        }
667                        0
668                    } else {
669                        word_len
670                    };
671                }
672
673                word_index += 1;
674            }
675
676            // Account for the line itself if we added any content
677            if current_line_len > 0 || words.is_empty() {
678                lines += 1;
679            }
680
681            current_line_len = 0;
682        }
683
684        lines.max(1)
685    }
686
687    /// Process code blocks with syntax highlighting
688    /// Returns a vector of (line, line_type, is_code_block, lang)
689    fn process_code_blocks(&self, content: &str) -> Vec<(String, LineType, bool, Option<String>)> {
690        let mut result = Vec::new();
691        let lines = content.lines().peekable();
692        let mut in_code_block = false;
693        let mut current_lang: Option<String> = None;
694
695        for line in lines {
696            if line.starts_with("```") {
697                if in_code_block {
698                    // End of code block
699                    in_code_block = false;
700                    current_lang = None;
701                } else {
702                    // Start of code block
703                    in_code_block = true;
704                    current_lang = line
705                        .strip_prefix("```")
706                        .map(|s| s.trim().to_string())
707                        .filter(|s| !s.is_empty());
708                }
709            } else if in_code_block {
710                result.push((
711                    line.to_string(),
712                    LineType::CodeBlock,
713                    true,
714                    current_lang.clone(),
715                ));
716            } else {
717                let (line_type, _) = detect_line_type(line);
718                result.push((line.to_string(), line_type, false, None));
719            }
720        }
721
722        result
723    }
724
725    /// Calculate total height needed to display all messages
726    fn calculate_total_height(&self, width: u16) -> usize {
727        // Check cache first - return cached value if not dirty
728        if !self.cache_dirty.get() {
729            return self.cached_height.get();
730        }
731
732        let mut total_height = 0;
733
734        // IMPORTANT: Calculate height for ALL messages, not just render window
735        // This is needed for correct scroll offset calculation
736        for message in &self.messages {
737            // Role badge line: "[USER] HH:MM"
738            total_height += 1;
739
740            // Message content lines (with wrapping)
741            let processed = self.process_code_blocks(&message.content);
742
743            for (line, _line_type, _is_code, _lang) in processed {
744                // Code blocks render line-by-line with height 1
745                // Regular text wraps to estimated height
746                let line_height = if _is_code {
747                    1 // Code blocks: one row per line, no wrapping
748                } else {
749                    Self::estimate_line_count(&line, width as usize)
750                };
751                total_height += line_height;
752            }
753
754            // Empty line between messages
755            total_height += 1;
756        }
757
758        // Cache result and mark as clean
759        self.cached_height.set(total_height);
760        self.cache_dirty.set(false);
761
762        total_height
763    }
764
765    /// Render visible messages based on scroll offset
766    /// Render visible messages based on scroll offset
767    fn render_to_buffer(&self, area: Rect, buf: &mut Buffer) {
768        // Clear render position metadata for this frame
769        self.render_positions.borrow_mut().clear();
770
771        let total_height = self.calculate_total_height(area.width);
772        let viewport_height = area.height as usize;
773
774        // Calculate scroll offset based on pinned state
775        let max_scroll_offset = if total_height > viewport_height {
776            total_height.saturating_sub(viewport_height)
777        } else {
778            0
779        };
780
781        // Cache the max offset for scroll functions to use
782        self.last_max_scroll_offset.set(max_scroll_offset);
783
784        let scroll_offset = if self.pinned_to_bottom {
785            // When pinned to bottom, always show the newest messages
786            max_scroll_offset
787        } else {
788            // User has scrolled - clamp to valid range
789            self.scroll_offset.min(max_scroll_offset)
790        };
791
792        // Content should always start at area.y - pinned_to_bottom only affects scroll_offset
793        let (initial_y_offset, skip_until, max_y) =
794            (area.y, scroll_offset, scroll_offset + viewport_height);
795
796        let mut y_offset = initial_y_offset;
797        let mut global_y: usize = 0;
798
799        // Use sliding window for large sessions when pinned to bottom
800        let (messages_to_render, hidden_count) = self.get_render_window();
801
802        // When using sliding window, we need to account for hidden messages in global_y
803        // This ensures scroll offset calculations work correctly
804        if hidden_count > 0 {
805            // Calculate approximate height of hidden messages
806            // This allows scroll to work correctly even with sliding window
807            for message in &self.messages[..hidden_count] {
808                let role_height = 1;
809                let processed = self.process_code_blocks(&message.content);
810                let content_height: usize = processed
811                    .iter()
812                    .map(|(line, _, _, _)| Self::estimate_line_count(line, area.width as usize))
813                    .sum();
814                let separator_height = 1;
815                global_y += role_height + content_height + separator_height;
816            }
817        }
818        for (local_msg_idx, message) in messages_to_render.iter().enumerate() {
819            let message_idx = hidden_count + local_msg_idx;
820
821            // Skip if this message is above the viewport
822            let role_height = 1;
823            let processed = self.process_code_blocks(&message.content);
824            let content_height: usize = processed
825                .iter()
826                .map(|(line, _, _, _)| Self::estimate_line_count(line, area.width as usize))
827                .sum();
828            let separator_height = 1;
829            let message_height = role_height + content_height + separator_height;
830
831            if global_y + message_height <= skip_until {
832                global_y += message_height;
833                continue;
834            }
835
836            if global_y >= max_y {
837                break;
838            }
839
840            // Render role badge
841            if global_y >= skip_until && y_offset < area.y + area.height {
842                let role_text = format!("[{}] {}", message.role.display_name(), message.timestamp);
843                let style = Style::default()
844                    .fg(message.role.badge_color())
845                    .add_modifier(Modifier::BOLD);
846
847                let line = Line::from(vec![Span::styled(role_text, style)]);
848
849                Paragraph::new(line)
850                    .wrap(Wrap { trim: false })
851                    .render(Rect::new(area.x, y_offset, area.width, 1), buf);
852
853                y_offset += 1;
854            }
855            global_y += 1;
856
857            // Render message content with markdown and code highlighting
858            // Track character offset within the message for selection mapping
859            let mut char_offset: usize = 0;
860            for (line_idx, (line, line_type, is_code_block, lang)) in processed.iter().enumerate() {
861                let line_height = Self::estimate_line_count(line, area.width as usize);
862                let line_char_count = line.chars().count();
863
864                // Track this line's render position for mouse selection
865                if global_y >= skip_until && y_offset < area.y + area.height {
866                    self.render_positions.borrow_mut().push(RenderPosition {
867                        message_idx,
868                        line_idx,
869                        char_start: char_offset,
870                        char_end: char_offset + line_char_count,
871                        screen_row: y_offset,
872                    });
873                }
874
875                if *is_code_block && global_y >= skip_until {
876                    // Code block with syntax highlighting
877                    if let Some(ref lang_str) = lang {
878                        if let Ok(highlighted_spans) = self
879                            .highlighter
880                            .highlight_to_spans(&format!("{}\n", line), lang_str)
881                        {
882                            // Render highlighted lines
883                            for highlighted_line in highlighted_spans {
884                                if y_offset < area.y + area.height && global_y < max_y {
885                                    let text = Text::from(Line::from(highlighted_line));
886                                    Paragraph::new(text)
887                                        .wrap(Wrap { trim: false })
888                                        .render(Rect::new(area.x, y_offset, area.width, 1), buf);
889                                    y_offset += 1;
890                                }
891                                global_y += 1;
892
893                                if global_y >= max_y {
894                                    break;
895                                }
896                            }
897                            continue;
898                        }
899                    }
900                }
901
902                // Regular text with markdown styling and selection highlighting
903                let base_style = line_type.style();
904                let spans = if self.has_selection() {
905                    // Apply selection highlighting on top of markdown styling
906                    self.apply_selection_highlight(line, message_idx, char_offset, base_style)
907                } else {
908                    parse_inline_markdown(line, base_style)
909                };
910                let text_line = Line::from(spans);
911
912                // Render the line
913                if global_y >= skip_until && y_offset < area.y + area.height {
914                    // Clamp height to remaining viewport space
915                    let render_height =
916                        line_height.min((area.y + area.height - y_offset) as usize) as u16;
917                    Paragraph::new(text_line)
918                        .wrap(Wrap { trim: false })
919                        .render(Rect::new(area.x, y_offset, area.width, render_height), buf);
920                    y_offset += line_height as u16;
921                }
922                global_y += line_height;
923
924                // Update character offset for next line
925                char_offset += line_char_count + 1; // +1 for newline
926
927                if global_y >= max_y {
928                    break;
929                }
930            }
931
932            // Add separator line
933            if global_y >= skip_until && global_y < max_y && y_offset < area.y + area.height {
934                Paragraph::new("─".repeat(area.width as usize).as_str())
935                    .style(Style::default().fg(Color::DarkGray))
936                    .render(Rect::new(area.x, y_offset, area.width, 1), buf);
937                y_offset += 1;
938            }
939            global_y += 1;
940        }
941    }
942}
943
944impl ratatui::widgets::Widget for &ChatView {
945    fn render(self, area: Rect, buf: &mut Buffer) {
946        // No border here - let the parent draw_ui handle borders for consistent layout
947        (*self).render_to_buffer(area, buf);
948    }
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954
955    #[test]
956    fn test_role_display_name() {
957        assert_eq!(Role::User.display_name(), "USER");
958        assert_eq!(Role::Assistant.display_name(), "ASSISTANT");
959        assert_eq!(Role::System.display_name(), "SYSTEM");
960    }
961
962    #[test]
963    fn test_role_badge_color() {
964        assert_eq!(Role::User.badge_color(), Color::Blue);
965        assert_eq!(Role::Assistant.badge_color(), Color::Green);
966        assert_eq!(Role::System.badge_color(), Color::Yellow);
967    }
968
969    #[test]
970    fn test_message_new() {
971        let message = Message::new(Role::User, "Hello, World!".to_string(), "12:34".to_string());
972
973        assert_eq!(message.role, Role::User);
974        assert_eq!(message.content, "Hello, World!");
975        assert_eq!(message.timestamp, "12:34");
976    }
977
978    #[test]
979    fn test_message_user() {
980        let message = Message::user("Test message".to_string());
981
982        assert_eq!(message.role, Role::User);
983        assert_eq!(message.content, "Test message");
984        assert!(!message.timestamp.is_empty());
985    }
986
987    #[test]
988    fn test_message_assistant() {
989        let message = Message::assistant("Response".to_string());
990
991        assert_eq!(message.role, Role::Assistant);
992        assert_eq!(message.content, "Response");
993        assert!(!message.timestamp.is_empty());
994    }
995
996    #[test]
997    fn test_message_system() {
998        let message = Message::system("System notification".to_string());
999
1000        assert_eq!(message.role, Role::System);
1001        assert_eq!(message.content, "System notification");
1002        assert!(!message.timestamp.is_empty());
1003    }
1004
1005    #[test]
1006    fn test_chat_view_new() {
1007        let chat = ChatView::new();
1008
1009        assert_eq!(chat.message_count(), 0);
1010        assert_eq!(chat.scroll_offset, 0);
1011        assert!(chat.messages().is_empty());
1012    }
1013
1014    #[test]
1015    fn test_chat_view_default() {
1016        let chat = ChatView::default();
1017
1018        assert_eq!(chat.message_count(), 0);
1019        assert_eq!(chat.scroll_offset, 0);
1020    }
1021
1022    #[test]
1023    fn test_chat_view_add_message() {
1024        let mut chat = ChatView::new();
1025
1026        chat.add_message(Message::user("Hello".to_string()));
1027        assert_eq!(chat.message_count(), 1);
1028
1029        chat.add_message(Message::assistant("Hi there!".to_string()));
1030        assert_eq!(chat.message_count(), 2);
1031    }
1032
1033    #[test]
1034    fn test_chat_view_add_multiple_messages() {
1035        let mut chat = ChatView::new();
1036
1037        for i in 0..5 {
1038            chat.add_message(Message::user(format!("Message {}", i)));
1039        }
1040
1041        assert_eq!(chat.message_count(), 5);
1042    }
1043
1044    #[test]
1045    fn test_chat_view_scroll_up() {
1046        let mut chat = ChatView::new();
1047
1048        // Add some messages
1049        for i in 0..10 {
1050            chat.add_message(Message::user(format!("Message {}", i)));
1051        }
1052
1053        // After adding messages, we're pinned to bottom
1054        assert!(chat.pinned_to_bottom);
1055
1056        // Scroll up should unpin and adjust offset
1057        chat.scroll_up();
1058        assert!(!chat.pinned_to_bottom);
1059        // scroll_offset doesn't change when pinned, but will be used after unpin
1060        // The actual visual scroll is calculated in render
1061    }
1062
1063    #[test]
1064    fn test_chat_view_scroll_up_bounds() {
1065        let mut chat = ChatView::new();
1066
1067        chat.add_message(Message::user("Test".to_string()));
1068        chat.scroll_to_top(); // Start at top with scroll_offset = 0
1069
1070        // Try to scroll up when at top - saturating_sub should keep it at 0
1071        chat.scroll_up();
1072        assert_eq!(chat.scroll_offset, 0);
1073        assert!(!chat.pinned_to_bottom);
1074
1075        chat.scroll_up();
1076        assert_eq!(chat.scroll_offset, 0);
1077    }
1078
1079    #[test]
1080    fn test_chat_view_scroll_down() {
1081        let mut chat = ChatView::new();
1082
1083        chat.add_message(Message::user("Test".to_string()));
1084
1085        // After adding, pinned to bottom
1086        assert!(chat.pinned_to_bottom);
1087
1088        chat.scroll_down();
1089        // Scroll down unpins from bottom
1090        assert!(!chat.pinned_to_bottom);
1091        // scroll_offset increases by SCROLL_LINES (5)
1092        assert_eq!(chat.scroll_offset, 5);
1093    }
1094
1095    #[test]
1096    fn test_chat_view_scroll_to_bottom() {
1097        let mut chat = ChatView::new();
1098
1099        for i in 0..5 {
1100            chat.add_message(Message::user(format!("Message {}", i)));
1101        }
1102
1103        chat.scroll_to_top();
1104        assert_eq!(chat.scroll_offset, 0);
1105        assert!(!chat.pinned_to_bottom);
1106
1107        chat.scroll_to_bottom();
1108        // scroll_to_bottom sets pinned_to_bottom, not a specific offset
1109        assert!(chat.pinned_to_bottom);
1110    }
1111
1112    #[test]
1113    fn test_chat_view_scroll_to_top() {
1114        let mut chat = ChatView::new();
1115
1116        for i in 0..5 {
1117            chat.add_message(Message::user(format!("Message {}", i)));
1118        }
1119
1120        chat.scroll_to_bottom();
1121        assert!(chat.pinned_to_bottom);
1122
1123        chat.scroll_to_top();
1124        assert_eq!(chat.scroll_offset, 0);
1125        assert!(!chat.pinned_to_bottom);
1126    }
1127
1128    #[test]
1129    fn test_chat_view_auto_scroll() {
1130        let mut chat = ChatView::new();
1131
1132        for i in 0..5 {
1133            chat.add_message(Message::user(format!("Message {}", i)));
1134            // After adding a message, should auto-scroll to bottom (pinned)
1135        }
1136
1137        // Auto-scroll sets pinned_to_bottom, not a specific scroll_offset
1138        assert!(chat.pinned_to_bottom);
1139    }
1140
1141    #[test]
1142    fn test_chat_view_render() {
1143        let mut chat = ChatView::new();
1144        chat.add_message(Message::user("Test message".to_string()));
1145
1146        let area = Rect::new(0, 0, 50, 20);
1147        let mut buffer = Buffer::empty(area);
1148
1149        // This should not panic
1150        chat.render(area, &mut buffer);
1151
1152        // Check that something was rendered
1153        let cell = buffer.cell((0, 0)).unwrap();
1154        // Should have at least the border character
1155        assert!(!cell.symbol().is_empty());
1156    }
1157
1158    #[test]
1159    fn test_chat_view_render_multiple_messages() {
1160        let mut chat = ChatView::new();
1161
1162        chat.add_message(Message::user("First message".to_string()));
1163        chat.add_message(Message::assistant("Second message".to_string()));
1164        chat.add_message(Message::system("System message".to_string()));
1165
1166        let area = Rect::new(0, 0, 50, 20);
1167        let mut buffer = Buffer::empty(area);
1168
1169        // This should not panic
1170        chat.render(area, &mut buffer);
1171    }
1172
1173    #[test]
1174    fn test_chat_view_render_with_long_message() {
1175        let mut chat = ChatView::new();
1176
1177        let long_message = "This is a very long message that should wrap across multiple lines in the buffer when rendered. ".repeat(5);
1178        chat.add_message(Message::user(long_message));
1179
1180        let area = Rect::new(0, 0, 30, 20);
1181        let mut buffer = Buffer::empty(area);
1182
1183        // This should not panic
1184        chat.render(area, &mut buffer);
1185    }
1186
1187    #[test]
1188    fn test_chat_view_messages_ref() {
1189        let mut chat = ChatView::new();
1190
1191        chat.add_message(Message::user("Message 1".to_string()));
1192        chat.add_message(Message::assistant("Message 2".to_string()));
1193
1194        let messages = chat.messages();
1195        assert_eq!(messages.len(), 2);
1196        assert_eq!(messages[0].content, "Message 1");
1197        assert_eq!(messages[1].content, "Message 2");
1198    }
1199
1200    #[test]
1201    fn test_calculate_total_height() {
1202        let mut chat = ChatView::new();
1203
1204        // Empty chat has 0 height
1205        assert_eq!(chat.calculate_total_height(50), 0);
1206
1207        chat.add_message(Message::user("Hello".to_string()));
1208        // 1 role line + 1 content line + 1 separator = 3
1209        assert_eq!(chat.calculate_total_height(50), 3);
1210    }
1211
1212    #[test]
1213    fn test_calculate_total_height_with_wrapping() {
1214        let mut chat = ChatView::new();
1215
1216        // Short message - single line
1217        chat.add_message(Message::user("Hi".to_string()));
1218        assert_eq!(chat.calculate_total_height(50), 3);
1219
1220        // Long message - multiple lines due to wrapping
1221        let long_msg = "This is a very long message that will definitely wrap onto multiple lines when displayed in a narrow container".to_string();
1222        chat.add_message(Message::assistant(long_msg));
1223
1224        // First message: 3 lines
1225        // Second message: role line + wrapped content lines + separator
1226        let height = chat.calculate_total_height(20);
1227        assert!(height > 6); // More than 2 * 3 due to wrapping
1228    }
1229
1230    #[test]
1231    fn test_short_content_pinned_to_bottom_should_start_at_top() {
1232        // Bug: When content is short and pinned to bottom, it incorrectly anchors to bottom
1233        // causing content to scroll up visually when new content is added
1234        let mut chat = ChatView::new();
1235
1236        chat.add_message(Message::user("Hello".to_string()));
1237
1238        let area = Rect::new(0, 0, 50, 20);
1239        let mut buffer = Buffer::empty(area);
1240
1241        // Render the chat
1242        chat.render(area, &mut buffer);
1243
1244        // Check that content starts at the top of the area (y=0 relative to inner area)
1245        // The first line should be the role badge, which should be at y=0 (after border)
1246        let cell = buffer.cell((0, 0)).unwrap();
1247        // Should not be empty - should have content
1248        assert!(
1249            !cell.symbol().is_empty(),
1250            "Content should start at top, not be pushed down"
1251        );
1252    }
1253
1254    #[test]
1255    fn test_streaming_content_stays_pinned() {
1256        // Bug: When content grows during streaming, it can scroll up unexpectedly
1257        let mut chat = ChatView::new();
1258
1259        // Start with short content
1260        chat.add_message(Message::assistant("Start".to_string()));
1261
1262        let area = Rect::new(0, 0, 50, 20);
1263        let mut buffer1 = Buffer::empty(area);
1264        chat.render(area, &mut buffer1);
1265
1266        // Add more content (simulating streaming)
1267        chat.append_to_last_assistant(" and continue with more text that is longer");
1268
1269        let mut buffer2 = Buffer::empty(area);
1270        chat.render(area, &mut buffer2);
1271
1272        // The last line should be visible (near bottom of viewport)
1273        // Check that content is still visible and not scrolled off-screen
1274        // Should have some content (not empty)
1275        let has_content_near_bottom = (0u16..20).any(|y| {
1276            let c = buffer2.cell((0, y)).unwrap();
1277            !c.symbol().is_empty() && c.symbol() != "│" && c.symbol() != " "
1278        });
1279
1280        assert!(
1281            has_content_near_bottom,
1282            "Content should remain visible near bottom when pinned"
1283        );
1284    }
1285
1286    #[test]
1287    fn test_content_shorter_than_viewport_no_excess_padding() {
1288        // Bug: When total_height < viewport_height, bottom_padding pushes content down
1289        let mut chat = ChatView::new();
1290
1291        chat.add_message(Message::user("Short message".to_string()));
1292
1293        let total_height = chat.calculate_total_height(50);
1294        let viewport_height: u16 = 20;
1295
1296        // Content should fit without needing padding
1297        assert!(
1298            total_height < viewport_height as usize,
1299            "Content should be shorter than viewport"
1300        );
1301
1302        let area = Rect::new(0, 0, 50, viewport_height);
1303        let mut buffer = Buffer::empty(area);
1304
1305        chat.render(area, &mut buffer);
1306
1307        // Content should start at y=0 (relative to inner area after border)
1308        // Find the first non-empty, non-border cell
1309        let mut first_content_y: Option<u16> = None;
1310        for y in 0..viewport_height {
1311            let cell = buffer.cell((0, y)).unwrap();
1312            let is_border = matches!(
1313                cell.symbol(),
1314                "─" | "│" | "┌" | "┐" | "└" | "┘" | "├" | "┤" | "┬" | "┴"
1315            );
1316            if !is_border && !cell.symbol().is_empty() {
1317                first_content_y = Some(y);
1318                break;
1319            }
1320        }
1321
1322        let first_content_y = first_content_y.expect("Should find content somewhere");
1323
1324        assert_eq!(
1325            first_content_y, 0,
1326            "Content should start at y=0, not be pushed down by padding"
1327        );
1328    }
1329
1330    #[test]
1331    fn test_pinned_state_after_scrolling() {
1332        let mut chat = ChatView::new();
1333
1334        // Add enough messages to fill more than viewport
1335        for i in 0..10 {
1336            chat.add_message(Message::user(format!("Message {}", i)));
1337        }
1338
1339        // Should be pinned initially
1340        assert!(chat.pinned_to_bottom);
1341
1342        // Scroll up
1343        chat.scroll_up();
1344        assert!(!chat.pinned_to_bottom);
1345
1346        // Scroll back down
1347        chat.scroll_to_bottom();
1348        assert!(chat.pinned_to_bottom);
1349    }
1350
1351    #[test]
1352    fn test_message_growth_maintains_correct_position() {
1353        // Simulate scenario where a message grows (streaming response)
1354        let mut chat = ChatView::new();
1355
1356        // Add initial message
1357        chat.add_message(Message::assistant("Initial".to_string()));
1358
1359        let area = Rect::new(0, 0, 60, 10);
1360        let mut buffer = Buffer::empty(area);
1361        chat.render(area, &mut buffer);
1362
1363        // Grow the message
1364        chat.append_to_last_assistant(" content that gets added");
1365
1366        let mut buffer2 = Buffer::empty(area);
1367        chat.render(area, &mut buffer2);
1368
1369        // Should still be pinned
1370        assert!(
1371            chat.pinned_to_bottom,
1372            "Should remain pinned after content growth"
1373        );
1374    }
1375}