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}