agent_core/tui/widgets/
chat.rs

1// Chat view widget for displaying messages
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Local};
6use ratatui::{
7    Frame,
8    layout::{Alignment, Rect},
9    style::{Color, Style},
10    text::{Line, Span},
11    widgets::{Block, Borders, Padding, Paragraph},
12};
13
14use crate::tui::themes::theme as app_theme;
15use crate::tui::markdown::{render_markdown_with_prefix, wrap_with_prefix};
16
17// First line prefixes (symbol + space)
18const USER_PREFIX: &str = "> ";
19const SYSTEM_PREFIX: &str = "* ";
20const TIMESTAMP_PREFIX: &str = "  - ";
21// Continuation line prefix (spaces to align with text after symbol)
22const CONTINUATION: &str = "  ";
23// Spinner characters for pending status animation
24const SPINNER_CHARS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
25
26/// Role of a chat message
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum MessageRole {
29    User,
30    Assistant,
31    System,
32    Tool,
33}
34
35/// Status of a tool execution
36#[derive(Debug, Clone, PartialEq)]
37pub enum ToolStatus {
38    Executing,
39    WaitingForUser,
40    Completed,
41    Failed(String),
42}
43
44/// Data for tool execution messages
45#[derive(Debug, Clone)]
46pub struct ToolMessageData {
47    #[allow(dead_code)] // Used as HashMap key, kept here for debugging
48    pub tool_use_id: String,
49    pub display_name: String,
50    pub display_title: String,
51    pub status: ToolStatus,
52}
53
54struct Message {
55    role: MessageRole,
56    content: String,
57    timestamp: DateTime<Local>,
58    /// Cached rendered lines for this message
59    cached_lines: Option<Vec<Line<'static>>>,
60    /// Width at which the cache was generated (invalidate if width changes)
61    cached_width: usize,
62    /// Tool-specific data (only populated for Tool role)
63    tool_data: Option<ToolMessageData>,
64}
65
66impl Message {
67    fn new(role: MessageRole, content: String) -> Self {
68        Self {
69            role,
70            content,
71            timestamp: Local::now(),
72            cached_lines: None,
73            cached_width: 0,
74            tool_data: None,
75        }
76    }
77
78    fn new_tool(tool_data: ToolMessageData) -> Self {
79        Self {
80            role: MessageRole::Tool,
81            content: String::new(),
82            timestamp: Local::now(),
83            cached_lines: None,
84            cached_width: 0,
85            tool_data: Some(tool_data),
86        }
87    }
88
89    /// Get or render cached lines for this message
90    fn get_rendered_lines(&mut self, available_width: usize) -> &[Line<'static>] {
91        // Invalidate cache if width changed
92        if self.cached_width != available_width {
93            self.cached_lines = None;
94        }
95
96        // Render and cache if needed
97        if self.cached_lines.is_none() {
98            let lines = self.render_lines(available_width);
99            self.cached_lines = Some(lines);
100            self.cached_width = available_width;
101        }
102
103        self.cached_lines.as_ref().unwrap()
104    }
105
106    /// Render this message to lines (called only when cache is invalid)
107    fn render_lines(&self, available_width: usize) -> Vec<Line<'static>> {
108        let mut lines = Vec::new();
109        let t = app_theme();
110
111        match self.role {
112            MessageRole::User => {
113                let rendered = wrap_with_prefix(
114                    &self.content,
115                    USER_PREFIX,
116                    t.user_prefix,
117                    CONTINUATION,
118                    available_width,
119                    &t,
120                );
121                lines.extend(rendered);
122            }
123            MessageRole::System => {
124                let rendered = wrap_with_prefix(
125                    &self.content,
126                    SYSTEM_PREFIX,
127                    t.system_prefix,
128                    CONTINUATION,
129                    available_width,
130                    &t,
131                );
132                lines.extend(rendered);
133            }
134            MessageRole::Assistant => {
135                let rendered = render_markdown_with_prefix(&self.content, available_width, &t);
136                lines.extend(rendered);
137            }
138            MessageRole::Tool => {
139                if let Some(ref data) = self.tool_data {
140                    lines.extend(render_tool_message(data));
141                }
142            }
143        }
144
145        // Timestamp line (only for user and system messages)
146        // Note: Using %I (with leading zero) instead of %-I for cross-platform compatibility
147        if self.role != MessageRole::Assistant && self.role != MessageRole::Tool {
148            let time_str = self.timestamp.format("%I:%M:%S %p").to_string();
149            let timestamp_text = format!("{}{}", TIMESTAMP_PREFIX, time_str);
150            lines.push(Line::from(vec![Span::styled(
151                timestamp_text,
152                app_theme().timestamp,
153            )]));
154        }
155
156        // Blank line after each message
157        lines.push(Line::from(""));
158
159        lines
160    }
161}
162
163// Re-export RenderFn from chat_helpers for backwards compatibility
164pub use super::chat_helpers::RenderFn;
165
166pub struct ChatView {
167    messages: Vec<Message>,
168    scroll_offset: u16,
169    /// Buffer for streaming assistant response
170    streaming_buffer: Option<String>,
171    /// Cached max scroll value from last render
172    last_max_scroll: u16,
173    /// Whether auto-scroll is enabled (disabled when user manually scrolls)
174    auto_scroll_enabled: bool,
175    /// Index for O(1) tool message lookup by tool_use_id
176    tool_index: HashMap<String, usize>,
177    /// Spinner index for pending status animation
178    spinner_index: usize,
179    /// Title displayed in the title bar
180    title: String,
181    /// Optional custom empty state renderer (shown when no messages)
182    render_empty_state: Option<RenderFn>,
183}
184
185impl ChatView {
186    /// Create a new ChatView with default settings
187    pub fn new() -> Self {
188        Self {
189            messages: Vec::new(),
190            scroll_offset: 0,
191            streaming_buffer: None,
192            last_max_scroll: 0,
193            auto_scroll_enabled: true,
194            tool_index: HashMap::new(),
195            spinner_index: 0,
196            title: "Chat".to_string(),
197            render_empty_state: None,
198        }
199    }
200
201    /// Set the title displayed in the title bar
202    pub fn with_title(mut self, title: impl Into<String>) -> Self {
203        self.title = title.into();
204        self
205    }
206
207    /// Set custom empty state renderer (shown when no messages)
208    ///
209    /// Use helper functions from `chat_helpers` for common patterns:
210    /// - `welcome_art()` - ASCII art welcome screen
211    /// - `centered_text()` - Simple centered message
212    ///
213    /// Or provide a custom closure for full ratatui control.
214    pub fn with_empty_state(mut self, render: RenderFn) -> Self {
215        self.render_empty_state = Some(render);
216        self
217    }
218
219    /// Set the title displayed in the title bar (mutable setter)
220    pub fn set_title(&mut self, title: impl Into<String>) {
221        self.title = title.into();
222    }
223
224    /// Get the current title
225    pub fn title(&self) -> &str {
226        &self.title
227    }
228
229    /// Advance the spinner animation
230    pub fn step_spinner(&mut self) {
231        self.spinner_index = (self.spinner_index + 1) % SPINNER_CHARS.len();
232    }
233
234    /// Add a user message (does not force scroll - caller should handle that)
235    pub fn add_user_message(&mut self, content: String) {
236        if !content.trim().is_empty() {
237            self.messages.push(Message::new(MessageRole::User, content));
238            // Only scroll to bottom if auto-scroll is enabled
239            if self.auto_scroll_enabled {
240                self.scroll_offset = u16::MAX;
241            }
242        }
243    }
244
245    /// Add an assistant message (complete)
246    pub fn add_assistant_message(&mut self, content: String) {
247        if !content.trim().is_empty() {
248            self.messages
249                .push(Message::new(MessageRole::Assistant, content));
250            // Only scroll to bottom if auto-scroll is enabled
251            if self.auto_scroll_enabled {
252                self.scroll_offset = u16::MAX;
253            }
254        }
255    }
256
257    /// Add a system message (ignores empty messages)
258    pub fn add_system_message(&mut self, content: String) {
259        if content.trim().is_empty() {
260            return;
261        }
262        self.messages
263            .push(Message::new(MessageRole::System, content));
264        // Only scroll to bottom if auto-scroll is enabled
265        if self.auto_scroll_enabled {
266            self.scroll_offset = u16::MAX;
267        }
268    }
269
270    /// Add a tool execution message
271    pub fn add_tool_message(
272        &mut self,
273        tool_use_id: &str,
274        display_name: &str,
275        display_title: &str,
276    ) {
277        let index = self.messages.len();
278
279        let tool_data = ToolMessageData {
280            tool_use_id: tool_use_id.to_string(),
281            display_name: display_name.to_string(),
282            display_title: display_title.to_string(),
283            status: ToolStatus::Executing,
284        };
285
286        self.messages.push(Message::new_tool(tool_data));
287        self.tool_index.insert(tool_use_id.to_string(), index);
288
289        // Only scroll to bottom if auto-scroll is enabled
290        if self.auto_scroll_enabled {
291            self.scroll_offset = u16::MAX;
292        }
293    }
294
295    /// Update a tool message status by tool_use_id - O(1) lookup
296    pub fn update_tool_status(&mut self, tool_use_id: &str, status: ToolStatus) {
297        if let Some(&index) = self.tool_index.get(tool_use_id) {
298            if let Some(msg) = self.messages.get_mut(index) {
299                if let Some(ref mut data) = msg.tool_data {
300                    data.status = status;
301                    msg.cached_lines = None; // Invalidate cache
302                }
303            }
304        }
305    }
306
307    /// Re-enable auto-scroll and scroll to bottom (call when user submits a message)
308    pub fn enable_auto_scroll(&mut self) {
309        self.auto_scroll_enabled = true;
310        self.scroll_offset = u16::MAX;
311    }
312
313    /// Append text to the streaming buffer
314    pub fn append_streaming(&mut self, text: &str) {
315        match &mut self.streaming_buffer {
316            Some(buffer) => buffer.push_str(text),
317            None => self.streaming_buffer = Some(text.to_string()),
318        }
319        // Only auto-scroll if enabled (user hasn't manually scrolled)
320        if self.auto_scroll_enabled {
321            self.scroll_offset = u16::MAX;
322        }
323    }
324
325    /// Complete the streaming response and add as assistant message
326    pub fn complete_streaming(&mut self) {
327        if let Some(content) = self.streaming_buffer.take() {
328            if !content.trim().is_empty() {
329                self.messages
330                    .push(Message::new(MessageRole::Assistant, content));
331            }
332        }
333    }
334
335    /// Discard the streaming buffer without saving (used on cancel)
336    pub fn discard_streaming(&mut self) {
337        self.streaming_buffer = None;
338    }
339
340    /// Check if currently streaming
341    pub fn is_streaming(&self) -> bool {
342        self.streaming_buffer.is_some()
343    }
344
345    pub fn scroll_up(&mut self) {
346        // If at auto-scroll (MAX), convert to actual position first
347        if self.scroll_offset == u16::MAX {
348            self.scroll_offset = self.last_max_scroll;
349        }
350        self.scroll_offset = self.scroll_offset.saturating_sub(3);
351        // User manually scrolled - disable auto-scroll
352        self.auto_scroll_enabled = false;
353    }
354
355    pub fn scroll_down(&mut self) {
356        // If at auto-scroll (MAX), already at bottom
357        if self.scroll_offset == u16::MAX {
358            return;
359        }
360        self.scroll_offset = self.scroll_offset.saturating_add(3);
361        // If we've scrolled to bottom, switch back to auto-scroll
362        if self.scroll_offset >= self.last_max_scroll {
363            self.scroll_offset = u16::MAX;
364            self.auto_scroll_enabled = true; // Re-enable when reaching bottom
365        }
366    }
367
368    pub fn render_chat(&mut self, frame: &mut Frame, area: Rect, pending_status: Option<&str>) {
369        // Green checkmark style for agent status
370        let check_style = Style::default().fg(Color::Green);
371
372        // Helper to create title lines (called multiple times if needed)
373        let create_titles = || {
374            let left = Line::from(vec![
375                Span::styled("\u{2500} ", app_theme().title_separator),
376                Span::styled("\u{25CF} ", app_theme().title_indicator_connected),
377                Span::styled(self.title.clone(), app_theme().title_text),
378            ]);
379
380            let right = Line::from(vec![
381                Span::styled("[\u{2713}]", check_style),
382                Span::styled(" Manager Agent (1) ", app_theme().title_text),
383                Span::styled("[\u{2713}]", check_style),
384                Span::styled(" Coding Agents (4) ", app_theme().title_text),
385                Span::styled("[\u{2713}]", check_style),
386                Span::styled(" Code Reviewers (2) ", app_theme().title_text),
387                Span::styled("\u{2500}", app_theme().title_separator),
388            ]);
389
390            (left, right)
391        };
392
393        // Check if we're in empty state with a custom renderer
394        let is_empty_state = self.messages.is_empty() && self.streaming_buffer.is_none() && pending_status.is_none();
395
396        // If we have a custom empty state renderer, use it and return early
397        if is_empty_state {
398            if let Some(ref render_fn) = self.render_empty_state {
399                let (left_title, right_title) = create_titles();
400
401                let empty_block = Block::default()
402                    .title(left_title)
403                    .title_alignment(Alignment::Left)
404                    .title(right_title.alignment(Alignment::Right))
405                    .borders(Borders::TOP)
406                    .border_style(app_theme().border)
407                    .padding(Padding::new(1, 0, 1, 0));
408
409                let inner = empty_block.inner(area);
410                frame.render_widget(empty_block, area);
411
412                // Call the custom renderer
413                render_fn(frame, inner, &app_theme());
414                return;
415            }
416        }
417
418        // Normal rendering path
419        let (left_title, right_title) = create_titles();
420
421        let content_block = Block::default()
422            .title(left_title)
423            .title_alignment(Alignment::Left)
424            .title(right_title.alignment(Alignment::Right))
425            .borders(Borders::TOP)
426            .border_style(app_theme().border)
427            .padding(Padding::new(1, 0, 1, 0)); // left=1, top=1
428
429        // Calculate available width for manual wrapping
430        let available_width = area.width.saturating_sub(2) as usize; // -2 for left padding + margin
431
432        // Build message lines using cached rendering
433        let mut message_lines: Vec<Line> = Vec::new();
434
435        // Show default empty state if no custom renderer
436        if is_empty_state {
437            message_lines.push(Line::from(""));
438            message_lines.push(Line::from(Span::styled(
439                "    Type a message to start chatting...",
440                Style::default().fg(Color::DarkGray),
441            )));
442        }
443
444        for msg in &mut self.messages {
445            // Use cached lines (renders only if cache is invalid)
446            let cached = msg.get_rendered_lines(available_width);
447            message_lines.extend(cached.iter().cloned());
448        }
449
450        // Add streaming buffer if present
451        if let Some(ref buffer) = self.streaming_buffer {
452            let rendered = render_markdown_with_prefix(buffer, available_width, &app_theme());
453            message_lines.extend(rendered);
454            // Add cursor on last line
455            if let Some(last) = message_lines.last_mut() {
456                last.spans
457                    .push(Span::styled("\u{2588}", app_theme().cursor));
458            }
459        } else if let Some(status) = pending_status {
460            // Show pending status with spinner when not streaming
461            let spinner_char = SPINNER_CHARS[self.spinner_index];
462            message_lines.push(Line::from(vec![
463                Span::styled(format!("{} ", spinner_char), app_theme().throbber_spinner),
464                Span::styled(status, app_theme().throbber_label),
465            ]));
466        }
467
468        // Calculate scroll (no wrapping estimation needed - we do manual wrapping)
469        let available_height = area.height.saturating_sub(2) as usize; // -2 for border + padding
470        let total_lines = message_lines.len();
471        let max_scroll = total_lines.saturating_sub(available_height) as u16;
472        self.last_max_scroll = max_scroll;
473
474        // Scroll behavior:
475        // - scroll_offset = u16::MAX: auto-scroll to bottom (show latest)
476        // - other values: manual scroll position (clamped to valid range)
477        //
478        // Important: Update self.scroll_offset when clamping to prevent stale values
479        // after terminal resize. This ensures scroll_up/scroll_down work correctly
480        // immediately after resize.
481        let scroll_offset = if self.scroll_offset == u16::MAX {
482            max_scroll
483        } else {
484            let clamped = self.scroll_offset.min(max_scroll);
485            if clamped != self.scroll_offset {
486                self.scroll_offset = clamped;
487            }
488            clamped
489        };
490
491        let messages_widget = Paragraph::new(message_lines)
492            .block(content_block)
493            .style(app_theme().background.patch(app_theme().text))
494            .scroll((scroll_offset, 0));
495        frame.render_widget(messages_widget, area);
496    }
497}
498
499/// Render a tool execution message
500fn render_tool_message(data: &ToolMessageData) -> Vec<Line<'static>> {
501    let mut lines = Vec::new();
502
503    // Line 1: hammer and pick icon + DisplayName(DisplayTitle)
504    let header = if data.display_title.is_empty() {
505        format!("\u{2692} {}", data.display_name)
506    } else {
507        format!("\u{2692} {}({})", data.display_name, data.display_title)
508    };
509    lines.push(Line::from(Span::styled(header, app_theme().tool_header)));
510
511    // Line 2: Status with appropriate icon and color
512    let status_line = match &data.status {
513        ToolStatus::Executing => Line::from(Span::styled(
514            "   \u{2192} executing...".to_string(),
515            app_theme().tool_executing,
516        )),
517        ToolStatus::WaitingForUser => Line::from(Span::styled(
518            "   \u{2192} waiting for user...".to_string(),
519            app_theme().tool_executing,
520        )),
521        ToolStatus::Completed => Line::from(Span::styled(
522            "   \u{2713} Completed".to_string(),
523            app_theme().tool_completed,
524        )),
525        ToolStatus::Failed(err) => Line::from(Span::styled(
526            format!("   \u{26A0} {}", err),
527            app_theme().tool_failed,
528        )),
529    };
530    lines.push(status_line);
531
532    lines
533}
534
535impl Default for ChatView {
536    fn default() -> Self {
537        Self::new()
538    }
539}
540
541// --- Widget trait implementation ---
542
543use std::any::Any;
544use crossterm::event::KeyEvent;
545use crate::tui::themes::Theme;
546use super::{widget_ids, Widget, WidgetKeyResult};
547
548impl Widget for ChatView {
549    fn id(&self) -> &'static str {
550        widget_ids::CHAT_VIEW
551    }
552
553    fn priority(&self) -> u8 {
554        50 // Low priority - core widget, handles scroll events
555    }
556
557    fn is_active(&self) -> bool {
558        true // Always active
559    }
560
561    fn handle_key(&mut self, _key: KeyEvent, _theme: &Theme) -> WidgetKeyResult {
562        // ChatView doesn't handle keys directly via Widget trait
563        // Scrolling is handled by App
564        WidgetKeyResult::NotHandled
565    }
566
567    fn render(&self, frame: &mut Frame, area: Rect, _theme: &Theme) {
568        // Note: This is a simplified render without pending_status
569        // App should use render_chat method with pending_status directly
570        let mut chat_view = self.clone_for_render();
571        chat_view.render_chat(frame, area, None);
572    }
573
574    fn required_height(&self, _available: u16) -> u16 {
575        0 // ChatView takes remaining space via layout
576    }
577
578    fn blocks_input(&self) -> bool {
579        false
580    }
581
582    fn is_overlay(&self) -> bool {
583        false
584    }
585
586    fn as_any(&self) -> &dyn Any {
587        self
588    }
589
590    fn as_any_mut(&mut self) -> &mut dyn Any {
591        self
592    }
593
594    fn into_any(self: Box<Self>) -> Box<dyn Any> {
595        self
596    }
597}
598
599impl ChatView {
600    /// Create a shallow clone for rendering (avoids borrow issues in Widget::render)
601    fn clone_for_render(&self) -> Self {
602        Self {
603            messages: Vec::new(), // Empty - we won't modify during render
604            scroll_offset: self.scroll_offset,
605            streaming_buffer: self.streaming_buffer.clone(),
606            last_max_scroll: self.last_max_scroll,
607            auto_scroll_enabled: self.auto_scroll_enabled,
608            tool_index: HashMap::new(),
609            spinner_index: self.spinner_index,
610            title: self.title.clone(),
611            render_empty_state: None, // Not cloned - callbacks aren't Clone
612        }
613    }
614}