steer_tui/tui/widgets/chat_widgets/
chat_widget.rs

1//! Chat widget trait and implementations for bounded rendering
2//!
3//! This module provides the core abstraction for rendering chat items
4//! within precise rectangular bounds, preventing buffer overlap issues.
5
6use crate::tui::theme::{Component, Theme};
7use crate::tui::widgets::chat_list_state::ViewMode;
8use crate::tui::widgets::chat_widgets::message_widget::MessageWidget;
9use ratatui::text::{Line, Span};
10use steer_core::app::conversation::{AssistantContent, Message, MessageData};
11use steer_tools::{ToolCall, ToolResult};
12
13/// Core trait for chat items that can compute their height and render themselves
14pub trait ChatRenderable: Send + Sync {
15    /// Return formatted lines; cache internally on `(width, mode)`.
16    fn lines(&mut self, width: u16, mode: ViewMode, theme: &Theme) -> &[Line<'static>];
17}
18
19/// Height cache for efficient scrolling
20#[derive(Debug, Clone)]
21pub struct HeightCache {
22    pub compact: Option<usize>,
23    pub detailed: Option<usize>,
24    pub last_width: u16,
25}
26
27impl Default for HeightCache {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl HeightCache {
34    pub fn new() -> Self {
35        Self {
36            compact: None,
37            detailed: None,
38            last_width: 0,
39        }
40    }
41
42    pub fn invalidate(&mut self) {
43        self.compact = None;
44        self.detailed = None;
45        self.last_width = 0;
46    }
47
48    pub fn get(&self, mode: ViewMode, width: u16) -> Option<usize> {
49        if self.last_width != width {
50            return None;
51        }
52        match mode {
53            ViewMode::Compact => self.compact,
54            ViewMode::Detailed => self.detailed,
55        }
56    }
57
58    pub fn set(&mut self, mode: ViewMode, width: u16, height: usize) {
59        self.last_width = width;
60        match mode {
61            ViewMode::Compact => self.compact = Some(height),
62            ViewMode::Detailed => self.detailed = Some(height),
63        }
64    }
65}
66
67/// Default widget that renders simple text as a paragraph
68pub struct ParagraphWidget {
69    lines: Vec<Line<'static>>,
70}
71
72impl ParagraphWidget {
73    pub fn new(lines: Vec<Line<'static>>) -> Self {
74        Self { lines }
75    }
76
77    pub fn from_text(text: String, theme: &Theme) -> Self {
78        Self::from_styled_text(text, theme.style(Component::NoticeInfo))
79    }
80
81    pub fn from_styled_text(text: String, style: ratatui::style::Style) -> Self {
82        let lines = text
83            .lines()
84            .map(|line| Line::from(Span::styled(line.to_string(), style)))
85            .collect();
86        Self::new(lines)
87    }
88}
89
90impl ChatRenderable for ParagraphWidget {
91    fn lines(&mut self, _width: u16, _mode: ViewMode, _theme: &Theme) -> &[Line<'static>] {
92        &self.lines
93    }
94}
95
96/// Represents different types of chat blocks that can be rendered
97#[derive(Debug, Clone)]
98pub enum ChatBlock {
99    /// A message from user or assistant
100    Message(Message),
101    /// A tool call and its result (coupled together)
102    ToolInteraction {
103        call: ToolCall,
104        result: Option<ToolResult>,
105    },
106}
107
108impl ChatBlock {
109    /// Create ChatBlocks from a MessageRow, handling coupled tool calls and results
110    pub fn from_message_row(message: &Message, all_messages: &[&Message]) -> Vec<ChatBlock> {
111        match &message.data {
112            MessageData::User { .. } => {
113                vec![ChatBlock::Message(message.clone())]
114            }
115            MessageData::Assistant { content, .. } => {
116                let mut blocks = vec![];
117                let mut has_text = false;
118                let mut tool_calls = vec![];
119
120                // Separate text content from tool calls
121                for block in content {
122                    match block {
123                        AssistantContent::Text { text } => {
124                            if !text.trim().is_empty() {
125                                has_text = true;
126                            }
127                        }
128                        AssistantContent::ToolCall { tool_call } => {
129                            tool_calls.push(tool_call.clone());
130                        }
131                        AssistantContent::Thought { .. } => {
132                            has_text = true; // Thoughts count as text content
133                        }
134                    }
135                }
136
137                // Add text message if present
138                if has_text {
139                    blocks.push(ChatBlock::Message(message.clone()));
140                }
141
142                // Add tool interactions (coupled with their results)
143                for tool_call in tool_calls {
144                    // Find the corresponding tool result
145                    let result = all_messages.iter().find_map(|msg_row| {
146                        if let MessageData::Tool {
147                            tool_use_id,
148                            result,
149                            ..
150                        } = &msg_row.data
151                        {
152                            if tool_use_id == &tool_call.id {
153                                Some(result.clone())
154                            } else {
155                                None
156                            }
157                        } else {
158                            None
159                        }
160                    });
161
162                    blocks.push(ChatBlock::ToolInteraction {
163                        call: tool_call,
164                        result,
165                    });
166                }
167
168                blocks
169            }
170            MessageData::Tool {
171                tool_use_id,
172                result,
173                ..
174            } => {
175                // Check if this tool result has a corresponding tool call in the assistant messages
176                let has_corresponding_call = all_messages.iter().any(|msg_row| {
177                    if let MessageData::Assistant { content, .. } = &msg_row.data {
178                        content.iter().any(|block| {
179                            if let AssistantContent::ToolCall { tool_call } = block {
180                                tool_call.id == *tool_use_id
181                            } else {
182                                false
183                            }
184                        })
185                    } else {
186                        false
187                    }
188                });
189
190                if has_corresponding_call {
191                    // This will be rendered as part of the assistant's tool interaction
192                    vec![]
193                } else {
194                    // Standalone tool result - render it
195                    vec![ChatBlock::ToolInteraction {
196                        call: steer_tools::ToolCall {
197                            id: tool_use_id.clone(),
198                            name: "Unknown".to_string(), // We don't have the tool name in standalone results
199                            parameters: serde_json::Value::Null,
200                        },
201                        result: Some(result.clone()),
202                    }]
203                }
204            }
205        }
206    }
207}
208
209/// Dynamic chat widget that can render any ChatBlock
210pub struct DynamicChatWidget {
211    inner: Box<dyn ChatRenderable + Send + Sync>,
212}
213
214impl DynamicChatWidget {
215    pub fn from_block(block: ChatBlock, _theme: &Theme) -> Self {
216        let inner: Box<dyn ChatRenderable + Send + Sync> = match block {
217            ChatBlock::Message(message) => Box::new(MessageWidget::new(message)),
218            ChatBlock::ToolInteraction { call, result } => {
219                // Use the ToolFormatterWidget for tool interactions
220                Box::new(
221                    crate::tui::widgets::chat_widgets::tool_widget::ToolWidget::new(call, result),
222                )
223            }
224        };
225        Self { inner }
226    }
227}
228
229impl ChatRenderable for DynamicChatWidget {
230    fn lines(&mut self, width: u16, mode: ViewMode, theme: &Theme) -> &[Line<'static>] {
231        self.inner.lines(width, mode, theme)
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::tui::theme::Theme;
239    use steer_core::app::conversation::UserContent;
240
241    #[test]
242    fn test_chat_block_from_message_row() {
243        let user_msg = Message {
244            data: MessageData::User {
245                content: vec![UserContent::Text {
246                    text: "Test".to_string(),
247                }],
248            },
249            timestamp: 0,
250            id: "test-id".to_string(),
251            parent_message_id: None,
252        };
253
254        let blocks = ChatBlock::from_message_row(&user_msg, &[]);
255
256        assert_eq!(blocks.len(), 1);
257        match &blocks[0] {
258            ChatBlock::Message(_) => {} // Expected
259            _ => panic!("Expected Message ChatBlock"),
260        }
261    }
262
263    #[test]
264    fn test_dynamic_chat_widget() {
265        let theme = Theme::default();
266        let user_msg = Message {
267            data: MessageData::User {
268                content: vec![UserContent::Text {
269                    text: "Test message".to_string(),
270                }],
271            },
272            timestamp: 0,
273            id: "test-id".to_string(),
274            parent_message_id: None,
275        };
276
277        let block = ChatBlock::Message(user_msg);
278        let mut widget = DynamicChatWidget::from_block(block, &theme);
279
280        // Test that it delegates correctly
281        let height = widget.lines(20, ViewMode::Compact, &theme).len();
282        assert_eq!(height, 1);
283    }
284}