Skip to main content

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