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 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 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 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; 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 lines.push(marked_line.line);
134 } else {
135 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 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 lines.push(marked_line.line);
250 } else {
251 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 }
273 AssistantContent::Thought { thought } => {
274 let thought_text = thought.display_text();
275 let thought_style = theme.style(Component::ThoughtText);
276
277 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 for marked_line in markdown_text.lines {
288 let mut styled_spans = Vec::new();
289
290 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 }
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 let height = widget.lines(20, ViewMode::Compact, &theme).len();
357 assert_eq!(height, 1); let height_wrapped = widget.lines(8, ViewMode::Compact, &theme).len();
361 assert!(height_wrapped > 1); }
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 let height = widget.lines(30, ViewMode::Compact, &theme).len();
421 assert_eq!(height, 1); }
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 let height = widget.lines(30, ViewMode::Compact, &theme).len();
445 assert_eq!(height, 3); }
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 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 let area = Rect::new(0, 0, 50, 5);
471 let buf_regular = Buffer::empty(area);
472 let buf_partial = Buffer::empty(area);
473
474 widget.lines(area.width, ViewMode::Compact, &theme);
476
477 widget.lines(area.width, ViewMode::Compact, &theme);
479
480 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 let wide_msg = Message {
511 data: MessageData::User {
512 content: vec![UserContent::Text {
513 text: "A中B".to_string(), }],
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 let area = Rect::new(0, 0, 10, 1);
525 let buf = Buffer::empty(area);
526 widget.lines(area.width, ViewMode::Compact, &theme);
527
528 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}