codex-mobile-bridge 0.2.6

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

const TIMELINE_SCHEMA_VERSION: i64 = 4;

#[derive(Clone, Debug)]
pub(super) struct TimelineSemanticInfo {
    pub(super) kind: String,
    pub(super) detail: Option<String>,
    pub(super) confidence: String,
    pub(super) role: String,
}

impl TimelineSemanticInfo {
    pub(super) fn new(
        kind: impl Into<String>,
        detail: Option<String>,
        confidence: impl Into<String>,
        role: impl Into<String>,
    ) -> Self {
        Self {
            kind: kind.into(),
            detail,
            confidence: confidence.into(),
            role: role.into(),
        }
    }

    pub(super) fn to_value(&self) -> Value {
        json!({
            "kind": self.kind,
            "detail": self.detail,
            "confidence": self.confidence,
            "role": self.role,
        })
    }

    pub(super) fn from_value(value: &Value) -> Option<Self> {
        let object = value.as_object()?;
        Some(Self {
            kind: object.get("kind")?.as_str()?.to_string(),
            detail: object
                .get("detail")
                .and_then(Value::as_str)
                .map(ToOwned::to_owned),
            confidence: object
                .get("confidence")
                .and_then(Value::as_str)
                .unwrap_or("low")
                .to_string(),
            role: object
                .get("role")
                .and_then(Value::as_str)
                .unwrap_or("primary")
                .to_string(),
        })
    }
}

#[derive(Clone, Debug)]
pub(super) struct TimelineLifecycleInfo {
    stage: String,
    source_status: Option<String>,
    has_visible_content: bool,
}

impl TimelineLifecycleInfo {
    pub(super) fn new(
        stage: impl Into<String>,
        source_status: Option<String>,
        has_visible_content: bool,
    ) -> Self {
        Self {
            stage: stage.into(),
            source_status,
            has_visible_content,
        }
    }

    fn to_value(&self) -> Value {
        json!({
            "stage": self.stage,
            "sourceStatus": self.source_status,
            "hasVisibleContent": self.has_visible_content,
        })
    }
}

#[derive(Clone, Debug, Default)]
pub(super) struct TimelineSummaryInfo {
    pub(super) title: Option<String>,
    pub(super) label: Option<String>,
    pub(super) command: Option<String>,
    pub(super) query: Option<String>,
    pub(super) targets: Vec<String>,
    pub(super) primary_path: Option<String>,
    pub(super) file_count: Option<i64>,
    pub(super) add_lines: Option<i64>,
    pub(super) remove_lines: Option<i64>,
    pub(super) wait_count: Option<i64>,
}

impl TimelineSummaryInfo {
    pub(super) fn to_value(&self) -> Value {
        json!({
            "title": self.title,
            "label": self.label,
            "command": self.command,
            "query": self.query,
            "targets": self.targets,
            "primaryPath": self.primary_path,
            "fileCount": self.file_count,
            "addLines": self.add_lines,
            "removeLines": self.remove_lines,
            "waitCount": self.wait_count,
        })
    }

    pub(super) fn from_value(value: &Value) -> Option<Self> {
        let object = value.as_object()?;
        Some(Self {
            title: object
                .get("title")
                .and_then(Value::as_str)
                .map(ToOwned::to_owned),
            label: object
                .get("label")
                .and_then(Value::as_str)
                .map(ToOwned::to_owned),
            command: object
                .get("command")
                .and_then(Value::as_str)
                .map(ToOwned::to_owned),
            query: object
                .get("query")
                .and_then(Value::as_str)
                .map(ToOwned::to_owned),
            targets: object
                .get("targets")
                .and_then(Value::as_array)
                .map(|items| {
                    items
                        .iter()
                        .filter_map(Value::as_str)
                        .map(ToOwned::to_owned)
                        .collect::<Vec<_>>()
                })
                .unwrap_or_default(),
            primary_path: object
                .get("primaryPath")
                .and_then(Value::as_str)
                .map(ToOwned::to_owned),
            file_count: object.get("fileCount").and_then(Value::as_i64),
            add_lines: object.get("addLines").and_then(Value::as_i64),
            remove_lines: object.get("removeLines").and_then(Value::as_i64),
            wait_count: object.get("waitCount").and_then(Value::as_i64),
        })
    }
}

pub(super) fn timeline_render_kind(raw_type: &str) -> &'static str {
    match raw_type {
        "userMessage" => "user",
        "agentMessage" | "message" => "agent",
        "reasoning" | "reasoning_text" | "summary_text" => "thinking",
        "plan" => "plan",
        "commandExecution"
        | "mcpToolCall"
        | "dynamicToolCall"
        | "webSearch"
        | "imageGeneration"
        | "imageView"
        | "collabToolCall"
        | "collabAgentToolCall"
        | "function_call"
        | "function_call_output"
        | "custom_tool_call"
        | "custom_tool_call_output"
        | "file_search_call"
        | "web_search_call"
        | "computer_call"
        | "computer_call_output"
        | "code_interpreter_call"
        | "shell_call"
        | "shell_call_output"
        | "local_shell_call"
        | "local_shell_call_output"
        | "apply_patch_call"
        | "apply_patch_call_output"
        | "image_generation_call"
        | "mcp_call"
        | "mcp_approval_request"
        | "mcp_approval_response"
        | "mcp_list_tools"
        | "tool_search_output" => "tool",
        "fileChange" | "diff" => "diff",
        "hookPrompt" | "contextCompaction" | "enteredReviewMode" | "exitedReviewMode"
        | "compaction" => "system",
        _ => "fallback",
    }
}

pub(super) fn timeline_collapse_hint(render_kind: &str, _raw_type: &str) -> Value {
    match render_kind {
        "user" | "agent" => json!({
            "enabled": false,
            "preferCollapsed": false,
            "lineThreshold": 0,
            "charThreshold": 0,
        }),
        "tool" | "thinking" => json!({
            "enabled": true,
            "preferCollapsed": true,
            "lineThreshold": 8,
            "charThreshold": 320,
        }),
        "diff" => json!({
            "enabled": true,
            "preferCollapsed": true,
            "lineThreshold": 12,
            "charThreshold": 480,
        }),
        _ => json!({
            "enabled": true,
            "preferCollapsed": false,
            "lineThreshold": 8,
            "charThreshold": 320,
        }),
    }
}

pub(super) fn timeline_stream_metadata(
    is_streaming: bool,
    authoritative: bool,
    phase: Option<String>,
    summary_index: Option<i64>,
    content_index: Option<i64>,
) -> Value {
    json!({
        "isStreaming": is_streaming,
        "authoritative": authoritative,
        "phase": phase,
        "summaryIndex": summary_index,
        "contentIndex": content_index,
    })
}

pub(super) fn timeline_lifecycle_info(
    authoritative: bool,
    status: Option<&str>,
    has_visible_content: bool,
) -> TimelineLifecycleInfo {
    let source_status = status
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    let normalized_status = source_status.as_deref().map(|value| value.to_lowercase());
    let stage = if authoritative {
        match normalized_status.as_deref() {
            Some("failed") => "failed",
            Some("declined") => "declined",
            _ => "completed",
        }
    } else if has_visible_content {
        "streaming"
    } else {
        "waiting"
    };
    TimelineLifecycleInfo::new(stage, source_status, has_visible_content)
}

pub(super) fn timeline_has_visible_content(
    text: &str,
    payload: &Value,
    summary: Option<&TimelineSummaryInfo>,
) -> bool {
    !text.trim().is_empty()
        || timeline_payload_has_visible_content(payload)
        || summary.is_some_and(summary_has_visible_content)
}

fn timeline_payload_has_visible_content(value: &Value) -> bool {
    match value {
        Value::Null => false,
        Value::Bool(flag) => *flag,
        Value::Number(_) => true,
        Value::String(text) => !text.trim().is_empty(),
        Value::Array(items) => items.iter().any(timeline_payload_has_visible_content),
        Value::Object(object) => object.iter().any(|(key, child)| {
            matches!(
                key.as_str(),
                "text"
                    | "content"
                    | "summary"
                    | "output"
                    | "result"
                    | "error"
                    | "stdout"
                    | "stderr"
                    | "changes"
                    | "query"
                    | "path"
                    | "targets"
                    | "command"
                    | "review"
                    | "diff"
                    | "explanation"
                    | "label"
                    | "title"
                    | "primaryPath"
            ) && timeline_payload_has_visible_content(child)
        }),
    }
}

fn summary_has_visible_content(summary: &TimelineSummaryInfo) -> bool {
    summary
        .title
        .as_deref()
        .is_some_and(|value| !value.trim().is_empty())
        || summary
            .label
            .as_deref()
            .is_some_and(|value| !value.trim().is_empty())
        || summary
            .command
            .as_deref()
            .is_some_and(|value| !value.trim().is_empty())
        || summary
            .query
            .as_deref()
            .is_some_and(|value| !value.trim().is_empty())
        || summary
            .primary_path
            .as_deref()
            .is_some_and(|value| !value.trim().is_empty())
        || !summary.targets.is_empty()
        || summary.file_count.unwrap_or_default() > 0
        || summary.add_lines.unwrap_or_default() > 0
        || summary.remove_lines.unwrap_or_default() > 0
        || summary.wait_count.unwrap_or_default() > 0
}

pub(super) fn timeline_metadata(
    source_kind: &str,
    raw_type: &str,
    render_kind: &str,
    collapse_hint: Value,
    stream: Value,
    lifecycle: Option<&TimelineLifecycleInfo>,
    payload: Value,
    semantic: Option<&TimelineSemanticInfo>,
    summary: Option<&TimelineSummaryInfo>,
    wire: Value,
) -> Value {
    json!({
        "schemaVersion": TIMELINE_SCHEMA_VERSION,
        "sourceKind": source_kind,
        "rawType": raw_type,
        "renderKind": render_kind,
        "collapseHint": collapse_hint,
        "stream": stream,
        "lifecycle": lifecycle.map(TimelineLifecycleInfo::to_value).unwrap_or(Value::Null),
        "payload": payload,
        "wire": wire,
        "semantic": semantic.map(TimelineSemanticInfo::to_value).unwrap_or(Value::Null),
        "summary": summary.map(TimelineSummaryInfo::to_value).unwrap_or(Value::Null),
    })
}