Skip to main content

steer_tui/tui/widgets/formatters/
bash.rs

1use super::{
2    ToolFormatter,
3    helpers::{separator_line, tool_error_user_message, truncate_lines},
4};
5use crate::tui::theme::{Component, Theme};
6use ratatui::{
7    style::{Color, Style},
8    text::{Line, Span},
9};
10use serde_json::Value;
11use steer_grpc::client_api::ToolResult;
12use steer_tools::tools::bash::BashParams;
13
14pub struct BashFormatter;
15
16impl ToolFormatter for BashFormatter {
17    fn compact(
18        &self,
19        params: &Value,
20        result: &Option<ToolResult>,
21        wrap_width: usize,
22        theme: &Theme,
23    ) -> Vec<Line<'static>> {
24        let mut lines = Vec::new();
25
26        let Ok(params) = serde_json::from_value::<BashParams>(params.clone()) else {
27            return vec![Line::from(Span::styled(
28                "Invalid bash params",
29                theme.style(Component::ErrorText),
30            ))];
31        };
32
33        // Wrap command if it's too long
34        let command_lines: Vec<&str> = params.command.lines().collect();
35        if command_lines.len() == 1 && params.command.len() <= wrap_width.saturating_sub(2) {
36            // Single line that fits - show inline with prompt
37            let mut spans = vec![
38                Span::styled("$ ", theme.style(Component::CommandPrompt)),
39                Span::styled(params.command.clone(), theme.style(Component::CommandText)),
40            ];
41
42            // Add exit code if error
43            if let Some(ToolResult::Error(error)) = result
44                && let Some(exit_code) = error
45                    .to_string()
46                    .strip_prefix("Exit code: ")
47                    .and_then(|s| s.parse::<i32>().ok())
48            {
49                spans.push(Span::styled(
50                    format!(" (exit {exit_code})"),
51                    theme.style(Component::ErrorText),
52                ));
53            }
54
55            lines.push(Line::from(spans));
56        } else {
57            // Multi-line or long command - wrap it
58            for (i, line) in params.command.lines().enumerate() {
59                for (j, wrapped_line) in textwrap::wrap(line, wrap_width.saturating_sub(2))
60                    .into_iter()
61                    .enumerate()
62                {
63                    if i == 0 && j == 0 {
64                        // First line gets the prompt
65                        lines.push(Line::from(vec![
66                            Span::styled("$ ", theme.style(Component::CommandPrompt)),
67                            Span::styled(
68                                wrapped_line.to_string(),
69                                theme.style(Component::CommandText),
70                            ),
71                        ]));
72                    } else {
73                        // Subsequent lines are indented
74                        lines.push(Line::from(vec![
75                            Span::styled("  ", Style::default()),
76                            Span::styled(
77                                wrapped_line.to_string(),
78                                theme.style(Component::CommandText),
79                            ),
80                        ]));
81                    }
82                }
83            }
84
85            // Add exit code on a new line if error
86            if let Some(ToolResult::Error(error)) = result
87                && let Some(exit_code) = error
88                    .to_string()
89                    .strip_prefix("Exit code: ")
90                    .and_then(|s| s.parse::<i32>().ok())
91            {
92                lines.push(Line::from(Span::styled(
93                    format!("(exit {exit_code})"),
94                    theme.style(Component::ErrorText),
95                )));
96            }
97        }
98
99        lines
100    }
101
102    fn detailed(
103        &self,
104        params: &Value,
105        result: &Option<ToolResult>,
106        wrap_width: usize,
107        theme: &Theme,
108    ) -> Vec<Line<'static>> {
109        let mut lines = Vec::new();
110
111        let Ok(params) = serde_json::from_value::<BashParams>(params.clone()) else {
112            return vec![Line::from(Span::styled(
113                "Invalid bash params",
114                theme.style(Component::ErrorText),
115            ))];
116        };
117
118        // Show full command
119        for line in params.command.lines() {
120            for wrapped_line in textwrap::wrap(line, wrap_width.saturating_sub(2)) {
121                lines.push(Line::from(Span::styled(
122                    wrapped_line.to_string(),
123                    Style::default().fg(Color::White),
124                )));
125            }
126        }
127
128        if let Some(timeout) = params.timeout {
129            lines.push(Line::from(Span::styled(
130                format!("Timeout: {timeout}ms"),
131                theme.style(Component::DimText),
132            )));
133        }
134
135        // Show output if we have results
136        if let Some(result) = result {
137            lines.push(separator_line(wrap_width, theme.style(Component::DimText)));
138
139            match result {
140                ToolResult::Bash(bash_result) => {
141                    // Show stdout if present
142                    if !bash_result.stdout.trim().is_empty() {
143                        const MAX_OUTPUT_LINES: usize = 20;
144                        let (output_lines, truncated) =
145                            truncate_lines(&bash_result.stdout, MAX_OUTPUT_LINES);
146
147                        for line in output_lines {
148                            for wrapped in textwrap::wrap(line, wrap_width) {
149                                lines.push(Line::from(Span::raw(wrapped.to_string())));
150                            }
151                        }
152
153                        if truncated {
154                            lines.push(Line::from(Span::styled(
155                                format!(
156                                    "... ({} more lines)",
157                                    bash_result.stdout.lines().count() - MAX_OUTPUT_LINES
158                                ),
159                                theme
160                                    .style(Component::DimText)
161                                    .add_modifier(ratatui::style::Modifier::ITALIC),
162                            )));
163                        }
164                    }
165
166                    // Show stderr if present
167                    if !bash_result.stderr.trim().is_empty() {
168                        if !bash_result.stdout.trim().is_empty() {
169                            lines.push(separator_line(wrap_width, theme.style(Component::DimText)));
170                        }
171                        lines.push(Line::from(Span::styled(
172                            "[stderr]",
173                            theme.style(Component::ErrorText),
174                        )));
175
176                        const MAX_ERROR_LINES: usize = 10;
177                        let (error_lines, truncated) =
178                            truncate_lines(&bash_result.stderr, MAX_ERROR_LINES);
179
180                        for line in error_lines {
181                            for wrapped in textwrap::wrap(line, wrap_width) {
182                                lines.push(Line::from(Span::styled(
183                                    wrapped.to_string(),
184                                    theme.style(Component::ErrorText),
185                                )));
186                            }
187                        }
188
189                        if truncated {
190                            lines.push(Line::from(Span::styled(
191                                format!(
192                                    "... ({} more lines)",
193                                    bash_result.stderr.lines().count() - MAX_ERROR_LINES
194                                ),
195                                theme
196                                    .style(Component::DimText)
197                                    .add_modifier(ratatui::style::Modifier::ITALIC),
198                            )));
199                        }
200                    }
201
202                    // Show exit code if non-zero
203                    if bash_result.exit_code != 0 {
204                        lines.push(Line::from(Span::styled(
205                            format!("Exit code: {}", bash_result.exit_code),
206                            theme.style(Component::ErrorText),
207                        )));
208                    } else if bash_result.stdout.trim().is_empty()
209                        && bash_result.stderr.trim().is_empty()
210                    {
211                        lines.push(Line::from(Span::styled(
212                            "(Command completed successfully with no output)",
213                            theme
214                                .style(Component::DimText)
215                                .add_modifier(ratatui::style::Modifier::ITALIC),
216                        )));
217                    }
218                }
219                ToolResult::Error(error) => {
220                    // Show error message
221                    const MAX_ERROR_LINES: usize = 10;
222                    let error_message = tool_error_user_message(error);
223                    let (error_lines, truncated) = truncate_lines(&error_message, MAX_ERROR_LINES);
224
225                    for line in error_lines {
226                        for wrapped in textwrap::wrap(line, wrap_width) {
227                            lines.push(Line::from(Span::styled(
228                                wrapped.to_string(),
229                                theme.style(Component::ErrorText),
230                            )));
231                        }
232                    }
233
234                    if truncated {
235                        lines.push(Line::from(Span::styled(
236                            format!(
237                                "... ({} more lines)",
238                                error_message.lines().count() - MAX_ERROR_LINES
239                            ),
240                            theme.style(Component::ErrorText),
241                        )));
242                    }
243                }
244                _ => {
245                    // Other result types shouldn't appear for bash tool
246                    lines.push(Line::from(Span::styled(
247                        "Unexpected result type",
248                        theme.style(Component::ErrorText),
249                    )));
250                }
251            }
252        }
253
254        lines
255    }
256}