codex-mobile-bridge 0.3.10

Remote bridge and service manager for codex-mobile.
Documentation
use serde_json::Value;

use super::{
    ThreadRenderNode, ThreadRenderSnapshot, render_node_id, set_inline_message_from_last_node,
    upsert_node_by_id,
};
use crate::state::helpers::optional_string;

pub(super) fn push_terminal_interaction(snapshot: &mut ThreadRenderSnapshot, params: &Value) {
    let turn_id = optional_string(params, "turnId");
    let item_id = optional_string(params, "itemId");
    let stdin = optional_string(params, "stdin").unwrap_or_default();
    let waited = stdin.is_empty();
    let command = item_id
        .as_deref()
        .and_then(|item_id| terminal_command_display(snapshot, item_id));
    snapshot.nodes.push(ThreadRenderNode::TerminalInteraction {
        id: transient_node_id(snapshot, turn_id.as_deref(), item_id.as_deref(), "terminal"),
        turn_id,
        item_id,
        title: if waited {
            "Waited for background terminal".to_string()
        } else {
            "Interacted with background terminal".to_string()
        },
        command,
        stdin,
        waited,
    });
    set_inline_message_from_last_node(snapshot);
}

pub(super) fn upsert_hook_event(
    snapshot: &mut ThreadRenderSnapshot,
    params: &Value,
    started: bool,
) {
    let turn_id = optional_string(params, "turnId");
    let run = params.get("run").unwrap_or(&Value::Null);
    let hook_id = run
        .get("id")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty());
    let event_label = hook_event_label(
        run.get("eventName")
            .and_then(Value::as_str)
            .unwrap_or_default(),
    );
    let status = if started {
        "inProgress".to_string()
    } else {
        run.get("status")
            .and_then(Value::as_str)
            .unwrap_or("completed")
            .to_string()
    };
    let node_id = hook_id
        .map(|hook_id| render_node_id(turn_id.as_deref(), Some(hook_id), "hook"))
        .unwrap_or_else(|| transient_node_id(snapshot, turn_id.as_deref(), hook_id, "hook"));
    upsert_node_by_id(
        snapshot,
        ThreadRenderNode::HookEvent {
            id: node_id,
            turn_id,
            item_id: hook_id.map(ToOwned::to_owned),
            title: if started {
                format!("Running {event_label} hook")
            } else {
                format!("{event_label} hook ({})", hook_status_label(&status))
            },
            state: status,
            detail_lines: hook_detail_lines(run),
        },
    );
}

pub(super) fn upsert_approval_review(
    snapshot: &mut ThreadRenderSnapshot,
    params: &Value,
    started: bool,
) {
    let turn_id = optional_string(params, "turnId");
    let item_id = optional_string(params, "targetItemId");
    let review_id = params
        .get("reviewId")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty());
    let action = params.get("action").unwrap_or(&Value::Null);
    let summary = approval_action_summary(action);
    let review = params.get("review").unwrap_or(&Value::Null);
    let state = review
        .get("status")
        .and_then(Value::as_str)
        .unwrap_or(if started { "inProgress" } else { "completed" })
        .to_string();
    let node_id = review_id
        .or(item_id.as_deref())
        .map(|id| render_node_id(turn_id.as_deref(), Some(id), "approval-review"))
        .unwrap_or_else(|| {
            transient_node_id(
                snapshot,
                turn_id.as_deref(),
                review_id.or(item_id.as_deref()),
                "approval-review",
            )
        });
    upsert_node_by_id(
        snapshot,
        ThreadRenderNode::ApprovalReview {
            id: node_id,
            turn_id,
            item_id,
            title: if started {
                format!("Reviewing {summary}")
            } else {
                format!("Reviewed {summary}")
            },
            state,
            detail_lines: approval_review_lines(params),
        },
    );
}

fn transient_node_id(
    snapshot: &ThreadRenderSnapshot,
    turn_id: Option<&str>,
    item_id: Option<&str>,
    suffix: &str,
) -> String {
    format!(
        "{}:{}:{}:{}",
        turn_id.unwrap_or("turn"),
        item_id.unwrap_or("item"),
        suffix,
        snapshot.revision + snapshot.nodes.len() as i64 + 1
    )
}

fn terminal_command_display(snapshot: &ThreadRenderSnapshot, item_id: &str) -> Option<String> {
    snapshot.nodes.iter().find_map(|node| match node {
        ThreadRenderNode::ExecGroup {
            item_id: Some(existing_item_id),
            commands,
            ..
        } if existing_item_id == item_id => {
            let command = commands
                .iter()
                .map(|entry| entry.text.trim())
                .filter(|entry| !entry.is_empty())
                .collect::<Vec<_>>()
                .join(" && ");
            if command.is_empty() {
                None
            } else {
                Some(command)
            }
        }
        _ => None,
    })
}

fn hook_event_label(event_name: &str) -> &'static str {
    match event_name {
        "preToolUse" => "PreToolUse",
        "postToolUse" => "PostToolUse",
        "sessionStart" => "SessionStart",
        "userPromptSubmit" => "UserPromptSubmit",
        "stop" => "Stop",
        _ => "Hook",
    }
}

fn hook_status_label(status: &str) -> &str {
    match status {
        "failed" => "failed",
        "blocked" => "blocked",
        "stopped" => "stopped",
        "running" | "inProgress" => "running",
        _ => "completed",
    }
}

fn hook_detail_lines(run: &Value) -> Vec<String> {
    let mut lines = Vec::new();
    if let Some(message) = run.get("statusMessage").and_then(Value::as_str) {
        let trimmed = message.trim();
        if !trimmed.is_empty() {
            lines.push(trimmed.to_string());
        }
    }
    if let Some(entries) = run.get("entries").and_then(Value::as_array) {
        for entry in entries {
            let text = entry
                .get("text")
                .and_then(Value::as_str)
                .unwrap_or_default();
            if text.trim().is_empty() {
                continue;
            }
            let prefix = match entry
                .get("kind")
                .and_then(Value::as_str)
                .unwrap_or_default()
            {
                "warning" => "warning",
                "stop" => "stop",
                "feedback" => "feedback",
                "context" => "hook context",
                "error" => "error",
                _ => "detail",
            };
            lines.push(format!("{prefix}: {text}"));
        }
    }
    lines
}

fn approval_action_summary(action: &Value) -> String {
    match action
        .get("type")
        .and_then(Value::as_str)
        .unwrap_or_default()
    {
        "command" | "execve" => "command approval request".to_string(),
        "applyPatch" => "patch approval request".to_string(),
        "network" => "network approval request".to_string(),
        other if !other.is_empty() => format!("{other} approval request"),
        _ => "approval request".to_string(),
    }
}

fn approval_review_lines(params: &Value) -> Vec<String> {
    let mut lines = Vec::new();
    if let Some(action) = params.get("action")
        && let Some(summary) = approval_action_line(action)
    {
        lines.push(summary);
    }
    let review = params.get("review").unwrap_or(&Value::Null);
    if let Some(status) = review.get("status").and_then(Value::as_str) {
        lines.push(format!("status: {status}"));
    }
    if let Some(risk) = review.get("riskLevel").and_then(Value::as_str) {
        lines.push(format!("risk: {risk}"));
    }
    if let Some(rationale) = review.get("rationale").and_then(Value::as_str) {
        let trimmed = rationale.trim();
        if !trimmed.is_empty() {
            lines.push(trimmed.to_string());
        }
    }
    if let Some(source) = params.get("decisionSource").and_then(Value::as_str) {
        lines.push(format!("decision source: {source}"));
    }
    lines
}

fn approval_action_line(action: &Value) -> Option<String> {
    match action
        .get("type")
        .and_then(Value::as_str)
        .unwrap_or_default()
    {
        "command" => {
            let command = action
                .get("command")
                .and_then(Value::as_str)
                .unwrap_or_default();
            let cwd = action
                .get("cwd")
                .and_then(Value::as_str)
                .unwrap_or_default();
            if command.is_empty() {
                None
            } else if cwd.is_empty() {
                Some(format!("command: {command}"))
            } else {
                Some(format!("command: {command} @ {cwd}"))
            }
        }
        "execve" => {
            let program = action
                .get("program")
                .and_then(Value::as_str)
                .unwrap_or_default();
            let argv = action
                .get("argv")
                .and_then(Value::as_array)
                .map(|items| {
                    items
                        .iter()
                        .filter_map(Value::as_str)
                        .collect::<Vec<_>>()
                        .join(" ")
                })
                .unwrap_or_default();
            let command = if argv.is_empty() {
                program.to_string()
            } else {
                argv
            };
            if command.is_empty() {
                None
            } else {
                Some(format!("command: {command}"))
            }
        }
        "applyPatch" => {
            let files = action
                .get("files")
                .and_then(Value::as_array)
                .map(|items| items.iter().filter_map(Value::as_str).collect::<Vec<_>>())
                .unwrap_or_default();
            if files.is_empty() {
                Some("patch".to_string())
            } else if files.len() == 1 {
                Some(format!("patch: {}", files[0]))
            } else {
                Some(format!("patch: {} files", files.len()))
            }
        }
        "network" => {
            let host = action
                .get("host")
                .and_then(Value::as_str)
                .unwrap_or_default();
            if host.is_empty() {
                Some("network access".to_string())
            } else {
                Some(format!("network: {host}"))
            }
        }
        _ => None,
    }
}