tact 0.1.1

Terminal interface for Nanocodex
use super::{Presentation, format_bytes};
use crate::tui::{theme::Theme, transcript::ToolEntry};
use ratatui::style::Style;
use serde_json::Value;

const OPERATIONS: [&str; 9] = [
    "search_query",
    "image_query",
    "open",
    "click",
    "find",
    "finance",
    "weather",
    "sports",
    "time",
];

pub(super) fn present(tool: &ToolEntry, width: u16, theme: &Theme, expanded: bool) -> Presentation {
    let subject = summary(&tool.arguments);
    let presentation = Presentation::new("Web", subject);
    if !expanded {
        return presentation;
    }

    let mut details = operation_details(&tool.arguments, width, theme);
    let result_size = tool
        .result
        .as_ref()
        .map_or(0, |result| result.to_string().len());
    if let Some(result) = tool.result.as_ref().and_then(Value::as_str) {
        details.extend(super::super::markdown::wrap_plain(
            &clean_result(result),
            width,
            Style::default().fg(theme.text()),
        ));
    } else if let Some(result) = &tool.result {
        details.extend(super::render_result(result, width, theme));
    }
    presentation
        .details(details)
        .footer(format!("web result · {}", format_bytes(result_size)))
}

fn summary(arguments: &Value) -> String {
    let counts = OPERATIONS
        .iter()
        .filter_map(|operation| {
            let count = arguments
                .get(operation)
                .and_then(Value::as_array)
                .map_or(0, Vec::len);
            (count > 0).then_some((*operation, count))
        })
        .collect::<Vec<_>>();
    if counts.len() > 1 {
        return counts
            .into_iter()
            .map(|(operation, count)| format!("{} {count}", operation_label(operation)))
            .collect::<Vec<_>>()
            .join(" · ");
    }
    if let Some(queries) = arguments.get("search_query").and_then(Value::as_array) {
        if queries.len() == 1
            && let Some(query) = queries[0].get("q").and_then(Value::as_str)
        {
            return format!("search {query:?}");
        }
        return format!("{} searches", queries.len());
    }
    if let Some(queries) = arguments.get("image_query").and_then(Value::as_array) {
        return format!("{} image queries", queries.len());
    }
    if let Some(open) = first_string(arguments, "open", "ref_id") {
        return format!("open {open}");
    }
    if let Some(pattern) = first_string(arguments, "find", "pattern") {
        return format!("find {pattern:?}");
    }
    if let Some(ticker) = first_string(arguments, "finance", "ticker") {
        return format!("finance {ticker}");
    }
    if let Some(location) = first_string(arguments, "weather", "location") {
        return format!("weather {location}");
    }
    if let Some(league) = first_string(arguments, "sports", "league") {
        return format!("sports {league}");
    }
    if let Some(offset) = first_string(arguments, "time", "utc_offset") {
        return format!("time {offset}");
    }
    let count = OPERATIONS
        .iter()
        .map(|key| {
            arguments
                .get(key)
                .and_then(Value::as_array)
                .map_or(0, Vec::len)
        })
        .sum::<usize>();
    format!("{count} operations")
}

fn operation_label(operation: &str) -> &str {
    match operation {
        "search_query" => "search",
        "image_query" => "images",
        operation => operation,
    }
}

fn operation_details(
    arguments: &Value,
    width: u16,
    theme: &Theme,
) -> Vec<ratatui::text::Line<'static>> {
    let mut details = Vec::new();
    for operation in OPERATIONS {
        let Some(values) = arguments.get(operation).and_then(Value::as_array) else {
            continue;
        };
        for value in values {
            let rendered = serde_json::to_string(value).unwrap_or_else(|_| value.to_string());
            details.extend(super::super::markdown::wrap_plain(
                &format!("{} {rendered}", operation.replace('_', " ")),
                width,
                Style::default().fg(theme.accent()),
            ));
        }
    }
    if let Some(length) = arguments.get("response_length").and_then(Value::as_str) {
        details.extend(super::super::markdown::wrap_plain(
            &format!("response length {length}"),
            width,
            Style::default().fg(theme.muted()),
        ));
    }
    details
}

fn first_string<'a>(arguments: &'a Value, operation: &str, field: &str) -> Option<&'a str> {
    arguments
        .get(operation)
        .and_then(Value::as_array)?
        .first()?
        .get(field)
        .and_then(Value::as_str)
}

fn clean_result(result: &str) -> String {
    let mut clean = String::with_capacity(result.len());
    let mut rest = result;
    while let Some(start) = rest.find("cite") {
        clean.push_str(&rest[..start]);
        let Some(end) = rest[start..].find('') else {
            rest = &rest[start..];
            break;
        };
        rest = &rest[start + end + ''.len_utf8()..];
    }
    clean.push_str(rest);
    clean
        .lines()
        .map(|line| line.split("[wordlim:").next().unwrap_or(line).trim_end())
        .collect::<Vec<_>>()
        .join("\n")
}