use serde_json::Value;
use super::format::{
collab_status_line, command_action_lines, dynamic_tool_detail_text, file_change_lines,
format_json_value, is_exploration_action_set, mcp_detail_text, preview_text,
};
use super::{
PREVIEW_LIMIT, ThreadRenderNode, node_id_value, node_item_id, render_collab_title,
render_node_id, render_notice_node, replace_item_nodes, set_inline_message_from_last_node,
};
use crate::state::helpers::optional_string;
pub(super) fn upsert_nodes_from_item(
snapshot: &mut super::ThreadRenderSnapshot,
turn_id: Option<&str>,
item: &Value,
streaming: bool,
) {
let item_id = optional_string(item, "id");
let Some(item_type) = item.get("type").and_then(Value::as_str) else {
return;
};
if item_type == "commandExecution" {
upsert_command_execution_item(snapshot, turn_id, item, streaming);
set_inline_message_from_last_node(snapshot);
return;
}
let nodes = match item_type {
"userMessage" => vec![ThreadRenderNode::UserMessage {
id: render_node_id(turn_id, item_id.as_deref(), "user"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
text: render_user_message_text(item),
}],
"agentMessage" => render_agent_message_nodes(snapshot, turn_id, item, streaming),
"plan" => vec![ThreadRenderNode::ProposedPlan {
id: render_node_id(turn_id, item_id.as_deref(), "proposed-plan"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: "Proposed plan".to_string(),
text: optional_string(item, "text").unwrap_or_default(),
}],
"reasoning" => vec![ThreadRenderNode::ReasoningSummary {
id: render_node_id(turn_id, item_id.as_deref(), "reasoning"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: "Thinking".to_string(),
text: render_reasoning_text(item),
}],
"fileChange" => vec![render_file_change_node(turn_id, item)],
"mcpToolCall" => vec![render_mcp_tool_call_node(turn_id, item)],
"dynamicToolCall" => vec![render_dynamic_tool_call_node(turn_id, item)],
"collabAgentToolCall" => vec![render_collab_event_node(turn_id, item)],
"webSearch" => vec![render_web_search_node(turn_id, item)],
"imageView" => vec![render_view_image_node(turn_id, item)],
"imageGeneration" => vec![render_image_generation_node(turn_id, item)],
"enteredReviewMode" => vec![render_notice_node(
turn_id,
item_id,
"Entered review mode",
optional_string(item, "review"),
)],
"exitedReviewMode" => vec![render_notice_node(
turn_id,
item_id,
"Exited review mode",
optional_string(item, "review"),
)],
"contextCompaction" => vec![render_notice_node(
turn_id,
item_id,
"Context compacted",
None,
)],
"hookPrompt" => vec![render_notice_node(
turn_id,
item_id,
"Hook prompt",
Some(render_hook_prompt_text(item)),
)],
_ => vec![render_notice_node(
turn_id,
item_id,
item_type,
format_json_value(item).map(|value| preview_text(&value, PREVIEW_LIMIT)),
)],
};
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
replace_item_nodes(snapshot, item_id, nodes);
} else {
snapshot.nodes.extend(nodes);
}
set_inline_message_from_last_node(snapshot);
}
fn upsert_command_execution_item(
snapshot: &mut super::ThreadRenderSnapshot,
turn_id: Option<&str>,
item: &Value,
streaming: bool,
) {
let next = render_command_execution_node(turn_id, item, streaming);
let node_id = node_id_value(&next).to_string();
if let Some(index) = snapshot
.nodes
.iter()
.position(|node| node_id_value(node) == node_id)
{
merge_command_execution_node(&mut snapshot.nodes[index], next);
return;
}
let item_id = item.get("id").and_then(Value::as_str);
if let Some(index) = item_id.and_then(|item_id| {
snapshot.nodes.iter().position(|node| {
matches!(node, ThreadRenderNode::ExecGroup { .. })
&& node_item_id(node) == Some(item_id)
})
}) {
merge_command_execution_node(&mut snapshot.nodes[index], next);
return;
}
if let Some(item_id) = item_id {
replace_item_nodes(snapshot, item_id, vec![next]);
} else {
snapshot.nodes.push(next);
}
}
fn merge_command_execution_node(existing: &mut ThreadRenderNode, next: ThreadRenderNode) {
let ThreadRenderNode::ExecGroup {
id: next_id,
turn_id: next_turn_id,
item_id: next_item_id,
title: next_title,
kind: next_kind,
state: next_state,
commands: next_commands,
output_text: next_output_text,
exit_code: next_exit_code,
} = next
else {
*existing = next;
return;
};
let merged_output_text = merge_exec_output_text(
match existing {
ThreadRenderNode::ExecGroup { output_text, .. } => output_text.take(),
_ => None,
},
next_output_text,
);
*existing = ThreadRenderNode::ExecGroup {
id: next_id,
turn_id: next_turn_id,
item_id: next_item_id,
title: next_title,
kind: next_kind,
state: next_state,
commands: next_commands,
output_text: merged_output_text,
exit_code: next_exit_code,
};
}
fn merge_exec_output_text(existing: Option<String>, next: Option<String>) -> Option<String> {
normalize_non_blank_text(next).or_else(|| normalize_non_blank_text(existing))
}
fn normalize_non_blank_text(text: Option<String>) -> Option<String> {
text.and_then(|text| {
if text.trim().is_empty() {
None
} else {
Some(text)
}
})
}
fn render_agent_message_nodes(
_snapshot: &mut super::ThreadRenderSnapshot,
turn_id: Option<&str>,
item: &Value,
streaming: bool,
) -> Vec<ThreadRenderNode> {
let item_id = optional_string(item, "id");
let phase = optional_string(item, "phase");
vec![ThreadRenderNode::AssistantMarkdown {
id: render_node_id(turn_id, item_id.as_deref(), "assistant"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
text: optional_string(item, "text").unwrap_or_default(),
phase,
streaming,
}]
}
fn render_command_execution_node(
turn_id: Option<&str>,
item: &Value,
streaming: bool,
) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
let state = optional_string(item, "status").unwrap_or_else(|| {
if streaming {
"inProgress".to_string()
} else {
"completed".to_string()
}
});
let exploring = is_exploration_action_set(item.get("commandActions").unwrap_or(&Value::Null));
let title = match (exploring, state.as_str()) {
(true, "inProgress") => "Exploring",
(true, _) => "Explored",
(false, "inProgress") => "Running",
_ => "Ran",
};
ThreadRenderNode::ExecGroup {
id: render_node_id(turn_id, item_id.as_deref(), "exec"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: title.to_string(),
kind: if exploring {
"explored".to_string()
} else {
"ran".to_string()
},
state,
commands: command_action_lines(
item.get("commandActions").unwrap_or(&Value::Null),
item.get("command").and_then(Value::as_str),
),
output_text: optional_string(item, "aggregatedOutput"),
exit_code: item.get("exitCode").and_then(Value::as_i64),
}
}
fn render_file_change_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
let state = optional_string(item, "status").unwrap_or_else(|| "completed".to_string());
ThreadRenderNode::FileChange {
id: render_node_id(turn_id, item_id.as_deref(), "file-change"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: if state == "inProgress" {
"Editing"
} else {
"Edited"
}
.to_string(),
state,
changes: file_change_lines(item.get("changes").unwrap_or(&Value::Null)),
}
}
fn render_mcp_tool_call_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
let state = optional_string(item, "status").unwrap_or_else(|| "completed".to_string());
let subtitle = format!(
"{}.{}({})",
optional_string(item, "server").unwrap_or_else(|| "mcp".to_string()),
optional_string(item, "tool").unwrap_or_else(|| "tool".to_string()),
format_json_value(item.get("arguments").unwrap_or(&Value::Null)).unwrap_or_default()
);
ThreadRenderNode::McpToolCall {
id: render_node_id(turn_id, item_id.as_deref(), "mcp"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: if state == "inProgress" {
"Calling"
} else {
"Called"
}
.to_string(),
state,
subtitle,
detail: mcp_detail_text(item.get("result"), item.get("error")),
}
}
fn render_dynamic_tool_call_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
let state = optional_string(item, "status").unwrap_or_else(|| "completed".to_string());
ThreadRenderNode::DynamicToolCall {
id: render_node_id(turn_id, item_id.as_deref(), "dynamic-tool"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: if state == "inProgress" {
"Calling"
} else {
"Called"
}
.to_string(),
state: state.clone(),
subtitle: format!(
"{}({})",
optional_string(item, "tool").unwrap_or_else(|| "tool".to_string()),
format_json_value(item.get("arguments").unwrap_or(&Value::Null)).unwrap_or_default()
),
detail: dynamic_tool_detail_text(
item.get("contentItems"),
item.get("success").and_then(Value::as_bool),
),
}
}
fn render_collab_event_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
let tool = optional_string(item, "tool").unwrap_or_else(|| "agent".to_string());
let status = optional_string(item, "status").unwrap_or_else(|| "completed".to_string());
let receiver_ids = item
.get("receiverThreadIds")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
let title = render_collab_title(&tool, &status, &receiver_ids);
let mut detail_lines = optional_string(item, "prompt")
.map(|prompt| vec![preview_text(&prompt, 160)])
.unwrap_or_default();
if tool == "wait" && status != "inProgress" {
if let Some(agents) = item.get("agentsStates").and_then(Value::as_object) {
detail_lines = agents
.iter()
.map(|(agent_id, agent_status)| collab_status_line(agent_id, agent_status))
.collect();
}
} else if let Some(model) = optional_string(item, "model") {
let reasoning = optional_string(item, "reasoningEffort").unwrap_or_default();
let suffix = if reasoning.is_empty() {
model
} else {
format!("{model} {reasoning}")
};
detail_lines.push(suffix);
}
ThreadRenderNode::CollabEvent {
id: render_node_id(turn_id, item_id.as_deref(), "collab"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title,
detail_lines,
}
}
fn render_web_search_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
let action = item.get("action").unwrap_or(&Value::Null);
let (title, detail) = match action.get("type").and_then(Value::as_str) {
Some("openPage") => ("Opened page", optional_string(action, "url")),
Some("findInPage") => (
"Found in page",
Some(
[
optional_string(action, "pattern"),
optional_string(action, "url"),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" @ "),
),
),
_ => ("Searched web", optional_string(item, "query")),
};
ThreadRenderNode::WebSearch {
id: render_node_id(turn_id, item_id.as_deref(), "web-search"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: title.to_string(),
state: "completed".to_string(),
detail,
}
}
fn render_view_image_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
ThreadRenderNode::ViewImage {
id: render_node_id(turn_id, item_id.as_deref(), "view-image"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: "Viewed image".to_string(),
path: optional_string(item, "path").unwrap_or_default(),
}
}
fn render_image_generation_node(turn_id: Option<&str>, item: &Value) -> ThreadRenderNode {
let item_id = optional_string(item, "id");
ThreadRenderNode::ImageGeneration {
id: render_node_id(turn_id, item_id.as_deref(), "image-generation"),
turn_id: turn_id.map(ToOwned::to_owned),
item_id,
title: "Image generation".to_string(),
state: optional_string(item, "status").unwrap_or_default(),
prompt: optional_string(item, "revisedPrompt"),
result: optional_string(item, "result").unwrap_or_default(),
saved_path: optional_string(item, "savedPath"),
}
}
fn render_user_message_text(item: &Value) -> String {
let mut parts = Vec::new();
for content in item
.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
match content.get("type").and_then(Value::as_str) {
Some("text") => parts.push(optional_string(content, "text").unwrap_or_default()),
Some("image") => parts.push(optional_string(content, "url").unwrap_or_default()),
Some("localImage") => parts.push(optional_string(content, "path").unwrap_or_default()),
Some("skill") | Some("mention") => {
let text = [
optional_string(content, "name"),
optional_string(content, "path"),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
if !text.is_empty() {
parts.push(text);
}
}
_ => {}
}
}
parts.join("\n")
}
fn render_reasoning_text(item: &Value) -> String {
let summary = item
.get("summary")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !summary.is_empty() {
return summary.join("\n");
}
item.get("content")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
}
fn render_hook_prompt_text(item: &Value) -> String {
item.get("fragments")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(format_json_value)
.collect::<Vec<_>>()
.join("\n")
}