steer_tui/tui/widgets/chat_widgets/
chat_widget.rs1use crate::tui::theme::{Component, Theme};
7use crate::tui::widgets::chat_list_state::ViewMode;
8use crate::tui::widgets::chat_widgets::message_widget::MessageWidget;
9use ratatui::text::{Line, Span};
10use steer_grpc::client_api::{AssistantContent, Message, MessageData, ToolCall, ToolResult};
11
12pub trait ChatRenderable: Send + Sync {
14 fn lines(&mut self, width: u16, mode: ViewMode, theme: &Theme) -> &[Line<'static>];
16}
17
18#[derive(Debug, Clone)]
20pub struct HeightCache {
21 pub compact: Option<usize>,
22 pub detailed: Option<usize>,
23 pub last_width: u16,
24}
25
26impl Default for HeightCache {
27 fn default() -> Self {
28 Self::new()
29 }
30}
31
32impl HeightCache {
33 pub fn new() -> Self {
34 Self {
35 compact: None,
36 detailed: None,
37 last_width: 0,
38 }
39 }
40
41 pub fn invalidate(&mut self) {
42 self.compact = None;
43 self.detailed = None;
44 self.last_width = 0;
45 }
46
47 pub fn get(&self, mode: ViewMode, width: u16) -> Option<usize> {
48 if self.last_width != width {
49 return None;
50 }
51 match mode {
52 ViewMode::Compact => self.compact,
53 ViewMode::Detailed => self.detailed,
54 }
55 }
56
57 pub fn set(&mut self, mode: ViewMode, width: u16, height: usize) {
58 self.last_width = width;
59 match mode {
60 ViewMode::Compact => self.compact = Some(height),
61 ViewMode::Detailed => self.detailed = Some(height),
62 }
63 }
64}
65
66pub struct ParagraphWidget {
68 lines: Vec<Line<'static>>,
69}
70
71impl ParagraphWidget {
72 pub fn new(lines: Vec<Line<'static>>) -> Self {
73 Self { lines }
74 }
75
76 pub fn from_text(text: String, theme: &Theme) -> Self {
77 Self::from_styled_text(text, theme.style(Component::NoticeInfo))
78 }
79
80 pub fn from_styled_text(text: String, style: ratatui::style::Style) -> Self {
81 let lines = text
82 .lines()
83 .map(|line| Line::from(Span::styled(line.to_string(), style)))
84 .collect();
85 Self::new(lines)
86 }
87}
88
89impl ChatRenderable for ParagraphWidget {
90 fn lines(&mut self, _width: u16, _mode: ViewMode, _theme: &Theme) -> &[Line<'static>] {
91 &self.lines
92 }
93}
94
95#[derive(Debug, Clone)]
97pub enum ChatBlock {
98 Message(Message),
100 ToolInteraction {
102 call: ToolCall,
103 result: Option<ToolResult>,
104 },
105}
106
107impl ChatBlock {
108 pub fn from_message_row(message: &Message, all_messages: &[&Message]) -> Vec<ChatBlock> {
110 match &message.data {
111 MessageData::User { .. } => {
112 vec![ChatBlock::Message(message.clone())]
113 }
114 MessageData::Assistant { content, .. } => {
115 let mut blocks = vec![];
116 let mut has_text = false;
117 let mut tool_calls = vec![];
118
119 for block in content {
121 match block {
122 AssistantContent::Text { text } => {
123 if !text.trim().is_empty() {
124 has_text = true;
125 }
126 }
127 AssistantContent::Image { .. } => {
128 has_text = true;
129 }
130 AssistantContent::ToolCall { tool_call, .. } => {
131 tool_calls.push(tool_call.clone());
132 }
133 AssistantContent::Thought { .. } => {
134 has_text = true; }
136 }
137 }
138
139 if has_text {
141 blocks.push(ChatBlock::Message(message.clone()));
142 }
143
144 for tool_call in tool_calls {
146 let result = all_messages.iter().find_map(|msg_row| {
148 if let MessageData::Tool {
149 tool_use_id,
150 result,
151 ..
152 } = &msg_row.data
153 {
154 if tool_use_id == &tool_call.id {
155 Some(result.clone())
156 } else {
157 None
158 }
159 } else {
160 None
161 }
162 });
163
164 blocks.push(ChatBlock::ToolInteraction {
165 call: tool_call,
166 result,
167 });
168 }
169
170 blocks
171 }
172 MessageData::Tool {
173 tool_use_id,
174 result,
175 ..
176 } => {
177 let has_corresponding_call = all_messages.iter().any(|msg_row| {
179 if let MessageData::Assistant { content, .. } = &msg_row.data {
180 content.iter().any(|block| {
181 if let AssistantContent::ToolCall { tool_call, .. } = block {
182 tool_call.id == *tool_use_id
183 } else {
184 false
185 }
186 })
187 } else {
188 false
189 }
190 });
191
192 if has_corresponding_call {
193 vec![]
195 } else {
196 vec![ChatBlock::ToolInteraction {
198 call: steer_tools::ToolCall {
199 id: tool_use_id.clone(),
200 name: "Unknown".to_string(), parameters: serde_json::Value::Null,
202 },
203 result: Some(result.clone()),
204 }]
205 }
206 }
207 }
208 }
209}
210
211pub struct DynamicChatWidget {
213 inner: Box<dyn ChatRenderable + Send + Sync>,
214}
215
216impl DynamicChatWidget {
217 pub fn from_block(block: ChatBlock, _theme: &Theme) -> Self {
218 let inner: Box<dyn ChatRenderable + Send + Sync> = match block {
219 ChatBlock::Message(message) => Box::new(MessageWidget::new(message)),
220 ChatBlock::ToolInteraction { call, result } => {
221 Box::new(
223 crate::tui::widgets::chat_widgets::tool_widget::ToolWidget::new(call, result),
224 )
225 }
226 };
227 Self { inner }
228 }
229}
230
231impl ChatRenderable for DynamicChatWidget {
232 fn lines(&mut self, width: u16, mode: ViewMode, theme: &Theme) -> &[Line<'static>] {
233 self.inner.lines(width, mode, theme)
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::tui::theme::Theme;
241 use steer_grpc::client_api::UserContent;
242
243 #[test]
244 fn test_chat_block_from_message_row() {
245 let user_msg = Message {
246 data: MessageData::User {
247 content: vec![UserContent::Text {
248 text: "Test".to_string(),
249 }],
250 },
251 timestamp: 0,
252 id: "test-id".to_string(),
253 parent_message_id: None,
254 };
255
256 let blocks = ChatBlock::from_message_row(&user_msg, &[]);
257
258 assert_eq!(blocks.len(), 1);
259 match &blocks[0] {
260 ChatBlock::Message(_) => {} ChatBlock::ToolInteraction { .. } => panic!("Expected Message ChatBlock"),
262 }
263 }
264
265 #[test]
266 fn test_dynamic_chat_widget() {
267 let theme = Theme::default();
268 let user_msg = Message {
269 data: MessageData::User {
270 content: vec![UserContent::Text {
271 text: "Test message".to_string(),
272 }],
273 },
274 timestamp: 0,
275 id: "test-id".to_string(),
276 parent_message_id: None,
277 };
278
279 let block = ChatBlock::Message(user_msg);
280 let mut widget = DynamicChatWidget::from_block(block, &theme);
281
282 let height = widget.lines(20, ViewMode::Compact, &theme).len();
284 assert_eq!(height, 1);
285 }
286}