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