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