use serde_json::Value;
use super::format::{extract_message_text, format_json_value, plan_steps, preview_text};
use super::{
PREVIEW_LIMIT, StatusSurfaceState, ThreadRenderNode, ThreadRenderSnapshot, node_id_value,
node_turn_id, optional_turn_id_from_params, render_node_id, render_notice_node,
set_inline_message_from_last_node, upsert_node_by_id, working_status,
};
use crate::state::helpers::optional_string;
pub(super) fn upsert_plan_update_node(snapshot: &mut ThreadRenderSnapshot, params: &Value) {
let turn_id = optional_string(params, "turnId");
let item_id = turn_id.as_ref().map(|turn_id| format!("turn-plan:{turn_id}"));
upsert_node_by_id(
snapshot,
ThreadRenderNode::PlanUpdate {
id: render_node_id(turn_id.as_deref(), item_id.as_deref(), "plan-update"),
turn_id,
item_id,
title: "Updated plan".to_string(),
explanation: optional_string(params, "explanation"),
steps: plan_steps(params.get("plan").unwrap_or(&Value::Null)),
streaming: true,
},
);
}
pub(super) fn upsert_assistant_delta(snapshot: &mut ThreadRenderSnapshot, params: &Value) {
let item_id = optional_string(params, "itemId");
let turn_id = optional_string(params, "turnId");
let delta = optional_string(params, "delta").unwrap_or_default();
let node_id = render_node_id(turn_id.as_deref(), item_id.as_deref(), "assistant");
match snapshot
.nodes
.iter_mut()
.find(|node| node_id_value(node) == node_id)
{
Some(ThreadRenderNode::AssistantMarkdown { text, streaming, .. }) => {
text.push_str(&delta);
*streaming = true;
}
_ => upsert_node_by_id(
snapshot,
ThreadRenderNode::AssistantMarkdown {
id: node_id,
turn_id,
item_id,
text: delta,
phase: None,
streaming: true,
},
),
}
}
pub(super) fn upsert_plan_delta(snapshot: &mut ThreadRenderSnapshot, params: &Value) {
let item_id = optional_string(params, "itemId");
let turn_id = optional_string(params, "turnId");
let delta = optional_string(params, "delta").unwrap_or_default();
let node_id = render_node_id(turn_id.as_deref(), item_id.as_deref(), "proposed-plan");
match snapshot
.nodes
.iter_mut()
.find(|node| node_id_value(node) == node_id)
{
Some(ThreadRenderNode::ProposedPlan { text, .. }) => text.push_str(&delta),
_ => upsert_node_by_id(
snapshot,
ThreadRenderNode::ProposedPlan {
id: node_id,
turn_id,
item_id,
title: "Proposed plan".to_string(),
text: delta,
},
),
}
}
pub(super) fn upsert_exec_output_delta(snapshot: &mut ThreadRenderSnapshot, params: &Value) {
let item_id = optional_string(params, "itemId");
let turn_id = optional_string(params, "turnId");
let delta = optional_string(params, "delta").unwrap_or_default();
let node_id = render_node_id(turn_id.as_deref(), item_id.as_deref(), "exec");
match snapshot
.nodes
.iter_mut()
.find(|node| node_id_value(node) == node_id)
{
Some(ThreadRenderNode::ExecGroup {
output_text, state, ..
}) => {
output_text.get_or_insert_with(String::new).push_str(&delta);
*state = "inProgress".to_string();
}
_ => upsert_node_by_id(
snapshot,
ThreadRenderNode::ExecGroup {
id: node_id,
turn_id,
item_id,
title: "Running".to_string(),
state: "inProgress".to_string(),
commands: Vec::new(),
output_text: Some(delta),
exit_code: None,
},
),
}
}
pub(super) fn upsert_file_change_placeholder(snapshot: &mut ThreadRenderSnapshot, params: &Value) {
let item_id = optional_string(params, "itemId");
let turn_id = optional_string(params, "turnId");
let node_id = render_node_id(turn_id.as_deref(), item_id.as_deref(), "file-change");
if snapshot.nodes.iter().any(|node| node_id_value(node) == node_id) {
return;
}
upsert_node_by_id(
snapshot,
ThreadRenderNode::FileChange {
id: node_id,
turn_id,
item_id,
title: "Editing".to_string(),
state: "inProgress".to_string(),
changes: Vec::new(),
},
);
}
pub(super) fn upsert_mcp_progress(snapshot: &mut ThreadRenderSnapshot, params: &Value) {
let item_id = optional_string(params, "itemId");
let turn_id = optional_string(params, "turnId");
let detail = optional_string(params, "message");
let node_id = render_node_id(turn_id.as_deref(), item_id.as_deref(), "mcp");
match snapshot
.nodes
.iter_mut()
.find(|node| node_id_value(node) == node_id)
{
Some(ThreadRenderNode::McpToolCall {
detail: current,
state,
..
}) => {
*current = detail;
*state = "inProgress".to_string();
}
_ => upsert_node_by_id(
snapshot,
ThreadRenderNode::McpToolCall {
id: node_id,
turn_id,
item_id,
title: "Calling".to_string(),
state: "inProgress".to_string(),
subtitle: "mcp tool".to_string(),
detail,
},
),
}
}
pub(super) fn ensure_reasoning_node(snapshot: &mut ThreadRenderSnapshot, params: &Value) {
let item_id = optional_string(params, "itemId");
let turn_id = optional_string(params, "turnId");
let node_id = render_node_id(turn_id.as_deref(), item_id.as_deref(), "reasoning");
if snapshot.nodes.iter().any(|node| node_id_value(node) == node_id) {
return;
}
upsert_node_by_id(
snapshot,
ThreadRenderNode::ReasoningSummary {
id: node_id,
turn_id,
item_id,
title: "Thinking".to_string(),
text: String::new(),
},
);
}
pub(super) fn append_reasoning_delta(
snapshot: &mut ThreadRenderSnapshot,
params: &Value,
summary: bool,
) {
ensure_reasoning_node(snapshot, params);
let item_id = optional_string(params, "itemId");
let turn_id = optional_string(params, "turnId");
let delta = optional_string(params, "delta").unwrap_or_default();
let node_id = render_node_id(turn_id.as_deref(), item_id.as_deref(), "reasoning");
if let Some(ThreadRenderNode::ReasoningSummary { text, .. }) =
snapshot.nodes.iter_mut().find(|node| node_id_value(node) == node_id)
{
if summary || text.is_empty() {
text.push_str(&delta);
}
}
}
pub(super) fn push_notice(
snapshot: &mut ThreadRenderSnapshot,
params: &Value,
title: &str,
detail: Option<String>,
) {
let turn_id = optional_string(params, "turnId");
let item_id = optional_string(params, "itemId");
snapshot.nodes.push(render_notice_node(
turn_id.as_deref(),
item_id,
title,
detail,
));
set_inline_message_from_last_node(snapshot);
}
pub(super) fn apply_turn_completed(snapshot: &mut ThreadRenderSnapshot, params: &Value) {
let turn = params.get("turn").unwrap_or(params);
let status = optional_string(turn, "status").unwrap_or_default();
if status == "failed" {
let detail = turn
.get("error")
.and_then(format_json_value)
.map(|value| preview_text(&value, PREVIEW_LIMIT));
set_status_surface(snapshot, working_status("Turn failed", detail));
} else {
snapshot.status_surface = None;
}
if let Some(turn_id) = optional_turn_id_from_params(params) {
for node in &mut snapshot.nodes {
if node_turn_id(node) == Some(turn_id.as_str())
&& let ThreadRenderNode::AssistantMarkdown { streaming, .. } = node
{
*streaming = false;
}
}
}
}
pub(super) fn apply_thread_status(snapshot: &mut ThreadRenderSnapshot, params: &Value) {
let kind = params
.get("statusInfo")
.and_then(|status| status.get("kind"))
.and_then(Value::as_str)
.or_else(|| params.get("status").and_then(Value::as_str))
.unwrap_or_default();
snapshot.status_surface = match kind {
"active" | "inProgress" => Some(working_status("Working", None)),
"failed" => Some(working_status("Failed", None)),
_ => None,
};
}
pub(super) fn set_status_surface(
snapshot: &mut ThreadRenderSnapshot,
status_surface: StatusSurfaceState,
) {
snapshot.status_surface = Some(status_surface);
}
pub(super) fn extract_message(params: &Value) -> Option<String> {
extract_message_text(params)
.or_else(|| {
params
.get("details")
.and_then(format_json_value)
.map(|value| preview_text(&value, PREVIEW_LIMIT))
})
}