codex-mobile-bridge 0.3.8

Remote bridge and service manager for codex-mobile.
Documentation
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",
    }
}