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;
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 invalidate(&self) {
402        // Rebuild from the last blocks (mirrors the TS invalidate path that
403        // re-runs updateContent on the cached lastMessage).
404        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        // A thinking block followed by a text block should render BOTH, in
489        // order, with a blank separator between them.
490        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        // Ordering: the thinking content appears before the answer text.
498        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        // Regression: the thinking base style used to be applied by wrapping
511        // the whole markdown source with ANSI. Inline code emits an `SGR 0`
512        // reset, which dropped the italic + thinking color for the rest of the
513        // block. The base style must be re-opened after that reset.
514        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        // Two adjacent thinking blocks coalesce into one section (no trailing
557        // blank between them — the spacer goes between the run and the next
558        // visible block, not between thinking blocks).
559        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        // Toggling hide_thinking after an update should rebuild the render.
574        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        // B5e: an installed transformer rewrites the raw markdown BEFORE styling.
593        // A trivial uppercasing transformer proves the text arm + the thinking
594        // arm both route through `transform_markdown` (and that the thinking arm
595        // transforms the PLAIN body, not the ANSI-wrapped output).
596        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        // B5e: swapping the transformer after an update rebuilds the last blocks
618        // so the new transform is reflected immediately (mirrors the option
619        // setters). A None clears the transform (identity).
620        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        // Clear → identity.
627        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        // B5e: with no transformer installed, raw text reaches the renderer
634        // unchanged (the default path — every existing test relies on this).
635        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}