Skip to main content

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::themes::theme as app_theme;
15use crate::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 - stored for debugging even though
189    /// primary lookup is done via HashMap key in ChatView.
190    #[allow(dead_code)]
191    pub tool_use_id: String,
192    /// Tool name for display.
193    pub display_name: String,
194    /// Tool execution title.
195    pub display_title: String,
196    /// Current execution status.
197    pub status: ToolStatus,
198}
199
200struct Message {
201    role: MessageRole,
202    content: String,
203    timestamp: DateTime<Local>,
204    /// Cached rendered lines for this message
205    cached_lines: Option<Vec<Line<'static>>>,
206    /// Width at which the cache was generated (invalidate if width changes)
207    cached_width: usize,
208    /// Tool-specific data (only populated for Tool role)
209    tool_data: Option<ToolMessageData>,
210}
211
212impl Message {
213    fn new(role: MessageRole, content: String) -> Self {
214        Self {
215            role,
216            content,
217            timestamp: Local::now(),
218            cached_lines: None,
219            cached_width: 0,
220            tool_data: None,
221        }
222    }
223
224    fn new_tool(tool_data: ToolMessageData) -> Self {
225        Self {
226            role: MessageRole::Tool,
227            content: String::new(),
228            timestamp: Local::now(),
229            cached_lines: None,
230            cached_width: 0,
231            tool_data: Some(tool_data),
232        }
233    }
234
235    /// Get or render cached lines for this message
236    fn get_rendered_lines(&mut self, available_width: usize, config: &ChatViewConfig) -> &[Line<'static>] {
237        // Invalidate cache if width changed
238        if self.cached_width != available_width {
239            self.cached_lines = None;
240        }
241
242        // Render and cache if needed
243        if self.cached_lines.is_none() {
244            let lines = self.render_lines(available_width, config);
245            self.cached_lines = Some(lines);
246            self.cached_width = available_width;
247        }
248
249        self.cached_lines.as_ref().unwrap()
250    }
251
252    /// Render this message to lines (called only when cache is invalid)
253    fn render_lines(&self, available_width: usize, config: &ChatViewConfig) -> Vec<Line<'static>> {
254        let mut lines = Vec::new();
255        let t = app_theme();
256
257        match self.role {
258            MessageRole::User => {
259                let rendered = wrap_with_prefix(
260                    &self.content,
261                    &config.user_prefix,
262                    t.user_prefix,
263                    &config.continuation,
264                    available_width,
265                    &t,
266                );
267                lines.extend(rendered);
268            }
269            MessageRole::System => {
270                let rendered = wrap_with_prefix(
271                    &self.content,
272                    &config.system_prefix,
273                    t.system_prefix,
274                    &config.continuation,
275                    available_width,
276                    &t,
277                );
278                lines.extend(rendered);
279            }
280            MessageRole::Assistant => {
281                let rendered = render_markdown_with_prefix(&self.content, available_width, &t);
282                lines.extend(rendered);
283            }
284            MessageRole::Tool => {
285                if let Some(ref data) = self.tool_data {
286                    lines.extend(render_tool_message(data, config, available_width));
287                }
288            }
289        }
290
291        // Timestamp line (only for user and system messages)
292        // Note: Using %I (with leading zero) instead of %-I for cross-platform compatibility
293        if self.role != MessageRole::Assistant && self.role != MessageRole::Tool {
294            let time_str = self.timestamp.format("%I:%M:%S %p").to_string();
295            let timestamp_text = format!("{}{}", config.timestamp_prefix, time_str);
296            lines.push(Line::from(vec![Span::styled(
297                timestamp_text,
298                app_theme().timestamp,
299            )]));
300        }
301
302        // Blank line after each message
303        lines.push(Line::from(""));
304
305        lines
306    }
307}
308
309// Re-export RenderFn from chat_helpers for backwards compatibility
310pub use super::chat_helpers::RenderFn;
311
312use crate::themes::Theme;
313
314/// Function type for rendering custom title bars
315/// Returns (left_title, right_title) as ratatui Lines
316pub type TitleRenderFn = Box<dyn Fn(&str, &Theme) -> (Line<'static>, Line<'static>) + Send + Sync>;
317
318/// Chat message display widget with streaming and tool execution support.
319pub struct ChatView {
320    messages: Vec<Message>,
321    scroll_offset: u16,
322    /// Buffer for streaming assistant response
323    streaming_buffer: Option<String>,
324    /// Cached rendered lines for streaming buffer
325    streaming_cache: Option<Vec<Line<'static>>>,
326    /// Length of streaming_buffer when cache was created
327    streaming_cache_len: usize,
328    /// Width at which streaming cache was created
329    streaming_cache_width: usize,
330    /// Cached max scroll value from last render
331    last_max_scroll: u16,
332    /// Whether auto-scroll is enabled (disabled when user manually scrolls)
333    auto_scroll_enabled: bool,
334    /// Index for O(1) tool message lookup by tool_use_id
335    tool_index: HashMap<String, usize>,
336    /// Spinner index for pending status animation
337    spinner_index: usize,
338    /// Title displayed in the title bar
339    title: String,
340    /// Optional custom initial content renderer (shown when no messages)
341    render_initial_content: Option<RenderFn>,
342    /// Optional custom title renderer
343    render_title: Option<TitleRenderFn>,
344    /// Configuration for display customization
345    config: ChatViewConfig,
346}
347
348impl ChatView {
349    /// Create a new ChatView with default settings
350    pub fn new() -> Self {
351        Self::with_config(ChatViewConfig::new())
352    }
353
354    /// Create a new ChatView with custom configuration
355    pub fn with_config(config: ChatViewConfig) -> Self {
356        let title = config.default_title.clone();
357        Self {
358            messages: Vec::new(),
359            scroll_offset: 0,
360            streaming_buffer: None,
361            streaming_cache: None,
362            streaming_cache_len: 0,
363            streaming_cache_width: 0,
364            last_max_scroll: 0,
365            auto_scroll_enabled: true,
366            tool_index: HashMap::new(),
367            spinner_index: 0,
368            title,
369            render_initial_content: None,
370            render_title: None,
371            config,
372        }
373    }
374
375    /// Get the current configuration
376    pub fn config(&self) -> &ChatViewConfig {
377        &self.config
378    }
379
380    /// Set a new configuration
381    pub fn set_config(&mut self, config: ChatViewConfig) {
382        self.config = config;
383        // Invalidate all message caches since prefixes may have changed
384        for msg in &mut self.messages {
385            msg.cached_lines = None;
386        }
387    }
388
389    /// Set the title displayed in the title bar
390    pub fn with_title(mut self, title: impl Into<String>) -> Self {
391        self.title = title.into();
392        self
393    }
394
395    /// Set custom initial content renderer (shown when no messages)
396    ///
397    /// Provide a closure for full ratatui control over what to display
398    /// when the chat has no messages yet.
399    pub fn with_initial_content(mut self, render: RenderFn) -> Self {
400        self.render_initial_content = Some(render);
401        self
402    }
403
404    /// Set custom title bar renderer
405    ///
406    /// The function receives the current title and theme, and returns
407    /// (left_title, right_title) as ratatui Lines.
408    pub fn with_title_renderer<F>(mut self, render: F) -> Self
409    where
410        F: Fn(&str, &Theme) -> (Line<'static>, Line<'static>) + Send + Sync + 'static,
411    {
412        self.render_title = Some(Box::new(render));
413        self
414    }
415
416    /// Set the title displayed in the title bar (mutable setter)
417    pub fn set_title(&mut self, title: impl Into<String>) {
418        self.title = title.into();
419    }
420
421    /// Get the current title
422    pub fn title(&self) -> &str {
423        &self.title
424    }
425
426    /// Advance the spinner animation
427    pub fn step_spinner(&mut self) {
428        let len = self.config.spinner_chars.len().max(1);
429        self.spinner_index = (self.spinner_index + 1) % len;
430    }
431
432    /// Add a user message (does not force scroll - caller should handle that)
433    pub fn add_user_message(&mut self, content: String) {
434        if !content.trim().is_empty() {
435            self.messages.push(Message::new(MessageRole::User, content));
436            // Only scroll to bottom if auto-scroll is enabled
437            if self.auto_scroll_enabled {
438                self.scroll_offset = u16::MAX;
439            }
440        }
441    }
442
443    /// Add an assistant message (complete)
444    pub fn add_assistant_message(&mut self, content: String) {
445        if !content.trim().is_empty() {
446            self.messages
447                .push(Message::new(MessageRole::Assistant, content));
448            // Only scroll to bottom if auto-scroll is enabled
449            if self.auto_scroll_enabled {
450                self.scroll_offset = u16::MAX;
451            }
452        }
453    }
454
455    /// Add a system message (ignores empty messages)
456    pub fn add_system_message(&mut self, content: String) {
457        if content.trim().is_empty() {
458            return;
459        }
460        self.messages
461            .push(Message::new(MessageRole::System, content));
462        // Only scroll to bottom if auto-scroll is enabled
463        if self.auto_scroll_enabled {
464            self.scroll_offset = u16::MAX;
465        }
466    }
467
468    /// Add a tool execution message
469    pub fn add_tool_message(
470        &mut self,
471        tool_use_id: &str,
472        display_name: &str,
473        display_title: &str,
474    ) {
475        let index = self.messages.len();
476
477        let tool_data = ToolMessageData {
478            tool_use_id: tool_use_id.to_string(),
479            display_name: display_name.to_string(),
480            display_title: display_title.to_string(),
481            status: ToolStatus::Executing,
482        };
483
484        self.messages.push(Message::new_tool(tool_data));
485        self.tool_index.insert(tool_use_id.to_string(), index);
486
487        // Only scroll to bottom if auto-scroll is enabled
488        if self.auto_scroll_enabled {
489            self.scroll_offset = u16::MAX;
490        }
491    }
492
493    /// Update a tool message status by tool_use_id - O(1) lookup
494    pub fn update_tool_status(&mut self, tool_use_id: &str, status: ToolStatus) {
495        if let Some(&index) = self.tool_index.get(tool_use_id) {
496            if let Some(msg) = self.messages.get_mut(index) {
497                if let Some(ref mut data) = msg.tool_data {
498                    data.status = status;
499                    msg.cached_lines = None; // Invalidate cache
500                }
501            }
502        }
503    }
504
505    /// Re-enable auto-scroll and scroll to bottom (call when user submits a message)
506    pub fn enable_auto_scroll(&mut self) {
507        self.auto_scroll_enabled = true;
508        self.scroll_offset = u16::MAX;
509    }
510
511    /// Append text to the streaming buffer
512    pub fn append_streaming(&mut self, text: &str) {
513        match &mut self.streaming_buffer {
514            Some(buffer) => buffer.push_str(text),
515            None => self.streaming_buffer = Some(text.to_string()),
516        }
517        // Invalidate streaming cache (buffer content changed)
518        self.streaming_cache = None;
519        self.streaming_cache_len = 0;
520        self.streaming_cache_width = 0;
521        // Only auto-scroll if enabled (user hasn't manually scrolled)
522        if self.auto_scroll_enabled {
523            self.scroll_offset = u16::MAX;
524        }
525    }
526
527    /// Complete the streaming response and add as assistant message
528    pub fn complete_streaming(&mut self) {
529        if let Some(content) = self.streaming_buffer.take() {
530            if !content.trim().is_empty() {
531                self.messages
532                    .push(Message::new(MessageRole::Assistant, content));
533            }
534        }
535        // Clear streaming cache
536        self.streaming_cache = None;
537        self.streaming_cache_len = 0;
538        self.streaming_cache_width = 0;
539    }
540
541    /// Discard the streaming buffer without saving (used on cancel)
542    pub fn discard_streaming(&mut self) {
543        self.streaming_buffer = None;
544        // Clear streaming cache
545        self.streaming_cache = None;
546        self.streaming_cache_len = 0;
547        self.streaming_cache_width = 0;
548    }
549
550    /// Check if currently streaming
551    pub fn is_streaming(&self) -> bool {
552        self.streaming_buffer.is_some()
553    }
554
555    pub fn scroll_up(&mut self) {
556        // If at auto-scroll (MAX), convert to actual position first
557        if self.scroll_offset == u16::MAX {
558            self.scroll_offset = self.last_max_scroll;
559        }
560        self.scroll_offset = self.scroll_offset.saturating_sub(3);
561        // User manually scrolled - disable auto-scroll
562        self.auto_scroll_enabled = false;
563    }
564
565    pub fn scroll_down(&mut self) {
566        // If at auto-scroll (MAX), already at bottom
567        if self.scroll_offset == u16::MAX {
568            return;
569        }
570        self.scroll_offset = self.scroll_offset.saturating_add(3);
571        // If we've scrolled to bottom, switch back to auto-scroll
572        if self.scroll_offset >= self.last_max_scroll {
573            self.scroll_offset = u16::MAX;
574            self.auto_scroll_enabled = true; // Re-enable when reaching bottom
575        }
576    }
577
578    pub fn render_chat(&mut self, frame: &mut Frame, area: Rect, pending_status: Option<&str>) {
579        let theme = app_theme();
580
581        // Create block - with custom title if renderer provided, plain border otherwise
582        let content_block = if let Some(ref render_fn) = self.render_title {
583            let (left_title, right_title) = render_fn(&self.title, &theme);
584            Block::default()
585                .title(left_title)
586                .title_alignment(Alignment::Left)
587                .title(right_title.alignment(Alignment::Right))
588                .borders(Borders::TOP)
589                .border_style(theme.border)
590                .padding(Padding::new(1, 0, 1, 0))
591        } else {
592            Block::default()
593                .borders(Borders::TOP)
594                .border_style(theme.border)
595                .padding(Padding::new(1, 0, 1, 0))
596        };
597
598        // Check if we're in initial state (no messages yet)
599        let is_initial_state = self.messages.is_empty() && self.streaming_buffer.is_none() && pending_status.is_none();
600
601        // If we have custom initial content renderer, use it and return early
602        if is_initial_state {
603            if let Some(ref render_fn) = self.render_initial_content {
604                let inner = content_block.inner(area);
605                frame.render_widget(content_block, area);
606                render_fn(frame, inner, &theme);
607                return;
608            }
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
657                    .push(Span::styled("\u{2588}", theme.cursor));
658            }
659        } else if let Some(status) = pending_status {
660            // Show pending status with spinner when not streaming
661            let spinner_char = self.config.spinner_chars.get(self.spinner_index).copied().unwrap_or(' ');
662            message_lines.push(Line::from(vec![
663                Span::styled(format!("{} ", spinner_char), theme.throbber_spinner),
664                Span::styled(status, theme.throbber_label),
665            ]));
666        }
667
668        // Calculate scroll (no wrapping estimation needed - we do manual wrapping)
669        let available_height = area.height.saturating_sub(2) as usize; // -2 for border + padding
670        let total_lines = message_lines.len();
671        let max_scroll = total_lines.saturating_sub(available_height) as u16;
672        self.last_max_scroll = max_scroll;
673
674        // Scroll behavior:
675        // - scroll_offset = u16::MAX: auto-scroll to bottom (show latest)
676        // - other values: manual scroll position (clamped to valid range)
677        //
678        // Important: Update self.scroll_offset when clamping to prevent stale values
679        // after terminal resize. This ensures scroll_up/scroll_down work correctly
680        // immediately after resize.
681        let scroll_offset = if self.scroll_offset == u16::MAX {
682            max_scroll
683        } else {
684            let clamped = self.scroll_offset.min(max_scroll);
685            if clamped != self.scroll_offset {
686                self.scroll_offset = clamped;
687            }
688            clamped
689        };
690
691        let messages_widget = Paragraph::new(message_lines)
692            .block(content_block)
693            .style(theme.background.patch(theme.text))
694            .scroll((scroll_offset, 0));
695        frame.render_widget(messages_widget, area);
696    }
697}
698
699/// Render a tool execution message
700fn render_tool_message(
701    data: &ToolMessageData,
702    config: &ChatViewConfig,
703    available_width: usize,
704) -> Vec<Line<'static>> {
705    let mut lines = Vec::new();
706    let theme = app_theme();
707
708    // Line 1: tool icon + DisplayName(DisplayTitle)
709    let header = if data.display_title.is_empty() {
710        format!("{} {}", config.tool_icon, data.display_name)
711    } else {
712        format!("{} {}({})", config.tool_icon, data.display_name, data.display_title)
713    };
714    lines.push(Line::from(Span::styled(header, theme.tool_header)));
715
716    // Line 2+: Status with appropriate icon and color
717    match &data.status {
718        ToolStatus::Executing => {
719            lines.push(Line::from(Span::styled(
720                format!("   {} executing...", config.tool_executing_arrow),
721                theme.tool_executing,
722            )));
723        }
724        ToolStatus::WaitingForUser => {
725            lines.push(Line::from(Span::styled(
726                format!("   {} waiting for user...", config.tool_executing_arrow),
727                theme.tool_executing,
728            )));
729        }
730        ToolStatus::Completed => {
731            lines.push(Line::from(Span::styled(
732                format!("   {} Completed", config.tool_completed_checkmark),
733                theme.tool_completed,
734            )));
735        }
736        ToolStatus::Failed(err) => {
737            // Wrap long error messages with proper indentation
738            let prefix = format!("   {} ", config.tool_failed_icon);
739            let cont_prefix = "     "; // Align continuation with error text
740            let wrapped = wrap_with_prefix(
741                err,
742                &prefix,
743                theme.tool_failed,
744                cont_prefix,
745                available_width,
746                &theme,
747            );
748            lines.extend(wrapped);
749        }
750    }
751
752    lines
753}
754
755impl Default for ChatView {
756    fn default() -> Self {
757        Self::new()
758    }
759}
760
761// --- ConversationView trait implementation ---
762
763use super::ConversationView;
764
765/// State snapshot for ChatView (used for session save/restore)
766#[derive(Clone)]
767struct ChatViewState {
768    messages: Vec<MessageSnapshot>,
769    scroll_offset: u16,
770    streaming_buffer: Option<String>,
771    last_max_scroll: u16,
772    auto_scroll_enabled: bool,
773    tool_index: HashMap<String, usize>,
774    spinner_index: usize,
775}
776
777/// Snapshot of a single message (Clone-friendly version)
778#[derive(Clone)]
779struct MessageSnapshot {
780    role: MessageRole,
781    content: String,
782    timestamp: DateTime<Local>,
783    tool_data: Option<ToolMessageData>,
784}
785
786impl From<&Message> for MessageSnapshot {
787    fn from(msg: &Message) -> Self {
788        Self {
789            role: msg.role,
790            content: msg.content.clone(),
791            timestamp: msg.timestamp,
792            tool_data: msg.tool_data.clone(),
793        }
794    }
795}
796
797impl From<MessageSnapshot> for Message {
798    fn from(snapshot: MessageSnapshot) -> Self {
799        Self {
800            role: snapshot.role,
801            content: snapshot.content,
802            timestamp: snapshot.timestamp,
803            cached_lines: None,
804            cached_width: 0,
805            tool_data: snapshot.tool_data,
806        }
807    }
808}
809
810impl ConversationView for ChatView {
811    fn add_user_message(&mut self, content: String) {
812        ChatView::add_user_message(self, content);
813    }
814
815    fn add_assistant_message(&mut self, content: String) {
816        ChatView::add_assistant_message(self, content);
817    }
818
819    fn add_system_message(&mut self, content: String) {
820        ChatView::add_system_message(self, content);
821    }
822
823    fn append_streaming(&mut self, text: &str) {
824        ChatView::append_streaming(self, text);
825    }
826
827    fn complete_streaming(&mut self) {
828        ChatView::complete_streaming(self);
829    }
830
831    fn discard_streaming(&mut self) {
832        ChatView::discard_streaming(self);
833    }
834
835    fn is_streaming(&self) -> bool {
836        ChatView::is_streaming(self)
837    }
838
839    fn add_tool_message(&mut self, tool_use_id: &str, display_name: &str, display_title: &str) {
840        ChatView::add_tool_message(self, tool_use_id, display_name, display_title);
841    }
842
843    fn update_tool_status(&mut self, tool_use_id: &str, status: ToolStatus) {
844        ChatView::update_tool_status(self, tool_use_id, status);
845    }
846
847    fn scroll_up(&mut self) {
848        ChatView::scroll_up(self);
849    }
850
851    fn scroll_down(&mut self) {
852        ChatView::scroll_down(self);
853    }
854
855    fn enable_auto_scroll(&mut self) {
856        ChatView::enable_auto_scroll(self);
857    }
858
859    fn render(&mut self, frame: &mut Frame, area: Rect, _theme: &Theme, pending_status: Option<&str>) {
860        self.render_chat(frame, area, pending_status);
861    }
862
863    fn step_spinner(&mut self) {
864        ChatView::step_spinner(self);
865    }
866
867    fn save_state(&self) -> Box<dyn Any + Send> {
868        let state = ChatViewState {
869            messages: self.messages.iter().map(MessageSnapshot::from).collect(),
870            scroll_offset: self.scroll_offset,
871            streaming_buffer: self.streaming_buffer.clone(),
872            last_max_scroll: self.last_max_scroll,
873            auto_scroll_enabled: self.auto_scroll_enabled,
874            tool_index: self.tool_index.clone(),
875            spinner_index: self.spinner_index,
876        };
877        Box::new(state)
878    }
879
880    fn restore_state(&mut self, state: Box<dyn Any + Send>) {
881        if let Ok(chat_state) = state.downcast::<ChatViewState>() {
882            self.messages = chat_state.messages.into_iter().map(Message::from).collect();
883            self.scroll_offset = chat_state.scroll_offset;
884            self.streaming_buffer = chat_state.streaming_buffer;
885            // Clear streaming cache (will be regenerated on next render)
886            self.streaming_cache = None;
887            self.streaming_cache_len = 0;
888            self.streaming_cache_width = 0;
889            self.last_max_scroll = chat_state.last_max_scroll;
890            self.auto_scroll_enabled = chat_state.auto_scroll_enabled;
891            self.tool_index = chat_state.tool_index;
892            self.spinner_index = chat_state.spinner_index;
893        }
894    }
895
896    fn clear(&mut self) {
897        self.messages.clear();
898        self.streaming_buffer = None;
899        self.streaming_cache = None;
900        self.streaming_cache_len = 0;
901        self.streaming_cache_width = 0;
902        self.tool_index.clear();
903        self.scroll_offset = 0;
904        self.last_max_scroll = 0;
905        self.auto_scroll_enabled = true;
906        self.spinner_index = 0;
907        // Preserve: title, render_title, render_initial_content, config
908    }
909}
910
911// --- Widget trait implementation ---
912
913use std::any::Any;
914use crossterm::event::KeyEvent;
915use super::{widget_ids, Widget, WidgetKeyContext, WidgetKeyResult};
916
917impl Widget for ChatView {
918    fn id(&self) -> &'static str {
919        widget_ids::CHAT_VIEW
920    }
921
922    fn priority(&self) -> u8 {
923        50 // Low priority - core widget, handles scroll events
924    }
925
926    fn is_active(&self) -> bool {
927        true // Always active
928    }
929
930    fn handle_key(&mut self, _key: KeyEvent, _ctx: &WidgetKeyContext) -> WidgetKeyResult {
931        // ChatView doesn't handle keys directly via Widget trait
932        // Scrolling is handled by App
933        WidgetKeyResult::NotHandled
934    }
935
936    fn render(&mut self, frame: &mut Frame, area: Rect, _theme: &Theme) {
937        // Note: This is a simplified render without pending_status
938        // App may use render_chat method with pending_status directly for richer status
939        self.render_chat(frame, area, None);
940    }
941
942    fn required_height(&self, _available: u16) -> u16 {
943        0 // ChatView takes remaining space via layout
944    }
945
946    fn blocks_input(&self) -> bool {
947        false
948    }
949
950    fn is_overlay(&self) -> bool {
951        false
952    }
953
954    fn as_any(&self) -> &dyn Any {
955        self
956    }
957
958    fn as_any_mut(&mut self) -> &mut dyn Any {
959        self
960    }
961
962    fn into_any(self: Box<Self>) -> Box<dyn Any> {
963        self
964    }
965}
966