steer_tui/tui/widgets/chat_widgets/
message_widget.rs1use 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 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 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 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 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; 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 lines.push(marked_line.line);
136 } else {
137 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 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 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 lines.push(marked_line.line);
248 } else {
249 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 continue;
264 }
265 AssistantContent::Thought { thought } => {
266 let thought_text = thought.display_text();
267 let thought_style = theme.style(Component::ThoughtText);
268
269 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 for marked_line in markdown_text.lines {
280 let mut styled_spans = Vec::new();
281
282 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 }
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 let height = widget.lines(20, ViewMode::Compact, &theme).len();
350 assert_eq!(height, 1); let height_wrapped = widget.lines(8, ViewMode::Compact, &theme).len();
354 assert!(height_wrapped > 1); }
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 let height = widget.lines(30, ViewMode::Compact, &theme).len();
375 assert_eq!(height, 1); }
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 let height = widget.lines(30, ViewMode::Compact, &theme).len();
399 assert_eq!(height, 3); }
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 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 let area = Rect::new(0, 0, 50, 5);
425 let buf_regular = Buffer::empty(area);
426 let buf_partial = Buffer::empty(area);
427
428 widget.lines(area.width, ViewMode::Compact, &theme);
430
431 widget.lines(area.width, ViewMode::Compact, &theme);
433
434 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 let wide_msg = Message {
462 data: MessageData::User {
463 content: vec![UserContent::Text {
464 text: "A中B".to_string(), }],
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 let area = Rect::new(0, 0, 10, 1);
476 let buf = Buffer::empty(area);
477 widget.lines(area.width, ViewMode::Compact, &theme);
478
479 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}