Skip to main content

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