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