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}