use super::*;
pub(super) fn bash_card(input: &ToolCardInput<'_>) -> CardDraft {
let detail = input.detail;
let mut status = input_status(input);
if status == CardStatus::Failed && tool_was_canceled(detail) {
status = CardStatus::Canceled;
}
let timed_out = detail
.and_then(|detail| detail.metadata.get("timed_out"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let exit_code = detail
.and_then(|detail| detail.metadata.get("exit_code"))
.and_then(serde_json::Value::as_i64);
let command = input
.bash_command
.map(sanitize_display_text)
.unwrap_or_default();
let title = bash_title(status, timed_out, exit_code);
let intent = detail
.and_then(|detail| detail.params.get(crate::tools::contract::arg_key::INTENT))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.map(sanitized_single_line)
.filter(|intent| !intent.is_empty());
let mut body_rows = intent.iter().cloned().collect::<Vec<_>>();
if !command.is_empty() {
body_rows.push(command);
}
let mut display_lines = Vec::new();
let status_row = match status {
CardStatus::Running | CardStatus::None => None,
CardStatus::Success => bash_output_line_count(detail)
.map(|lines| format!("{lines} {}", if lines == 1 { "line" } else { "lines" })),
CardStatus::Failed if timed_out => Some(
detail
.and_then(|detail| detail.params.get("timeout"))
.and_then(serde_json::Value::as_u64)
.map_or_else(
|| "timed out".to_string(),
|timeout| format!("timed out after {timeout}s"),
),
),
CardStatus::Failed => Some(
bash_failure_reason(detail)
.or_else(|| first_non_empty_line(input.preview))
.unwrap_or_else(|| "command failed".to_string()),
),
CardStatus::Canceled => Some("canceled".to_string()),
};
if let Some(status_row) = status_row {
display_lines.push(TextLine::plain(&status_row));
body_rows.push(status_row);
}
CardDraft {
role: ToolCardRole::Bash,
status,
title,
metadata: bash_jev_metadata(detail),
body: body_rows.join("\n"),
body_lines: display_lines,
activity_rows: Vec::new(),
}
}
fn bash_title(status: CardStatus, timed_out: bool, exit_code: Option<i64>) -> String {
if timed_out {
return "Bash • timed out".to_string();
}
if matches!(status, CardStatus::Success | CardStatus::Failed)
&& let Some(exit_code) = exit_code
{
format!("Bash • exit {exit_code}")
} else if status == CardStatus::Canceled {
"Bash • canceled".to_string()
} else {
"Bash".to_string()
}
}
fn bash_output_line_count(detail: Option<&ToolActivityDetail>) -> Option<usize> {
let metadata = &detail?.metadata;
let streams = ["stdout", "stderr"].map(|key| metadata[key].as_str());
if streams.iter().all(Option::is_none) {
return None;
}
Some(
streams
.into_iter()
.flatten()
.map(|stream| stream.lines().count())
.sum(),
)
}
fn bash_failure_reason(detail: Option<&crate::output::ToolActivityDetail>) -> Option<String> {
let detail = detail?;
detail
.metadata
.get("cleanup_warning")
.and_then(serde_json::Value::as_str)
.and_then(first_non_empty_line)
.or_else(|| {
(detail
.metadata
.get("stdout_truncated")
.and_then(serde_json::Value::as_bool)
== Some(true)
|| detail
.metadata
.get("stderr_truncated")
.and_then(serde_json::Value::as_bool)
== Some(true))
.then(|| "output truncated".to_string())
})
.or_else(|| {
detail
.metadata
.get("stderr")
.and_then(serde_json::Value::as_str)
.and_then(first_non_empty_line)
})
.or_else(|| bash_error_from_output(detail.output.as_ref()))
}
fn bash_error_from_output(output: &str) -> Option<String> {
output
.lines()
.map(str::trim)
.find(|line| {
!line.is_empty()
&& !matches!(*line, "stdout:" | "stderr:")
&& !line.starts_with("[output truncated:")
})
.map(|line| normalize_inline_separators(&redact_sensitive_text(line)))
}