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