use serde_json::Value;
use super::super::helpers::optional_string;
use super::diff::looks_like_patch_text;
use super::metadata::TimelineSemanticInfo;
pub(super) fn timeline_semantic_from_item(
raw_type: &str,
entry_type: &str,
item: &Value,
payload: &Value,
) -> Option<TimelineSemanticInfo> {
match entry_type {
"commandExecution" => {
let actions = payload
.get("commandActions")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
if let Some(detail) = explored_detail_from_command_actions(actions) {
return Some(TimelineSemanticInfo::new(
"explored",
Some(detail.to_string()),
"high",
"primary",
));
}
Some(TimelineSemanticInfo::new(
"ran",
Some("run".to_string()),
"high",
"primary",
))
}
"webSearch" => Some(TimelineSemanticInfo::new(
"explored",
Some("search".to_string()),
"high",
"primary",
)),
"fileChange" | "diff" => Some(TimelineSemanticInfo::new("edited", None, "high", "primary")),
"collabToolCall" => {
let tool = payload_string(payload, "tool")
.or_else(|| optional_string(item, "tool"))
.unwrap_or_default();
if normalize_token(&tool) == "wait" {
return Some(TimelineSemanticInfo::new(
"waited",
Some("wait_agents".to_string()),
"high",
"primary",
));
}
None
}
"shell_call" | "local_shell_call" => classify_shell_like_semantic(payload, "primary"),
"web_search_call" | "file_search_call" => Some(TimelineSemanticInfo::new(
"explored",
Some("search".to_string()),
"low",
"primary",
)),
"apply_patch_call" | "apply_patch_call_output" => {
let patch_like = payload_string(payload, "operation")
.or_else(|| payload_string(payload, "output"))
.is_some_and(|text| looks_like_patch_text(&text));
patch_like.then(|| {
TimelineSemanticInfo::new(
"edited",
None,
"low",
if entry_type.ends_with("_output") {
"output"
} else {
"primary"
},
)
})
}
_ => {
let normalized_raw_type = normalize_token(raw_type);
if normalized_raw_type == "websearchcall" || normalized_raw_type == "filesearchcall" {
return Some(TimelineSemanticInfo::new(
"explored",
Some("search".to_string()),
"low",
"primary",
));
}
None
}
}
}
pub(super) fn semantic_title(kind: &str) -> &'static str {
match kind {
"ran" => "Ran",
"explored" => "Explored",
"edited" => "Edited",
"waited" => "Waited",
_ => "Tool",
}
}
pub(super) fn detail_label(detail: Option<&str>) -> &'static str {
match detail.unwrap_or_default() {
"read" => "Read",
"list" => "List",
"search" => "Search",
"mixed" => "Mixed",
"wait_agents" => "Wait",
"run" | "" => "Run",
_ => "Run",
}
}
pub(super) fn payload_string(payload: &Value, key: &str) -> Option<String> {
payload
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
pub(super) fn first_shell_command(payload: &Value) -> Option<String> {
payload_string(payload, "command").or_else(|| {
payload
.get("commands")
.and_then(Value::as_array)
.into_iter()
.flatten()
.find_map(|item| match item {
Value::String(text) => Some(text.clone()),
Value::Object(_) => item
.get("command")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
_ => None,
})
})
}
pub(super) fn first_search_query(payload: &Value) -> Option<String> {
payload_string(payload, "query").or_else(|| {
payload
.get("queries")
.and_then(Value::as_array)
.into_iter()
.flatten()
.find_map(|item| match item {
Value::String(text) => Some(text.clone()),
Value::Object(_) => item
.get("query")
.or_else(|| item.get("text"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
_ => None,
})
})
}
pub(super) fn extract_query_from_web_search_action(payload: &Value) -> Option<String> {
let action = payload.get("action")?;
action
.get("query")
.or_else(|| action.get("pattern"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| {
action
.get("queries")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.next()
})
}
pub(super) fn command_action_target(action: &Value) -> Option<String> {
let action_type = normalize_token(&payload_string(action, "type").unwrap_or_default());
match action_type.as_str() {
"read" => payload_string(action, "name").or_else(|| payload_string(action, "path")),
"listfiles" => payload_string(action, "path").or_else(|| payload_string(action, "command")),
"search" => payload_string(action, "path"),
_ => None,
}
}
pub(super) fn command_action_query(action: &Value) -> Option<String> {
let action_type = normalize_token(&payload_string(action, "type").unwrap_or_default());
(action_type == "search")
.then(|| payload_string(action, "query").or_else(|| payload_string(action, "command")))
.flatten()
}
pub(super) fn extract_targets_from_payload(payload: &Value) -> Vec<String> {
let mut targets = Vec::new();
if let Some(path) = payload_string(payload, "path") {
push_unique(&mut targets, path);
}
if let Some(items) = payload.get("queries").and_then(Value::as_array) {
items.iter().for_each(|item| {
if let Some(text) = item.as_str().map(ToOwned::to_owned) {
push_unique(&mut targets, text);
}
});
}
targets
}
pub(super) fn normalize_token(value: &str) -> String {
value
.trim()
.to_lowercase()
.replace(['_', '-', '\n', '\r', '\t'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
pub(super) fn push_unique(values: &mut Vec<String>, candidate: String) {
if candidate.trim().is_empty() || values.iter().any(|value| value == &candidate) {
return;
}
values.push(candidate);
}
fn classify_shell_like_semantic(payload: &Value, role: &str) -> Option<TimelineSemanticInfo> {
let command = first_shell_command(payload)?;
let normalized = normalize_token(&unwrap_shell_wrapper(&command));
let detail = if normalized.starts_with("cat ")
|| normalized.starts_with("sed ")
|| normalized.starts_with("head ")
|| normalized.starts_with("tail ")
|| normalized.starts_with("bat ")
|| normalized == "cat"
|| normalized == "sed"
|| normalized == "head"
|| normalized == "tail"
|| normalized == "bat"
{
"read"
} else if normalized.starts_with("ls ")
|| normalized.starts_with("tree ")
|| normalized.starts_with("find ")
|| normalized.starts_with("fd ")
|| normalized == "ls"
|| normalized == "tree"
|| normalized == "find"
|| normalized == "fd"
{
"list"
} else if normalized.starts_with("rg ")
|| normalized.starts_with("grep ")
|| normalized.starts_with("git grep ")
|| normalized.starts_with("findstr ")
|| normalized == "rg"
|| normalized == "grep"
|| normalized == "findstr"
{
"search"
} else {
"run"
};
let kind = if detail == "run" { "ran" } else { "explored" };
Some(TimelineSemanticInfo::new(
kind,
Some(detail.to_string()),
"low",
role.to_string(),
))
}
fn explored_detail_from_command_actions(actions: &[Value]) -> Option<&'static str> {
if actions.is_empty() {
return None;
}
let mut has_read = false;
let mut has_list = false;
let mut has_search = false;
for action in actions {
match normalize_token(&payload_string(action, "type").unwrap_or_default()).as_str() {
"read" => has_read = true,
"listfiles" => has_list = true,
"search" => has_search = true,
_ => return None,
}
}
Some(match (has_read, has_list, has_search) {
(true, false, false) => "read",
(false, true, false) => "list",
(false, false, true) => "search",
_ => "mixed",
})
}
fn unwrap_shell_wrapper(command: &str) -> String {
let trimmed = command.trim();
for marker in [" -lc ", " -c "] {
if let Some((_, script)) = trimmed.split_once(marker) {
return script
.trim()
.trim_matches('\'')
.trim_matches('"')
.to_string();
}
}
trimmed.to_string()
}