steer_tui/tui/widgets/chat_widgets/command_response.rs
1use crate::tui::model::{CommandResponse, TuiCommandResponse};
2use crate::tui::theme::{Component, Theme};
3
4use crate::tui::widgets::chat_list_state::ViewMode;
5use crate::tui::widgets::chat_widgets::chat_widget::{ChatRenderable, HeightCache};
6use ratatui::text::{Line, Span};
7use steer_core::app::conversation::{CommandResponse as CoreCommandResponse, CompactResult};
8
9/// Widget for command responses (both app commands and tui commands)
10pub struct CommandResponseWidget {
11 command: String,
12 response: CommandResponse,
13 cache: HeightCache,
14 rendered_lines: Option<Vec<Line<'static>>>,
15}
16
17impl CommandResponseWidget {
18 pub fn new(command: String, response: CommandResponse) -> Self {
19 Self {
20 command,
21 response,
22 cache: HeightCache::new(),
23 rendered_lines: None,
24 }
25 }
26}
27
28impl ChatRenderable for CommandResponseWidget {
29 fn lines(&mut self, width: u16, _mode: ViewMode, theme: &Theme) -> &[Line<'static>] {
30 if self.rendered_lines.is_none() || self.cache.last_width != width {
31 let mut lines = vec![];
32 let wrap_width = width.saturating_sub(2) as usize;
33
34 // Command prompt on its own line
35 lines.push(Line::from(vec![
36 Span::styled(self.command.clone(), theme.style(Component::CommandPrompt)),
37 Span::raw(":"),
38 ]));
39
40 // Format response based on type
41 match &self.response {
42 CommandResponse::Core(core_response) => {
43 match core_response {
44 CoreCommandResponse::Text(text) => {
45 // Simple text wrapping
46 for line in text.lines() {
47 let wrapped = textwrap::wrap(line, wrap_width);
48 if wrapped.is_empty() {
49 lines.push(Line::from(""));
50 } else {
51 for wrapped_line in wrapped {
52 lines.push(Line::from(Span::styled(
53 wrapped_line.to_string(),
54 theme.style(Component::CommandText),
55 )));
56 }
57 }
58 }
59 }
60 CoreCommandResponse::Compact(result) => match result {
61 CompactResult::Success(summary) => {
62 lines.push(Line::from(vec![
63 Span::styled("✓ ", theme.style(Component::CommandSuccess)),
64 Span::styled(
65 summary.clone(),
66 theme.style(Component::CommandText),
67 ),
68 ]));
69 }
70 CompactResult::Cancelled => {
71 lines.push(Line::from(Span::styled(
72 "Compact cancelled.",
73 theme.style(Component::CommandError),
74 )));
75 }
76 CompactResult::InsufficientMessages => {
77 lines.push(Line::from(Span::styled(
78 "Not enough messages to compact.",
79 theme.style(Component::CommandError),
80 )));
81 }
82 },
83 }
84 }
85
86 CommandResponse::Tui(tui_response) => {
87 match tui_response {
88 TuiCommandResponse::Text(text) => {
89 // Simple text wrapping
90 for line in text.lines() {
91 let wrapped = textwrap::wrap(line, wrap_width);
92 if wrapped.is_empty() {
93 lines.push(Line::from(""));
94 } else {
95 for wrapped_line in wrapped {
96 lines.push(Line::from(Span::styled(
97 wrapped_line.to_string(),
98 theme.style(Component::CommandText),
99 )));
100 }
101 }
102 }
103 }
104
105 TuiCommandResponse::Theme { name } => {
106 lines.push(Line::from(vec![
107 Span::styled(
108 "✓ Theme changed to ",
109 theme.style(Component::CommandText),
110 ),
111 Span::styled(
112 format!("'{name}'"),
113 theme.style(Component::CommandSuccess),
114 ),
115 ]));
116 }
117
118 TuiCommandResponse::ListThemes(themes) => {
119 if themes.is_empty() {
120 lines.push(Line::from(Span::styled(
121 "No themes found.",
122 theme.style(Component::CommandText),
123 )));
124 } else {
125 lines.push(Line::from(Span::styled(
126 "Available themes:",
127 theme.style(Component::CommandText),
128 )));
129 for theme_name in themes {
130 lines.push(Line::from(vec![
131 Span::raw(" • "),
132 Span::styled(
133 theme_name.clone(),
134 theme.style(Component::CommandSuccess),
135 ),
136 ]));
137 }
138 }
139 }
140
141 TuiCommandResponse::ListMcpServers(servers) => {
142 use steer_core::session::state::McpConnectionState;
143
144 if servers.is_empty() {
145 lines.push(Line::from(Span::styled(
146 "No MCP servers configured.",
147 theme.style(Component::CommandText),
148 )));
149 } else {
150 lines.push(Line::from(Span::styled(
151 "MCP Server Status:",
152 theme.style(Component::CommandText),
153 )));
154 lines.push(Line::from("")); // Empty line for spacing
155
156 for server in servers {
157 // Server name and status
158 // let status_icon = match &server.state {
159 // McpConnectionState::Connecting => "⏳",
160 // McpConnectionState::Connected { .. } => "✅",
161 // McpConnectionState::Failed { .. } => "❌",
162 // };
163
164 lines.push(Line::from(vec![
165 // Span::raw(format!("{} ", status_icon)),
166 Span::styled(
167 server.server_name.clone(),
168 theme.style(Component::CommandPrompt),
169 ),
170 ]));
171
172 // Connection state details
173 match &server.state {
174 McpConnectionState::Connecting => {
175 lines.push(Line::from(vec![
176 Span::raw(" Status: "),
177 Span::styled(
178 "Connecting...",
179 theme.style(Component::DimText),
180 ),
181 ]));
182 }
183 McpConnectionState::Connected { tool_names } => {
184 lines.push(Line::from(vec![
185 Span::raw(" Status: "),
186 Span::styled(
187 "Connected",
188 theme.style(Component::CommandSuccess),
189 ),
190 ]));
191
192 if !tool_names.is_empty() {
193 lines.push(Line::from(vec![
194 Span::raw(" Tools: "),
195 Span::styled(
196 format!("{} available", tool_names.len()),
197 theme.style(Component::CommandText),
198 ),
199 ]));
200
201 // Show first few tool names
202 let display_count = tool_names.len().min(5);
203 for tool in &tool_names[..display_count] {
204 lines.push(Line::from(vec![
205 Span::raw(" • "),
206 Span::styled(
207 tool.clone(),
208 theme.style(Component::ToolCall),
209 ),
210 ]));
211 }
212 if tool_names.len() > 5 {
213 lines.push(Line::from(vec![
214 Span::raw(" "),
215 Span::styled(
216 format!(
217 "... and {} more",
218 tool_names.len() - 5
219 ),
220 theme.style(Component::CommandText),
221 ),
222 ]));
223 }
224 }
225 }
226 McpConnectionState::Failed { error } => {
227 lines.push(Line::from(vec![
228 Span::raw(" Status: "),
229 Span::styled(
230 "Failed",
231 theme.style(Component::CommandError),
232 ),
233 ]));
234
235 // Wrap error message
236 let error_prefix = " Error: ";
237 let error_wrap_width =
238 wrap_width.saturating_sub(error_prefix.len());
239 let wrapped_error =
240 textwrap::wrap(error, error_wrap_width);
241
242 for (i, wrapped_line) in
243 wrapped_error.iter().enumerate()
244 {
245 if i == 0 {
246 lines.push(Line::from(vec![
247 Span::raw(error_prefix),
248 Span::styled(
249 wrapped_line.to_string(),
250 theme.style(Component::ErrorText),
251 ),
252 ]));
253 } else {
254 lines.push(Line::from(Span::styled(
255 format!(" {wrapped_line}"),
256 theme.style(Component::ErrorText),
257 )));
258 }
259 }
260 }
261 }
262
263 // Transport info
264 use steer_core::tools::McpTransport;
265 let transport_desc = match &server.transport {
266 McpTransport::Stdio { command, args } => {
267 format!("stdio: {} {}", command, args.join(" "))
268 }
269 McpTransport::Tcp { host, port } => {
270 format!("tcp: {host}:{port}")
271 }
272 McpTransport::Unix { path } => {
273 format!("unix: {path}")
274 }
275 McpTransport::Sse { url, .. } => {
276 format!("sse: {url}")
277 }
278 McpTransport::Http { url, .. } => {
279 format!("http: {url}")
280 }
281 };
282
283 lines.push(Line::from(vec![
284 Span::raw(" Transport: "),
285 Span::styled(
286 transport_desc,
287 theme.style(Component::CommandText),
288 ),
289 ]));
290
291 lines.push(Line::from("")); // Empty line between servers
292 }
293 }
294 }
295 }
296 }
297 }
298
299 self.rendered_lines = Some(lines);
300 }
301
302 self.rendered_lines.as_ref().unwrap()
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use crate::tui::model::TuiCommandResponse;
309
310 use super::*;
311
312 #[test]
313 fn test_command_response_widget_inline() {
314 let theme = Theme::default();
315 let mut widget = CommandResponseWidget::new(
316 "/help".to_string(),
317 TuiCommandResponse::Text("Shows help".to_string()).into(),
318 );
319
320 let height = widget.lines(80, ViewMode::Compact, &theme).len();
321 assert_eq!(height, 2); // Command line + response line (always multi-line now)
322 }
323
324 #[test]
325 fn test_command_response_widget_multiline() {
326 let theme = Theme::default();
327 let mut widget = CommandResponseWidget::new(
328 "/help".to_string(),
329 TuiCommandResponse::Text("Line 1\nLine 2\nLine 3".to_string()).into(),
330 );
331
332 let height = widget.lines(80, ViewMode::Compact, &theme).len();
333 assert_eq!(height, 4); // Command line + 3 response lines
334 }
335}