fur-cli 2.1.1

Turn your AI chats into a durable, local-first diary. Save messages, attach notes, organize conversations, and stop losing context every time the model forgets you exist.
Documentation
use crate::avatars::resolve_avatar;
use colored::*;
use serde_json::Value;
use std::collections::HashMap;

type Map = HashMap<String, Value>;

/// Print active conversation title
pub fn print_active_conversation(index: &Value, conversation: &Value) {
    let fallback_title = conversation["title"].as_str().unwrap_or("Untitled");

    let title = index["title"].as_str().unwrap_or(fallback_title);

    println!(
        "{} {} {}",
        "Active conversation:".bright_cyan().bold(),
        title.bright_green().bold(),
        format!("({})", index["active_thread"].as_str().unwrap_or("?")).bright_black()
    );
}

/// Print current message line
pub fn print_current_message(current_msg_id: &str) {
    println!(
        "{} {}",
        "Current message:".bright_cyan().bold(),
        current_msg_id.bright_black()
    );
}

/// Print lineage (ancestors)
pub fn print_lineage(map: &HashMap<String, Value>, current: &str, avatars: &Value) {
    let mut chain = vec![];
    let mut cur = current.to_string();

    while let Some(msg) = map.get(&cur) {
        chain.push(cur.clone());
        match msg["parent"].as_str() {
            Some(pid) => cur = pid.to_string(),
            None => break,
        }
    }

    chain.reverse();

    for mid in chain {
        if let Some(msg) = map.get(&mid) {
            let avatar_key = msg["avatar"].as_str().unwrap_or("???");
            let (name, emoji) = resolve_avatar(avatars, avatar_key);

            let preview = crate::renderer::utils::preview_of(msg, 40);

            let marker = if mid == current {
                "(current)".cyan().bold()
            } else {
                "✅".green()
            };

            let branch_label = compute_branch_label(&mid, map);

            println!(
                "{} {} {} {} {} {}",
                preview.white(),
                emoji,
                format!("[{}]", name).bright_yellow().bold(),
                &mid[..8].bright_black(),
                branch_label.bright_green(),
                marker
            );
        }
    }
}

pub fn print_next_messages(map: &Map, conversation: &Value, current: &str, avatars: &Value) {
    let curr_msg = match map.get(current) {
        Some(v) => v,
        None => return println!("{}", "(No current message found.)".red()),
    };

    let next = get_children(curr_msg)
        .or_else(|| get_sibling_branch(map, curr_msg, current))
        .or_else(|| get_top_level_siblings(conversation, current))
        .unwrap_or_default();

    if next.is_empty() {
        println!("{}", "(No further messages in this branch.)".bright_black());
        return;
    }

    for cid in next {
        if let Some(msg) = map.get(&cid) {
            render_preview(msg, avatars, &cid, map);
        }
    }
}

fn get_children(curr_msg: &Value) -> Option<Vec<String>> {
    let arr = curr_msg["children"].as_array()?;
    let v = arr
        .iter()
        .filter_map(|c| c.as_str().map(|s| s.to_string()))
        .collect::<Vec<_>>();

    if v.is_empty() {
        None
    } else {
        Some(v)
    }
}

fn get_sibling_branch(map: &Map, curr_msg: &Value, current: &str) -> Option<Vec<String>> {
    let parent_id = curr_msg["parent"].as_str()?;
    let parent = map.get(parent_id)?;

    let blocks = parent["branches"].as_array()?;
    for block in blocks {
        if let Some(arr) = block.as_array() {
            if let Some(pos) = arr.iter().position(|v| v.as_str() == Some(current)) {
                let sibs = arr
                    .iter()
                    .skip(pos + 1)
                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
                    .collect::<Vec<_>>();
                if !sibs.is_empty() {
                    return Some(sibs);
                }
            }
        }
    }

    None
}

fn get_top_level_siblings(conversation: &Value, current: &str) -> Option<Vec<String>> {
    let arr = conversation["messages"].as_array()?;

    let pos = arr.iter().position(|v| v.as_str() == Some(current))?;
    let v = arr
        .iter()
        .skip(pos + 1)
        .filter_map(|v| v.as_str().map(|s| s.to_string()))
        .collect::<Vec<_>>();

    if v.is_empty() {
        None
    } else {
        Some(v)
    }
}

fn render_preview(msg: &Value, avatars: &Value, cid: &str, map: &Map) {
    let avatar_key = msg["avatar"].as_str().unwrap_or("???");
    let (name, emoji) = resolve_avatar(avatars, avatar_key);

    let preview = crate::renderer::utils::preview_of(msg, 40);

    let branch_label = compute_branch_label(cid, map);

    println!(
        "🔹 {} {} {} {} {}",
        preview.white(),
        emoji,
        format!("[{}]", name).bright_yellow().bold(),
        &cid[..8].bright_black(),
        branch_label.bright_green(),
    );
}

/// Compute branch label
pub fn compute_branch_label(msg_id: &str, map: &HashMap<String, Value>) -> String {
    let mut labels = vec![];
    let mut cur = msg_id;

    while let Some(msg) = map.get(cur) {
        if let Some(pid) = msg["parent"].as_str() {
            if let Some(parent) = map.get(pid) {
                if let Some(blocks) = parent["branches"].as_array() {
                    for (i, block) in blocks.iter().enumerate() {
                        if let Some(arr) = block.as_array() {
                            if arr.iter().any(|v| v.as_str() == Some(cur)) {
                                labels.push(format!("{}", i + 1));
                            }
                        }
                    }
                }
                cur = pid;
                continue;
            }
        }
        break;
    }

    labels.reverse();
    if labels.is_empty() {
        "[Root]".to_string()
    } else {
        format!("[Branch {}]", labels.join("."))
    }
}