use super::subagent_aggregation;
use super::*;
use crate::rendering::{DisplayRole, DisplaySpan};
fn write_card(
state: &MissionControlState,
id: TranscriptCardId,
link: Option<TranscriptActivityLink>,
body: &str,
) -> TranscriptCard {
use crate::tools::contract::{arg_key, metadata_key};
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 metadata = detail.map(|detail| &detail.metadata);
let params = detail.map(|detail| &detail.params);
let path = if status == TranscriptCardStatus::Success {
metadata
.and_then(|value| value.get(metadata_key::PATH))
.and_then(serde_json::Value::as_str)
.or_else(|| {
params
.and_then(|value| value.get(arg_key::PATH))
.and_then(serde_json::Value::as_str)
})
} else {
params
.and_then(|value| value.get(arg_key::PATH))
.and_then(serde_json::Value::as_str)
}
.map(sanitized_single_line)
.map(|path| truncate_preview_line(&path, PREVIEW_LINE_MAX_DISPLAY_WIDTH))
.unwrap_or_default();
let bytes = (status == TranscriptCardStatus::Success)
.then(|| {
metadata
.and_then(|value| value.get(metadata_key::BYTES))
.and_then(serde_json::Value::as_u64)
})
.flatten();
let title = match status {
TranscriptCardStatus::Success => bytes.map_or_else(
|| "Write".to_string(),
|bytes| format!("Write • {bytes} bytes"),
),
TranscriptCardStatus::Canceled => "Write • canceled".to_string(),
_ => "Write".to_string(),
};
let path_lines = usize::from(!path.is_empty());
let body = match status {
TranscriptCardStatus::Canceled => [path.as_str(), "Canceled before completion"]
.into_iter()
.filter(|row| !row.is_empty())
.collect::<Vec<_>>()
.join("\n"),
TranscriptCardStatus::Failed => {
let error = bounded_error_line(detail.map_or(body, |detail| detail.output.as_ref()));
[path, error]
.into_iter()
.filter(|row| !row.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
_ => path,
};
let body_lines = body
.lines()
.enumerate()
.map(|(index, line)| {
DisplayLine::from_span(
line,
if index < path_lines {
DisplayRole::InlineCode
} else {
DisplayRole::Plain
},
)
})
.collect();
TranscriptCard {
id,
role: TranscriptCardRole::Write,
status,
title,
metadata: Vec::new(),
body,
body_lines,
children: Vec::new(),
subagent_card: None,
}
}
fn view_image_card(
state: &MissionControlState,
id: TranscriptCardId,
link: Option<TranscriptActivityLink>,
body: &str,
) -> TranscriptCard {
use crate::tools::contract::{arg_key, metadata_key};
let detail = link
.as_ref()
.and_then(|link| state.nodes.get(&link.activity_id))
.and_then(|node| node.tool_detail.as_ref());
let metadata = detail.map(|detail| &detail.metadata);
let status = detail
.map(|detail| transcript_status_from_activity(detail.status))
.or_else(|| {
link.as_ref()
.and_then(|link| state.nodes.get(&link.activity_id))
.map(|node| transcript_status_from_activity(node.status))
})
.unwrap_or_else(|| transcript_tool_status(state, id.entry_index));
let source_path = metadata
.and_then(|value| value.get(metadata_key::PATH))
.and_then(serde_json::Value::as_str)
.or_else(|| {
detail
.and_then(|detail| detail.params.get(arg_key::PATH))
.and_then(serde_json::Value::as_str)
});
let path = source_path.map(sanitized_single_line);
let mut card_metadata = Vec::new();
if let Some(provider) = metadata
.and_then(|value| value.get(metadata_key::PROVIDER))
.and_then(serde_json::Value::as_str)
{
card_metadata.push(format!("provider {}", sanitized_single_line(provider)));
}
if let Some(model) = metadata
.and_then(|value| value.get(metadata_key::MODEL))
.and_then(serde_json::Value::as_str)
{
card_metadata.push(format!("model {}", sanitized_single_line(model)));
}
if let Some(bytes) = metadata
.and_then(|value| value.get(metadata_key::IMAGE_BYTES))
.and_then(serde_json::Value::as_u64)
{
card_metadata.push(format!("{bytes} bytes"));
}
if let Some(chars) = metadata
.and_then(|value| value.get(metadata_key::PROMPT_CHARS))
.and_then(serde_json::Value::as_u64)
{
card_metadata.push(format!("prompt {chars} chars"));
}
if metadata
.and_then(|value| value.get(metadata_key::TRUNCATED))
.and_then(serde_json::Value::as_bool)
== Some(true)
{
card_metadata.push("response truncated".to_string());
}
let response = if status == TranscriptCardStatus::Success {
let source = detail
.map(|detail| detail.output.as_ref())
.filter(|output| !output.trim().is_empty())
.unwrap_or("");
bounded_preview_lines(source)
} else if matches!(
status,
TranscriptCardStatus::Failed | TranscriptCardStatus::Canceled
) {
let source = detail.map(|detail| detail.output.as_ref()).unwrap_or(body);
bounded_error_line(source)
} else {
String::new()
};
let title = if status == TranscriptCardStatus::Canceled {
"View Image • canceled"
} else {
"View Image"
};
if let Some(path) = path {
card_metadata.insert(0, path);
}
TranscriptCard {
id,
role: TranscriptCardRole::ViewImage,
status,
title: title.to_string(),
metadata: card_metadata,
body: response,
body_lines: Vec::new(),
children: Vec::new(),
subagent_card: None,
}
}
fn bounded_preview_lines(text: &str) -> String {
let lines = text
.lines()
.map(|line| {
truncate_preview_line(
&transcript::sanitize_preview(line),
PREVIEW_LINE_MAX_DISPLAY_WIDTH,
)
})
.collect::<Vec<_>>();
if lines.len() <= TOOL_PREVIEW_LINES {
return lines.join("\n");
}
format!(
"{}\n… response preview limited to {TOOL_PREVIEW_LINES} lines",
lines[..TOOL_PREVIEW_LINES].join("\n")
)
}
fn bounded_error_line(text: &str) -> String {
let line = text
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("operation failed");
truncate_preview_line(
&transcript::sanitize_preview(line),
PREVIEW_LINE_MAX_DISPLAY_WIDTH,
)
}
fn hash_edit_running_ops(input: &str) -> Vec<String> {
crate::tools::hash_edit_operation_summaries(input)
.into_iter()
.map(|operation| match operation {
crate::tools::HashEditOperationSummary::Update { path } => {
format!("Update {}", sanitized_single_line(&path))
}
crate::tools::HashEditOperationSummary::Move { path, destination } => format!(
"Move {} → {}",
sanitized_single_line(&path),
sanitized_single_line(&destination)
),
crate::tools::HashEditOperationSummary::Delete { path } => {
format!("Delete {}", sanitized_single_line(&path))
}
})
.collect()
}
fn hash_edit_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 metadata = detail.map(|detail| &detail.metadata);
let error_kind = hash_edit_metadata_str(metadata, "error_kind");
let outcome = hash_edit_metadata_str(metadata, "outcome");
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 error_kind == Some("canceled") && outcome != Some("partial") {
status = TranscriptCardStatus::Canceled;
}
let files = metadata
.and_then(|metadata| metadata.get("files"))
.and_then(serde_json::Value::as_array);
let added = hash_edit_metadata_usize(metadata, "added").unwrap_or(0);
let removed = hash_edit_metadata_usize(metadata, "removed").unwrap_or(0);
let stale = outcome == Some("stale_tag") || error_kind == Some("stale_tag");
let partial = outcome == Some("partial");
let mut diff_preview_start = None;
let (title, lines) = if status == TranscriptCardStatus::Running {
let operations = detail
.and_then(|detail| detail.params.get("input"))
.and_then(serde_json::Value::as_str)
.map(hash_edit_running_ops)
.unwrap_or_default();
("Hash Edit".to_string(), hash_edit_bounded_rows(operations))
} else if status == TranscriptCardStatus::Canceled {
(
"Hash Edit • canceled".to_string(),
vec!["Canceled before completion".to_string()],
)
} else if stale {
let path = hash_edit_metadata_str(metadata, "path")
.map(sanitized_single_line)
.unwrap_or_default();
let expected = hash_edit_metadata_str(metadata, "expected_tag")
.map(sanitized_single_line)
.unwrap_or_default();
let current = hash_edit_metadata_str(metadata, "current_tag")
.map(sanitized_single_line)
.unwrap_or_default();
(
"Hash Edit • stale tag".to_string(),
vec![
path,
format!("Expected #{expected} · current #{current}"),
"Re-read file, then retry.".to_string(),
],
)
} else if partial {
let committed = hash_edit_metadata_usize(metadata, "committed").unwrap_or(0);
let changed = hash_edit_metadata_usize(metadata, "changed").unwrap_or(committed);
let sections = hash_edit_metadata_usize(metadata, "sections")
.unwrap_or_else(|| files.map_or(0, Vec::len));
let rows = files
.map(|files| {
files
.iter()
.filter_map(hash_edit_partial_row)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut rows = hash_edit_bounded_rows(rows);
rows.push(format!(
"Changes to {changed} {} remain on disk.",
plural(changed, "file", "files")
));
(format!("Hash Edit • partial {committed}/{sections}"), rows)
} else if status == TranscriptCardStatus::Failed {
let mut rows = Vec::new();
if let Some(path) = hash_edit_failure_path(detail, files) {
rows.push(path);
}
rows.push(
detail
.and_then(|detail| first_non_empty_line(detail.output.as_ref()))
.unwrap_or_else(|| "hash_edit failed".to_string()),
);
("Hash Edit".to_string(), rows)
} else {
let total = files.map_or(0, Vec::len);
let operations = files
.map(|files| {
files
.iter()
.filter_map(hash_edit_success_row)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut rows = hash_edit_bounded_rows(operations);
let title = hash_edit_success_title(files, added, removed);
let contains_delete = files.is_some_and(|files| {
files.iter().any(|file| {
file.get("operation").and_then(serde_json::Value::as_str) == Some("delete")
})
});
if !contains_delete
&& (added > 0 || removed > 0)
&& let Some(diff) = detail.and_then(|detail| detail.applied_diff.as_deref())
{
diff_preview_start = Some(rows.len());
rows.extend(hash_edit_diff_preview(
diff,
metadata
.and_then(|metadata| metadata.get("diff_truncated"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
));
}
debug_assert!(total == 0 || !rows.is_empty());
(title, rows)
};
let body_lines = lines
.iter()
.enumerate()
.map(|(index, line)| {
let role = if diff_preview_start.is_some_and(|start| index >= start) {
match line.as_bytes().first() {
Some(b'+') => DisplayRole::DiffInserted,
Some(b'-') => DisplayRole::DiffRemoved,
_ => DisplayRole::Plain,
}
} else {
DisplayRole::Plain
};
DisplayLine::from_span(line, role)
})
.collect();
TranscriptCard {
id,
role: TranscriptCardRole::HashEdit,
status,
title,
metadata: Vec::new(),
body: lines.join("\n"),
body_lines,
children: Vec::new(),
subagent_card: None,
}
}
fn hash_edit_metadata_str<'a>(
metadata: Option<&'a serde_json::Value>,
key: &str,
) -> Option<&'a str> {
metadata?.get(key)?.as_str()
}
fn hash_edit_metadata_usize(metadata: Option<&serde_json::Value>, key: &str) -> Option<usize> {
metadata?
.get(key)?
.as_u64()
.and_then(|value| usize::try_from(value).ok())
}
fn hash_edit_bounded_rows(mut rows: Vec<String>) -> Vec<String> {
if rows.len() <= TOOL_PREVIEW_LINES {
return rows;
}
let remaining = rows.len() - TOOL_PREVIEW_LINES;
rows.truncate(TOOL_PREVIEW_LINES);
rows.push(format!("… {remaining} more files"));
rows
}
fn hash_edit_success_title(
files: Option<&Vec<serde_json::Value>>,
added: usize,
removed: usize,
) -> String {
let total = files.map_or(0, Vec::len);
if total > 1 {
let all_noop = files.is_some_and(|files| {
files.iter().all(|file| {
file.get("operation").and_then(serde_json::Value::as_str) == Some("noop")
})
});
return if all_noop {
format!("Hash Edit • {total} files • no changes")
} else {
format!("Hash Edit • {total} files • +{added} −{removed}")
};
}
match files
.and_then(|files| files.first())
.and_then(|file| file.get("operation"))
.and_then(serde_json::Value::as_str)
{
Some("move") if added + removed > 0 => {
format!("Hash Edit • moved • +{added} −{removed}")
}
Some("move") => "Hash Edit • moved".to_string(),
Some("delete") => format!("Hash Edit • deleted • −{removed}"),
Some("noop") => "Hash Edit • no changes".to_string(),
Some("update") => format!("Hash Edit • +{added} −{removed}"),
_ => "Hash Edit".to_string(),
}
}
fn hash_edit_partial_row(value: &serde_json::Value) -> Option<String> {
let path = value
.get("path")
.and_then(serde_json::Value::as_str)
.map(sanitized_single_line)?;
let status = value
.get("status")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
match status {
"committed" => hash_edit_success_row(value).map(|row| format!("✓ {row}")),
"failed"
| "committed_but_undurable"
| "committed_with_error"
| "destination_written_source_retained"
| "partially_applied" => Some(format!(
"✗ {path} · {}",
value
.get("error")
.and_then(serde_json::Value::as_str)
.and_then(first_non_empty_line)
.unwrap_or_else(|| "error".to_string())
)),
_ => Some(format!("○ {path} · not written")),
}
}
fn hash_edit_success_row(value: &serde_json::Value) -> Option<String> {
let path = value
.get("path")
.and_then(serde_json::Value::as_str)
.map(sanitized_single_line)?;
match value.get("operation").and_then(serde_json::Value::as_str) {
Some("move") => Some(format!(
"Moved {path} → {}",
value
.get("destination")
.and_then(serde_json::Value::as_str)
.map(sanitized_single_line)
.unwrap_or_default()
)),
Some("delete") => Some(format!("Deleted {path}")),
Some("noop") => Some(format!("Unchanged {path}")),
_ => Some(format!("Updated {path}")),
}
}
fn hash_edit_failure_path(
detail: Option<&crate::output::ToolActivityDetail>,
files: Option<&Vec<serde_json::Value>>,
) -> Option<String> {
files
.and_then(|files| files.first())
.and_then(|file| file.get("path"))
.and_then(serde_json::Value::as_str)
.map(sanitized_single_line)
.or_else(|| {
detail
.and_then(|detail| detail.metadata.get("path"))
.and_then(serde_json::Value::as_str)
.map(sanitized_single_line)
})
.or_else(|| {
detail
.and_then(|detail| detail.params.get("input"))
.and_then(serde_json::Value::as_str)
.map(hash_edit_running_ops)
.and_then(|rows| rows.first().cloned())
.and_then(|row| row.split_once(' ').map(|(_, path)| path.to_string()))
})
}
fn hash_edit_diff_preview(diff: &str, diff_truncated: bool) -> Vec<String> {
let mut changed = Vec::new();
let mut header_rows_remaining = 0usize;
for line in diff.lines() {
if line.starts_with("diff --git ") {
header_rows_remaining = 2;
continue;
}
if header_rows_remaining > 0 {
header_rows_remaining -= 1;
continue;
}
if line.starts_with("--- a/")
|| line == "--- /dev/null"
|| line.starts_with("+++ b/")
|| line == "+++ /dev/null"
{
continue;
}
if !(line.starts_with('+') || line.starts_with('-')) {
continue;
}
let safe = transcript::sanitize_preview(line);
changed.push(truncate_preview_line(&safe, PREVIEW_LINE_MAX_DISPLAY_WIDTH));
}
let limited = diff_truncated || changed.len() > TOOL_PREVIEW_LINES;
changed.truncate(TOOL_PREVIEW_LINES);
if limited {
changed.push("… diff preview limited to 4 changed lines".to_string());
}
changed
}
fn read_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 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));
let targets = detail
.and_then(|detail| read_targets(&detail.params))
.unwrap_or_default();
let results = detail
.and_then(|detail| detail.metadata.get("results"))
.and_then(serde_json::Value::as_array);
let total = read_usize(detail, "files")
.unwrap_or_else(|| results.map_or(targets.len(), Vec::len).max(targets.len()));
let succeeded = read_usize(detail, "succeeded").unwrap_or_else(|| {
results.map_or(0, |items| {
items
.iter()
.filter(|item| {
item.get("success").and_then(serde_json::Value::as_bool) == Some(true)
})
.count()
})
});
let failed = read_usize(detail, "failed").unwrap_or_else(|| total.saturating_sub(succeeded));
let omitted = read_usize(detail, "omitted").unwrap_or(0);
let incomplete = failed > 0 || omitted > 0 || succeeded < total;
let multi = total > 1;
let successful_truncation = read_has_successful_truncation(detail, results, multi);
let title = if multi {
match status {
TranscriptCardStatus::Success if incomplete => {
format!("Read • {succeeded}/{total} resources")
}
_ => format!("Read • {total} resources"),
}
} else if status == TranscriptCardStatus::Success && successful_truncation {
"Read • truncated".to_string()
} else {
"Read".to_string()
};
let card_body = if status == TranscriptCardStatus::Canceled {
targets
.iter()
.cloned()
.chain(std::iter::once("Canceled before completion".to_string()))
.collect::<Vec<_>>()
.join("\n")
} else if status == TranscriptCardStatus::Running {
targets.join("\n")
} else if multi {
read_multi_body(&targets, results)
} else {
let target = targets.first().cloned().unwrap_or_default();
if status == TranscriptCardStatus::Failed {
let error = detail
.and_then(read_single_error)
.unwrap_or_else(|| "read failed".to_string());
format!("{target}\n{error}")
} else {
format!("{target}\n{} lines", read_line_count(detail))
}
};
let card_body = sanitize_display_controls(&card_body);
let body_lines = read_body_lines(&card_body, &targets, status, multi);
TranscriptCard {
id,
role: TranscriptCardRole::Read,
status,
title,
metadata: Vec::new(),
body: card_body,
body_lines,
children: Vec::new(),
subagent_card: None,
}
}
fn read_targets(params: &serde_json::Value) -> Option<Vec<String>> {
params
.get("paths")
.and_then(serde_json::Value::as_array)
.map(|paths| {
paths
.iter()
.filter_map(serde_json::Value::as_str)
.map(sanitize_read_target)
.collect()
})
.or_else(|| {
params
.get("path")
.and_then(serde_json::Value::as_str)
.map(|path| vec![sanitize_read_target(path)])
})
}
fn sanitize_read_target(text: &str) -> String {
sanitize_display_controls(text)
.chars()
.filter(|character| !character.is_control())
.collect()
}
fn read_multi_body(targets: &[String], results: Option<&Vec<serde_json::Value>>) -> String {
targets
.iter()
.enumerate()
.map(|(index, target)| {
let result = results.and_then(|items| items.get(index));
if result
.and_then(|item| item.get("success"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
if result
.and_then(|item| item.get("truncated"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
format!("△ {target}\n result truncated")
} else {
format!("✓ {target}")
}
} else {
format!(
"✗ {target}\n {}",
result
.and_then(|item| item.get("error"))
.and_then(serde_json::Value::as_str)
.and_then(read_first_non_empty_line)
.unwrap_or_else(|| "read failed".to_string())
)
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn read_has_successful_truncation(
detail: Option<&crate::output::ToolActivityDetail>,
results: Option<&Vec<serde_json::Value>>,
multi: bool,
) -> bool {
if !multi
&& detail
.and_then(|detail| detail.metadata.get("truncated"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
return true;
}
results.is_some_and(|items| {
items.iter().any(|item| {
item.get("success").and_then(serde_json::Value::as_bool) == Some(true)
&& item.get("truncated").and_then(serde_json::Value::as_bool) == Some(true)
})
})
}
fn read_usize(detail: Option<&crate::output::ToolActivityDetail>, key: &str) -> Option<usize> {
detail?
.metadata
.get(key)?
.as_u64()
.and_then(|value| usize::try_from(value).ok())
}
fn read_single_error(detail: &crate::output::ToolActivityDetail) -> Option<String> {
detail
.metadata
.get("error")
.and_then(serde_json::Value::as_str)
.and_then(read_first_non_empty_line)
.or_else(|| read_first_non_empty_line(detail.output.as_ref()))
}
fn read_first_non_empty_line(text: &str) -> Option<String> {
sanitize_display_controls(text)
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(str::to_string)
}
fn read_line_count(detail: Option<&crate::output::ToolActivityDetail>) -> usize {
let seen_lines = detail
.and_then(|detail| {
detail.metadata.get("hashline_seen_lines").or_else(|| {
detail
.metadata
.get("results")
.and_then(serde_json::Value::as_array)
.and_then(|results| results.first())
.and_then(|result| result.get("hashline_seen_lines"))
})
})
.and_then(serde_json::Value::as_array)
.map(Vec::len);
seen_lines.unwrap_or_else(|| detail.map_or(0, |detail| detail.output.lines().count()))
}
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 mut body_lines = Vec::new();
if !command.is_empty() {
body_lines.push(command);
}
match status {
TranscriptCardStatus::Running => {}
TranscriptCardStatus::Success => {
let lines = bash_output_line_count(detail);
body_lines.push(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);
body_lines.push(format!("timed out after {timeout}s"));
}
TranscriptCardStatus::Failed => body_lines.push(
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 => body_lines.push("canceled".to_string()),
TranscriptCardStatus::None => {}
}
TranscriptCard {
id,
role: TranscriptCardRole::Bash,
status,
title,
metadata: Vec::new(),
body: body_lines.join("\n"),
body_lines: Vec::new(),
children: 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 tool_was_canceled(detail: Option<&crate::output::ToolActivityDetail>) -> bool {
detail.is_some_and(|detail| {
detail
.output
.lines()
.map(str::trim)
.any(|line| line == "prompt canceled")
})
}
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)))
}
fn grep_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 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));
let timed_out = grep_bool(detail, "timed_out");
let truncated = grep_bool(detail, "truncated");
let matches = grep_usize(detail, "matches_returned");
let title = match status {
TranscriptCardStatus::Success if truncated => matches.map_or_else(
|| "Grep • results returned".to_string(),
|count| {
format!(
"Grep • {count} {} returned",
plural(count, "match", "matches")
)
},
),
TranscriptCardStatus::Success => matches.map_or_else(
|| "Grep".to_string(),
|count| format!("Grep • {count} {}", plural(count, "match", "matches")),
),
TranscriptCardStatus::Failed if timed_out => "Grep • timed out".to_string(),
TranscriptCardStatus::Canceled => "Grep • canceled".to_string(),
_ => "Grep".to_string(),
};
let mut lines = Vec::new();
if let Some(patterns) = grep_patterns(detail) {
lines.push(grep_quoted_patterns(&patterns));
}
let scope_index = lines.len();
let scope = grep_param(detail, "path").unwrap_or(".");
lines.push(format!("in {}", sanitized_single_line(scope)));
let preview_start = lines.len();
let mut preview_end = preview_start;
match status {
TranscriptCardStatus::Running => {}
TranscriptCardStatus::Success => {
if let Some(preview) = detail.map(|detail| grep_preview(detail.output.as_ref()))
&& !preview.is_empty()
{
lines.extend(preview.lines().map(str::to_string));
preview_end = lines.len();
}
if truncated {
lines.extend(grep_truncation_messages(detail));
}
}
TranscriptCardStatus::Failed if timed_out => lines.push("search timed out".to_string()),
TranscriptCardStatus::Failed => lines.push(
detail
.and_then(|detail| first_non_empty_line(detail.output.as_ref()))
.or_else(|| node.and_then(|node| first_non_empty_line(&node.preview)))
.unwrap_or_else(|| "grep failed".to_string()),
),
TranscriptCardStatus::Canceled => lines.push("canceled".to_string()),
TranscriptCardStatus::None => {}
}
let body_lines = lines
.iter()
.enumerate()
.map(|(index, line)| {
if index == scope_index {
split_role_line(line, 3, DisplayRole::Plain, DisplayRole::InlineCode)
} else if index < scope_index {
DisplayLine::from_span(line, DisplayRole::String)
} else if (preview_start..preview_end).contains(&index) {
grep_match_line(line)
} else {
DisplayLine::plain(line)
}
})
.collect();
TranscriptCard {
id,
role: TranscriptCardRole::Grep,
status,
title,
metadata: Vec::new(),
body: lines.join("\n"),
body_lines,
children: Vec::new(),
subagent_card: None,
}
}
fn grep_patterns(detail: Option<&crate::output::ToolActivityDetail>) -> Option<Vec<&str>> {
let detail = detail?;
if let Some(patterns) = detail
.params
.get(crate::tools::contract::arg_key::PATTERNS)
.and_then(serde_json::Value::as_array)
{
let patterns = patterns
.iter()
.filter_map(serde_json::Value::as_str)
.filter(|pattern| !pattern.is_empty())
.collect::<Vec<_>>();
if !patterns.is_empty() {
return Some(patterns);
}
}
detail
.params
.get(crate::tools::contract::arg_key::PATTERN)
.and_then(serde_json::Value::as_str)
.filter(|pattern| !pattern.is_empty())
.map(|pattern| vec![pattern])
}
fn grep_param<'a>(
detail: Option<&'a crate::output::ToolActivityDetail>,
key: &str,
) -> Option<&'a str> {
detail?
.params
.get(key)
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
}
fn grep_bool(detail: Option<&crate::output::ToolActivityDetail>, key: &str) -> bool {
detail
.and_then(|detail| detail.metadata.get(key))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
fn grep_usize(detail: Option<&crate::output::ToolActivityDetail>, key: &str) -> Option<usize> {
detail?
.metadata
.get(key)?
.as_u64()
.and_then(|value| usize::try_from(value).ok())
}
fn plural<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
if count == 1 { singular } else { plural }
}
fn grep_quoted_pattern(text: &str) -> String {
serde_json::to_string(&transcript::sanitize_preview(text))
.unwrap_or_else(|_| "\"grep pattern\"".to_string())
}
fn grep_quoted_patterns(patterns: &[&str]) -> String {
if patterns.len() == 1 {
return grep_quoted_pattern(patterns[0]);
}
let quoted = patterns
.iter()
.map(|pattern| grep_quoted_pattern(pattern))
.collect::<Vec<_>>();
format!("[{}]", quoted.join(", "))
}
fn grep_preview(output: &str) -> String {
let sanitized = transcript::sanitize_preview(output.trim_end_matches('\n'));
let mut lines = sanitized.lines();
let mut preview = lines
.by_ref()
.take(TOOL_PREVIEW_LINES)
.map(|line| truncate_preview_line(line, PREVIEW_LINE_MAX_DISPLAY_WIDTH))
.collect::<Vec<_>>();
if lines.next().is_some() {
preview.push(format!("… preview limited to {TOOL_PREVIEW_LINES} lines"));
}
preview.join("\n")
}
fn grep_truncation_messages(detail: Option<&crate::output::ToolActivityDetail>) -> Vec<String> {
let mut messages = Vec::new();
if let Some(reasons) = detail
.and_then(|detail| detail.metadata.get("truncation_reasons"))
.and_then(serde_json::Value::as_array)
{
for reason in reasons.iter().filter_map(serde_json::Value::as_str) {
let message = match reason {
"matches" => "… more matches may exist",
"file_bytes" => "… search incomplete: file size limit",
"scan_bytes" => "… search incomplete: scan byte limit",
"file_limit" => "… search incomplete: file limit",
"read_errors" => "… search incomplete: unreadable files skipped",
"walk_errors" => "… search incomplete: workspace walk errors or omissions",
"per_file" => "… search limited to 5 matches per file",
"deadline" => "… search timed out",
"output_bytes" => "… output limited to 65,536 bytes",
_ => continue,
};
if !messages.iter().any(|existing| existing == message) {
messages.push(message.to_string());
}
}
}
if !messages.is_empty() {
return messages;
}
if grep_bool(detail, "match_limit_reached") {
messages.push("… more matches may exist".to_string());
}
if grep_bool(detail, "byte_limit_reached") {
messages.push("… search incomplete: file size limit".to_string());
}
if grep_bool(detail, "scan_byte_limit_reached") {
messages.push("… search incomplete: scan byte limit".to_string());
}
if grep_bool(detail, "file_limit_reached") {
messages.push("… search incomplete: file limit".to_string());
}
if grep_usize(detail, "read_errors_skipped").is_some_and(|count| count > 0) {
messages.push("… search incomplete: unreadable files skipped".to_string());
}
if grep_usize(detail, "walk_errors").is_some_and(|count| count > 0)
|| grep_usize(detail, "walk_entries_omitted").is_some_and(|count| count > 0)
{
messages.push("… search incomplete: workspace walk errors or omissions".to_string());
}
if grep_bool(detail, "per_file_limit_reached") {
messages.push("… search limited to 5 matches per file".to_string());
}
if grep_bool(detail, "output_byte_limit_reached")
|| grep_usize(detail, "context_lines_omitted").is_some_and(|count| count > 0)
{
messages.push("… output limited to 65,536 bytes".to_string());
}
if !messages.is_empty() {
return messages;
}
vec![match detail
.and_then(|detail| detail.metadata.get("limit_reason"))
.and_then(serde_json::Value::as_str)
{
Some("matches") => "… more matches may exist".to_string(),
Some("file_bytes") => "… search incomplete: file size limit".to_string(),
Some("scan_bytes") => "… search incomplete: scan byte limit".to_string(),
Some("file_limit") => "… search incomplete: file limit".to_string(),
Some("read_errors") => "… search incomplete: unreadable files skipped".to_string(),
Some("walk_errors") => {
"… search incomplete: workspace walk errors or omissions".to_string()
}
Some("per_file") => "… search limited to 5 matches per file".to_string(),
Some("deadline") => "… search timed out".to_string(),
Some("output_bytes") => "… output limited to 65,536 bytes".to_string(),
_ => "… search incomplete".to_string(),
}]
}
fn ast_grep_card(
state: &MissionControlState,
id: TranscriptCardId,
link: Option<TranscriptActivityLink>,
body: &str,
) -> TranscriptCard {
use crate::tools::contract::{arg_key, metadata_key};
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));
let timed_out = grep_bool(detail, metadata_key::TIMED_OUT);
if status == TranscriptCardStatus::Failed
&& detail.is_some_and(|detail| detail.output.trim() == "prompt canceled")
{
status = TranscriptCardStatus::Canceled;
}
if timed_out && status != TranscriptCardStatus::Canceled {
status = TranscriptCardStatus::Failed;
}
let param = |key: &str| {
detail
.and_then(|detail| detail.params.get(key))
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
};
let pattern = param(arg_key::PATTERN);
let path = param(arg_key::PATH)
.or_else(|| {
detail
.and_then(|detail| detail.metadata.get(metadata_key::PATH))
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
})
.unwrap_or(".");
let language = param(arg_key::LANGUAGE).or_else(|| {
detail
.and_then(|detail| detail.metadata.get(metadata_key::LANGUAGE))
.and_then(serde_json::Value::as_str)
});
let rewrite = param(arg_key::REWRITE);
let limit = detail
.and_then(|detail| detail.params.get(arg_key::LIMIT))
.and_then(serde_json::Value::as_u64)
.and_then(|n| usize::try_from(n).ok());
let matches = grep_usize(detail, "matches_returned");
let truncated = grep_bool(detail, metadata_key::TRUNCATED);
let output_truncated = grep_bool(detail, metadata_key::STDOUT_TRUNCATED)
|| grep_bool(detail, metadata_key::STDERR_TRUNCATED);
let title = match status {
TranscriptCardStatus::Success if truncated => matches.map_or_else(
|| "AST Grep".into(),
|n| format!("AST Grep • {n} {} returned", plural(n, "match", "matches")),
),
TranscriptCardStatus::Success => matches.map_or_else(
|| "AST Grep".into(),
|n| format!("AST Grep • {n} {}", plural(n, "match", "matches")),
),
TranscriptCardStatus::Failed if timed_out => "AST Grep • timed out".into(),
TranscriptCardStatus::Canceled => "AST Grep • canceled".into(),
_ => "AST Grep".into(),
};
let mut lines = Vec::new();
if let Some(pattern) = pattern {
lines.push(ast_grep_bounded(&ast_grep_quoted(pattern)));
}
lines.push(ast_grep_bounded(&format!(
"in {}",
sanitized_single_line(path)
)));
if let Some(language) = language {
lines.push(ast_grep_bounded(&format!(
"language {}",
sanitized_single_line(language)
)));
}
if let Some(rewrite) = rewrite {
lines.push(ast_grep_bounded(&format!(
"rewrite {}",
ast_grep_quoted(rewrite)
)));
}
if let Some(limit) = limit {
lines.push(format!("limit {limit}"));
}
match status {
TranscriptCardStatus::Success => {
let output = detail.map(|detail| detail.output.as_ref()).unwrap_or("");
let rows = output
.lines()
.filter(|line| !line.trim().is_empty())
.map(ast_grep_bounded)
.collect::<Vec<_>>();
lines.extend(rows.iter().take(TOOL_PREVIEW_LINES).cloned());
if rows.len() > TOOL_PREVIEW_LINES {
lines.push(format!("… preview limited to {TOOL_PREVIEW_LINES} lines"));
}
if output_truncated {
lines.push("… output truncated".into());
} else if truncated {
lines.push("… search incomplete".into());
}
}
TranscriptCardStatus::Failed if timed_out => lines.push("search timed out".into()),
TranscriptCardStatus::Failed => lines.push(
detail
.and_then(|d| d.metadata.get(metadata_key::CLEANUP_WARNING))
.and_then(serde_json::Value::as_str)
.and_then(ast_grep_first_line)
.or_else(|| detail.and_then(|d| ast_grep_first_line(d.output.as_ref())))
.or_else(|| ast_grep_first_line(body))
.unwrap_or_else(|| "ast-grep failed".into()),
),
TranscriptCardStatus::Canceled => lines.push("Canceled before completion".into()),
_ => {}
}
TranscriptCard {
id,
role: TranscriptCardRole::AstGrep,
status,
title,
metadata: Vec::new(),
body: lines.join("\n"),
body_lines: Vec::new(),
children: Vec::new(),
subagent_card: None,
}
}
fn ast_grep_quoted(text: &str) -> String {
serde_json::to_string(&redact_sensitive_text(&transcript::sanitize_preview(text)))
.unwrap_or_else(|_| "\"ast-grep value\"".into())
}
fn ast_grep_bounded(text: &str) -> String {
truncate_preview_line(
&redact_sensitive_text(&transcript::sanitize_preview(text)),
PREVIEW_LINE_MAX_DISPLAY_WIDTH,
)
}
fn ast_grep_first_line(text: &str) -> Option<String> {
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(ast_grep_bounded)
}
fn list_files_card(
state: &MissionControlState,
id: TranscriptCardId,
link: Option<TranscriptActivityLink>,
body: &str,
) -> TranscriptCard {
use crate::tools::contract::{arg_key, metadata_key};
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 metadata = detail.map(|detail| &detail.metadata);
let params = detail.map(|detail| &detail.params);
let path = if status == TranscriptCardStatus::Success {
metadata
.and_then(|value| value.get(metadata_key::PATH))
.and_then(serde_json::Value::as_str)
.or_else(|| {
params
.and_then(|value| value.get(arg_key::PATH))
.and_then(serde_json::Value::as_str)
})
} else {
params
.and_then(|value| value.get(arg_key::PATH))
.and_then(serde_json::Value::as_str)
.or_else(|| {
metadata
.and_then(|value| value.get(metadata_key::PATH))
.and_then(serde_json::Value::as_str)
})
}
.unwrap_or(".");
let path = truncate_preview_line(&sanitized_single_line(path), PREVIEW_LINE_MAX_DISPLAY_WIDTH);
let files = list_files_count(metadata, metadata_key::FILES);
let directories = list_files_count(metadata, metadata_key::DIRECTORIES);
let include_directories = params
.and_then(|value| value.get(arg_key::INCLUDE_DIRECTORIES))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let includes_directories = include_directories || directories.is_some_and(|count| count > 0);
let truncated = metadata
.and_then(|value| value.get(metadata_key::TRUNCATED))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let returned = files
.zip(directories)
.map(|(files, directories)| files.saturating_add(directories));
let title = match status {
TranscriptCardStatus::Success if truncated => returned.map_or_else(
|| "List Files • entries returned".to_string(),
|returned| format!("List Files • {returned} entries returned"),
),
TranscriptCardStatus::Success if includes_directories => returned.map_or_else(
|| "List Files".to_string(),
|returned| format!("List Files • {returned} entries"),
),
TranscriptCardStatus::Success => files.map_or_else(
|| "List Files".to_string(),
|files| format!("List Files • {files} {}", plural(files, "file", "files")),
),
TranscriptCardStatus::Canceled => "List Files • canceled".to_string(),
_ => "List Files".to_string(),
};
let mut lines = vec![path];
match status {
TranscriptCardStatus::Running => {
if include_directories {
lines.push("include directories".to_string());
}
}
TranscriptCardStatus::Success => {
let output_lines = detail
.map(|detail| {
detail
.output
.lines()
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
lines.extend(output_lines.iter().take(TOOL_PREVIEW_LINES).map(|line| {
truncate_preview_line(&sanitized_single_line(line), PREVIEW_LINE_MAX_DISPLAY_WIDTH)
}));
if output_lines.len() > TOOL_PREVIEW_LINES {
lines.push(format!("… preview limited to {TOOL_PREVIEW_LINES} lines"));
}
if truncated {
lines.push("… output capped at 1000 entries".to_string());
}
if output_lines.is_empty() && returned == Some(0) {
lines.push("0 entries".to_string());
}
}
TranscriptCardStatus::Failed => lines.push(bounded_error_line(
detail.map_or(body, |detail| detail.output.as_ref()),
)),
TranscriptCardStatus::Canceled => lines.push("Canceled before completion".to_string()),
TranscriptCardStatus::None => {}
}
TranscriptCard {
id,
role: TranscriptCardRole::ListFiles,
status,
title,
metadata: Vec::new(),
body: lines.join("\n"),
body_lines: Vec::new(),
children: Vec::new(),
subagent_card: None,
}
}
fn list_files_count(metadata: Option<&serde_json::Value>, key: &str) -> Option<usize> {
metadata
.and_then(|value| value.get(key))
.and_then(serde_json::Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
}
fn find_card(
state: &MissionControlState,
id: TranscriptCardId,
link: Option<TranscriptActivityLink>,
) -> TranscriptCard {
const DEFAULT_LIMIT: usize = 50;
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 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));
let query = find_metadata_str(detail, "query")
.or_else(|| find_param(detail, "query"))
.unwrap_or("");
let path = find_param(detail, "path").unwrap_or(".");
let kind = find_param(detail, "kind").unwrap_or("files");
let offset = find_param_usize(detail, "offset").unwrap_or(0);
let limit = find_param_usize(detail, "limit").unwrap_or(DEFAULT_LIMIT);
let returned = find_usize(detail, "matches_returned")
.or_else(|| detail.map(|detail| find_output_paths(detail.output.as_ref()).len()))
.unwrap_or(0);
let total = find_usize(detail, "total_matched");
let incomplete = find_bool(detail, "truncated")
|| offset != 0
|| total.is_some_and(|total| returned != total);
let title = match status {
TranscriptCardStatus::Success => format!(
"Find • {returned} {}{}",
plural(returned, "path", "paths"),
if incomplete { " returned" } else { "" }
),
TranscriptCardStatus::Canceled => "Find • canceled".to_string(),
_ => "Find".to_string(),
};
let mut lines = vec![
find_quoted(query),
format!("{} in {}", find_kind(kind), sanitized_single_line(path)),
];
if offset != 0 || limit != DEFAULT_LIMIT {
lines.push(format!("offset {offset} · limit {limit}"));
}
match status {
TranscriptCardStatus::Running => {}
TranscriptCardStatus::Success => {
let paths = detail
.map(|detail| find_output_paths(detail.output.as_ref()))
.unwrap_or_default();
lines.extend(paths.iter().take(TOOL_PREVIEW_LINES).cloned());
if paths.len() > TOOL_PREVIEW_LINES {
lines.push(format!("… preview limited to {TOOL_PREVIEW_LINES} paths"));
}
if incomplete {
if let Some(total) = total {
if returned == 0 || offset >= total {
lines.push(format!("… showing 0 of {total} paths"));
} else {
let start = offset.saturating_add(1);
let end = offset.saturating_add(returned).min(total);
lines.push(format!("… showing {start}–{end} of {total} paths"));
}
} else {
lines.push("… more paths may exist".to_string());
}
}
if detail
.and_then(|detail| detail.metadata.get("index_ready"))
.and_then(serde_json::Value::as_bool)
== Some(false)
{
lines.push("… index not ready; results may be incomplete".to_string());
}
}
TranscriptCardStatus::Failed => lines.push(
detail
.and_then(|detail| find_first_non_empty_line(detail.output.as_ref()))
.or_else(|| node.and_then(|node| find_first_non_empty_line(&node.preview)))
.unwrap_or_else(|| "find failed".to_string()),
),
TranscriptCardStatus::Canceled => lines.push("canceled".to_string()),
TranscriptCardStatus::None => {}
}
TranscriptCard {
id,
role: TranscriptCardRole::Find,
status,
title,
metadata: Vec::new(),
body: lines.join("\n"),
body_lines: Vec::new(),
children: Vec::new(),
subagent_card: None,
}
}
fn find_param<'a>(
detail: Option<&'a crate::output::ToolActivityDetail>,
key: &str,
) -> Option<&'a str> {
detail?
.params
.get(key)?
.as_str()
.filter(|value| !value.is_empty())
}
fn find_metadata_str<'a>(
detail: Option<&'a crate::output::ToolActivityDetail>,
key: &str,
) -> Option<&'a str> {
detail?
.metadata
.get(key)?
.as_str()
.filter(|value| !value.is_empty())
}
fn find_param_usize(
detail: Option<&crate::output::ToolActivityDetail>,
key: &str,
) -> Option<usize> {
detail
.and_then(|detail| detail.params.get(key))
.and_then(serde_json::Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
.or_else(|| find_usize(detail, key))
}
fn find_usize(detail: Option<&crate::output::ToolActivityDetail>, key: &str) -> Option<usize> {
detail?
.metadata
.get(key)?
.as_u64()
.and_then(|value| usize::try_from(value).ok())
}
fn find_bool(detail: Option<&crate::output::ToolActivityDetail>, key: &str) -> bool {
detail
.and_then(|detail| detail.metadata.get(key))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
fn find_kind(kind: &str) -> &'static str {
match kind {
"directories" | "dirs" => "directories",
"mixed" | "both" | "files_and_directories" => "files and directories",
_ => "files",
}
}
fn find_quoted(query: &str) -> String {
serde_json::to_string(&transcript::sanitize_preview(query))
.unwrap_or_else(|_| "\"find query\"".to_string())
}
fn find_output_paths(output: &str) -> Vec<String> {
output
.lines()
.filter(|line| !line.is_empty())
.map(|line| {
let safe = sanitized_single_line(line);
truncate_preview_line(&safe, PREVIEW_LINE_MAX_DISPLAY_WIDTH)
})
.collect()
}
fn find_first_non_empty_line(text: &str) -> Option<String> {
transcript::sanitize_preview(text)
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(normalize_inline_separators)
}
pub(super) fn project_tool_card(
state: &MissionControlState,
id: TranscriptCardId,
link: Option<TranscriptActivityLink>,
body: &str,
) -> TranscriptCard {
let tool_capability = link
.as_ref()
.and_then(|link| ToolCapability::from_dispatch_name(&link.tool_name));
match tool_capability {
Some(ToolCapability::ViewImage) => view_image_card(state, id, link, body),
Some(ToolCapability::Write) => write_card(state, id, link, body),
Some(ToolCapability::HashEdit) => hash_edit_card(state, id, link),
Some(ToolCapability::Read) => read_card(state, id, link),
Some(ToolCapability::Bash) => {
if is_bash_mode_response_card(state, id.entry_index, tool_capability) {
generic_tool_card(state, id, link, body)
} else {
bash_card(state, id, link)
}
}
Some(ToolCapability::AstGrep) => ast_grep_card(state, id, link, body),
Some(ToolCapability::Grep) => grep_card(state, id, link),
Some(ToolCapability::ListFiles) => list_files_card(state, id, link, body),
Some(ToolCapability::Find) => find_card(state, id, link),
Some(ToolCapability::Subagents | ToolCapability::Web) | None => {
generic_tool_card(state, id, link, body)
}
}
}
fn generic_tool_card(
state: &MissionControlState,
id: TranscriptCardId,
link: Option<TranscriptActivityLink>,
body: &str,
) -> TranscriptCard {
let activity = link
.as_ref()
.and_then(|link| state.nodes.get(&link.activity_id).map(|node| (link, node)));
let tool_capability = link
.as_ref()
.and_then(|link| ToolCapability::from_dispatch_name(&link.tool_name));
let role = if tool_capability == Some(ToolCapability::Subagents)
|| (link.is_none()
&& transcript_tool_name(state, id.entry_index)
== Some(ToolCapability::Subagents.canonical_name()))
{
TranscriptCardRole::SubagentBatch
} else {
TranscriptCardRole::Tool
};
let bash_mode_response = is_bash_mode_response_card(state, id.entry_index, tool_capability);
let title = if bash_mode_response {
BASH_MODE_RESPONSE_TITLE.to_string()
} else {
tool_title(
link.as_ref().map(|link| link.tool_name.as_str()),
transcript_tool_name(state, id.entry_index),
role,
)
};
let mut status = transcript_tool_status(state, id.entry_index);
let metadata = Vec::new();
let mut preview = String::new();
let children = if role == TranscriptCardRole::SubagentBatch {
subagent_aggregation::subagent_children(
state,
link.as_ref().map(|link| &link.activity_id),
id.entry_index,
)
} else {
Vec::new()
};
if let Some((_, node)) = activity {
status = transcript_status_from_activity(node.status);
preview = if bash_mode_response {
full_activity_preview(&node.preview)
} else if role != TranscriptCardRole::SubagentBatch {
bounded_lines(&node.preview, TOOL_PREVIEW_LINES)
} else {
String::new()
};
}
let body =
normalize_inline_separators(&transcript::sanitize_preview(body.trim_end_matches('\n')));
if preview.is_empty() && role != TranscriptCardRole::SubagentBatch {
preview = body.clone();
}
let subagent_card = if role == TranscriptCardRole::SubagentBatch {
Some(subagent_aggregation::subagent_card_summary_from_children(
state,
link.as_ref().map(|link| &link.activity_id),
&children,
activity.map(|(_, node)| node),
))
} else {
None
};
TranscriptCard {
id,
role,
status,
title: transcript::sanitize_preview(&title),
metadata,
body: preview,
body_lines: Vec::new(),
children,
subagent_card,
}
}
fn split_role_line(
text: &str,
split: usize,
prefix: DisplayRole,
suffix: DisplayRole,
) -> DisplayLine {
tool_display_line(vec![
DisplaySpan::new(&text[..split], prefix),
DisplaySpan::new(&text[split..], suffix),
])
}
fn read_body_lines(
body: &str,
targets: &[String],
status: TranscriptCardStatus,
multi: bool,
) -> Vec<DisplayLine> {
body.lines()
.enumerate()
.map(|(index, line)| {
let bare_target_row = index == 0
|| (matches!(
status,
TranscriptCardStatus::Running | TranscriptCardStatus::Canceled
) && index < targets.len());
if bare_target_row && targets.iter().any(|target| target == line) {
return DisplayLine::from_span(line, DisplayRole::InlineCode);
}
if multi
&& let Some((marker, target)) = line.split_once(' ')
&& matches!(marker, "✓" | "△" | "✗")
&& targets.iter().any(|expected| expected == target)
{
return split_role_line(
line,
marker.len() + 1,
DisplayRole::ListMarker,
DisplayRole::InlineCode,
);
}
if !multi
&& index == 1
&& status == TranscriptCardStatus::Success
&& let Some(count) = line.strip_suffix(" lines")
{
return split_role_line(line, count.len(), DisplayRole::Number, DisplayRole::Plain);
}
DisplayLine::plain(line)
})
.collect()
}
fn grep_match_line(line: &str) -> DisplayLine {
for (path_end, _) in line.match_indices(':') {
let after_path = &line[path_end + 1..];
let Some((number, _)) = after_path.split_once(':') else {
continue;
};
if path_end == 0 || number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) {
continue;
}
let source_start = path_end + number.len() + 2;
return tool_display_line(vec![
DisplaySpan::new(&line[..path_end], DisplayRole::InlineCode),
DisplaySpan::new(":", DisplayRole::Punctuation),
DisplaySpan::new(number, DisplayRole::Number),
DisplaySpan::new(":", DisplayRole::Punctuation),
DisplaySpan::new(&line[source_start..], DisplayRole::Plain),
]);
}
DisplayLine::plain(line)
}
fn tool_display_line(spans: Vec<DisplaySpan>) -> DisplayLine {
use unicode_segmentation::UnicodeSegmentation;
let line = DisplayLine { spans };
let text = line.plain_text();
let mut boundary = 0;
for span in line.spans.iter().take(line.spans.len().saturating_sub(1)) {
boundary += span.text.len();
if boundary != text.len()
&& !text
.grapheme_indices(true)
.any(|(index, _)| index == boundary)
{
return DisplayLine::plain(text);
}
}
line
}