use super::*;
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()
}
pub(super) 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()),
);
if metadata
.and_then(|value| value.get("humanize_protection_denied"))
.and_then(serde_json::Value::as_bool)
== Some(true)
{
rows.extend(attempted_change_preview(
detail.and_then(|detail| detail.params.get("input")),
));
}
("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: humanize_protection_metadata(detail),
body: lines.join("\n"),
body_lines,
children: Vec::new(),
activity_rows: 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()))
})
}
pub(super) const HASH_EDIT_DIFF_PREVIEW_LINES: usize = 10;
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() > HASH_EDIT_DIFF_PREVIEW_LINES;
changed.truncate(HASH_EDIT_DIFF_PREVIEW_LINES);
if limited {
changed.push(format!(
"… diff preview limited to {HASH_EDIT_DIFF_PREVIEW_LINES} changed lines"
));
}
changed
}