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