steer_tui/tui/widgets/formatters/
bash.rs

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