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 mut title = bash_title(status, timed_out, exit_code);
if detail.is_some_and(|detail| {
detail
.params
.get("background")
.and_then(serde_json::Value::as_bool)
== Some(true)
|| detail
.metadata
.get("job_id")
.is_some_and(serde_json::Value::is_string)
}) {
title.push_str(" • background");
}
let intent = detail
.and_then(|detail| detail.params.get(crate::tool_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 job_rows = Vec::new();
append_job_status(&mut job_rows, detail);
body_rows.extend(job_rows.iter().cloned());
let mut display_lines = job_rows
.into_iter()
.map(TextLine::plain)
.collect::<Vec<_>>();
let notices = super::super::bash_notices::bash_execution_notices(detail);
body_rows.extend(notices.iter().map(|notice| {
format!(
"{} • {}",
notice.label,
notice.value.as_deref().unwrap_or("not reported")
)
}));
let status_row = match status {
CardStatus::Canceled => Some("canceled"),
CardStatus::Failed if notices.iter().all(|notice| notice.value.is_none()) => {
Some("command failed")
}
_ => None,
};
if let Some(status_row) = status_row {
display_lines.push(TextLine::plain(status_row));
body_rows.push(status_row.to_owned());
}
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 append_job_status(rows: &mut Vec<String>, detail: Option<&ToolActivityDetail>) {
let Some(detail) = detail else {
return;
};
if detail.metadata["job_id"].as_str().is_none() && detail.params["job_id"].as_str().is_none() {
return;
}
for (key, label) in [
("job_id", "Job ID"),
("pid", "Process ID"),
("pgid", "Process group ID"),
("status", "Status"),
("exit_code", "Exit code"),
("stdout_bytes_dropped", "Standard output bytes dropped"),
("stderr_bytes_dropped", "Standard error bytes dropped"),
("cleanup_warning", "Cleanup warning"),
] {
let value = detail.metadata.get(key).filter(|value| !value.is_null());
let value = value.or_else(|| {
if key == "job_id" {
detail.params.get(key)
} else {
None
}
});
if key.ends_with("_bytes_dropped") && value.and_then(serde_json::Value::as_u64) == Some(0) {
continue;
}
if let Some(value) = value {
let text = value
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| value.to_string());
rows.push(sanitized_single_line(&format!("{label}: {text}")));
}
}
}
pub(super) fn job_card(input: &ToolCardInput<'_>) -> CardDraft {
let action = input
.detail
.and_then(|detail| detail.params["action"].as_str());
let title = match action {
Some("output") => "Bash Job • Collecting job output",
Some("status") => "Bash Job • Checking job status",
Some("stop") => "Bash Job • Stopping job",
Some("list") => "Bash Job • Listing jobs",
_ => "Bash Job",
};
let mut rows = Vec::new();
append_job_status(&mut rows, input.detail);
if let Some(detail) = input.detail {
if matches!(action, Some("output" | "stop")) {
let mut has_output = false;
for (key, label) in [("stdout", "Standard output"), ("stderr", "Standard error")] {
if let Some(output) = detail.metadata[key]
.as_str()
.filter(|text| !text.is_empty())
{
rows.push(format!("{label}:"));
rows.extend(bounded_preview_lines(output).lines().map(str::to_owned));
has_output = true;
}
}
if !has_output && detail.metadata["stdout"].is_string() {
rows.push("No new output".into());
}
if detail.metadata["output_truncated"] == true {
rows.push("… job output truncated".into());
}
if detail.metadata["has_more_output"] == true {
rows.push("More job output available".into());
}
}
if input_status(input) == CardStatus::Failed {
rows.push(bounded_error_line(&detail.output));
}
}
let mut card = basic_card(ToolCardRole::Bash, title, &rows.join("\n"));
card.body_lines = rows.into_iter().map(TextLine::plain).collect();
card.status = input_status(input);
card
}