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