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