Skip to content

Commit 3e9ec6f

Browse files
committed
cap bash tool at 30 lines on screen
1 parent 2676ca7 commit 3e9ec6f

3 files changed

Lines changed: 78 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to Sofos are documented in this file.
44

55
## [Unreleased]
66

7+
### Changed
8+
9+
- **Bash tool output is capped at 30 lines on screen** with a `... (N more lines hidden)` footer when longer. Long file dumps from `cat`, `head`, `tail`, `nl | sed -n '1,Np'`, and verbose builds no longer flood the terminal. The model still receives the full output (subject to the existing tool-output token cap), so context and follow-up reasoning are unaffected — only the on-screen view and the saved session transcript are shortened. Note: line-counting is `\n`-based, so a single multi-megabyte line (e.g. `cat binary | base64`) is still printed in full.
10+
11+
### Fixed
12+
13+
- **Session resume now shows the bash command above its output again.** The replay path was extracting the command from the wrong field (the saved output text instead of the saved input JSON), so the `Executing: <command>` header silently fell through to nothing on every replayed bash entry — users saw the output of a bash call but not which command produced it. Live execution was unaffected; only the `--resume` rendering was broken.
14+
715
## [0.2.8] - 2026-05-06
816

917
### Added

src/tools/tool_name.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,23 @@ impl ToolName {
132132
let char_count = output.len();
133133
format!("Fetched {} ({} chars)", url.bright_cyan(), char_count)
134134
}
135+
ToolName::ExecuteBash => {
136+
// Truncate the on-screen view. The full text still
137+
// reaches the model via the tool_result block.
138+
const MAX_DISPLAY_LINES: usize = 30;
139+
let mut lines = output.lines();
140+
let kept: Vec<&str> = lines.by_ref().take(MAX_DISPLAY_LINES).collect();
141+
let hidden = lines.count();
142+
if hidden == 0 {
143+
return output.to_string();
144+
}
145+
format!(
146+
"{}\n... ({} more line{} hidden)",
147+
kept.join("\n"),
148+
hidden,
149+
if hidden == 1 { "" } else { "s" }
150+
)
151+
}
135152
ToolName::SearchCode => {
136153
let pattern = tool_input
137154
.get("pattern")
@@ -213,4 +230,45 @@ mod tests {
213230
fn test_unknown_tool() {
214231
assert!(ToolName::from_str("unknown_tool").is_err());
215232
}
233+
234+
#[test]
235+
fn execute_bash_display_caps_long_output() {
236+
let input = serde_json::json!({"command": "seq 1 100"});
237+
let output: String = (1..=100)
238+
.map(|n| n.to_string())
239+
.collect::<Vec<_>>()
240+
.join("\n");
241+
242+
let summary = ToolName::ExecuteBash.display_summary(&input, &output);
243+
244+
let line_count = summary.lines().count();
245+
assert_eq!(line_count, 31, "expected 30 content lines + 1 footer");
246+
assert!(summary.starts_with("1\n"));
247+
assert!(summary.contains("\n30\n"));
248+
assert!(!summary.contains("\n31\n"));
249+
assert!(summary.ends_with("... (70 more lines hidden)"));
250+
}
251+
252+
#[test]
253+
fn execute_bash_display_passes_short_output_through() {
254+
let input = serde_json::json!({"command": "echo hi"});
255+
let output = "STDOUT:\nhi\n";
256+
257+
let summary = ToolName::ExecuteBash.display_summary(&input, output);
258+
259+
assert_eq!(summary, output);
260+
}
261+
262+
#[test]
263+
fn execute_bash_display_singular_footer() {
264+
let input = serde_json::json!({"command": "seq 1 31"});
265+
let output: String = (1..=31)
266+
.map(|n| n.to_string())
267+
.collect::<Vec<_>>()
268+
.join("\n");
269+
270+
let summary = ToolName::ExecuteBash.display_summary(&input, &output);
271+
272+
assert!(summary.ends_with("... (1 more line hidden)"));
273+
}
216274
}

src/ui/mod.rs

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -310,20 +310,21 @@ impl UI {
310310
}
311311
DisplayMessage::ToolExecution {
312312
tool_name,
313-
tool_input: _,
313+
tool_input,
314314
tool_output,
315315
} => {
316-
if tool_name == "execute_bash" {
317-
if let Ok(input_val) = serde_json::from_value::<serde_json::Value>(
318-
serde_json::to_value(tool_output).unwrap_or_default(),
319-
) {
320-
if let Some(command) = input_val.get("command").and_then(|v| v.as_str())
321-
{
322-
self.print_tool_header(tool_name, Some(command));
323-
}
324-
}
316+
let command = if tool_name == "execute_bash" {
317+
tool_input.get("command").and_then(|v| v.as_str())
325318
} else {
326-
self.print_tool_header(tool_name, None);
319+
None
320+
};
321+
self.print_tool_header(tool_name, command);
322+
// `print_tool_header` doesn't terminate the bash
323+
// header with a newline — the live path relies on
324+
// the post-execution `println!()` to do that. Replay
325+
// it here so the header doesn't run into the output.
326+
if tool_name == "execute_bash" && command.is_some() {
327+
println!();
327328
}
328329
self.print_tool_output(tool_output);
329330
}

0 commit comments

Comments
 (0)