codex-mobile-bridge 0.2.11

Remote bridge and service manager for codex-mobile.
Documentation
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, final_separator_exists, render_node_id, render_notice_node,
    render_collab_title, replace_item_nodes, set_inline_message_from_last_node, turn_has_activity,
};
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;
    };

    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),
        }],
        "commandExecution" => vec![render_command_execution_node(turn_id, item, streaming)],
        "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 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");
    let mut nodes = Vec::new();
    if phase.as_deref() == Some("final_answer")
        && turn_has_activity(snapshot, turn_id)
        && !final_separator_exists(snapshot, turn_id)
    {
        nodes.push(ThreadRenderNode::FinalSeparator {
            id: render_node_id(turn_id, item_id.as_deref(), "final-separator"),
            turn_id: turn_id.map(ToOwned::to_owned),
        });
    }
    nodes.push(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,
    });
    nodes
}

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(),
        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")
}