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    /// Whether this message has tool calls
76    has_tool_calls: Mutex<bool>,
77    /// Whether currently streaming
78    is_streaming: Mutex<bool>,
79    /// The last blocks rendered (kept so `invalidate`/option changes can rebuild).
80    last_blocks: Mutex<Vec<AssistantBlock>>,
81    /// B5e: an optional markdown transformer applied to the raw assistant text
82    /// BEFORE it reaches the [`Markdown`] renderer. The host (`rpi-cli`) injects
83    /// a closure wrapping any plugin `register_markdown_transformer` handlers
84    /// (called as a sync request/response across the cdylib FFI). `None` is the
85    /// no-op identity transform, so this render crate gains **no** `rpi-extensions`
86    /// dep — only a `Fn(&str) -> String` trait object. The transform fires on
87    /// every `rebuild_content` (i.e. once per streaming delta + once on
88    /// finalize), so the plugin always sees the latest full markdown; the FFI
89    /// call is cheap (a bare fn-pointer invocation + one JSON round-trip).
90    markdown_transformer: Mutex<Option<Arc<dyn Fn(&str) -> String + Send + Sync>>>,
91}
92
93impl AssistantMessageComponent {
94    /// Create a new assistant message component.
95    pub fn new(options: AssistantMessageOptions) -> Self {
96        let content_container = Arc::new(Container::new());
97
98        Self {
99            content_container,
100            options: Mutex::new(options),
101            has_tool_calls: Mutex::new(false),
102            is_streaming: Mutex::new(false),
103            last_blocks: Mutex::new(Vec::new()),
104            markdown_transformer: Mutex::new(None),
105        }
106    }
107
108    /// B5e: install a markdown transformer applied to raw assistant text before
109    /// the [`Markdown`] renderer styles it. The closure is `Send + Sync` so it
110    /// is safe across the streaming-drain + render threads. Idempotent against
111    /// `rebuild_content`: callers may set it before the first `update_blocks` or
112    /// swap it on a `/reload`; either way the next rebuild re-applies it. A
113    /// `None` value clears the transform (identity) — used on a reload that
114    /// unregisters all markdown transformers.
115    ///
116    /// On a transform CHANGE, the last blocks are rebuilt so the new transform
117    /// is reflected immediately (mirrors `set_hide_thinking`/`set_output_pad`).
118    pub fn set_markdown_transformer(
119        &self,
120        transformer: Option<Arc<dyn Fn(&str) -> String + Send + Sync>>,
121    ) {
122        let blocks = {
123            let mut cur = self.markdown_transformer.lock().unwrap();
124            // `Arc<dyn Fn>` has no PartialEq, so we can't short-circuit on
125            // "unchanged" like the option setters. Always rebuild: the call is
126            // cheap and the rebuild only fires on an explicit set/swap.
127            *cur = transformer;
128            self.last_blocks.lock().unwrap().clone()
129        };
130        if !blocks.is_empty() {
131            self.rebuild_content(&blocks);
132        }
133    }
134
135    /// Apply the installed transformer to a raw markdown string (identity when
136    /// none is installed). Both the text arm and the thinking arm of
137    /// `rebuild_content` route through here so a single install point covers
138    /// both. Thinking is treated like text — the spec transforms "assistant
139    /// markdown text" and thinking is rendered as markdown too; a plugin that
140    /// wants to skip thinking can inspect the input it receives (no flag is
141    /// carried in the `{"markdown": …}` envelope).
142    fn transform_markdown(&self, raw: &str) -> String {
143        match self.markdown_transformer.lock().unwrap().clone() {
144            Some(f) => f(raw),
145            None => raw.to_string(),
146        }
147    }
148
149    /// Create with default options.
150    pub fn default() -> Self {
151        Self::new(AssistantMessageOptions::default())
152    }
153
154    /// Update the content from a flat block list. Mirrors the TS
155    /// `updateContent` ordering + spacing: text blocks render as markdown,
156    /// **runs** of consecutive thinking blocks coalesce into one dim-italic
157    /// markdown section (joined with `\n\n`), and a blank line separates a
158    /// thinking run from a following visible block. When `hide_thinking` is
159    /// set, each thinking run collapses to a single dim label line.
160    pub fn update_blocks(&self, blocks: &[AssistantBlock]) {
161        *self.last_blocks.lock().unwrap() = blocks.to_vec();
162        self.rebuild_content(blocks);
163    }
164
165    /// Update the content with assistant message text.
166    ///
167    /// Convenience for callers that have only text (the legacy path). The
168    /// block-aware [`update_blocks`] supersedes this where thinking blocks
169    /// are available; both share the rebuild path.
170    pub fn update_text(&self, text: &str) {
171        if text.trim().is_empty() {
172            self.update_blocks(&[]);
173        } else {
174            self.update_blocks(&[AssistantBlock::Text(text.to_string())]);
175        }
176    }
177
178    /// Rebuild the content container from a block list (the shared render path).
179    ///
180    /// Mirrors the TS `updateContent` loop, simplified to the text/thinking
181    /// block pair (tool-call / image / error rendering is owned by other
182    /// components: tool-exec blocks render via `ToolExecutionComponent`,
183    /// and stop-reason error lines surface through `add_error_message` in the
184    /// host). Empty content renders nothing.
185    fn rebuild_content(&self, blocks: &[AssistantBlock]) {
186        self.content_container.clear();
187
188        let opts = self.options.lock().unwrap().clone();
189        let has_visible = blocks.iter().any(|b| {
190            matches!(b, AssistantBlock::Text(t) if !t.trim().is_empty())
191                || matches!(b, AssistantBlock::Thinking(t) if !t.trim().is_empty())
192                || matches!(b, AssistantBlock::Image(data) if !data.is_empty())
193        });
194
195        if !has_visible {
196            // Empty / whitespace-only content renders nothing (matches the TS
197            // early return when no visible block is present).
198            return;
199        }
200
201        // No leading spacer here — the host adds a Spacer(1) after each
202        // transcript entry, which already separates this message from the
203        // one above. A leading spacer stacked with the host's trailing one
204        // produced a double blank between messages.
205        let mut i = 0;
206        while i < blocks.len() {
207            let block = &blocks[i];
208            match block {
209                AssistantBlock::Text(t) if !t.trim().is_empty() => {
210                    // B5e: apply the installed markdown transformer (identity
211                    // when none) BEFORE styling, so a plugin's
212                    // `register_markdown_transformer` sees the raw assistant
213                    // markdown and `Markdown::render_markdown` styles the
214                    // transformed text.
215                    let transformed = self.transform_markdown(t.trim());
216                    let md = Arc::new(Markdown::new(transformed, opts.output_pad, 0));
217                    self.content_container.add_child(md);
218                    i += 1;
219                }
220                AssistantBlock::Text(_) => {
221                    // Whitespace-only text block — skip (TS trims + skips empty).
222                    i += 1;
223                }
224                AssistantBlock::Image(data) if !data.is_empty() => {
225                    self.content_container.add_child(Arc::new(Image::from_data(
226                        data.clone(),
227                        ImageOptions::default(),
228                    )));
229                    i += 1;
230                }
231                AssistantBlock::Image(_) => {
232                    i += 1;
233                }
234                AssistantBlock::Thinking(_) => {
235                    // Coalesce a run of consecutive thinking blocks into one
236                    // section, exactly like the TS inner loop.
237                    let mut joined: Vec<String> = Vec::new();
238                    while i < blocks.len() {
239                        match &blocks[i] {
240                            AssistantBlock::Thinking(t) if !t.trim().is_empty() => {
241                                joined.push(t.trim().to_string());
242                                i += 1;
243                            }
244                            AssistantBlock::Thinking(_) => {
245                                i += 1; // whitespace-only thinking, skip
246                            }
247                            _ => break,
248                        }
249                    }
250                    if joined.is_empty() {
251                        continue;
252                    }
253
254                    if opts.hide_thinking {
255                        // One static dim label per thinking run (TS path).
256                        self.content_container.add_child(Arc::new(Text::new(
257                            italic(&theme().colors.thinking_text.fg(&opts.hidden_thinking_label)),
258                            opts.output_pad,
259                            0,
260                        )));
261                    } else {
262                        // Render the joined thinking as one dim italic markdown
263                        // section — the TS `color: thinkingText, italic: true`
264                        // styling. The base style must be applied per rendered
265                        // line (not by wrapping the whole markdown source with
266                        // ANSI): the renderer's inner spans (inline code,
267                        // links, headings) emit `SGR 0` resets, which would
268                        // otherwise drop the enclosing italic/color from the
269                        // rest of the block.
270                        let body = joined.join("\n\n");
271                        // B5e: transform the PLAIN thinking markdown first (the
272                        // plugin must see markdown, not ANSI).
273                        let transformed = self.transform_markdown(&body);
274                        let base_style = format!("\x1b[3m{}", theme().colors.thinking_text.to_fg());
275                        let md = Arc::new(
276                            Markdown::new(transformed, opts.output_pad, 0)
277                                .with_base_style(base_style),
278                        );
279                        self.content_container.add_child(md);
280                    }
281
282                    // Spacer before a following visible block (TS
283                    // `hasVisibleContentAfter` → Spacer(1)).
284                    let has_after = blocks[i..].iter().any(|b| {
285                        matches!(b, AssistantBlock::Text(t) if !t.trim().is_empty())
286                            || matches!(b, AssistantBlock::Thinking(t) if !t.trim().is_empty())
287                            || matches!(b, AssistantBlock::Image(data) if !data.is_empty())
288                    });
289                    if has_after {
290                        self.content_container.add_child(Arc::new(Spacer::new(1)));
291                    }
292                }
293            }
294        }
295    }
296
297    /// Update with error message.
298    pub fn update_error(&self, error: &str, stop_reason: &str) {
299        // Clear content container
300        self.content_container.clear();
301        *self.last_blocks.lock().unwrap() = Vec::new();
302
303        // Add spacing
304        self.content_container.add_child(Arc::new(Spacer::new(1)));
305
306        // Add error message
307        let error_text = match stop_reason {
308            "aborted" => format!("❌ Operation aborted: {}", error),
309            "error" => format!("❌ Error: {}", error),
310            "length" => "⚠️ Response was truncated before completion.".to_string(),
311            _ => format!("❌ {}", error),
312        };
313
314        let error_component = Arc::new(Text::new(error_text, 1, 0));
315        self.content_container.add_child(error_component);
316    }
317
318    /// Set hide thinking option. Rebuilds the last content so the toggle is
319    /// reflected immediately (mirrors the TS setter).
320    pub fn set_hide_thinking(&self, hide: bool) {
321        let blocks = {
322            let mut opts = self.options.lock().unwrap();
323            if opts.hide_thinking == hide {
324                return;
325            }
326            opts.hide_thinking = hide;
327            self.last_blocks.lock().unwrap().clone()
328        };
329        self.rebuild_content(&blocks);
330    }
331
332    /// Set hidden thinking label. Rebuilds the last content (mirrors TS).
333    pub fn set_hidden_thinking_label(&self, label: &str) {
334        let blocks = {
335            let mut opts = self.options.lock().unwrap();
336            opts.hidden_thinking_label = label.to_string();
337            self.last_blocks.lock().unwrap().clone()
338        };
339        self.rebuild_content(&blocks);
340    }
341
342    /// Set output padding. Rebuilds the last content (mirrors TS).
343    pub fn set_output_pad(&self, pad: usize) {
344        let blocks = {
345            let mut opts = self.options.lock().unwrap();
346            opts.output_pad = pad;
347            self.last_blocks.lock().unwrap().clone()
348        };
349        self.rebuild_content(&blocks);
350    }
351
352    /// Check if has tool calls.
353    pub fn has_tool_calls(&self) -> bool {
354        *self.has_tool_calls.lock().unwrap()
355    }
356
357    /// The currently installed markdown transformer (B5e), if any. The host
358    /// reads this to carry an existing transform into a fresh component (e.g.
359    /// after a `/reload`) so the new component renders with the same plugin
360    /// transformer without the host re-querying the registry.
361    pub fn markdown_transformer(&self) -> Option<Arc<dyn Fn(&str) -> String + Send + Sync>> {
362        self.markdown_transformer.lock().unwrap().clone()
363    }
364
365    /// Set streaming state.
366    pub fn set_streaming(&self, streaming: bool) {
367        if let Ok(mut s) = self.is_streaming.lock() {
368            *s = streaming;
369        }
370    }
371}
372
373impl Component for AssistantMessageComponent {
374    fn render(&self, width: usize) -> Vec<String> {
375        // Render content container
376        self.content_container.render(width)
377    }
378
379    fn invalidate(&self) {
380        // Rebuild from the last blocks (mirrors the TS invalidate path that
381        // re-runs updateContent on the cached lastMessage).
382        let blocks = self.last_blocks.lock().unwrap().clone();
383        if !blocks.is_empty() {
384            self.rebuild_content(&blocks);
385        }
386        self.content_container.invalidate();
387    }
388
389    fn as_any(&self) -> &dyn Any {
390        self
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::ansi::strip_ansi;
398
399    #[test]
400    fn test_assistant_message_basic() {
401        let msg = AssistantMessageComponent::default();
402        msg.update_text("Hello, world!");
403
404        let lines = msg.render(80);
405        assert!(!lines.is_empty());
406    }
407
408    #[test]
409    fn test_assistant_message_empty() {
410        let msg = AssistantMessageComponent::default();
411        msg.update_text("");
412
413        let lines = msg.render(80);
414        assert!(lines.is_empty() || lines.iter().all(|l| l.trim().is_empty()));
415    }
416
417    #[test]
418    fn test_assistant_message_error() {
419        let msg = AssistantMessageComponent::default();
420        msg.update_error("Something went wrong", "error");
421
422        let lines = msg.render(80);
423        assert!(!lines.is_empty());
424        assert!(strip_ansi(&lines.join("\n")).contains("Error:"));
425    }
426
427    #[test]
428    fn test_assistant_message_options() {
429        let msg = AssistantMessageComponent::new(AssistantMessageOptions {
430            hide_thinking: true,
431            hidden_thinking_label: "Processing...".to_string(),
432            output_pad: 2,
433        });
434
435        msg.update_text("Test");
436        let lines = msg.render(80);
437        assert!(!lines.is_empty());
438    }
439
440    #[test]
441    fn test_thinking_and_text_render_in_order() {
442        // A thinking block followed by a text block should render BOTH, in
443        // order, with a blank separator between them.
444        let msg = AssistantMessageComponent::default();
445        msg.update_blocks(&[
446            AssistantBlock::Thinking("Let me consider the options.".to_string()),
447            AssistantBlock::Text("Here is the answer.".to_string()),
448        ]);
449        let lines = msg.render(80);
450        let joined = strip_ansi(&lines.join("\n"));
451        // Ordering: the thinking content appears before the answer text.
452        let think_pos = joined.find("consider the options");
453        let text_pos = joined.find("Here is the answer");
454        assert!(think_pos.is_some(), "thinking not rendered: {joined}");
455        assert!(text_pos.is_some(), "text not rendered: {joined}");
456        assert!(
457            think_pos < text_pos,
458            "thinking should precede text: {joined}"
459        );
460    }
461
462    #[test]
463    fn test_thinking_style_survives_inline_code_reset() {
464        // Regression: the thinking base style used to be applied by wrapping
465        // the whole markdown source with ANSI. Inline code emits an `SGR 0`
466        // reset, which dropped the italic + thinking color for the rest of the
467        // block. The base style must be re-opened after that reset.
468        let msg = AssistantMessageComponent::default();
469        msg.update_blocks(&[AssistantBlock::Thinking(
470            "consider `render_inline` then keep thinking".to_string(),
471        )]);
472        let rendered = msg.render(80).join("\n");
473        let fg = theme().colors.thinking_text.to_fg();
474        let after_code = rendered
475            .split("render_inline")
476            .nth(1)
477            .expect("inline code not rendered");
478        assert!(
479            after_code.contains("keep thinking"),
480            "text after inline code missing: {rendered:?}"
481        );
482        assert!(
483            after_code.contains("\x1b[3m") && after_code.contains(&fg),
484            "thinking italic/color lost after inline code: {rendered:?}"
485        );
486    }
487
488    #[test]
489    fn test_hide_thinking_shows_label_only() {
490        let msg = AssistantMessageComponent::new(AssistantMessageOptions {
491            hide_thinking: true,
492            hidden_thinking_label: "Thinking...".to_string(),
493            ..Default::default()
494        });
495        msg.update_blocks(&[
496            AssistantBlock::Thinking("secret reasoning".to_string()),
497            AssistantBlock::Text("Public answer.".to_string()),
498        ]);
499        let joined = strip_ansi(&msg.render(80).join("\n"));
500        assert!(joined.contains("Thinking..."), "label missing: {joined}");
501        assert!(
502            !joined.contains("secret reasoning"),
503            "hidden thinking leaked: {joined}"
504        );
505        assert!(joined.contains("Public answer."), "text missing: {joined}");
506    }
507
508    #[test]
509    fn test_consecutive_thinking_blocks_coalesce() {
510        // Two adjacent thinking blocks coalesce into one section (no trailing
511        // blank between them — the spacer goes between the run and the next
512        // visible block, not between thinking blocks).
513        let msg = AssistantMessageComponent::default();
514        msg.update_blocks(&[
515            AssistantBlock::Thinking("part one".to_string()),
516            AssistantBlock::Thinking("part two".to_string()),
517            AssistantBlock::Text("Done.".to_string()),
518        ]);
519        let joined = strip_ansi(&msg.render(80).join("\n"));
520        assert!(joined.contains("part one"));
521        assert!(joined.contains("part two"));
522        assert!(joined.contains("Done."));
523    }
524
525    #[test]
526    fn test_set_hide_thinking_rebuilds() {
527        // Toggling hide_thinking after an update should rebuild the render.
528        let msg = AssistantMessageComponent::default();
529        msg.update_blocks(&[
530            AssistantBlock::Thinking("internal".to_string()),
531            AssistantBlock::Text("visible".to_string()),
532        ]);
533        let before = strip_ansi(&msg.render(80).join("\n"));
534        assert!(before.contains("internal"));
535        msg.set_hide_thinking(true);
536        let after = strip_ansi(&msg.render(80).join("\n"));
537        assert!(
538            !after.contains("internal"),
539            "not rebuilt after toggle: {after}"
540        );
541        assert!(after.contains("Thinking..."));
542    }
543
544    #[test]
545    fn test_markdown_transformer_applied_to_text_and_thinking() {
546        // B5e: an installed transformer rewrites the raw markdown BEFORE styling.
547        // A trivial uppercasing transformer proves the text arm + the thinking
548        // arm both route through `transform_markdown` (and that the thinking arm
549        // transforms the PLAIN body, not the ANSI-wrapped output).
550        let msg = AssistantMessageComponent::default();
551        let transformer: Arc<dyn Fn(&str) -> String + Send + Sync> =
552            Arc::new(|raw: &str| raw.to_uppercase());
553        msg.set_markdown_transformer(Some(transformer));
554        msg.update_blocks(&[
555            AssistantBlock::Thinking("quiet reasoning".to_string()),
556            AssistantBlock::Text("hello world".to_string()),
557        ]);
558        let joined = strip_ansi(&msg.render(80).join("\n"));
559        assert!(
560            joined.contains("QUIET REASONING"),
561            "thinking not transformed: {joined}"
562        );
563        assert!(
564            joined.contains("HELLO WORLD"),
565            "text not transformed: {joined}"
566        );
567    }
568
569    #[test]
570    fn test_markdown_transformer_swap_rebuilds() {
571        // B5e: swapping the transformer after an update rebuilds the last blocks
572        // so the new transform is reflected immediately (mirrors the option
573        // setters). A None clears the transform (identity).
574        let msg = AssistantMessageComponent::default();
575        msg.update_blocks(&[AssistantBlock::Text("hello".to_string())]);
576        let upper: Arc<dyn Fn(&str) -> String + Send + Sync> =
577            Arc::new(|raw: &str| raw.to_uppercase());
578        msg.set_markdown_transformer(Some(upper));
579        assert!(strip_ansi(&msg.render(80).join("\n")).contains("HELLO"));
580        // Clear → identity.
581        msg.set_markdown_transformer(None);
582        assert!(strip_ansi(&msg.render(80).join("\n")).contains("hello"));
583    }
584
585    #[test]
586    fn test_no_transformer_is_identity() {
587        // B5e: with no transformer installed, raw text reaches the renderer
588        // unchanged (the default path — every existing test relies on this).
589        let msg = AssistantMessageComponent::default();
590        msg.update_blocks(&[AssistantBlock::Text("plain text".to_string())]);
591        assert!(msg.markdown_transformer().is_none());
592        assert!(strip_ansi(&msg.render(80).join("\n")).contains("plain text"));
593    }
594}