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