use serde_json::Value;
use crate::bridge_protocol::{ExecCommandLine, FileChangeLine, RenderPlanStep};
pub(super) fn preview_text(value: &str, max_chars: usize) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
return String::new();
}
let mut out = String::new();
for (index, ch) in trimmed.chars().enumerate() {
if index >= max_chars {
out.push_str("...");
break;
}
out.push(ch);
}
out
}
pub(super) fn plan_steps(value: &Value) -> Vec<RenderPlanStep> {
value
.as_array()
.into_iter()
.flatten()
.map(|step| RenderPlanStep {
step: step
.get("step")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
status: step
.get("status")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
})
.collect()
}
pub(super) fn file_change_lines(value: &Value) -> Vec<FileChangeLine> {
value
.as_array()
.into_iter()
.flatten()
.map(|change| FileChangeLine {
path: change
.get("path")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
summary: file_change_summary(change),
diff: change
.get("diff")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
})
.collect()
}
pub(super) fn command_action_lines(
actions: &Value,
raw_command: Option<&str>,
) -> Vec<ExecCommandLine> {
let Some(items) = actions.as_array() else {
return raw_command
.map(|command| {
vec![ExecCommandLine {
label: "Run".to_string(),
text: command.to_string(),
}]
})
.unwrap_or_default();
};
let mut lines = Vec::new();
let mut reads = Vec::new();
for action in items {
match action
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown")
{
"read" => {
let name = action
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.or_else(|| action.get("path").and_then(Value::as_str))
.unwrap_or_default()
.to_string();
if !name.is_empty() {
reads.push(name);
}
}
"listFiles" => {
flush_read_lines(&mut lines, &mut reads);
lines.push(ExecCommandLine {
label: "List".to_string(),
text: action
.get("path")
.and_then(Value::as_str)
.or_else(|| action.get("command").and_then(Value::as_str))
.unwrap_or_default()
.to_string(),
});
}
"search" => {
flush_read_lines(&mut lines, &mut reads);
lines.push(ExecCommandLine {
label: "Search".to_string(),
text: search_action_text(action),
});
}
_ => {
flush_read_lines(&mut lines, &mut reads);
lines.push(ExecCommandLine {
label: "Run".to_string(),
text: action
.get("command")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
});
}
}
}
flush_read_lines(&mut lines, &mut reads);
if lines.is_empty() {
raw_command
.map(|command| {
lines.push(ExecCommandLine {
label: "Run".to_string(),
text: command.to_string(),
});
})
.unwrap_or(());
}
lines
}
pub(super) fn is_exploration_action_set(actions: &Value) -> bool {
let Some(items) = actions.as_array() else {
return false;
};
!items.is_empty()
&& items.iter().all(|action| {
matches!(
action.get("type").and_then(Value::as_str),
Some("read") | Some("listFiles") | Some("search")
)
})
}
pub(super) fn mcp_detail_text(result: Option<&Value>, error: Option<&Value>) -> Option<String> {
if let Some(message) = error
.and_then(|value| value.get("message"))
.and_then(Value::as_str)
{
return Some(format!("Error: {message}"));
}
let content = result.and_then(|value| value.get("content"))?;
if let Some(text) = text_from_content_array(content) {
return Some(text);
}
let structured = result.and_then(|value| value.get("structuredContent"));
structured.and_then(format_json_value)
}
pub(super) fn dynamic_tool_detail_text(
content_items: Option<&Value>,
success: Option<bool>,
) -> Option<String> {
let text = content_items.and_then(text_from_dynamic_content_items);
match (success, text) {
(Some(false), Some(text)) => Some(format!("Error: {text}")),
(Some(false), None) => Some("Error".to_string()),
(_, Some(text)) => Some(text),
_ => None,
}
}
pub(super) fn collab_status_line(agent_id: &str, status: &Value) -> String {
let status_name = status
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown");
let mut line = format!("{agent_id}: {}", status_label(status_name));
if let Some(message) = status.get("message").and_then(Value::as_str) {
let preview = preview_text(message, 120);
if !preview.is_empty() {
line.push_str(" - ");
line.push_str(&preview);
}
}
line
}
pub(super) fn format_json_value(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(text) => Some(text.clone()),
Value::Bool(flag) => Some(flag.to_string()),
Value::Number(number) => Some(number.to_string()),
_ => serde_json::to_string(value).ok(),
}
}
pub(super) fn extract_message_text(params: &Value) -> Option<String> {
params
.get("message")
.and_then(format_json_value)
.or_else(|| params.get("summary").and_then(format_json_value))
}
fn flush_read_lines(lines: &mut Vec<ExecCommandLine>, reads: &mut Vec<String>) {
if reads.is_empty() {
return;
}
lines.push(ExecCommandLine {
label: "Read".to_string(),
text: reads.join(", "),
});
reads.clear();
}
fn search_action_text(action: &Value) -> String {
match (
action.get("query").and_then(Value::as_str),
action.get("path").and_then(Value::as_str),
) {
(Some(query), Some(path)) if !query.is_empty() && !path.is_empty() => {
format!("{query} in {path}")
}
(Some(query), _) if !query.is_empty() => query.to_string(),
_ => action
.get("command")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
}
}
fn file_change_summary(change: &Value) -> String {
match change
.get("kind")
.and_then(Value::as_object)
.and_then(|kind| kind.keys().next())
.map(String::as_str)
{
Some("add") => "Added".to_string(),
Some("delete") => "Deleted".to_string(),
Some("update") => "Updated".to_string(),
_ => "Changed".to_string(),
}
}
fn text_from_content_array(value: &Value) -> Option<String> {
let texts = value
.as_array()
.into_iter()
.flatten()
.filter_map(|item| {
item.get("text")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| {
item.get("content")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
})
.collect::<Vec<_>>();
if texts.is_empty() {
None
} else {
Some(texts.join("\n"))
}
}
fn text_from_dynamic_content_items(value: &Value) -> Option<String> {
let texts = value
.as_array()
.into_iter()
.flatten()
.filter_map(|item| {
item.get("text")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| {
item.get("imageUrl")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
})
.collect::<Vec<_>>();
if texts.is_empty() {
None
} else {
Some(texts.join("\n"))
}
}
fn status_label(status: &str) -> &'static str {
match status {
"pendingInit" => "Pending init",
"running" => "Running",
"interrupted" => "Interrupted",
"completed" => "Completed",
"errored" => "Error",
"shutdown" => "Shutdown",
"notFound" => "Not found",
_ => "Unknown",
}
}