steer_tui/tui/widgets/chat_widgets/
message_widget.rs

1use ratatui::text::{Line, Span};
2use steer_core::app::conversation::{AppCommandType, AssistantContent, MessageData};
3use steer_core::app::conversation::{Message, UserContent};
4
5use crate::tui::theme::{Component, Theme};
6use crate::tui::widgets::formatters::helpers::style_wrap;
7use crate::tui::widgets::{ChatRenderable, HeightCache, ViewMode, markdown};
8
9pub struct MessageWidget {
10    message: Message,
11    cache: HeightCache,
12    rendered_lines: Option<Vec<Line<'static>>>,
13}
14
15impl MessageWidget {
16    pub fn new(message: Message) -> Self {
17        Self {
18            message,
19            cache: HeightCache::new(),
20            rendered_lines: None,
21        }
22    }
23
24    fn render_as_markdown(text: &str, theme: &Theme, max_width: usize) -> markdown::MarkedText {
25        let markdown_styles = markdown::MarkdownStyles::from_theme(theme);
26        markdown::from_str_with_width(text, &markdown_styles, theme, Some(max_width as u16))
27    }
28}
29
30impl ChatRenderable for MessageWidget {
31    fn lines(&mut self, width: u16, _mode: ViewMode, theme: &Theme) -> &[Line<'static>] {
32        if self.rendered_lines.is_some() && self.cache.last_width == width {
33            return self.rendered_lines.as_ref().unwrap();
34        }
35
36        let max_width = width.saturating_sub(4) as usize; // Account for gutters
37        let mut lines = Vec::new();
38
39        match &self.message.data {
40            MessageData::User { content, .. } => {
41                for user_content in content {
42                    match user_content {
43                        UserContent::Text { text } => {
44                            let marked_text = Self::render_as_markdown(text, theme, max_width);
45                            for marked_line in marked_text.lines {
46                                if marked_line.no_wrap {
47                                    // Don't wrap code block lines
48                                    lines.push(marked_line.line);
49                                } else {
50                                    // Wrap normal lines
51                                    let wrapped = style_wrap(marked_line.line, max_width as u16);
52                                    for line in wrapped {
53                                        lines.push(line);
54                                    }
55                                }
56                            }
57                        }
58                        UserContent::CommandExecution {
59                            command,
60                            stdout,
61                            stderr,
62                            exit_code,
63                        } => {
64                            // Format command execution
65                            let cmd_style = theme.style(Component::CommandPrompt);
66                            lines.push(Line::from(Span::styled(format!("$ {command}"), cmd_style)));
67
68                            if !stdout.is_empty() {
69                                let output_style = theme.style(Component::UserMessage);
70                                for line in stdout.lines() {
71                                    let wrapped = textwrap::wrap(line, max_width);
72                                    for wrapped_line in wrapped {
73                                        lines.push(Line::from(Span::styled(
74                                            wrapped_line.to_string(),
75                                            output_style,
76                                        )));
77                                    }
78                                }
79                            }
80
81                            if !stderr.is_empty() {
82                                let error_style = theme.style(Component::ErrorText);
83                                for line in stderr.lines() {
84                                    let wrapped = textwrap::wrap(line, max_width);
85                                    for wrapped_line in wrapped {
86                                        lines.push(Line::from(Span::styled(
87                                            wrapped_line.to_string(),
88                                            error_style,
89                                        )));
90                                    }
91                                }
92                            }
93
94                            if *exit_code != 0 {
95                                lines.push(Line::from(Span::styled(
96                                    format!("Exit code: {exit_code}"),
97                                    theme.style(Component::DimText),
98                                )));
99                            }
100                        }
101                        UserContent::AppCommand { command, response } => {
102                            // Format app command
103                            let cmd_style = theme.style(Component::CommandPrompt);
104                            let cmd_text = match command {
105                                AppCommandType::Model { target } => {
106                                    if let Some(model) = target {
107                                        format!("/model {model}")
108                                    } else {
109                                        "/model".to_string()
110                                    }
111                                }
112                                AppCommandType::Compact => "/compact".to_string(),
113                                AppCommandType::Clear => "/clear".to_string(),
114                            };
115                            lines.push(Line::from(Span::styled(cmd_text, cmd_style)));
116
117                            if let Some(resp) = response {
118                                let resp_text = match resp {
119                                    steer_core::app::conversation::CommandResponse::Text(text) => text.clone(),
120                                    steer_core::app::conversation::CommandResponse::Compact(result) => {
121                                        match result {
122                                            steer_core::app::conversation::CompactResult::Success(summary) => summary.clone(),
123                                            steer_core::app::conversation::CompactResult::Cancelled => "Compact cancelled.".to_string(),
124                                            steer_core::app::conversation::CompactResult::InsufficientMessages => "Not enough messages to compact.".to_string(),
125                                        }
126                                    }
127                                };
128
129                                let resp_style = theme.style(Component::CommandText);
130                                for line in resp_text.lines() {
131                                    let wrapped = textwrap::wrap(line, max_width);
132                                    for wrapped_line in wrapped {
133                                        lines.push(Line::from(Span::styled(
134                                            wrapped_line.to_string(),
135                                            resp_style,
136                                        )));
137                                    }
138                                }
139                            }
140                        }
141                    }
142                }
143            }
144            MessageData::Assistant { content, .. } => {
145                for block in content {
146                    match block {
147                        AssistantContent::Text { text } => {
148                            if text.trim().is_empty() {
149                                continue;
150                            }
151
152                            let marked_text = Self::render_as_markdown(text, theme, max_width);
153                            for marked_line in marked_text.lines {
154                                if marked_line.no_wrap {
155                                    // Don't wrap code block lines
156                                    lines.push(marked_line.line);
157                                } else {
158                                    // Wrap normal lines
159                                    let wrapped = style_wrap(marked_line.line, max_width as u16);
160                                    for line in wrapped {
161                                        lines.push(line);
162                                    }
163                                }
164                            }
165                        }
166                        AssistantContent::ToolCall { .. } => {
167                            // Tool calls are rendered separately
168                            continue;
169                        }
170                        AssistantContent::Thought { thought } => {
171                            let thought_text = thought.display_text();
172                            let thought_style = theme.style(Component::ThoughtText);
173
174                            // Parse markdown for the thought
175                            let markdown_styles = markdown::MarkdownStyles::from_theme(theme);
176                            let markdown_text = markdown::from_str_with_width(
177                                &thought_text,
178                                &markdown_styles,
179                                theme,
180                                Some(max_width as u16),
181                            );
182
183                            // Process each line with thought styling
184                            for marked_line in markdown_text.lines {
185                                let mut styled_spans = Vec::new();
186
187                                // Apply thought style to all spans
188                                for span in marked_line.line.spans {
189                                    styled_spans.push(Span::styled(
190                                        span.content.into_owned(),
191                                        thought_style,
192                                    ));
193                                }
194
195                                let thought_line = Line::from(styled_spans);
196
197                                if marked_line.no_wrap {
198                                    lines.push(thought_line);
199                                } else {
200                                    let wrapped = style_wrap(thought_line, max_width as u16);
201                                    for line in wrapped {
202                                        lines.push(line);
203                                    }
204                                }
205                            }
206                        }
207                    }
208                }
209            }
210            MessageData::Tool { .. } => {
211                // Tools are rendered as part of ToolInteraction blocks
212            }
213        }
214
215        self.rendered_lines = Some(lines);
216        self.rendered_lines.as_ref().unwrap()
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::MessageWidget;
223    use crate::tui::theme::Theme;
224    use crate::tui::widgets::ChatRenderable;
225    use crate::tui::widgets::ViewMode;
226    use steer_core::app::conversation::AssistantContent;
227    use steer_core::app::conversation::{Message, MessageData, UserContent};
228
229    #[test]
230    fn test_message_widget_user_text() {
231        let theme = Theme::default();
232        let user_msg = Message {
233            data: MessageData::User {
234                content: vec![UserContent::Text {
235                    text: "Hello, world!".to_string(),
236                }],
237            },
238            timestamp: 0,
239            id: "test-id".to_string(),
240            parent_message_id: None,
241        };
242
243        let mut widget = MessageWidget::new(user_msg);
244
245        // Test height calculation
246        let height = widget.lines(20, ViewMode::Compact, &theme).len();
247        assert_eq!(height, 1); // Single line message
248
249        // Test with wrapping
250        let height_wrapped = widget.lines(8, ViewMode::Compact, &theme).len();
251        assert!(height_wrapped > 1); // Should wrap
252    }
253
254    #[test]
255    fn test_message_widget_assistant_text() {
256        let theme = Theme::default();
257        let assistant_msg = Message {
258            data: MessageData::Assistant {
259                content: vec![AssistantContent::Text {
260                    text: "Hello from assistant".to_string(),
261                }],
262            },
263            timestamp: 0,
264            id: "test-id".to_string(),
265            parent_message_id: None,
266        };
267
268        let mut widget = MessageWidget::new(assistant_msg);
269
270        // Test height calculation
271        let height = widget.lines(30, ViewMode::Compact, &theme).len();
272        assert_eq!(height, 1); // Single line message
273    }
274
275    #[test]
276    fn test_message_widget_command_execution() {
277        let theme = Theme::default();
278        let cmd_msg = Message {
279            data: MessageData::User {
280                content: vec![UserContent::CommandExecution {
281                    command: "ls -la".to_string(),
282                    stdout: "file1.txt\nfile2.txt".to_string(),
283                    stderr: "".to_string(),
284                    exit_code: 0,
285                }],
286            },
287            timestamp: 0,
288            id: "test-id".to_string(),
289            parent_message_id: None,
290        };
291
292        let mut widget = MessageWidget::new(cmd_msg);
293
294        // Test height calculation - should have command line + 2 output lines
295        let height = widget.lines(30, ViewMode::Compact, &theme).len();
296        assert_eq!(height, 3); // $ ls -la + file1.txt + file2.txt
297    }
298
299    #[test]
300    fn test_unicode_width_handling() {
301        use ratatui::buffer::Buffer;
302        use ratatui::layout::Rect;
303
304        let theme = Theme::default();
305
306        // Create a message with various Unicode characters
307        let unicode_msg = Message {
308            data: MessageData::User {
309                content: vec![UserContent::Text {
310                    text: "Hello 你好 👋 café".to_string(),
311                }],
312            },
313            timestamp: 0,
314            id: "test-unicode".to_string(),
315            parent_message_id: None,
316        };
317
318        let mut widget = MessageWidget::new(unicode_msg);
319
320        // Create buffers for both render methods
321        let area = Rect::new(0, 0, 50, 5);
322        let buf_regular = Buffer::empty(area);
323        let buf_partial = Buffer::empty(area);
324
325        // Render with regular method
326        widget.lines(area.width, ViewMode::Compact, &theme);
327
328        // Render with partial method
329        widget.lines(area.width, ViewMode::Compact, &theme);
330
331        // Compare the buffers - they should be identical
332        for y in 0..area.height {
333            for x in 0..area.width {
334                let regular_cell = buf_regular.cell((x, y)).unwrap();
335                let partial_cell = buf_partial.cell((x, y)).unwrap();
336
337                assert_eq!(
338                    regular_cell.symbol(),
339                    partial_cell.symbol(),
340                    "Mismatch at ({}, {}): regular='{}' partial='{}'",
341                    x,
342                    y,
343                    regular_cell.symbol(),
344                    partial_cell.symbol()
345                );
346            }
347        }
348    }
349
350    #[test]
351    fn test_wide_character_positioning() {
352        use ratatui::buffer::Buffer;
353        use ratatui::layout::Rect;
354
355        let theme = Theme::default();
356
357        // Create a message with wide characters (CJK takes 2 columns each)
358        let wide_msg = Message {
359            data: MessageData::User {
360                content: vec![UserContent::Text {
361                    text: "A中B".to_string(), // A=1 col, 中=2 cols, B=1 col
362                }],
363            },
364            timestamp: 0,
365            id: "test-wide".to_string(),
366            parent_message_id: None,
367        };
368
369        let mut widget = MessageWidget::new(wide_msg);
370
371        // Render with partial method
372        let area = Rect::new(0, 0, 10, 1);
373        let buf = Buffer::empty(area);
374        widget.lines(area.width, ViewMode::Compact, &theme);
375
376        // With correct Unicode handling:
377        // Position 0: 'A' (1 column)
378        // Position 1-2: '中' (2 columns)
379        // Position 3: 'B' (1 column)
380
381        // With the bug (incrementing by 1):
382        // Position 0: 'A'
383        // Position 1: '中' (but should occupy 2 columns)
384        // Position 2: 'B' (overlapping with second half of '中')
385
386        // This test will fail with current implementation
387        // because 'B' will be at position 2 instead of position 3
388
389        // Check that B is not at position 2 (would indicate the bug)
390        let cell_at_2 = buf.cell((2, 0)).unwrap();
391        assert_ne!(
392            cell_at_2.symbol(),
393            "B",
394            "Character 'B' incorrectly positioned due to Unicode width bug"
395        );
396    }
397}