tact 0.1.1

Terminal interface for Nanocodex
use super::{super::markdown::wrap_plain, Presentation};
use crate::tui::{theme::Theme, transcript::ToolEntry};
use ratatui::style::Style;
use serde_json::Value;
use std::borrow::Cow;

pub(super) fn present(tool: &ToolEntry, width: u16, theme: &Theme, expanded: bool) -> Presentation {
    let recipient = tool
        .arguments
        .get("agent_id")
        .and_then(Value::as_u64)
        .map_or_else(|| "unknown agent".to_owned(), |id| format!("→ #{id}"));
    let purpose = tool
        .arguments
        .get("purpose")
        .and_then(Value::as_str)
        .unwrap_or("coordinate");
    let priority = tool
        .arguments
        .get("priority")
        .and_then(Value::as_str)
        .unwrap_or("deferred");
    let receipt = tool.result.as_ref().and_then(decoded_result);
    let disposition = receipt
        .as_deref()
        .and_then(|receipt| receipt.get("disposition"))
        .and_then(Value::as_str);
    let outcome = [
        Some(purpose),
        (priority != "deferred").then_some(priority),
        disposition,
    ]
    .into_iter()
    .flatten()
    .collect::<Vec<_>>()
    .join(" · ");
    let mut presentation = Presentation::new("Message", recipient).outcome(outcome);
    if !expanded {
        return presentation;
    }

    let body = tool
        .arguments
        .get("message")
        .and_then(Value::as_str)
        .unwrap_or_default();
    let mut details = wrap_plain(body, width, Style::default().fg(theme.text()));
    let footer = receipt.as_deref().map_or_else(
        || "message body".to_owned(),
        |receipt| {
            let message = receipt
                .get("message_id")
                .and_then(Value::as_u64)
                .map_or_else(|| "message ?".to_owned(), |id| format!("message #{id}"));
            let thread = receipt
                .get("thread_id")
                .and_then(Value::as_u64)
                .map_or_else(|| "thread ?".to_owned(), |id| format!("thread #{id}"));
            format!("{message} · {thread}")
        },
    );
    if let Some(disposition) = disposition {
        details.extend(wrap_plain(
            &format!("Delivery: {disposition}"),
            width,
            Style::default().fg(theme.muted()),
        ));
    }
    presentation = presentation.details(details).footer(footer);
    presentation
}

fn decoded_result(value: &Value) -> Option<Cow<'_, Value>> {
    if let Some(encoded) = value.as_str() {
        return serde_json::from_str(encoded).ok().map(Cow::Owned);
    }
    Some(Cow::Borrowed(value))
}

#[cfg(test)]
mod tests {
    use super::present;
    use crate::tui::{
        theme::Theme,
        transcript::{ToolEntry, ToolState},
    };
    use serde_json::json;

    #[test]
    fn presents_recipient_intent_and_body() {
        let tool = ToolEntry {
            name: "send_agent_message".to_owned(),
            arguments: json!({
                "agent_id": 7,
                "message": "Please verify the ordering.",
                "priority": "urgent",
                "purpose": "question"
            }),
            started_at_unix_ms: 0,
            state: ToolState::Succeeded,
            duration_ns: None,
            result: Some(json!({
                "message_id": 9,
                "thread_id": 4,
                "disposition": "steered"
            })),
            metadata: None,
            substeps: Vec::new(),
        };

        let collapsed = present(&tool, 80, &Theme::default(), false);
        let expanded = present(&tool, 80, &Theme::default(), true);

        assert_eq!(collapsed.title, "Message");
        assert_eq!(
            collapsed.outcome.as_deref(),
            Some("question · urgent · steered")
        );
        assert!(expanded.details[0].to_string().contains("Please verify"));
        assert!(
            expanded
                .details
                .last()
                .unwrap()
                .to_string()
                .contains("steered")
        );
        assert_eq!(expanded.footer.as_deref(), Some("message #9 · thread #4"));
    }
}