Skip to main content

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}