use super::*;
pub(super) fn bash_card(
state: &MissionControlState,
id: TranscriptCardId,
link: Option<TranscriptActivityLink>,
) -> TranscriptCard {
let node = link
.as_ref()
.and_then(|link| state.nodes.get(&link.activity_id));
let detail = node.and_then(|node| node.tool_detail.as_ref());
let mut status = node
.map(|node| transcript_status_from_activity(node.status))
.or_else(|| detail.map(|detail| transcript_status_from_activity(detail.status)))
.unwrap_or_else(|| transcript_tool_status(state, id.entry_index));
if status == TranscriptCardStatus::Failed && tool_was_canceled(detail) {
status = TranscriptCardStatus::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 = node
.and_then(|node| node.transcript_bash_command.clone())
.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<_>>();
let mut display_lines = if command.is_empty() {
Vec::new()
} else {
body_rows.push(command.clone());
highlight_code(&command, Some("bash"))
};
let status_row = match status {
TranscriptCardStatus::Running | TranscriptCardStatus::None => None,
TranscriptCardStatus::Success => {
let lines = bash_output_line_count(detail);
Some(format!(
"{lines} {}",
if lines == 1 { "line" } else { "lines" }
))
}
TranscriptCardStatus::Failed if timed_out => {
let timeout = detail
.and_then(|detail| detail.params.get("timeout"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(30);
Some(format!("timed out after {timeout}s"))
}
TranscriptCardStatus::Failed => Some(
bash_failure_reason(detail)
.or_else(|| node.and_then(|node| first_non_empty_line(&node.preview)))
.unwrap_or_else(|| "command failed".to_string()),
),
TranscriptCardStatus::Canceled => Some("canceled".to_string()),
};
if let Some(status_row) = status_row {
display_lines.push(DisplayLine::plain(&status_row));
body_rows.push(status_row);
}
TranscriptCard {
id,
role: TranscriptCardRole::Bash,
status,
title,
metadata: bash_jev_metadata(detail),
body: body_rows.join("\n"),
body_lines: display_lines,
children: Vec::new(),
activity_rows: Vec::new(),
subagent_card: None,
}
}
fn bash_title(status: TranscriptCardStatus, timed_out: bool, exit_code: Option<i64>) -> String {
if timed_out {
return "Bash • timed out".to_string();
}
if matches!(
status,
TranscriptCardStatus::Success | TranscriptCardStatus::Failed
) && let Some(exit_code) = exit_code
{
format!("Bash • exit {exit_code}")
} else if status == TranscriptCardStatus::Canceled {
"Bash • canceled".to_string()
} else {
"Bash".to_string()
}
}
fn bash_output_line_count(detail: Option<&crate::output::ToolActivityDetail>) -> usize {
["stdout", "stderr"]
.iter()
.filter_map(|key| {
detail?
.metadata
.get(key)
.and_then(serde_json::Value::as_str)
})
.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)))
}