Skip to main content

rab/agent/ui/components/
assistant_message.rs

1use std::cell::RefCell;
2use std::sync::Arc;
3
4use crate::agent::ui::theme::current_theme;
5use crate::tui::Component;
6use crate::tui::components::markdown::{DefaultTextStyle, Markdown, StyleFn};
7
8const OSC133_ZONE_START: &str = "\x1b]133;A\x07";
9const OSC133_ZONE_END: &str = "\x1b]133;B\x07";
10const OSC133_ZONE_FINAL: &str = "\x1b]133;C\x07";
11
12/// Assistant message component - matches pi's AssistantMessageComponent.
13/// Renders text content with Markdown, optional thinking blocks.
14pub struct AssistantMessageComponent {
15    text: String,
16    thinking: Vec<ThinkingBlock>,
17    hide_thinking: bool,
18    cached_lines: RefCell<Option<Vec<String>>>,
19    cached_width: RefCell<usize>,
20}
21
22pub struct ThinkingBlock {
23    pub text: String,
24    pub level: Option<String>,
25}
26
27impl AssistantMessageComponent {
28    pub fn new(text: impl Into<String>) -> Self {
29        Self {
30            text: text.into(),
31            thinking: Vec::new(),
32            hide_thinking: false,
33            cached_lines: RefCell::new(None),
34            cached_width: RefCell::new(0),
35        }
36    }
37
38    pub fn add_thinking(&mut self, text: impl Into<String>, level: Option<String>) {
39        let text = text.into();
40        if text.is_empty() {
41            return;
42        }
43        if let Some(last) = self.thinking.last_mut() {
44            // Skip exact duplicates (same content sent again by the provider).
45            if text == last.text {
46                return;
47            }
48            // Some providers (e.g. Ollama) send FULL accumulated content in each
49            // chunk instead of a delta. Detect this: if the new text is longer,
50            // and its trimmed version starts with the trimmed existing text,
51            // it's a full accumulation — replace instead of append.
52            if text.len() > last.text.len() {
53                let t_trimmed = text.trim_start();
54                let l_trimmed = last.text.trim_start();
55                if t_trimmed.starts_with(l_trimmed) {
56                    last.text = text;
57                    self.invalidate();
58                    return;
59                }
60            }
61            // Default: treat as delta and append
62            last.text.push_str(&text);
63        } else {
64            self.thinking.push(ThinkingBlock { text, level });
65        }
66        self.invalidate();
67    }
68
69    pub fn append_text(&mut self, delta: &str) {
70        if delta.is_empty() {
71            return;
72        }
73        // Some providers (e.g. Ollama) send FULL accumulated content in each chunk
74        // instead of a delta. Detect this: if delta is longer than existing text,
75        // and its trimmed version starts with the trimmed existing text,
76        // replace instead of append.
77        if delta.len() > self.text.len() {
78            let d_trimmed = delta.trim_start();
79            let s_trimmed = self.text.trim_start();
80            if delta == self.text {
81                return; // Skip exact duplicate
82            }
83            if d_trimmed.starts_with(s_trimmed) {
84                self.text = delta.to_string();
85                self.invalidate();
86                return;
87            }
88        } else if delta == self.text {
89            return; // Skip exact duplicate
90        }
91        self.text.push_str(delta);
92        self.invalidate();
93    }
94
95    pub fn set_text(&mut self, text: impl Into<String>) {
96        self.text = text.into();
97        self.invalidate();
98    }
99}
100
101impl Component for AssistantMessageComponent {
102    // No-op: expand/collapse is controlled separately via set_hide_thinking.
103    // Pi keeps app.thinking.toggle (Ctrl+T) and app.tools.expand (Ctrl+O)
104    // as independent concerns — tool expansion must not affect thinking visibility.
105
106    fn render(&self, width: usize) -> Vec<String> {
107        let cached = self.cached_lines.borrow();
108        if *self.cached_width.borrow() == width
109            && let Some(ref lines) = *cached
110        {
111            return lines.clone();
112        }
113        drop(cached);
114
115        let mut lines: Vec<String> = Vec::new();
116        let md_theme = crate::agent::ui::theme::get_markdown_theme();
117
118        let has_thinking =
119            !self.thinking.is_empty() && self.thinking.iter().any(|b| !b.text.trim().is_empty());
120        let has_text = !self.text.trim().is_empty();
121        let has_any_content = has_thinking || has_text;
122
123        // Leading blank line before any content (matching pi's leading Spacer(1))
124        if has_any_content {
125            lines.push(String::new());
126        }
127
128        // Render thinking blocks through Markdown (matching pi's approach)
129        for block in &self.thinking {
130            if block.text.trim().is_empty() {
131                continue;
132            }
133            if self.hide_thinking {
134                // Match pi: Text with padding_x=1, italic, thinkingText color
135                let theme = current_theme();
136                let label = theme.italic(&theme.fg("thinkingText", "Thinking..."));
137                let padded = format!(" {} ", label);
138                lines.push(padded);
139            } else {
140                // Pi always uses thinkingText color for ALL thinking blocks (not per-level)
141                let color_fn: StyleFn = Arc::new(|s: &str| -> String {
142                    crate::agent::ui::theme::current_theme().fg("thinkingText", s)
143                });
144                let default_style = DefaultTextStyle {
145                    color: Some(color_fn),
146                    bg_color: None,
147                    bold: false,
148                    italic: true,
149                    strikethrough: false,
150                    underline: false,
151                };
152                // Match pi: padding_x=1, padding_y=0, thinkingText + italic
153                let md = Markdown::new(
154                    block.text.trim().to_string(),
155                    1,
156                    0,
157                    crate::agent::ui::theme::get_markdown_theme(),
158                    Some(default_style),
159                    None,
160                );
161                lines.extend(md.render(width));
162            }
163        }
164
165        // Blank line between thinking and text (matching pi's Spacer(1) after thinking)
166        if has_thinking && has_text {
167            lines.push(String::new());
168        }
169
170        // Render main text content through Markdown (matching pi)
171        if has_text {
172            let md = Markdown::new(self.text.trim().to_string(), 1, 0, md_theme, None, None);
173            lines.extend(md.render(width));
174        }
175
176        // Add OSC133 zones around the entire component (matching pi)
177        if has_any_content && !lines.is_empty() {
178            lines[0] = format!("{}{}", OSC133_ZONE_START, &lines[0]);
179            if let Some(last) = lines.last_mut() {
180                last.push_str(OSC133_ZONE_END);
181                last.push_str(OSC133_ZONE_FINAL);
182            }
183        }
184
185        let result = lines.clone();
186        *self.cached_lines.borrow_mut() = Some(lines);
187        *self.cached_width.borrow_mut() = width;
188        result
189    }
190
191    fn set_hide_thinking(&mut self, hide: bool) {
192        self.hide_thinking = hide;
193        self.invalidate();
194    }
195
196    fn invalidate(&mut self) {
197        *self.cached_lines.borrow_mut() = None;
198    }
199}