use std::fmt::Write as _;
use crate::tui::theme::Theme;
pub struct ToolDisplayConfig {
pub visible_lines: usize,
pub max_chars: usize,
}
impl Default for ToolDisplayConfig {
fn default() -> Self {
Self {
visible_lines: 10,
max_chars: 4_000,
}
}
}
pub struct CollapsedToolOutput {
pub visible: String,
pub total_lines: usize,
pub was_truncated: bool,
pub summary: String,
}
const DISPLAY_TRUNCATION_NOTICE: &str =
"\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m";
pub fn collapse_tool_output(
output: &str,
tool_name: &str,
is_error: bool,
config: &ToolDisplayConfig,
) -> CollapsedToolOutput {
let lines: Vec<&str> = output.lines().collect();
let total_lines = lines.len();
let was_truncated = total_lines > config.visible_lines;
let mut visible = if was_truncated {
lines
.iter()
.take(config.visible_lines)
.cloned()
.collect::<Vec<_>>()
.join("\n")
} else {
output.to_string()
};
if visible.chars().count() > config.max_chars {
let prefix: String = visible
.chars()
.take(config.max_chars.saturating_sub(1))
.collect();
visible = format!("{prefix}…");
}
let icon = if is_error { "fail" } else { "ok" };
let summary = if was_truncated {
format!(
"{} {} ({} lines) — full output in session · [scroll up or /debugToolCall to inspect]",
icon, tool_name, total_lines
)
} else {
format!("{} {}", icon, tool_name)
};
if was_truncated {
write!(&mut visible, "\n{}", DISPLAY_TRUNCATION_NOTICE).expect("write to string");
}
CollapsedToolOutput {
visible,
total_lines,
was_truncated,
summary,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_output_is_not_truncated() {
let config = ToolDisplayConfig::default();
let result = collapse_tool_output("line 1\nline 2\n", "bash", false, &config);
assert!(!result.was_truncated);
assert_eq!(result.total_lines, 2);
}
#[test]
fn long_output_is_truncated() {
let config = ToolDisplayConfig {
visible_lines: 3,
max_chars: 100_000,
};
let input = (1..=20)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let result = collapse_tool_output(&input, "bash", false, &config);
assert!(result.was_truncated);
assert_eq!(result.total_lines, 20);
assert!(result.visible.contains("line 1"));
assert!(result.visible.contains("line 3"));
assert!(!result.visible.contains("line 4"));
}
#[test]
fn error_tool_gets_error_icon() {
let config = ToolDisplayConfig::default();
let result = collapse_tool_output("error", "bash", true, &config);
assert!(result.summary.starts_with("fail"));
}
#[test]
fn success_tool_gets_check_icon() {
let config = ToolDisplayConfig::default();
let result = collapse_tool_output("ok", "bash", false, &config);
assert!(result.summary.starts_with("ok"));
}
#[test]
fn empty_output_is_not_truncated() {
let config = ToolDisplayConfig::default();
let result = collapse_tool_output("", "bash", false, &config);
assert!(!result.was_truncated);
assert_eq!(result.total_lines, 0);
}
#[test]
fn max_chars_enforced_on_visible() {
let config = ToolDisplayConfig {
visible_lines: 100,
max_chars: 20,
};
let input = "a".repeat(50);
let result = collapse_tool_output(&input, "bash", false, &config);
assert!(result.visible.chars().count() <= 20);
assert!(result.visible.ends_with('…'));
}
#[test]
fn summary_contains_line_count() {
let config = ToolDisplayConfig {
visible_lines: 2,
max_chars: 100_000,
};
let input = (1..=10)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let result = collapse_tool_output(&input, "bash", false, &config);
assert!(result.summary.contains("10 lines"));
}
}