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 serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::Path;

use crate::avatars::emojis::pick_emoji;
use crate::avatars::{
    display_name_of, get_random_emoji_for_name, kind_of, load_avatars, role_of, save_avatars,
    set_meta, MAIN_EMOJI,
};
use crate::commands::utils::input::{ask_string, ask_yes_no, default_yes};
use crate::renderer::table::render_table;
use colored::*;
use serde_json::json;

pub fn count_messages_per_avatar() -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    let messages_dir = Path::new(".fur/messages");

    if !messages_dir.exists() {
        return counts;
    }

    if let Ok(entries) = fs::read_dir(messages_dir) {
        for entry in entries.flatten() {
            if let Ok(content) = fs::read_to_string(entry.path()) {
                if let Ok(json) = serde_json::from_str::<Value>(&content) {
                    if let Some(avatar) = json["avatar"].as_str() {
                        *counts.entry(avatar.to_string()).or_insert(0) += 1;
                    }
                }
            }
        }
    }

    counts
}

pub fn run_avatar_view() {
    let avatars = load_avatars();

    let Some(map) = avatars.as_object() else {
        return;
    };
    if map.is_empty() {
        println!("(no avatars yet)");
        return;
    }

    let msg_counts = count_messages_per_avatar();

    // The Role column appears only when something occupies it, so a project
    // that never sets roles sees the table it has always seen.
    let any_role = map
        .keys()
        .any(|name| !crate::avatars::is_reserved_key(name) && role_of(&avatars, name).is_some());

    let mut rows = Vec::new();
    let mut active_idx = None;

    for (name, value) in map.iter() {
        // `main` is a pointer, not an avatar: it renders as its own starred row
        // naming the avatar it points at.
        if name == "main" {
            let Some(target) = value.as_str() else {
                continue;
            };
            let count = msg_counts.get(target).copied().unwrap_or(0);
            let mut row = vec![
                "⭐ main".to_string(),
                target.to_string(),
                count.to_string(),
                kind_of(&avatars, target),
            ];
            if any_role {
                row.push(role_of(&avatars, target).unwrap_or_default());
            }
            active_idx = Some(rows.len());
            rows.push(row);
            continue;
        }

        if crate::avatars::is_reserved_key(name) {
            continue;
        }

        let emoji = value.as_str().unwrap_or("🐾").to_string();
        let count = msg_counts.get(name).copied().unwrap_or(0);

        // Kind is its own column, not a suffix on the name: the name is the
        // identifier that appears in every message marker, and decorating it
        // makes the table disagree with the documents.
        let mut row = vec![
            name.to_string(),
            emoji,
            count.to_string(),
            kind_of(&avatars, name),
        ];
        if any_role {
            row.push(role_of(&avatars, name).unwrap_or_default());
        }
        rows.push(row);
    }

    if any_role {
        render_table(
            "Avatars",
            &["Role", "Emoji", "Messages", "Kind", "Function"],
            rows,
            active_idx,
            true,
        );
    } else {
        render_table(
            "Avatars",
            &["Role", "Emoji", "Messages", "Kind"],
            rows,
            active_idx,
            true,
        );
    }
}

pub fn run_avatar_onboarding() {
    let mut avatars = load_avatars();

    println!("\n{}", "== Create Avatar ==".bright_magenta().bold());
    println!(
        "{}",
        "A secondary avatar is anyone who isn’t you (the main user).\n\
         Examples: an AI, your boss, your therapist, or your cat. \n\
         If you choose [n], you’re creating or replacing the *main* avatar."
            .bright_cyan()
    );

    let is_secondary = ask_yes_no("Secondary avatar? [Y/n]: ", default_yes);

    if is_secondary {
        create_secondary_avatar(&mut avatars);
    } else {
        create_main_avatar(&mut avatars);
    }

    save_avatars(&avatars);
    println!("✅ Avatar creation complete. Use `fur avatar --view` to list all avatars.");
}

fn create_main_avatar(avatars: &mut serde_json::Value) {
    let name = ask_string("Main avatar name [me]: ", Some("me"));

    avatars["main"] = json!(name);
    avatars[name.clone()] = json!(MAIN_EMOJI);
    println!("[OK] Main avatar set: {}", name);
}

fn create_secondary_avatar(avatars: &mut serde_json::Value) {
    let name = ask_string("Choose name [ai]: ", Some("ai"));

    let suggested = get_random_emoji_for_name(&name);

    let skip = ask_yes_no(
        &format!("Use suggested emoji {}? [Y/n]: ", suggested),
        default_yes,
    );

    // viceroy: the picker moved to `avatars::emojis::pick_emoji` so onboarding
    // can use the same one instead of a hardcoded lookup table.
    let emoji = if skip {
        suggested
    } else {
        pick_emoji("Your choice: ", None)
    };

    avatars[name.clone()] = json!(emoji);

    println!(
        "[OK] Other avatar '{}' created with emoji '{}'",
        name, emoji
    );
}

/// `fur avatar <handle> --name "…" --role "…" --kind ai|human --clear-role`
pub fn run_avatar_meta(
    handle: &str,
    display_name: Option<&str>,
    role: Option<&str>,
    kind: Option<&str>,
    clear_role: bool,
) {
    let mut avatars = load_avatars();

    if avatars.get(handle).is_none() {
        let emoji = get_random_emoji_for_name(handle);
        avatars[handle] = json!(emoji);
        println!("[OK] Avatar '{}' created {}", handle, emoji);
    }

    if let Some(value) = display_name {
        set_meta(&mut avatars, handle, "name", Some(value));
    }

    if clear_role {
        set_meta(&mut avatars, handle, "role", None);
    } else if let Some(value) = role {
        set_meta(&mut avatars, handle, "role", Some(value));
    }

    if let Some(value) = kind {
        set_meta(&mut avatars, handle, "kind", Some(value));
    }

    save_avatars(&avatars);

    println!(
        "🏷️  @{} · {} · {} · {}",
        handle.bright_yellow(),
        display_name_of(&avatars, handle).unwrap_or_else(|| "—".to_string()),
        kind_of(&avatars, handle),
        role_of(&avatars, handle).unwrap_or_else(|| "—".to_string())
    );
}