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/// Default configuration values for ChatView
18pub mod defaults {
19    /// Default prefix for user messages
20    pub const USER_PREFIX: &str = "> ";
21    /// Default prefix for system messages
22    pub const SYSTEM_PREFIX: &str = "* ";
23    /// Default prefix for timestamps
24    pub const TIMESTAMP_PREFIX: &str = "  - ";
25    /// Default continuation line prefix (spaces to align with text after symbol)
26    pub const CONTINUATION: &str = "  ";
27    /// Default spinner characters for pending status animation
28    pub const SPINNER_CHARS: &[char] = &['\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', '\u{2827}', '\u{2807}', '\u{280F}'];
29    /// Default title for the chat view
30    pub const DEFAULT_TITLE: &str = "Chat";
31    /// Default empty state message
32    pub const DEFAULT_EMPTY_MESSAGE: &str = "    Type a message to start chatting...";
33    /// Default tool header icon (hammer and pick)
34    pub const TOOL_ICON: &str = "\u{2692}";
35    /// Default tool executing arrow
36    pub const TOOL_EXECUTING_ARROW: &str = "\u{2192}";
37    /// Default tool completed checkmark
38    pub const TOOL_COMPLETED_CHECKMARK: &str = "\u{2713}";
39    /// Default tool failed warning icon
40    pub const TOOL_FAILED_ICON: &str = "\u{26A0}";
41}
42
43/// Configuration for ChatView widget
44///
45/// Use the builder pattern to customize the chat view appearance.
46///
47/// # Example
48/// ```rust,ignore
49/// let config = ChatViewConfig::new()
50///     .with_user_prefix("You: ")
51///     .with_spinner_chars(&['|', '/', '-', '\\']);
52/// let chat = ChatView::with_config(config);
53/// ```
54#[derive(Clone)]
55pub struct ChatViewConfig {
56    /// Prefix for user messages (e.g., "> ")
57    pub user_prefix: String,
58    /// Prefix for system messages (e.g., "* ")
59    pub system_prefix: String,
60    /// Prefix for timestamps (e.g., "  - ")
61    pub timestamp_prefix: String,
62    /// Continuation line prefix for wrapped text
63    pub continuation: String,
64    /// Spinner characters for pending status animation
65    pub spinner_chars: Vec<char>,
66    /// Default title for the chat view
67    pub default_title: String,
68    /// Message shown when chat is empty
69    pub empty_message: String,
70    /// Icon for tool headers
71    pub tool_icon: String,
72    /// Arrow for executing tools
73    pub tool_executing_arrow: String,
74    /// Checkmark for completed tools
75    pub tool_completed_checkmark: String,
76    /// Warning icon for failed tools
77    pub tool_failed_icon: String,
78}
79
80impl Default for ChatViewConfig {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl ChatViewConfig {
87    /// Create a new ChatViewConfig with default values
88    pub fn new() -> Self {
89        Self {
90            user_prefix: defaults::USER_PREFIX.to_string(),
91            system_prefix: defaults::SYSTEM_PREFIX.to_string(),
92            timestamp_prefix: defaults::TIMESTAMP_PREFIX.to_string(),
93            continuation: defaults::CONTINUATION.to_string(),
94            spinner_chars: defaults::SPINNER_CHARS.to_vec(),
95            default_title: defaults::DEFAULT_TITLE.to_string(),
96            empty_message: defaults::DEFAULT_EMPTY_MESSAGE.to_string(),
97            tool_icon: defaults::TOOL_ICON.to_string(),
98            tool_executing_arrow: defaults::TOOL_EXECUTING_ARROW.to_string(),
99            tool_completed_checkmark: defaults::TOOL_COMPLETED_CHECKMARK.to_string(),
100            tool_failed_icon: defaults::TOOL_FAILED_ICON.to_string(),
101        }
102    }
103
104    /// Set the user message prefix
105    pub fn with_user_prefix(mut self, prefix: impl Into<String>) -> Self {
106        self.user_prefix = prefix.into();
107        self
108    }
109
110    /// Set the system message prefix
111    pub fn with_system_prefix(mut self, prefix: impl Into<String>) -> Self {
112        self.system_prefix = prefix.into();
113        self
114    }
115
116    /// Set the timestamp prefix
117    pub fn with_timestamp_prefix(mut self, prefix: impl Into<String>) -> Self {
118        self.timestamp_prefix = prefix.into();
119        self
120    }
121
122    /// Set the continuation line prefix
123    pub fn with_continuation(mut self, continuation: impl Into<String>) -> Self {
124        self.continuation = continuation.into();
125        self
126    }
127
128    /// Set the spinner characters
129    pub fn with_spinner_chars(mut self, chars: &[char]) -> Self {
130        self.spinner_chars = chars.to_vec();
131        self
132    }
133
134    /// Set the default title
135    pub fn with_default_title(mut self, title: impl Into<String>) -> Self {
136        self.default_title = title.into();
137        self
138    }
139
140    /// Set the empty state message
141    pub fn with_empty_message(mut self, message: impl Into<String>) -> Self {
142        self.empty_message = message.into();
143        self
144    }
145
146    /// Set the tool header icon
147    pub fn with_tool_icon(mut self, icon: impl Into<String>) -> Self {
148        self.tool_icon = icon.into();
149        self
150    }
151
152    /// Set all tool status icons at once
153    pub fn with_tool_status_icons(
154        mut self,
155        executing_arrow: impl Into<String>,
156        completed_checkmark: impl Into<String>,
157        failed_icon: impl Into<String>,
158    ) -> Self {
159        self.tool_executing_arrow = executing_arrow.into();
160        self.tool_completed_checkmark = completed_checkmark.into();
161        self.tool_failed_icon = failed_icon.into();
162        self
163    }
164}
165
166/// Role of a chat message
167#[derive(Debug, Clone, Copy, PartialEq)]
168pub enum MessageRole {
169    User,
170    Assistant,
171    System,
172    Tool,
173}
174
175/// Status of a tool execution
176#[derive(Debug, Clone, PartialEq)]
177pub enum ToolStatus {
178    Executing,
179    WaitingForUser,
180    Completed,
181    Failed(String),
182}
183
184/// Data for tool execution messages
185/// Display data for tool execution messages.
186#[derive(Debug, Clone)]
187pub struct ToolMessageData {
188    /// Unique identifier for the tool use.
189    #[allow(dead_code)] // Used as HashMap key, kept here for debugging
190    pub tool_use_id: String,
191    /// Tool name for display.
192    pub display_name: String,
193    /// Tool execution title.
194    pub display_title: String,
195    /// Current execution status.
196    pub status: ToolStatus,
197}
198
199struct Message {
200    role: MessageRole,
201    content: String,
202    timestamp: DateTime<Local>,
203    /// Cached rendered lines for this message
204    cached_lines: Option<Vec<Line<'static>>>,
205    /// Width at which the cache was generated (invalidate if width changes)
206    cached_width: usize,
207    /// Tool-specific data (only populated for Tool role)
208    tool_data: Option<ToolMessageData>,
209}
210
211impl Message {
212    fn new(role: MessageRole, content: String) -> Self {
213        Self {
214            role,
215            content,
216            timestamp: Local::now(),
217            cached_lines: None,
218            cached_width: 0,
219            tool_data: None,
220        }
221    }
222
223    fn new_tool(tool_data: ToolMessageData) -> Self {
224        Self {
225            role: MessageRole::Tool,
226            content: String::new(),
227            timestamp: Local::now(),
228            cached_lines: None,
229            cached_width: 0,
230            tool_data: Some(tool_data),
231        }
232    }
233
234    /// Get or render cached lines for this message
235    fn get_rendered_lines(&mut self, available_width: usize, config: &ChatViewConfig) -> &[Line<'static>] {
236        // Invalidate cache if width changed
237        if self.cached_width != available_width {
238            self.cached_lines = None;
239        }
240
241        // Render and cache if needed
242        if self.cached_lines.is_none() {
243            let lines = self.render_lines(available_width, config);
244            self.cached_lines = Some(lines);
245            self.cached_width = available_width;
246        }
247
248        self.cached_lines.as_ref().unwrap()
249    }
250
251    /// Render this message to lines (called only when cache is invalid)
252    fn render_lines(&self, available_width: usize, config: &ChatViewConfig) -> Vec<Line<'static>> {
253        let mut lines = Vec::new();
254        let t = app_theme();
255
256        match self.role {
257            MessageRole::User => {
258                let rendered = wrap_with_prefix(
259                    &self.content,
260                    &config.user_prefix,
261                    t.user_prefix,
262                    &config.continuation,
263                    available_width,
264                    &t,
265                );
266                lines.extend(rendered);
267            }
268            MessageRole::System => {
269                let rendered = wrap_with_prefix(
270                    &self.content,
271                    &config.system_prefix,
272                    t.system_prefix,
273                    &config.continuation,
274                    available_width,
275                    &t,
276                );
277                lines.extend(rendered);
278            }
279            MessageRole::Assistant => {
280                let rendered = render_markdown_with_prefix(&self.content, available_width, &t);
281                lines.extend(rendered);
282            }
283            MessageRole::Tool => {
284                if let Some(ref data) = self.tool_data {
285                    lines.extend(render_tool_message(data, config));
286                }
287            }
288        }
289
290        // Timestamp line (only for user and system messages)
291        // Note: Using %I (with leading zero) instead of %-I for cross-platform compatibility
292        if self.role != MessageRole::Assistant && self.role != MessageRole::Tool {
293            let time_str = self.timestamp.format("%I:%M:%S %p").to_string();
294            let timestamp_text = format!("{}{}", config.timestamp_prefix, time_str);
295            lines.push(Line::from(vec![Span::styled(
296                timestamp_text,
297                app_theme().timestamp,
298            )]));
299        }
300
301        // Blank line after each message
302        lines.push(Line::from(""));
303
304        lines
305    }
306}
307
308// Re-export RenderFn from chat_helpers for backwards compatibility
309pub use super::chat_helpers::RenderFn;
310
311use crate::tui::themes::Theme;
312
313/// Function type for rendering custom title bars
314/// Returns (left_title, right_title) as ratatui Lines
315pub type TitleRenderFn = Box<dyn Fn(&str, &Theme) -> (Line<'static>, Line<'static>) + Send + Sync>;
316
317/// Chat message display widget with streaming and tool execution support.
318pub struct ChatView {
319    messages: Vec<Message>,
320    scroll_offset: u16,
321    /// Buffer for streaming assistant response
322    streaming_buffer: Option<String>,
323    /// Cached max scroll value from last render
324    last_max_scroll: u16,
325    /// Whether auto-scroll is enabled (disabled when user manually scrolls)
326    auto_scroll_enabled: bool,
327    /// Index for O(1) tool message lookup by tool_use_id
328    tool_index: HashMap<String, usize>,
329    /// Spinner index for pending status animation
330    spinner_index: usize,
331    /// Title displayed in the title bar
332    title: String,
333    /// Optional custom initial content renderer (shown when no messages)
334    render_initial_content: Option<RenderFn>,
335    /// Optional custom title renderer
336    render_title: Option<TitleRenderFn>,
337    /// Configuration for display customization
338    config: ChatViewConfig,
339}
340
341impl ChatView {
342    /// Create a new ChatView with default settings
343    pub fn new() -> Self {
344        Self::with_config(ChatViewConfig::new())
345    }
346
347    /// Create a new ChatView with custom configuration
348    pub fn with_config(config: ChatViewConfig) -> Self {
349        let title = config.default_title.clone();
350        Self {
351            messages: Vec::new(),
352            scroll_offset: 0,
353            streaming_buffer: None,
354            last_max_scroll: 0,
355            auto_scroll_enabled: true,
356            tool_index: HashMap::new(),
357            spinner_index: 0,
358            title,
359            render_initial_content: None,
360            render_title: None,
361            config,
362        }
363    }
364
365    /// Get the current configuration
366    pub fn config(&self) -> &ChatViewConfig {
367        &self.config
368    }
369
370    /// Set a new configuration
371    pub fn set_config(&mut self, config: ChatViewConfig) {
372        self.config = config;
373        // Invalidate all message caches since prefixes may have changed
374        for msg in &mut self.messages {
375            msg.cached_lines = None;
376        }
377    }
378
379    /// Set the title displayed in the title bar
380    pub fn with_title(mut self, title: impl Into<String>) -> Self {
381        self.title = title.into();
382        self
383    }
384
385    /// Set custom initial content renderer (shown when no messages)
386    ///
387    /// Provide a closure for full ratatui control over what to display
388    /// when the chat has no messages yet.
389    pub fn with_initial_content(mut self, render: RenderFn) -> Self {
390        self.render_initial_content = Some(render);
391        self
392    }
393
394    /// Set custom title bar renderer
395    ///
396    /// The function receives the current title and theme, and returns
397    /// (left_title, right_title) as ratatui Lines.
398    pub fn with_title_renderer<F>(mut self, render: F) -> Self
399    where
400        F: Fn(&str, &Theme) -> (Line<'static>, Line<'static>) + Send + Sync + 'static,
401    {
402        self.render_title = Some(Box::new(render));
403        self
404    }
405
406    /// Set the title displayed in the title bar (mutable setter)
407    pub fn set_title(&mut self, title: impl Into<String>) {
408        self.title = title.into();
409    }
410
411    /// Get the current title
412    pub fn title(&self) -> &str {
413        &self.title
414    }
415
416    /// Advance the spinner animation
417    pub fn step_spinner(&mut self) {
418        let len = self.config.spinner_chars.len().max(1);
419        self.spinner_index = (self.spinner_index + 1) % len;
420    }
421
422    /// Add a user message (does not force scroll - caller should handle that)
423    pub fn add_user_message(&mut self, content: String) {
424        if !content.trim().is_empty() {
425            self.messages.push(Message::new(MessageRole::User, content));
426            // Only scroll to bottom if auto-scroll is enabled
427            if self.auto_scroll_enabled {
428                self.scroll_offset = u16::MAX;
429            }
430        }
431    }
432
433    /// Add an assistant message (complete)
434    pub fn add_assistant_message(&mut self, content: String) {
435        if !content.trim().is_empty() {
436            self.messages
437                .push(Message::new(MessageRole::Assistant, content));
438            // Only scroll to bottom if auto-scroll is enabled
439            if self.auto_scroll_enabled {
440                self.scroll_offset = u16::MAX;
441            }
442        }
443    }
444
445    /// Add a system message (ignores empty messages)
446    pub fn add_system_message(&mut self, content: String) {
447        if content.trim().is_empty() {
448            return;
449        }
450        self.messages
451            .push(Message::new(MessageRole::System, content));
452        // Only scroll to bottom if auto-scroll is enabled
453        if self.auto_scroll_enabled {
454            self.scroll_offset = u16::MAX;
455        }
456    }
457
458    /// Add a tool execution message
459    pub fn add_tool_message(
460        &mut self,
461        tool_use_id: &str,
462        display_name: &str,
463        display_title: &str,
464    ) {
465        let index = self.messages.len();
466
467        let tool_data = ToolMessageData {
468            tool_use_id: tool_use_id.to_string(),
469            display_name: display_name.to_string(),
470            display_title: display_title.to_string(),
471            status: ToolStatus::Executing,
472        };
473
474        self.messages.push(Message::new_tool(tool_data));
475        self.tool_index.insert(tool_use_id.to_string(), index);
476
477        // Only scroll to bottom if auto-scroll is enabled
478        if self.auto_scroll_enabled {
479            self.scroll_offset = u16::MAX;
480        }
481    }
482
483    /// Update a tool message status by tool_use_id - O(1) lookup
484    pub fn update_tool_status(&mut self, tool_use_id: &str, status: ToolStatus) {
485        if let Some(&index) = self.tool_index.get(tool_use_id) {
486            if let Some(msg) = self.messages.get_mut(index) {
487                if let Some(ref mut data) = msg.tool_data {
488                    data.status = status;
489                    msg.cached_lines = None; // Invalidate cache
490                }
491            }
492        }
493    }
494
495    /// Re-enable auto-scroll and scroll to bottom (call when user submits a message)
496    pub fn enable_auto_scroll(&mut self) {
497        self.auto_scroll_enabled = true;
498        self.scroll_offset = u16::MAX;
499    }
500
501    /// Append text to the streaming buffer
502    pub fn append_streaming(&mut self, text: &str) {
503        match &mut self.streaming_buffer {
504            Some(buffer) => buffer.push_str(text),
505            None => self.streaming_buffer = Some(text.to_string()),
506        }
507        // Only auto-scroll if enabled (user hasn't manually scrolled)
508        if self.auto_scroll_enabled {
509            self.scroll_offset = u16::MAX;
510        }
511    }
512
513    /// Complete the streaming response and add as assistant message
514    pub fn complete_streaming(&mut self) {
515        if let Some(content) = self.streaming_buffer.take() {
516            if !content.trim().is_empty() {
517                self.messages
518                    .push(Message::new(MessageRole::Assistant, content));
519            }
520        }
521    }
522
523    /// Discard the streaming buffer without saving (used on cancel)
524    pub fn discard_streaming(&mut self) {
525        self.streaming_buffer = None;
526    }
527
528    /// Check if currently streaming
529    pub fn is_streaming(&self) -> bool {
530        self.streaming_buffer.is_some()
531    }
532
533    pub fn scroll_up(&mut self) {
534        // If at auto-scroll (MAX), convert to actual position first
535        if self.scroll_offset == u16::MAX {
536            self.scroll_offset = self.last_max_scroll;
537        }
538        self.scroll_offset = self.scroll_offset.saturating_sub(3);
539        // User manually scrolled - disable auto-scroll
540        self.auto_scroll_enabled = false;
541    }
542
543    pub fn scroll_down(&mut self) {
544        // If at auto-scroll (MAX), already at bottom
545        if self.scroll_offset == u16::MAX {
546            return;
547        }
548        self.scroll_offset = self.scroll_offset.saturating_add(3);
549        // If we've scrolled to bottom, switch back to auto-scroll
550        if self.scroll_offset >= self.last_max_scroll {
551            self.scroll_offset = u16::MAX;
552            self.auto_scroll_enabled = true; // Re-enable when reaching bottom
553        }
554    }
555
556    pub fn render_chat(&mut self, frame: &mut Frame, area: Rect, pending_status: Option<&str>) {
557        let theme = app_theme();
558
559        // Create block - with custom title if renderer provided, plain border otherwise
560        let content_block = if let Some(ref render_fn) = self.render_title {
561            let (left_title, right_title) = render_fn(&self.title, &theme);
562            Block::default()
563                .title(left_title)
564                .title_alignment(Alignment::Left)
565                .title(right_title.alignment(Alignment::Right))
566                .borders(Borders::TOP)
567                .border_style(theme.border)
568                .padding(Padding::new(1, 0, 1, 0))
569        } else {
570            Block::default()
571                .borders(Borders::TOP)
572                .border_style(theme.border)
573                .padding(Padding::new(1, 0, 1, 0))
574        };
575
576        // Check if we're in initial state (no messages yet)
577        let is_initial_state = self.messages.is_empty() && self.streaming_buffer.is_none() && pending_status.is_none();
578
579        // If we have custom initial content renderer, use it and return early
580        if is_initial_state {
581            if let Some(ref render_fn) = self.render_initial_content {
582                let inner = content_block.inner(area);
583                frame.render_widget(content_block, area);
584                render_fn(frame, inner, &theme);
585                return;
586            }
587        }
588
589        // Calculate available width for manual wrapping
590        let available_width = area.width.saturating_sub(2) as usize; // -2 for left padding + margin
591
592        // Build message lines using cached rendering
593        let mut message_lines: Vec<Line> = Vec::new();
594
595        // Show default initial message if no custom renderer
596        if is_initial_state {
597            message_lines.push(Line::from(""));
598            message_lines.push(Line::from(Span::styled(
599                self.config.empty_message.clone(),
600                Style::default().fg(Color::DarkGray),
601            )));
602        }
603
604        for msg in &mut self.messages {
605            // Use cached lines (renders only if cache is invalid)
606            let cached = msg.get_rendered_lines(available_width, &self.config);
607            message_lines.extend(cached.iter().cloned());
608        }
609
610        // Add streaming buffer if present
611        if let Some(ref buffer) = self.streaming_buffer {
612            let rendered = render_markdown_with_prefix(buffer, available_width, &theme);
613            message_lines.extend(rendered);
614            // Add cursor on last line
615            if let Some(last) = message_lines.last_mut() {
616                last.spans
617                    .push(Span::styled("\u{2588}", theme.cursor));
618            }
619        } else if let Some(status) = pending_status {
620            // Show pending status with spinner when not streaming
621            let spinner_char = self.config.spinner_chars.get(self.spinner_index).copied().unwrap_or(' ');
622            message_lines.push(Line::from(vec![
623                Span::styled(format!("{} ", spinner_char), theme.throbber_spinner),
624                Span::styled(status, theme.throbber_label),
625            ]));
626        }
627
628        // Calculate scroll (no wrapping estimation needed - we do manual wrapping)
629        let available_height = area.height.saturating_sub(2) as usize; // -2 for border + padding
630        let total_lines = message_lines.len();
631        let max_scroll = total_lines.saturating_sub(available_height) as u16;
632        self.last_max_scroll = max_scroll;
633
634        // Scroll behavior:
635        // - scroll_offset = u16::MAX: auto-scroll to bottom (show latest)
636        // - other values: manual scroll position (clamped to valid range)
637        //
638        // Important: Update self.scroll_offset when clamping to prevent stale values
639        // after terminal resize. This ensures scroll_up/scroll_down work correctly
640        // immediately after resize.
641        let scroll_offset = if self.scroll_offset == u16::MAX {
642            max_scroll
643        } else {
644            let clamped = self.scroll_offset.min(max_scroll);
645            if clamped != self.scroll_offset {
646                self.scroll_offset = clamped;
647            }
648            clamped
649        };
650
651        let messages_widget = Paragraph::new(message_lines)
652            .block(content_block)
653            .style(theme.background.patch(theme.text))
654            .scroll((scroll_offset, 0));
655        frame.render_widget(messages_widget, area);
656    }
657}
658
659/// Render a tool execution message
660fn render_tool_message(data: &ToolMessageData, config: &ChatViewConfig) -> Vec<Line<'static>> {
661    let mut lines = Vec::new();
662
663    // Line 1: tool icon + DisplayName(DisplayTitle)
664    let header = if data.display_title.is_empty() {
665        format!("{} {}", config.tool_icon, data.display_name)
666    } else {
667        format!("{} {}({})", config.tool_icon, data.display_name, data.display_title)
668    };
669    lines.push(Line::from(Span::styled(header, app_theme().tool_header)));
670
671    // Line 2: Status with appropriate icon and color
672    let status_line = match &data.status {
673        ToolStatus::Executing => Line::from(Span::styled(
674            format!("   {} executing...", config.tool_executing_arrow),
675            app_theme().tool_executing,
676        )),
677        ToolStatus::WaitingForUser => Line::from(Span::styled(
678            format!("   {} waiting for user...", config.tool_executing_arrow),
679            app_theme().tool_executing,
680        )),
681        ToolStatus::Completed => Line::from(Span::styled(
682            format!("   {} Completed", config.tool_completed_checkmark),
683            app_theme().tool_completed,
684        )),
685        ToolStatus::Failed(err) => Line::from(Span::styled(
686            format!("   {} {}", config.tool_failed_icon, err),
687            app_theme().tool_failed,
688        )),
689    };
690    lines.push(status_line);
691
692    lines
693}
694
695impl Default for ChatView {
696    fn default() -> Self {
697        Self::new()
698    }
699}
700
701// --- ConversationView trait implementation ---
702
703use super::ConversationView;
704
705/// State snapshot for ChatView (used for session save/restore)
706#[derive(Clone)]
707struct ChatViewState {
708    messages: Vec<MessageSnapshot>,
709    scroll_offset: u16,
710    streaming_buffer: Option<String>,
711    last_max_scroll: u16,
712    auto_scroll_enabled: bool,
713    tool_index: HashMap<String, usize>,
714    spinner_index: usize,
715}
716
717/// Snapshot of a single message (Clone-friendly version)
718#[derive(Clone)]
719struct MessageSnapshot {
720    role: MessageRole,
721    content: String,
722    timestamp: DateTime<Local>,
723    tool_data: Option<ToolMessageData>,
724}
725
726impl From<&Message> for MessageSnapshot {
727    fn from(msg: &Message) -> Self {
728        Self {
729            role: msg.role,
730            content: msg.content.clone(),
731            timestamp: msg.timestamp,
732            tool_data: msg.tool_data.clone(),
733        }
734    }
735}
736
737impl From<MessageSnapshot> for Message {
738    fn from(snapshot: MessageSnapshot) -> Self {
739        Self {
740            role: snapshot.role,
741            content: snapshot.content,
742            timestamp: snapshot.timestamp,
743            cached_lines: None,
744            cached_width: 0,
745            tool_data: snapshot.tool_data,
746        }
747    }
748}
749
750impl ConversationView for ChatView {
751    fn add_user_message(&mut self, content: String) {
752        ChatView::add_user_message(self, content);
753    }
754
755    fn add_assistant_message(&mut self, content: String) {
756        ChatView::add_assistant_message(self, content);
757    }
758
759    fn add_system_message(&mut self, content: String) {
760        ChatView::add_system_message(self, content);
761    }
762
763    fn append_streaming(&mut self, text: &str) {
764        ChatView::append_streaming(self, text);
765    }
766
767    fn complete_streaming(&mut self) {
768        ChatView::complete_streaming(self);
769    }
770
771    fn discard_streaming(&mut self) {
772        ChatView::discard_streaming(self);
773    }
774
775    fn is_streaming(&self) -> bool {
776        ChatView::is_streaming(self)
777    }
778
779    fn add_tool_message(&mut self, tool_use_id: &str, display_name: &str, display_title: &str) {
780        ChatView::add_tool_message(self, tool_use_id, display_name, display_title);
781    }
782
783    fn update_tool_status(&mut self, tool_use_id: &str, status: ToolStatus) {
784        ChatView::update_tool_status(self, tool_use_id, status);
785    }
786
787    fn scroll_up(&mut self) {
788        ChatView::scroll_up(self);
789    }
790
791    fn scroll_down(&mut self) {
792        ChatView::scroll_down(self);
793    }
794
795    fn enable_auto_scroll(&mut self) {
796        ChatView::enable_auto_scroll(self);
797    }
798
799    fn render(&mut self, frame: &mut Frame, area: Rect, _theme: &Theme, pending_status: Option<&str>) {
800        self.render_chat(frame, area, pending_status);
801    }
802
803    fn step_spinner(&mut self) {
804        ChatView::step_spinner(self);
805    }
806
807    fn save_state(&self) -> Box<dyn Any + Send> {
808        let state = ChatViewState {
809            messages: self.messages.iter().map(MessageSnapshot::from).collect(),
810            scroll_offset: self.scroll_offset,
811            streaming_buffer: self.streaming_buffer.clone(),
812            last_max_scroll: self.last_max_scroll,
813            auto_scroll_enabled: self.auto_scroll_enabled,
814            tool_index: self.tool_index.clone(),
815            spinner_index: self.spinner_index,
816        };
817        Box::new(state)
818    }
819
820    fn restore_state(&mut self, state: Box<dyn Any + Send>) {
821        if let Ok(chat_state) = state.downcast::<ChatViewState>() {
822            self.messages = chat_state.messages.into_iter().map(Message::from).collect();
823            self.scroll_offset = chat_state.scroll_offset;
824            self.streaming_buffer = chat_state.streaming_buffer;
825            self.last_max_scroll = chat_state.last_max_scroll;
826            self.auto_scroll_enabled = chat_state.auto_scroll_enabled;
827            self.tool_index = chat_state.tool_index;
828            self.spinner_index = chat_state.spinner_index;
829        }
830    }
831
832    fn clear(&mut self) {
833        self.messages.clear();
834        self.streaming_buffer = None;
835        self.tool_index.clear();
836        self.scroll_offset = 0;
837        self.last_max_scroll = 0;
838        self.auto_scroll_enabled = true;
839        self.spinner_index = 0;
840        // Preserve: title, render_title, render_initial_content, config
841    }
842}
843
844// --- Widget trait implementation ---
845
846use std::any::Any;
847use crossterm::event::KeyEvent;
848use super::{widget_ids, Widget, WidgetKeyContext, WidgetKeyResult};
849
850impl Widget for ChatView {
851    fn id(&self) -> &'static str {
852        widget_ids::CHAT_VIEW
853    }
854
855    fn priority(&self) -> u8 {
856        50 // Low priority - core widget, handles scroll events
857    }
858
859    fn is_active(&self) -> bool {
860        true // Always active
861    }
862
863    fn handle_key(&mut self, _key: KeyEvent, _ctx: &WidgetKeyContext) -> WidgetKeyResult {
864        // ChatView doesn't handle keys directly via Widget trait
865        // Scrolling is handled by App
866        WidgetKeyResult::NotHandled
867    }
868
869    fn render(&mut self, frame: &mut Frame, area: Rect, _theme: &Theme) {
870        // Note: This is a simplified render without pending_status
871        // App may use render_chat method with pending_status directly for richer status
872        self.render_chat(frame, area, None);
873    }
874
875    fn required_height(&self, _available: u16) -> u16 {
876        0 // ChatView takes remaining space via layout
877    }
878
879    fn blocks_input(&self) -> bool {
880        false
881    }
882
883    fn is_overlay(&self) -> bool {
884        false
885    }
886
887    fn as_any(&self) -> &dyn Any {
888        self
889    }
890
891    fn as_any_mut(&mut self) -> &mut dyn Any {
892        self
893    }
894
895    fn into_any(self: Box<Self>) -> Box<dyn Any> {
896        self
897    }
898}
899