Skip to main content

rpi_tui/
assistant_message.rs

1//! Assistant message component for rendering AI responses.
2//!
3//! Based on TypeScript implementation:
4//! packages/coding-agent/src/modes/interactive/components/assistant-message.ts
5//!
6//! Renders an assistant message's content blocks **in order** — text blocks
7//! as markdown, thinking/reasoning blocks as dim italic markdown, with a
8//! spacing rule that mirrors the TS `updateContent` (a blank line between a
9//! thinking run and a following visible block). The block model
10//! ([`AssistantBlock`]) is a small, provider-free projection so this library
11//! crate never depends on `rpi-ai` (project constraint); the host glue in
12//! `rpi-cli` maps an `AssistantMessage` into `Vec<AssistantBlock>` and feeds
13//! it to [`AssistantMessageComponent::update_blocks`].
14
15use 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/// A single visible block of an assistant message, in document order. This is
28/// the provider-free projection the component renders from — the host maps
29/// `rpi_ai::Content::{Text, Thinking}` into these (dropping tool-call / image
30/// blocks, which are rendered by their own components).
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum AssistantBlock {
33    /// Plain assistant text (rendered as markdown).
34    Text(String),
35    /// Reasoning / thinking text (rendered as dim italic markdown).
36    Thinking(String),
37    /// Inline image bytes decoded from the provider's base64 image block.
38    Image(Vec<u8>),
39}
40
41/// Configuration for assistant message rendering.
42#[derive(Debug, Clone)]
43pub struct AssistantMessageOptions {
44    /// Hide thinking blocks (show placeholder instead)
45    pub hide_thinking: bool,
46    /// Label to show when thinking is hidden
47    pub hidden_thinking_label: String,
48    /// Horizontal padding
49    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
62/// Component that renders a complete assistant message.
63///
64/// Mirrors TypeScript AssistantMessageComponent class. Content is rebuilt from
65/// [`AssistantBlock`]s on every [`update_blocks`] (and the legacy
66/// [`update_text`]), keeping thinking + text in order with TS-faithful spacing.
67///
68/// [`update_blocks`]: AssistantMessageComponent::update_blocks
69/// [`update_text`]: AssistantMessageComponent::update_text
70pub struct AssistantMessageComponent {
71    /// Content container for message parts (rebuilt per update).
72    content_container: Arc<Container>,
73    /// Rendering options
74    options: Mutex<AssistantMessageOptions>,
75    show_images: Mutex<bool>,
76    trailing_spacing: Mutex<usize>,
77    /// Whether this message has tool calls
78    has_tool_calls: Mutex<bool>,
79    /// Whether currently streaming
80    is_streaming: Mutex<bool>,
81    /// The last blocks rendered (kept so `invalidate`/option changes can rebuild).
82    last_blocks: Mutex<Vec<AssistantBlock>>,
83    /// B5e: an optional markdown transformer applied to the raw assistant text
84    /// BEFORE it reaches the [`Markdown`] renderer. The host (`rpi-cli`) injects
85    /// a closure wrapping any plugin `register_markdown_transformer` handlers
86    /// (called as a sync request/response across the cdylib FFI). `None` is the
87    /// no-op identity transform, so this render crate gains **no** `rpi-extensions`
88    /// dep — only a `Fn(&str) -> String` trait object. The transform fires on
89    /// every `rebuild_content` (i.e. once per streaming delta + once on
90    /// finalize), so the plugin always sees the latest full markdown; the FFI
91    /// call is cheap (a bare fn-pointer invocation + one JSON round-trip).
92    markdown_transformer: Mutex<Option<Arc<dyn Fn(&str) -> String + Send + Sync>>>,
93}
94
95impl AssistantMessageComponent {
96    /// Create a new assistant message component.
97    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    /// B5e: install a markdown transformer applied to raw assistant text before
113    /// the [`Markdown`] renderer styles it. The closure is `Send + Sync` so it
114    /// is safe across the streaming-drain + render threads. Idempotent against
115    /// `rebuild_content`: callers may set it before the first `update_blocks` or
116    /// swap it on a `/reload`; either way the next rebuild re-applies it. A
117    /// `None` value clears the transform (identity) — used on a reload that
118    /// unregisters all markdown transformers.
119    ///
120    /// On a transform CHANGE, the last blocks are rebuilt so the new transform
121    /// is reflected immediately (mirrors `set_hide_thinking`/`set_output_pad`).
122    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            // `Arc<dyn Fn>` has no PartialEq, so we can't short-circuit on
129            // "unchanged" like the option setters. Always rebuild: the call is
130            // cheap and the rebuild only fires on an explicit set/swap.
131            *cur = transformer;
132            self.last_blocks.lock().unwrap().clone()
133        };
134        if !blocks.is_empty() {
135            self.rebuild_content(&blocks);
136        }
137    }
138
139    /// Apply the installed transformer to a raw markdown string (identity when
140    /// none is installed). Both the text arm and the thinking arm of
141    /// `rebuild_content` route through here so a single install point covers
142    /// both. Thinking is treated like text — the spec transforms "assistant
143    /// markdown text" and thinking is rendered as markdown too; a plugin that
144    /// wants to skip thinking can inspect the input it receives (no flag is
145    /// carried in the `{"markdown": …}` envelope).
146    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    /// Create with default options.
154    pub fn default() -> Self {
155        Self::new(AssistantMessageOptions::default())
156    }
157
158    /// Update the content from a flat block list. Mirrors the TS
159    /// `updateContent` ordering + spacing: text blocks render as markdown,
160    /// **runs** of consecutive thinking blocks coalesce into one dim-italic
161    /// markdown section (joined with `\n\n`), and a blank line separates a
162    /// thinking run from a following visible block. When `hide_thinking` is
163    /// set, each thinking run collapses to a single dim label line.
164    pub fn update_blocks(&self, blocks: &[AssistantBlock]) {
165        *self.last_blocks.lock().unwrap() = blocks.to_vec();
166        self.rebuild_content(blocks);
167    }
168
169    /// Update the content with assistant message text.
170    ///
171    /// Convenience for callers that have only text (the legacy path). The
172    /// block-aware [`update_blocks`] supersedes this where thinking blocks
173    /// are available; both share the rebuild path.
174    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    /// Rebuild the content container from a block list (the shared render path).
183    ///
184    /// Mirrors the TS `updateContent` loop, simplified to the text/thinking
185    /// block pair (tool-call / image / error rendering is owned by other
186    /// components: tool-exec blocks render via `ToolExecutionComponent`,
187    /// and stop-reason error lines surface through `add_error_message` in the
188    /// host). Empty content renders nothing.
189    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            // Empty / whitespace-only content renders nothing (matches the TS
201            // early return when no visible block is present).
202            return;
203        }
204
205        // No leading spacer here — the host adds a Spacer(1) after each
206        // transcript entry, which already separates this message from the
207        // one above. A leading spacer stacked with the host's trailing one
208        // produced a double blank between messages.
209        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                    // B5e: apply the installed markdown transformer (identity
215                    // when none) BEFORE styling, so a plugin's
216                    // `register_markdown_transformer` sees the raw assistant
217                    // markdown and `Markdown::render_markdown` styles the
218                    // transformed text.
219                    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                    // Whitespace-only text block — skip (TS trims + skips empty).
226                    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                    // Coalesce a run of consecutive thinking blocks into one
239                    // section, exactly like the TS inner loop.
240                    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; // whitespace-only thinking, skip
249                            }
250                            _ => break,
251                        }
252                    }
253                    if joined.is_empty() {
254                        continue;
255                    }
256
257                    if opts.hide_thinking {
258                        // One static dim label per thinking run (TS path).
259                        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                        // Render the joined thinking as one dim italic markdown
266                        // section — the TS `color: thinkingText, italic: true`
267                        // styling. The base style must be applied per rendered
268                        // line (not by wrapping the whole markdown source with
269                        // ANSI): the renderer's inner spans (inline code,
270                        // links, headings) emit `SGR 0` resets, which would
271                        // otherwise drop the enclosing italic/color from the
272                        // rest of the block.
273                        let body = joined.join("\n\n");
274                        // B5e: transform the PLAIN thinking markdown first (the
275                        // plugin must see markdown, not ANSI).
276                        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                    // Spacer before a following visible block (TS
286                    // `hasVisibleContentAfter` → Spacer(1)).
287                    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    /// Update with error message.
301    pub fn update_error(&self, error: &str, stop_reason: &str) {
302        // Clear content container
303        self.content_container.clear();
304        *self.last_blocks.lock().unwrap() = Vec::new();
305
306        // Add spacing
307        self.content_container.add_child(Arc::new(Spacer::new(1)));
308
309        // Add error message
310        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    /// Set hide thinking option. Rebuilds the last content so the toggle is
322    /// reflected immediately (mirrors the TS setter).
323    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    /// Set hidden thinking label. Rebuilds the last content (mirrors TS).
345    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    /// Set output padding. Rebuilds the last content (mirrors TS).
355    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    /// Check if has tool calls.
365    pub fn has_tool_calls(&self) -> bool {
366        *self.has_tool_calls.lock().unwrap()
367    }
368
369    /// The currently installed markdown transformer (B5e), if any. The host
370    /// reads this to carry an existing transform into a fresh component (e.g.
371    /// after a `/reload`) so the new component renders with the same plugin
372    /// transformer without the host re-querying the registry.
373    pub fn markdown_transformer(&self) -> Option<Arc<dyn Fn(&str) -> String + Send + Sync>> {
374        self.markdown_transformer.lock().unwrap().clone()
375    }
376
377    /// Add trailing rows only when the message renders visible content.
378    pub fn set_trailing_spacing(&self, rows: usize) {
379        *self.trailing_spacing.lock().unwrap() = rows;
380    }
381
382    /// Set streaming state.
383    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        // A provider can start with no content or whitespace-only deltas.
394        // Such a message must not reserve even its inter-message spacing.
395        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        // Rebuild from the last blocks (mirrors the TS invalidate path that
414        // re-runs updateContent on the cached lastMessage).
415        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        // A thinking block followed by a text block should render BOTH, in
500        // order, with a blank separator between them.
501        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        // Ordering: the thinking content appears before the answer text.
509        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        // Regression: the thinking base style used to be applied by wrapping
522        // the whole markdown source with ANSI. Inline code emits an `SGR 0`
523        // reset, which dropped the italic + thinking color for the rest of the
524        // block. The base style must be re-opened after that reset.
525        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        // Two adjacent thinking blocks coalesce into one section (no trailing
568        // blank between them — the spacer goes between the run and the next
569        // visible block, not between thinking blocks).
570        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        // Toggling hide_thinking after an update should rebuild the render.
585        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        // B5e: an installed transformer rewrites the raw markdown BEFORE styling.
604        // A trivial uppercasing transformer proves the text arm + the thinking
605        // arm both route through `transform_markdown` (and that the thinking arm
606        // transforms the PLAIN body, not the ANSI-wrapped output).
607        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        // B5e: swapping the transformer after an update rebuilds the last blocks
629        // so the new transform is reflected immediately (mirrors the option
630        // setters). A None clears the transform (identity).
631        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        // Clear → identity.
638        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        // B5e: with no transformer installed, raw text reaches the renderer
645        // unchanged (the default path — every existing test relies on this).
646        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}