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;
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; 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 lines.push(marked_line.line);
49 } else {
50 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 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 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 lines.push(marked_line.line);
157 } else {
158 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 continue;
169 }
170 AssistantContent::Thought { thought } => {
171 let thought_text = thought.display_text();
172 let thought_style = theme.style(Component::ThoughtText);
173
174 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 for marked_line in markdown_text.lines {
185 let mut styled_spans = Vec::new();
186
187 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 }
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 let height = widget.lines(20, ViewMode::Compact, &theme).len();
247 assert_eq!(height, 1); let height_wrapped = widget.lines(8, ViewMode::Compact, &theme).len();
251 assert!(height_wrapped > 1); }
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 let height = widget.lines(30, ViewMode::Compact, &theme).len();
272 assert_eq!(height, 1); }
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 let height = widget.lines(30, ViewMode::Compact, &theme).len();
296 assert_eq!(height, 3); }
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 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 let area = Rect::new(0, 0, 50, 5);
322 let buf_regular = Buffer::empty(area);
323 let buf_partial = Buffer::empty(area);
324
325 widget.lines(area.width, ViewMode::Compact, &theme);
327
328 widget.lines(area.width, ViewMode::Compact, &theme);
330
331 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 let wide_msg = Message {
359 data: MessageData::User {
360 content: vec![UserContent::Text {
361 text: "A中B".to_string(), }],
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 let area = Rect::new(0, 0, 10, 1);
373 let buf = Buffer::empty(area);
374 widget.lines(area.width, ViewMode::Compact, &theme);
375
376 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}