Skip to main content

steer_tui/tui/widgets/chat_widgets/
message_widget.rs

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