steer_tui/tui/widgets/formatters/
bash.rs1use 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 let command_lines: Vec<&str> = params.command.lines().collect();
32 if command_lines.len() == 1 && params.command.len() <= wrap_width.saturating_sub(2) {
33 let mut spans = vec![
35 Span::styled("$ ", theme.style(Component::CommandPrompt)),
36 Span::styled(params.command.clone(), theme.style(Component::CommandText)),
37 ];
38
39 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 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 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 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 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 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 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 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 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 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 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 lines.push(Line::from(Span::styled(
246 "Unexpected result type",
247 theme.style(Component::ErrorText),
248 )));
249 }
250 }
251 }
252
253 lines
254 }
255}