dot-ai 0.6.1

A minimal AI agent that lives in your terminal
Documentation
#[derive(Debug, Clone, PartialEq)]
pub enum ToolCategory {
    FileRead,
    FileWrite,
    MultiEdit,
    Directory,
    Search,
    Command,
    Glob,
    Grep,
    WebFetch,
    Patch,
    Batch,
    Snapshot,
    Question,
    Mcp { server: String },
    Skill,
    Subagent,
    Unknown,
}

impl ToolCategory {
    pub fn from_name(name: &str) -> Self {
        match name {
            "read_file" => Self::FileRead,
            "write_file" => Self::FileWrite,
            "multiedit" => Self::MultiEdit,
            "list_directory" => Self::Directory,
            "search_files" => Self::Search,
            "run_command" => Self::Command,
            "glob" => Self::Glob,
            "grep" => Self::Grep,
            "webfetch" => Self::WebFetch,
            "apply_patch" => Self::Patch,
            "batch" => Self::Batch,
            "snapshot_list" | "snapshot_restore" => Self::Snapshot,
            "question" => Self::Question,
            "skill" => Self::Skill,
            "subagent" | "subagent_result" => Self::Subagent,
            other => {
                if let Some(idx) = other.find('_') {
                    let prefix = &other[..idx];
                    if ![
                        "read", "write", "list", "search", "run", "snapshot", "apply",
                    ]
                    .contains(&prefix)
                    {
                        return Self::Mcp {
                            server: prefix.to_string(),
                        };
                    }
                }
                Self::Unknown
            }
        }
    }

    pub fn icon(&self) -> &'static str {
        match self {
            Self::FileRead => "\u{f15c} ",
            Self::FileWrite => "\u{270e} ",
            Self::MultiEdit => "\u{270e} ",
            Self::Directory => "\u{f07b} ",
            Self::Search => "\u{f002} ",
            Self::Command => "\u{f120} ",
            Self::Glob => "\u{f002} ",
            Self::Grep => "\u{f002} ",
            Self::WebFetch => "\u{f0ac} ",
            Self::Patch => "\u{270e} ",
            Self::Batch => "\u{f0c2} ",
            Self::Snapshot => "\u{f0c2} ",
            Self::Question => "\u{f128} ",
            Self::Mcp { .. } => "\u{f1e6} ",
            Self::Skill => "\u{f0eb} ",
            Self::Subagent => "\u{f0c0} ",
            Self::Unknown => "\u{f013} ",
        }
    }

    pub fn label(&self) -> String {
        match self {
            Self::FileRead => "read".to_string(),
            Self::FileWrite => "write".to_string(),
            Self::MultiEdit => "edit".to_string(),
            Self::Directory => "list".to_string(),
            Self::Search => "search".to_string(),
            Self::Command => "run".to_string(),
            Self::Glob => "glob".to_string(),
            Self::Grep => "grep".to_string(),
            Self::WebFetch => "fetch".to_string(),
            Self::Patch => "patch".to_string(),
            Self::Batch => "batch".to_string(),
            Self::Snapshot => "snapshot".to_string(),
            Self::Question => "question".to_string(),
            Self::Mcp { server } => format!("mcp:{}", server),
            Self::Skill => "skill".to_string(),
            Self::Subagent => "agent".to_string(),
            Self::Unknown => "tool".to_string(),
        }
    }

    pub fn intent(&self) -> &'static str {
        match self {
            Self::FileRead => "reading",
            Self::FileWrite => "writing",
            Self::MultiEdit => "editing",
            Self::Directory => "listing",
            Self::Search => "searching",
            Self::Command => "running",
            Self::Glob => "finding",
            Self::Grep => "searching",
            Self::WebFetch => "fetching",
            Self::Patch => "patching",
            Self::Batch => "running",
            Self::Snapshot => "checking",
            Self::Question => "asking",
            Self::Mcp { .. } => "calling",
            Self::Skill => "loading",
            Self::Subagent => "delegating",
            Self::Unknown => "running",
        }
    }
}

#[derive(Debug, Clone)]
pub struct ToolCallDisplay {
    pub name: String,
    pub input: String,
    pub output: Option<String>,
    pub is_error: bool,
    pub category: ToolCategory,
    pub detail: String,
}

#[derive(Debug, Clone)]
pub enum StreamSegment {
    Text(String),
    ToolCall(ToolCallDisplay),
}

pub fn extract_tool_detail(name: &str, input: &str) -> String {
    let parsed: Result<serde_json::Value, _> = serde_json::from_str(input);
    let val = match parsed {
        Ok(v) => v,
        Err(_) => return String::new(),
    };

    match name {
        "read_file" => val
            .get("path")
            .and_then(|v| v.as_str())
            .map(shorten_path)
            .unwrap_or_default(),
        "write_file" => val
            .get("path")
            .and_then(|v| v.as_str())
            .map(shorten_path)
            .unwrap_or_default(),
        "list_directory" => val
            .get("path")
            .and_then(|v| v.as_str())
            .map(shorten_path)
            .unwrap_or_default(),
        "search_files" => {
            let pattern = val.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
            let path = val.get("path").and_then(|v| v.as_str()).unwrap_or("");
            if path.is_empty() {
                format!("\"{}\"", pattern)
            } else {
                format!("\"{}\" in {}", pattern, shorten_path(path))
            }
        }
        "run_command" => val
            .get("command")
            .and_then(|v| v.as_str())
            .map(|c| {
                if c.len() > 60 {
                    format!("{}...", &c[..57])
                } else {
                    c.to_string()
                }
            })
            .unwrap_or_default(),
        "glob" => val
            .get("pattern")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        "grep" => {
            let pattern = val.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
            let path = val.get("path").and_then(|v| v.as_str()).unwrap_or("");
            if path.is_empty() {
                format!("\"{}\"", pattern)
            } else {
                format!("\"{}\"; in {}", pattern, shorten_path(path))
            }
        }
        "webfetch" => val
            .get("url")
            .and_then(|v| v.as_str())
            .map(|u| {
                if u.len() > 60 {
                    format!("{}...", &u[..57])
                } else {
                    u.to_string()
                }
            })
            .unwrap_or_default(),
        "apply_patch" => {
            let count = val
                .get("patches")
                .and_then(|v| v.as_array())
                .map(|a| a.len())
                .unwrap_or(0);
            format!("{} patches", count)
        }
        "multiedit" => {
            let path = val
                .get("path")
                .and_then(|v| v.as_str())
                .map(shorten_path)
                .unwrap_or_default();
            let count = val
                .get("edits")
                .and_then(|v| v.as_array())
                .map(|a| a.len())
                .unwrap_or(0);
            format!("{} ({} edits)", path, count)
        }
        "batch" => {
            let count = val
                .get("invocations")
                .and_then(|v| v.as_array())
                .map(|a| a.len())
                .unwrap_or(0);
            format!("{} tools", count)
        }
        "snapshot_list" => "listing changes".to_string(),
        "snapshot_restore" => val
            .get("path")
            .and_then(|v| v.as_str())
            .map(shorten_path)
            .unwrap_or_else(|| "all files".to_string()),
        "question" => val
            .get("question")
            .and_then(|v| v.as_str())
            .map(|q| {
                if q.len() > 50 {
                    format!("{}...", &q[..47])
                } else {
                    q.to_string()
                }
            })
            .unwrap_or_default(),
        "skill" => val
            .get("name")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        "subagent" => {
            let desc = val
                .get("description")
                .and_then(|v| v.as_str())
                .map(|d| {
                    if d.len() > 40 {
                        format!("{}...", &d[..37])
                    } else {
                        d.to_string()
                    }
                })
                .unwrap_or_default();
            let bg = val
                .get("background")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);
            if bg { format!("{} (bg)", desc) } else { desc }
        }
        "subagent_result" => val
            .get("id")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string(),
        _ => {
            if let Some(first_str) = val
                .as_object()
                .and_then(|o| o.values().find_map(|v| v.as_str().map(|s| s.to_string())))
            {
                if first_str.len() > 50 {
                    format!("{}...", &first_str[..47])
                } else {
                    first_str
                }
            } else {
                String::new()
            }
        }
    }
}

fn shorten_path(path: &str) -> String {
    if let Ok(home) = std::env::var("HOME")
        && let Some(rest) = path.strip_prefix(&home)
    {
        return format!("~{}", rest);
    }
    if let Ok(cwd) = std::env::current_dir() {
        let cwd_str = cwd.to_string_lossy();
        if let Some(rest) = path.strip_prefix(cwd_str.as_ref()) {
            let rest = rest.strip_prefix('/').unwrap_or(rest);
            return if rest.is_empty() {
                ".".to_string()
            } else {
                format!("./{}", rest)
            };
        }
    }
    path.to_string()
}