use super::*;
pub(super) 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(),
activity_rows: 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())
}
pub(super) 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)
}
pub(super) 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 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(", "))
}
pub(super) const GREP_PREVIEW_LINES: usize = 10;
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(GREP_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 {GREP_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 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)
}