rab/agent/ui/components/
assistant_message.rs1use 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
12pub 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 if text == last.text {
46 return;
47 }
48 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 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 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; }
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; }
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 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 if has_any_content {
125 lines.push(String::new());
126 }
127
128 for block in &self.thinking {
130 if block.text.trim().is_empty() {
131 continue;
132 }
133 if self.hide_thinking {
134 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 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 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 if has_thinking && has_text {
167 lines.push(String::new());
168 }
169
170 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 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}