tandem-tui 0.4.28

Terminal user interface for the Tandem engine
use super::*;
use serde_json::Value;
use std::path::PathBuf;

impl App {
    pub(super) fn format_local_agent_team_bindings(team_filter: Option<&str>) -> String {
        let root = Self::agent_team_workspace_root();
        if !root.exists() {
            return "No local agent-team state found.".to_string();
        }
        let filter = team_filter.map(str::trim).filter(|s| !s.is_empty());
        let Ok(entries) = std::fs::read_dir(&root) else {
            return "Failed to read local agent-team state.".to_string();
        };
        let mut output = Vec::new();
        for entry in entries.flatten() {
            let path = entry.path();
            if !path.is_dir() {
                continue;
            }
            let Some(team_name) = path.file_name().and_then(|v| v.to_str()) else {
                continue;
            };
            if let Some(filter_name) = filter {
                if team_name != filter_name {
                    continue;
                }
            }
            let members_path = path.join("members.json");
            if !members_path.exists() {
                continue;
            }
            let Ok(raw) = std::fs::read_to_string(&members_path) else {
                continue;
            };
            let Ok(parsed) = serde_json::from_str::<Value>(&raw) else {
                continue;
            };
            let Some(items) = parsed.as_array() else {
                continue;
            };
            let mut lines = Vec::new();
            for item in items {
                let Some(name) = item.get("name").and_then(|v| v.as_str()) else {
                    continue;
                };
                let session = item
                    .get("sessionID")
                    .or_else(|| item.get("session_id"))
                    .and_then(|v| v.as_str())
                    .unwrap_or("-");
                lines.push(format!("  - {} -> {}", name, session));
            }
            if !lines.is_empty() {
                output.push(format!("{}:\n{}", team_name, lines.join("\n")));
            }
        }
        if output.is_empty() {
            return "No local agent-team bindings found.".to_string();
        }
        format!("Agent-Team Bindings:\n{}", output.join("\n"))
    }

    pub(super) async fn load_agent_team_mailbox_prompt(
        team_name: &str,
        recipient: &str,
    ) -> Option<String> {
        let mailbox_path = Self::agent_team_workspace_root()
            .join(team_name)
            .join("mailboxes")
            .join(format!("{}.jsonl", recipient));
        let raw = tokio::fs::read_to_string(mailbox_path).await.ok()?;
        let line = raw
            .lines()
            .rev()
            .map(str::trim)
            .find(|line| !line.is_empty())?;
        let payload = serde_json::from_str::<Value>(line).ok()?;
        let msg_type = payload.get("type").and_then(|v| v.as_str()).unwrap_or("");
        if !matches!(msg_type, "task_prompt" | "message" | "broadcast") {
            return None;
        }
        let content = payload
            .get("content")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(str::to_string)?;
        let summary = payload
            .get("summary")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty());
        let from = payload
            .get("from")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .unwrap_or("team-lead");
        let prompt = if let Some(summary) = summary {
            format!(
                "Agent-team assignment from {}.\nSummary: {}\n\n{}",
                from, summary, content
            )
        } else {
            format!("Agent-team assignment from {}.\n\n{}", from, content)
        };
        Some(prompt)
    }

    pub(super) fn agent_team_workspace_root() -> PathBuf {
        std::env::current_dir()
            .unwrap_or_else(|_| PathBuf::from("."))
            .join(".tandem")
            .join("agent-teams")
    }

    pub(super) fn member_name_matches_recipient(member_name: &str, recipient: &str) -> bool {
        if member_name.eq_ignore_ascii_case(recipient.trim()) {
            return true;
        }
        match (
            Self::normalize_recipient_agent_id(member_name),
            Self::normalize_recipient_agent_id(recipient),
        ) {
            (Some(left), Some(right)) => left == right,
            _ => false,
        }
    }

    pub(super) async fn load_agent_team_member_session_binding(
        team_name: &str,
        recipient: &str,
    ) -> Option<String> {
        let members_path = Self::agent_team_workspace_root()
            .join(team_name)
            .join("members.json");
        let raw = tokio::fs::read_to_string(members_path).await.ok()?;
        let parsed = serde_json::from_str::<Value>(&raw).ok()?;
        let entries = parsed.as_array()?;
        for entry in entries {
            let Some(name) = entry.get("name").and_then(|v| v.as_str()) else {
                continue;
            };
            if !Self::member_name_matches_recipient(name, recipient) {
                continue;
            }
            if let Some(session_id) = entry
                .get("sessionID")
                .or_else(|| entry.get("session_id"))
                .and_then(|v| v.as_str())
                .map(str::trim)
                .filter(|s| !s.is_empty())
            {
                return Some(session_id.to_string());
            }
        }
        None
    }

    pub(super) async fn persist_agent_team_member_session_binding(
        team_name: &str,
        recipient: &str,
        session_id: &str,
    ) -> bool {
        let members_path = Self::agent_team_workspace_root()
            .join(team_name)
            .join("members.json");
        let mut entries = if members_path.exists() {
            let Ok(raw) = tokio::fs::read_to_string(&members_path).await else {
                return false;
            };
            serde_json::from_str::<Value>(&raw)
                .ok()
                .and_then(|v| v.as_array().cloned())
                .unwrap_or_default()
        } else {
            Vec::new()
        };

        let now_ms = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0);
        let mut updated = false;
        for entry in &mut entries {
            let Some(name) = entry.get("name").and_then(|v| v.as_str()) else {
                continue;
            };
            if !Self::member_name_matches_recipient(name, recipient) {
                continue;
            }
            if let Some(obj) = entry.as_object_mut() {
                obj.insert(
                    "sessionID".to_string(),
                    Value::String(session_id.to_string()),
                );
                obj.insert("updatedAtMs".to_string(), Value::Number(now_ms.into()));
                updated = true;
                break;
            }
        }
        if !updated {
            let member_name = Self::normalize_recipient_agent_id(recipient)
                .unwrap_or_else(|| recipient.to_string());
            entries.push(serde_json::json!({
                "name": member_name,
                "sessionID": session_id,
                "updatedAtMs": now_ms
            }));
        }

        if let Some(parent) = members_path.parent() {
            if tokio::fs::create_dir_all(parent).await.is_err() {
                return false;
            }
        }
        tokio::fs::write(
            members_path,
            serde_json::to_vec_pretty(&Value::Array(entries)).unwrap_or_default(),
        )
        .await
        .is_ok()
    }

    pub(super) async fn persist_agent_team_session_context(
        team_name: &str,
        session_id: &str,
    ) -> bool {
        let context_path = Self::agent_team_workspace_root()
            .join("session-context")
            .join(format!("{}.json", session_id));
        if let Some(parent) = context_path.parent() {
            if tokio::fs::create_dir_all(parent).await.is_err() {
                return false;
            }
        }
        let now_ms = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0);
        let payload = serde_json::json!({
            "team_name": team_name,
            "updatedAtMs": now_ms
        });
        tokio::fs::write(
            context_path,
            serde_json::to_vec_pretty(&payload).unwrap_or_default(),
        )
        .await
        .is_ok()
    }

    pub(super) fn normalize_recipient_agent_id(recipient: &str) -> Option<String> {
        let trimmed = recipient.trim();
        if trimmed.is_empty() {
            return None;
        }
        if let Some(rest) = trimmed
            .strip_prefix('A')
            .or_else(|| trimmed.strip_prefix('a'))
        {
            if let Ok(index) = rest.parse::<u32>() {
                if index > 0 {
                    return Some(format!("A{}", index));
                }
            }
        }
        let lowered = trimmed.to_ascii_lowercase();
        if let Some(rest) = lowered.strip_prefix("agent-") {
            if let Ok(index) = rest.parse::<u32>() {
                if index > 0 {
                    return Some(format!("A{}", index));
                }
            }
        }
        None
    }

    pub(super) fn recipient_agent_number(recipient: &str) -> Option<usize> {
        let normalized = Self::normalize_recipient_agent_id(recipient)?;
        normalized
            .strip_prefix('A')
            .and_then(|v| v.parse::<usize>().ok())
            .filter(|n| *n > 0)
    }

    pub(super) fn resolve_agent_target_for_recipient(
        agents: &[AgentPane],
        recipient: &str,
    ) -> Option<(String, String)> {
        if let Some(agent) = agents
            .iter()
            .find(|agent| agent.agent_id.eq_ignore_ascii_case(recipient.trim()))
        {
            return Some((agent.session_id.clone(), agent.agent_id.clone()));
        }
        let normalized = Self::normalize_recipient_agent_id(recipient)?;
        let agent = agents.iter().find(|agent| agent.agent_id == normalized)?;
        Some((agent.session_id.clone(), agent.agent_id.clone()))
    }

    pub(super) fn resolve_agent_target_for_bound_session(
        agents: &[AgentPane],
        recipient: &str,
        session_id: &str,
    ) -> Option<(String, String)> {
        if let Some(normalized) = Self::normalize_recipient_agent_id(recipient) {
            if let Some(agent) = agents
                .iter()
                .find(|agent| agent.session_id == session_id && agent.agent_id == normalized)
            {
                return Some((agent.session_id.clone(), agent.agent_id.clone()));
            }
        }
        let agent = agents.iter().find(|agent| agent.session_id == session_id)?;
        Some((agent.session_id.clone(), agent.agent_id.clone()))
    }

    pub(super) fn is_agent_team_assignment_prompt(text: &str) -> bool {
        text.trim_start().starts_with("Agent-team assignment from ")
    }
}