1use std::any::Any;
16use std::sync::{Arc, Mutex};
17
18use super::component::Component;
19use super::container::Container;
20use super::image::{Image, ImageOptions};
21use super::markdown::Markdown;
22use super::spacer::Spacer;
23use super::text::Text;
24use crate::ansi::italic;
25use crate::theme::theme;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum AssistantBlock {
33 Text(String),
35 Thinking(String),
37 Image(Vec<u8>),
39}
40
41#[derive(Debug, Clone)]
43pub struct AssistantMessageOptions {
44 pub hide_thinking: bool,
46 pub hidden_thinking_label: String,
48 pub output_pad: usize,
50}
51
52impl Default for AssistantMessageOptions {
53 fn default() -> Self {
54 Self {
55 hide_thinking: false,
56 hidden_thinking_label: "Thinking...".to_string(),
57 output_pad: 1,
58 }
59 }
60}
61
62pub struct AssistantMessageComponent {
71 content_container: Arc<Container>,
73 options: Mutex<AssistantMessageOptions>,
75 show_images: Mutex<bool>,
76 trailing_spacing: Mutex<usize>,
77 has_tool_calls: Mutex<bool>,
79 is_streaming: Mutex<bool>,
81 last_blocks: Mutex<Vec<AssistantBlock>>,
83 markdown_transformer: Mutex<Option<Arc<dyn Fn(&str) -> String + Send + Sync>>>,
93}
94
95impl AssistantMessageComponent {
96 pub fn new(options: AssistantMessageOptions) -> Self {
98 let content_container = Arc::new(Container::new());
99
100 Self {
101 content_container,
102 options: Mutex::new(options),
103 show_images: Mutex::new(true),
104 trailing_spacing: Mutex::new(0),
105 has_tool_calls: Mutex::new(false),
106 is_streaming: Mutex::new(false),
107 last_blocks: Mutex::new(Vec::new()),
108 markdown_transformer: Mutex::new(None),
109 }
110 }
111
112 pub fn set_markdown_transformer(
123 &self,
124 transformer: Option<Arc<dyn Fn(&str) -> String + Send + Sync>>,
125 ) {
126 let blocks = {
127 let mut cur = self.markdown_transformer.lock().unwrap();
128 *cur = transformer;
132 self.last_blocks.lock().unwrap().clone()
133 };
134 if !blocks.is_empty() {
135 self.rebuild_content(&blocks);
136 }
137 }
138
139 fn transform_markdown(&self, raw: &str) -> String {
147 match self.markdown_transformer.lock().unwrap().clone() {
148 Some(f) => f(raw),
149 None => raw.to_string(),
150 }
151 }
152
153 pub fn default() -> Self {
155 Self::new(AssistantMessageOptions::default())
156 }
157
158 pub fn update_blocks(&self, blocks: &[AssistantBlock]) {
165 *self.last_blocks.lock().unwrap() = blocks.to_vec();
166 self.rebuild_content(blocks);
167 }
168
169 pub fn update_text(&self, text: &str) {
175 if text.trim().is_empty() {
176 self.update_blocks(&[]);
177 } else {
178 self.update_blocks(&[AssistantBlock::Text(text.to_string())]);
179 }
180 }
181
182 fn rebuild_content(&self, blocks: &[AssistantBlock]) {
190 self.content_container.clear();
191
192 let opts = self.options.lock().unwrap().clone();
193 let has_visible = blocks.iter().any(|b| {
194 matches!(b, AssistantBlock::Text(t) if !t.trim().is_empty())
195 || matches!(b, AssistantBlock::Thinking(t) if !t.trim().is_empty())
196 || matches!(b, AssistantBlock::Image(data) if !data.is_empty())
197 });
198
199 if !has_visible {
200 return;
203 }
204
205 let mut i = 0;
210 while i < blocks.len() {
211 let block = &blocks[i];
212 match block {
213 AssistantBlock::Text(t) if !t.trim().is_empty() => {
214 let transformed = self.transform_markdown(t.trim());
220 let md = Arc::new(Markdown::new(transformed, opts.output_pad, 0));
221 self.content_container.add_child(md);
222 i += 1;
223 }
224 AssistantBlock::Text(_) => {
225 i += 1;
227 }
228 AssistantBlock::Image(data) if !data.is_empty() => {
229 let image = Arc::new(Image::from_data(data.clone(), ImageOptions::default()));
230 image.set_inline_visible(*self.show_images.lock().unwrap());
231 self.content_container.add_child(image);
232 i += 1;
233 }
234 AssistantBlock::Image(_) => {
235 i += 1;
236 }
237 AssistantBlock::Thinking(_) => {
238 let mut joined: Vec<String> = Vec::new();
241 while i < blocks.len() {
242 match &blocks[i] {
243 AssistantBlock::Thinking(t) if !t.trim().is_empty() => {
244 joined.push(t.trim().to_string());
245 i += 1;
246 }
247 AssistantBlock::Thinking(_) => {
248 i += 1; }
250 _ => break,
251 }
252 }
253 if joined.is_empty() {
254 continue;
255 }
256
257 if opts.hide_thinking {
258 self.content_container.add_child(Arc::new(Text::new(
260 italic(&theme().colors.thinking_text.fg(&opts.hidden_thinking_label)),
261 opts.output_pad,
262 0,
263 )));
264 } else {
265 let body = joined.join("\n\n");
274 let transformed = self.transform_markdown(&body);
277 let base_style = format!("\x1b[3m{}", theme().colors.thinking_text.to_fg());
278 let md = Arc::new(
279 Markdown::new(transformed, opts.output_pad, 0)
280 .with_base_style(base_style),
281 );
282 self.content_container.add_child(md);
283 }
284
285 let has_after = blocks[i..].iter().any(|b| {
288 matches!(b, AssistantBlock::Text(t) if !t.trim().is_empty())
289 || matches!(b, AssistantBlock::Thinking(t) if !t.trim().is_empty())
290 || matches!(b, AssistantBlock::Image(data) if !data.is_empty())
291 });
292 if has_after {
293 self.content_container.add_child(Arc::new(Spacer::new(1)));
294 }
295 }
296 }
297 }
298 }
299
300 pub fn update_error(&self, error: &str, stop_reason: &str) {
302 self.content_container.clear();
304 *self.last_blocks.lock().unwrap() = Vec::new();
305
306 self.content_container.add_child(Arc::new(Spacer::new(1)));
308
309 let error_text = match stop_reason {
311 "aborted" => format!("❌ Operation aborted: {}", error),
312 "error" => format!("❌ Error: {}", error),
313 "length" => "⚠️ Response was truncated before completion.".to_string(),
314 _ => format!("❌ {}", error),
315 };
316
317 let error_component = Arc::new(Text::new(error_text, 1, 0));
318 self.content_container.add_child(error_component);
319 }
320
321 pub fn set_hide_thinking(&self, hide: bool) {
324 let blocks = {
325 let mut opts = self.options.lock().unwrap();
326 if opts.hide_thinking == hide {
327 return;
328 }
329 opts.hide_thinking = hide;
330 self.last_blocks.lock().unwrap().clone()
331 };
332 self.rebuild_content(&blocks);
333 }
334
335 pub fn set_show_images(&self, show: bool) {
336 *self.show_images.lock().unwrap() = show;
337 for child in self.content_container.get_children() {
338 if let Some(image) = child.as_any().downcast_ref::<Image>() {
339 image.set_inline_visible(show);
340 }
341 }
342 }
343
344 pub fn set_hidden_thinking_label(&self, label: &str) {
346 let blocks = {
347 let mut opts = self.options.lock().unwrap();
348 opts.hidden_thinking_label = label.to_string();
349 self.last_blocks.lock().unwrap().clone()
350 };
351 self.rebuild_content(&blocks);
352 }
353
354 pub fn set_output_pad(&self, pad: usize) {
356 let blocks = {
357 let mut opts = self.options.lock().unwrap();
358 opts.output_pad = pad;
359 self.last_blocks.lock().unwrap().clone()
360 };
361 self.rebuild_content(&blocks);
362 }
363
364 pub fn has_tool_calls(&self) -> bool {
366 *self.has_tool_calls.lock().unwrap()
367 }
368
369 pub fn markdown_transformer(&self) -> Option<Arc<dyn Fn(&str) -> String + Send + Sync>> {
374 self.markdown_transformer.lock().unwrap().clone()
375 }
376
377 pub fn set_trailing_spacing(&self, rows: usize) {
379 *self.trailing_spacing.lock().unwrap() = rows;
380 }
381
382 pub fn set_streaming(&self, streaming: bool) {
384 if let Ok(mut s) = self.is_streaming.lock() {
385 *s = streaming;
386 }
387 }
388}
389
390impl Component for AssistantMessageComponent {
391 fn render(&self, width: usize) -> Vec<String> {
392 let mut lines = self.content_container.render(width);
393 if !lines.is_empty() {
396 lines.extend((0..*self.trailing_spacing.lock().unwrap()).map(|_| String::new()));
397 }
398 lines
399 }
400
401 fn invalidate(&self) {
402 let blocks = self.last_blocks.lock().unwrap().clone();
405 if !blocks.is_empty() {
406 self.rebuild_content(&blocks);
407 }
408 self.content_container.invalidate();
409 }
410
411 fn as_any(&self) -> &dyn Any {
412 self
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::ansi::strip_ansi;
420
421 #[test]
422 fn test_assistant_message_basic() {
423 let msg = AssistantMessageComponent::default();
424 msg.update_text("Hello, world!");
425
426 let lines = msg.render(80);
427 assert!(!lines.is_empty());
428 }
429
430 #[test]
431 fn test_assistant_message_empty() {
432 let msg = AssistantMessageComponent::default();
433 msg.update_text("");
434
435 let lines = msg.render(80);
436 assert!(lines.is_empty() || lines.iter().all(|l| l.trim().is_empty()));
437 }
438
439 #[test]
440 fn trailing_spacing_does_not_render_for_empty_streaming_content() {
441 let msg = AssistantMessageComponent::default();
442 msg.set_trailing_spacing(1);
443 msg.set_streaming(true);
444 for blocks in [
445 vec![],
446 vec![AssistantBlock::Text(" \n\t".into())],
447 vec![AssistantBlock::Thinking(" ".into())],
448 ] {
449 msg.update_blocks(&blocks);
450 assert!(msg.render(80).is_empty());
451 }
452 msg.update_text("Hello");
453 msg.set_trailing_spacing(0);
454 let mut expected = msg.render(80);
455 expected.push(String::new());
456 msg.set_trailing_spacing(1);
457 assert_eq!(msg.render(80), expected);
458 msg.update_blocks(&[]);
459 msg.set_streaming(false);
460 assert!(msg.render(80).is_empty());
461 }
462
463 #[test]
464 fn test_assistant_message_error() {
465 let msg = AssistantMessageComponent::default();
466 msg.update_error("Something went wrong", "error");
467
468 let lines = msg.render(80);
469 assert!(!lines.is_empty());
470 assert!(strip_ansi(&lines.join("\n")).contains("Error:"));
471 }
472
473 #[test]
474 fn test_assistant_message_options() {
475 let msg = AssistantMessageComponent::new(AssistantMessageOptions {
476 hide_thinking: true,
477 hidden_thinking_label: "Processing...".to_string(),
478 output_pad: 2,
479 });
480
481 msg.update_text("Test");
482 let lines = msg.render(80);
483 assert!(!lines.is_empty());
484 }
485
486 #[test]
487 fn test_thinking_and_text_render_in_order() {
488 let msg = AssistantMessageComponent::default();
491 msg.update_blocks(&[
492 AssistantBlock::Thinking("Let me consider the options.".to_string()),
493 AssistantBlock::Text("Here is the answer.".to_string()),
494 ]);
495 let lines = msg.render(80);
496 let joined = strip_ansi(&lines.join("\n"));
497 let think_pos = joined.find("consider the options");
499 let text_pos = joined.find("Here is the answer");
500 assert!(think_pos.is_some(), "thinking not rendered: {joined}");
501 assert!(text_pos.is_some(), "text not rendered: {joined}");
502 assert!(
503 think_pos < text_pos,
504 "thinking should precede text: {joined}"
505 );
506 }
507
508 #[test]
509 fn test_thinking_style_survives_inline_code_reset() {
510 let msg = AssistantMessageComponent::default();
515 msg.update_blocks(&[AssistantBlock::Thinking(
516 "consider `render_inline` then keep thinking".to_string(),
517 )]);
518 let rendered = msg.render(80).join("\n");
519 let fg = theme().colors.thinking_text.to_fg();
520 let after_code = rendered
521 .split("render_inline")
522 .nth(1)
523 .expect("inline code not rendered");
524 assert!(
525 after_code.contains("keep thinking"),
526 "text after inline code missing: {rendered:?}"
527 );
528 assert!(
529 after_code.contains("\x1b[3m") && after_code.contains(&fg),
530 "thinking italic/color lost after inline code: {rendered:?}"
531 );
532 }
533
534 #[test]
535 fn test_hide_thinking_shows_label_only() {
536 let msg = AssistantMessageComponent::new(AssistantMessageOptions {
537 hide_thinking: true,
538 hidden_thinking_label: "Thinking...".to_string(),
539 ..Default::default()
540 });
541 msg.update_blocks(&[
542 AssistantBlock::Thinking("secret reasoning".to_string()),
543 AssistantBlock::Text("Public answer.".to_string()),
544 ]);
545 let joined = strip_ansi(&msg.render(80).join("\n"));
546 assert!(joined.contains("Thinking..."), "label missing: {joined}");
547 assert!(
548 !joined.contains("secret reasoning"),
549 "hidden thinking leaked: {joined}"
550 );
551 assert!(joined.contains("Public answer."), "text missing: {joined}");
552 }
553
554 #[test]
555 fn test_consecutive_thinking_blocks_coalesce() {
556 let msg = AssistantMessageComponent::default();
560 msg.update_blocks(&[
561 AssistantBlock::Thinking("part one".to_string()),
562 AssistantBlock::Thinking("part two".to_string()),
563 AssistantBlock::Text("Done.".to_string()),
564 ]);
565 let joined = strip_ansi(&msg.render(80).join("\n"));
566 assert!(joined.contains("part one"));
567 assert!(joined.contains("part two"));
568 assert!(joined.contains("Done."));
569 }
570
571 #[test]
572 fn test_set_hide_thinking_rebuilds() {
573 let msg = AssistantMessageComponent::default();
575 msg.update_blocks(&[
576 AssistantBlock::Thinking("internal".to_string()),
577 AssistantBlock::Text("visible".to_string()),
578 ]);
579 let before = strip_ansi(&msg.render(80).join("\n"));
580 assert!(before.contains("internal"));
581 msg.set_hide_thinking(true);
582 let after = strip_ansi(&msg.render(80).join("\n"));
583 assert!(
584 !after.contains("internal"),
585 "not rebuilt after toggle: {after}"
586 );
587 assert!(after.contains("Thinking..."));
588 }
589
590 #[test]
591 fn test_markdown_transformer_applied_to_text_and_thinking() {
592 let msg = AssistantMessageComponent::default();
597 let transformer: Arc<dyn Fn(&str) -> String + Send + Sync> =
598 Arc::new(|raw: &str| raw.to_uppercase());
599 msg.set_markdown_transformer(Some(transformer));
600 msg.update_blocks(&[
601 AssistantBlock::Thinking("quiet reasoning".to_string()),
602 AssistantBlock::Text("hello world".to_string()),
603 ]);
604 let joined = strip_ansi(&msg.render(80).join("\n"));
605 assert!(
606 joined.contains("QUIET REASONING"),
607 "thinking not transformed: {joined}"
608 );
609 assert!(
610 joined.contains("HELLO WORLD"),
611 "text not transformed: {joined}"
612 );
613 }
614
615 #[test]
616 fn test_markdown_transformer_swap_rebuilds() {
617 let msg = AssistantMessageComponent::default();
621 msg.update_blocks(&[AssistantBlock::Text("hello".to_string())]);
622 let upper: Arc<dyn Fn(&str) -> String + Send + Sync> =
623 Arc::new(|raw: &str| raw.to_uppercase());
624 msg.set_markdown_transformer(Some(upper));
625 assert!(strip_ansi(&msg.render(80).join("\n")).contains("HELLO"));
626 msg.set_markdown_transformer(None);
628 assert!(strip_ansi(&msg.render(80).join("\n")).contains("hello"));
629 }
630
631 #[test]
632 fn test_no_transformer_is_identity() {
633 let msg = AssistantMessageComponent::default();
636 msg.update_blocks(&[AssistantBlock::Text("plain text".to_string())]);
637 assert!(msg.markdown_transformer().is_none());
638 assert!(strip_ansi(&msg.render(80).join("\n")).contains("plain text"));
639 }
640}