1use std::any::Any;
16use std::sync::{Arc, Mutex};
17
18use super::component::{Component, RenderedDocument};
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 render_document(&self, width: usize) -> RenderedDocument {
402 let mut document = self.content_container.render_document(width);
403 if !document.is_empty() {
404 let spacing = *self.trailing_spacing.lock().unwrap();
405 if spacing > 0 {
406 document.append(RenderedDocument::from_lines(vec![String::new(); spacing]));
407 }
408 }
409 document
410 }
411
412 fn invalidate(&self) {
413 let blocks = self.last_blocks.lock().unwrap().clone();
416 if !blocks.is_empty() {
417 self.rebuild_content(&blocks);
418 }
419 self.content_container.invalidate();
420 }
421
422 fn as_any(&self) -> &dyn Any {
423 self
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::ansi::strip_ansi;
431
432 #[test]
433 fn test_assistant_message_basic() {
434 let msg = AssistantMessageComponent::default();
435 msg.update_text("Hello, world!");
436
437 let lines = msg.render(80);
438 assert!(!lines.is_empty());
439 }
440
441 #[test]
442 fn test_assistant_message_empty() {
443 let msg = AssistantMessageComponent::default();
444 msg.update_text("");
445
446 let lines = msg.render(80);
447 assert!(lines.is_empty() || lines.iter().all(|l| l.trim().is_empty()));
448 }
449
450 #[test]
451 fn trailing_spacing_does_not_render_for_empty_streaming_content() {
452 let msg = AssistantMessageComponent::default();
453 msg.set_trailing_spacing(1);
454 msg.set_streaming(true);
455 for blocks in [
456 vec![],
457 vec![AssistantBlock::Text(" \n\t".into())],
458 vec![AssistantBlock::Thinking(" ".into())],
459 ] {
460 msg.update_blocks(&blocks);
461 assert!(msg.render(80).is_empty());
462 }
463 msg.update_text("Hello");
464 msg.set_trailing_spacing(0);
465 let mut expected = msg.render(80);
466 expected.push(String::new());
467 msg.set_trailing_spacing(1);
468 assert_eq!(msg.render(80), expected);
469 msg.update_blocks(&[]);
470 msg.set_streaming(false);
471 assert!(msg.render(80).is_empty());
472 }
473
474 #[test]
475 fn test_assistant_message_error() {
476 let msg = AssistantMessageComponent::default();
477 msg.update_error("Something went wrong", "error");
478
479 let lines = msg.render(80);
480 assert!(!lines.is_empty());
481 assert!(strip_ansi(&lines.join("\n")).contains("Error:"));
482 }
483
484 #[test]
485 fn test_assistant_message_options() {
486 let msg = AssistantMessageComponent::new(AssistantMessageOptions {
487 hide_thinking: true,
488 hidden_thinking_label: "Processing...".to_string(),
489 output_pad: 2,
490 });
491
492 msg.update_text("Test");
493 let lines = msg.render(80);
494 assert!(!lines.is_empty());
495 }
496
497 #[test]
498 fn test_thinking_and_text_render_in_order() {
499 let msg = AssistantMessageComponent::default();
502 msg.update_blocks(&[
503 AssistantBlock::Thinking("Let me consider the options.".to_string()),
504 AssistantBlock::Text("Here is the answer.".to_string()),
505 ]);
506 let lines = msg.render(80);
507 let joined = strip_ansi(&lines.join("\n"));
508 let think_pos = joined.find("consider the options");
510 let text_pos = joined.find("Here is the answer");
511 assert!(think_pos.is_some(), "thinking not rendered: {joined}");
512 assert!(text_pos.is_some(), "text not rendered: {joined}");
513 assert!(
514 think_pos < text_pos,
515 "thinking should precede text: {joined}"
516 );
517 }
518
519 #[test]
520 fn test_thinking_style_survives_inline_code_reset() {
521 let msg = AssistantMessageComponent::default();
526 msg.update_blocks(&[AssistantBlock::Thinking(
527 "consider `render_inline` then keep thinking".to_string(),
528 )]);
529 let rendered = msg.render(80).join("\n");
530 let fg = theme().colors.thinking_text.to_fg();
531 let after_code = rendered
532 .split("render_inline")
533 .nth(1)
534 .expect("inline code not rendered");
535 assert!(
536 after_code.contains("keep thinking"),
537 "text after inline code missing: {rendered:?}"
538 );
539 assert!(
540 after_code.contains("\x1b[3m") && after_code.contains(&fg),
541 "thinking italic/color lost after inline code: {rendered:?}"
542 );
543 }
544
545 #[test]
546 fn test_hide_thinking_shows_label_only() {
547 let msg = AssistantMessageComponent::new(AssistantMessageOptions {
548 hide_thinking: true,
549 hidden_thinking_label: "Thinking...".to_string(),
550 ..Default::default()
551 });
552 msg.update_blocks(&[
553 AssistantBlock::Thinking("secret reasoning".to_string()),
554 AssistantBlock::Text("Public answer.".to_string()),
555 ]);
556 let joined = strip_ansi(&msg.render(80).join("\n"));
557 assert!(joined.contains("Thinking..."), "label missing: {joined}");
558 assert!(
559 !joined.contains("secret reasoning"),
560 "hidden thinking leaked: {joined}"
561 );
562 assert!(joined.contains("Public answer."), "text missing: {joined}");
563 }
564
565 #[test]
566 fn test_consecutive_thinking_blocks_coalesce() {
567 let msg = AssistantMessageComponent::default();
571 msg.update_blocks(&[
572 AssistantBlock::Thinking("part one".to_string()),
573 AssistantBlock::Thinking("part two".to_string()),
574 AssistantBlock::Text("Done.".to_string()),
575 ]);
576 let joined = strip_ansi(&msg.render(80).join("\n"));
577 assert!(joined.contains("part one"));
578 assert!(joined.contains("part two"));
579 assert!(joined.contains("Done."));
580 }
581
582 #[test]
583 fn test_set_hide_thinking_rebuilds() {
584 let msg = AssistantMessageComponent::default();
586 msg.update_blocks(&[
587 AssistantBlock::Thinking("internal".to_string()),
588 AssistantBlock::Text("visible".to_string()),
589 ]);
590 let before = strip_ansi(&msg.render(80).join("\n"));
591 assert!(before.contains("internal"));
592 msg.set_hide_thinking(true);
593 let after = strip_ansi(&msg.render(80).join("\n"));
594 assert!(
595 !after.contains("internal"),
596 "not rebuilt after toggle: {after}"
597 );
598 assert!(after.contains("Thinking..."));
599 }
600
601 #[test]
602 fn test_markdown_transformer_applied_to_text_and_thinking() {
603 let msg = AssistantMessageComponent::default();
608 let transformer: Arc<dyn Fn(&str) -> String + Send + Sync> =
609 Arc::new(|raw: &str| raw.to_uppercase());
610 msg.set_markdown_transformer(Some(transformer));
611 msg.update_blocks(&[
612 AssistantBlock::Thinking("quiet reasoning".to_string()),
613 AssistantBlock::Text("hello world".to_string()),
614 ]);
615 let joined = strip_ansi(&msg.render(80).join("\n"));
616 assert!(
617 joined.contains("QUIET REASONING"),
618 "thinking not transformed: {joined}"
619 );
620 assert!(
621 joined.contains("HELLO WORLD"),
622 "text not transformed: {joined}"
623 );
624 }
625
626 #[test]
627 fn test_markdown_transformer_swap_rebuilds() {
628 let msg = AssistantMessageComponent::default();
632 msg.update_blocks(&[AssistantBlock::Text("hello".to_string())]);
633 let upper: Arc<dyn Fn(&str) -> String + Send + Sync> =
634 Arc::new(|raw: &str| raw.to_uppercase());
635 msg.set_markdown_transformer(Some(upper));
636 assert!(strip_ansi(&msg.render(80).join("\n")).contains("HELLO"));
637 msg.set_markdown_transformer(None);
639 assert!(strip_ansi(&msg.render(80).join("\n")).contains("hello"));
640 }
641
642 #[test]
643 fn test_no_transformer_is_identity() {
644 let msg = AssistantMessageComponent::default();
647 msg.update_blocks(&[AssistantBlock::Text("plain text".to_string())]);
648 assert!(msg.markdown_transformer().is_none());
649 assert!(strip_ansi(&msg.render(80).join("\n")).contains("plain text"));
650 }
651}