rab/agent/ui/components/
assistant_message.rs1use std::sync::Arc;
2
3use crate::agent::ui::theme::ThemeKey;
4use crate::agent::ui::theme::current_theme;
5use crate::tui::Component;
6use crate::tui::component::RenderCacheKey;
7use crate::tui::components::markdown::{DefaultTextStyle, Markdown, StyleFn};
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
12pub struct AssistantMessageComponent {
15 text: String,
16 thinking: Vec<ThinkingBlock>,
17 hide_thinking: bool,
18 cached_lines: Option<Vec<String>>,
19 cached_width: usize,
20 text_md: Option<Markdown>,
24 thinking_md: Vec<Markdown>,
26}
27
28pub struct ThinkingBlock {
29 pub text: String,
30 pub level: Option<String>,
31}
32
33impl AssistantMessageComponent {
34 pub fn new(text: impl Into<String>) -> Self {
35 Self {
36 text: text.into(),
37 thinking: Vec::new(),
38 hide_thinking: false,
39 cached_lines: None,
40 cached_width: 0,
41 text_md: None,
42 thinking_md: Vec::new(),
43 }
44 }
45
46 fn sync_text_md(&mut self) {
48 if self.text.is_empty() {
49 self.text_md = None;
50 return;
51 }
52 let md_theme = crate::agent::ui::theme::get_markdown_theme();
53 let should_recreate = match self.text_md {
54 Some(ref mut md) => {
55 let needs_update = !md.cached_text_matches(&self.text);
56 if needs_update {
57 md.set_text(&self.text);
58 }
59 false }
61 None => true, };
63 if should_recreate {
64 self.text_md = Some(Markdown::new(self.text.clone(), 1, 0, md_theme, None));
65 }
66 }
67
68 fn sync_thinking_md(&mut self) {
70 while self.thinking_md.len() > self.thinking.len() {
72 self.thinking_md.pop();
73 }
74 for (i, block) in self.thinking.iter().enumerate() {
75 if block.text.trim().is_empty() {
76 continue;
77 }
78 if i >= self.thinking_md.len() {
79 let color_fn: StyleFn = Arc::new(|s: &str| -> String {
81 crate::agent::ui::theme::current_theme().fg_key(ThemeKey::ThinkingText, s)
82 });
83 let default_style = DefaultTextStyle {
84 color: Some(color_fn),
85 bold: false,
86 italic: true,
87 strikethrough: false,
88 underline: false,
89 };
90 let mut md = Markdown::new(
91 block.text.clone(),
92 1,
93 0,
94 crate::agent::ui::theme::get_markdown_theme(),
95 Some(default_style),
96 );
97 md.invalidate();
100 self.thinking_md.push(md);
101 } else {
102 let needs_update = !self.thinking_md[i].cached_text_matches(&block.text);
103 if needs_update {
104 self.thinking_md[i].set_text(&block.text);
105 }
106 }
107 }
108 }
109
110 fn state_hash(&self) -> u64 {
112 use std::collections::hash_map::DefaultHasher;
113 use std::hash::{Hash, Hasher};
114 let mut hasher = DefaultHasher::new();
115 self.text.hash(&mut hasher);
116 self.hide_thinking.hash(&mut hasher);
117 for block in &self.thinking {
118 block.text.hash(&mut hasher);
119 block.level.hash(&mut hasher);
120 }
121 hasher.finish()
122 }
123
124 pub fn add_thinking(&mut self, text: impl Into<String>, level: Option<String>) {
125 let text = text.into();
126 if text.is_empty() {
127 return;
128 }
129 if let Some(last) = self.thinking.last_mut() {
130 if text == last.text {
132 return;
133 }
134 if text.len() > last.text.len() {
139 let t_trimmed = text.trim_start();
140 let l_trimmed = last.text.trim_start();
141 if t_trimmed.starts_with(l_trimmed) {
142 last.text = text;
143 self.invalidate();
144 return;
145 }
146 }
147 last.text.push_str(&text);
149 } else {
150 self.thinking.push(ThinkingBlock { text, level });
151 }
152 self.invalidate();
153 }
154
155 pub fn append_text(&mut self, delta: &str) {
156 if delta.is_empty() {
157 return;
158 }
159 if delta.len() > self.text.len() {
164 let d_trimmed = delta.trim_start();
165 let s_trimmed = self.text.trim_start();
166 if delta == self.text {
167 return; }
169 if d_trimmed.starts_with(s_trimmed) {
170 self.text = delta.to_string();
171 self.invalidate();
172 return;
173 }
174 } else if delta == self.text {
175 return; }
177 self.text.push_str(delta);
178 self.invalidate();
179 }
180
181 pub fn set_text(&mut self, text: impl Into<String>) {
182 self.text = text.into();
183 self.invalidate();
184 }
185}
186
187impl Component for AssistantMessageComponent {
188 fn render(&mut self, width: usize) -> Vec<String> {
193 if self.cached_width == width
194 && let Some(ref lines) = self.cached_lines
195 {
196 return lines.clone();
197 }
198
199 self.sync_text_md();
202 if !self.thinking.is_empty() {
203 self.sync_thinking_md();
204 }
205
206 let mut lines: Vec<String> = Vec::new();
207
208 let has_thinking = self.thinking.iter().any(|b| !b.text.trim().is_empty());
209 let has_text = !self.text.trim().is_empty();
210 if !has_thinking && !has_text {
211 self.cached_lines = Some(Vec::new());
212 self.cached_width = width;
213 return Vec::new();
214 }
215
216 lines.push(String::new());
218
219 let mut think_idx = 0;
220 for (block_idx, block) in self.thinking.iter().enumerate() {
221 let trimmed = block.text.trim();
222 if trimmed.is_empty() {
223 continue;
224 }
225 if self.hide_thinking {
226 let theme = current_theme();
227 let label = theme.italic(&theme.fg_key(ThemeKey::ThinkingText, "Thinking..."));
228 let padded = format!(" {} ", label);
229 lines.push(padded);
230 } else {
231 if let Some(md) = self.thinking_md.get_mut(think_idx) {
233 lines.extend(md.render(width));
234 }
235 think_idx += 1;
236 }
237
238 let has_content_after = self.thinking.iter().skip(block_idx + 1).any(|b| {
240 if self.hide_thinking {
241 true
242 } else {
243 !b.text.trim().is_empty()
244 }
245 }) || has_text;
246 if has_content_after {
247 lines.push(String::new());
248 }
249 }
250
251 if has_text {
252 if let Some(ref mut md) = self.text_md {
254 lines.extend(md.render(width));
255 }
256 }
257
258 if !lines.is_empty() {
260 lines[0] = format!("{}{}", OSC133_ZONE_START, &lines[0]);
261 if let Some(last) = lines.last_mut() {
262 last.push_str(OSC133_ZONE_END);
263 last.push_str(OSC133_ZONE_FINAL);
264 }
265 }
266
267 let result = lines.clone();
268 self.cached_lines = Some(lines);
269 self.cached_width = width;
270 result
271 }
272
273 fn cache_key(&self, width: usize) -> Option<RenderCacheKey> {
274 Some(RenderCacheKey {
275 width,
276 expanded: false,
277 state_hash: self.state_hash(),
278 })
279 }
280
281 fn set_hide_thinking(&mut self, hide: bool) {
282 self.hide_thinking = hide;
283 self.invalidate();
284 }
285
286 fn invalidate(&mut self) {
287 self.cached_lines = None;
288 }
289}