use ratatui::style::Color;
use std::path::Path;
const GENERIC_INTERPRETERS: &[&str] = &["node", "python", "python3", "bun", "deno"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AgentKind {
Claude,
Codex,
OpenCode,
Gemini,
Pi,
KiroCli,
Vibe,
Copilot,
}
impl AgentKind {
pub fn as_str(self) -> &'static str {
match self {
AgentKind::Claude => "claude",
AgentKind::Codex => "codex",
AgentKind::OpenCode => "opencode",
AgentKind::Gemini => "gemini",
AgentKind::Pi => "pi",
AgentKind::KiroCli => "kiro-cli",
AgentKind::Vibe => "vibe",
AgentKind::Copilot => "copilot",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"claude" => Some(AgentKind::Claude),
"codex" => Some(AgentKind::Codex),
"opencode" => Some(AgentKind::OpenCode),
"gemini" => Some(AgentKind::Gemini),
"pi" => Some(AgentKind::Pi),
"kiro-cli" => Some(AgentKind::KiroCli),
"vibe" => Some(AgentKind::Vibe),
"copilot" => Some(AgentKind::Copilot),
_ => None,
}
}
pub fn default_icon(self) -> &'static str {
match self {
AgentKind::Claude => "CC",
AgentKind::Codex => "CX",
AgentKind::OpenCode => "OC",
AgentKind::Gemini => "G",
AgentKind::Pi => "π",
AgentKind::KiroCli => "K",
AgentKind::Vibe => "V",
AgentKind::Copilot => "CP",
}
}
pub fn default_label(self) -> &'static str {
match self {
AgentKind::Claude => "Claude",
AgentKind::Codex => "Codex",
AgentKind::OpenCode => "OpenCode",
AgentKind::Gemini => "Gemini",
AgentKind::Pi => "Pi",
AgentKind::KiroCli => "Kiro",
AgentKind::Vibe => "Vibe",
AgentKind::Copilot => "Copilot",
}
}
pub fn default_color(self) -> Option<Color> {
Some(match self {
AgentKind::Claude => Color::Rgb(0xd9, 0x77, 0x57),
AgentKind::Codex => Color::Rgb(0x10, 0xa3, 0x7f),
AgentKind::Gemini => Color::Rgb(0x07, 0x8e, 0xfa),
AgentKind::Copilot => Color::Rgb(0x89, 0x57, 0xe5),
AgentKind::Vibe => Color::Rgb(0xff, 0x82, 0x05),
AgentKind::Pi => Color::Rgb(0x96, 0xbb, 0xb5),
AgentKind::OpenCode => Color::Blue,
AgentKind::KiroCli => return None,
})
}
}
pub fn classify_agent_kind(command: Option<&str>, pane_title: Option<&str>) -> Option<String> {
classify_agent_kind_enum(command, pane_title).map(|k| k.as_str().to_string())
}
fn classify_agent_kind_enum(command: Option<&str>, pane_title: Option<&str>) -> Option<AgentKind> {
let raw = command.unwrap_or("").trim();
let stem = command_stem(raw);
if let Some(kind) = classify_by_command(raw, &stem) {
return Some(kind);
}
if is_generic_interpreter(&stem)
&& let Some(kind) = classify_by_title(pane_title.unwrap_or(""))
{
return Some(kind);
}
None
}
fn classify_by_command(raw: &str, stem: &str) -> Option<AgentKind> {
if stem.is_empty() {
return None;
}
if is_version_string(stem) || is_version_string(raw) {
return Some(AgentKind::Claude);
}
if stem == "codex" || stem.starts_with("codex-") {
return Some(AgentKind::Codex);
}
AgentKind::from_str(stem)
}
fn classify_by_title(title: &str) -> Option<AgentKind> {
if title.contains("Claude Code") {
return Some(AgentKind::Claude);
}
if title.contains("opencode") {
return Some(AgentKind::OpenCode);
}
if title.contains("Gemini") || title.contains('\u{25C7}') {
return Some(AgentKind::Gemini);
}
if title.contains('\u{03C0}') {
return Some(AgentKind::Pi);
}
if title.contains("Vibe") {
return Some(AgentKind::Vibe);
}
None
}
fn is_generic_interpreter(stem: &str) -> bool {
let lower = stem.to_ascii_lowercase();
GENERIC_INTERPRETERS.iter().any(|i| *i == lower)
}
fn is_version_string(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut has_dot = false;
let mut prev_dot = true;
for c in s.chars() {
if c == '.' {
if prev_dot {
return false;
}
has_dot = true;
prev_dot = true;
} else if c.is_ascii_digit() {
prev_dot = false;
} else {
return false;
}
}
has_dot && !prev_dot
}
fn command_stem(command: &str) -> String {
let token = command.split_whitespace().next().unwrap_or("");
Path::new(token)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(token)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn classify(cmd: &str, title: &str) -> Option<String> {
classify_agent_kind(Some(cmd), Some(title))
}
#[test]
fn version_string_matches_claude() {
assert_eq!(classify("2.1.118", ""), Some("claude".into()));
assert_eq!(classify("2.1.111", "✳ task"), Some("claude".into()));
assert_eq!(classify("3.0.0.1", ""), Some("claude".into()));
}
#[test]
fn codex_truncated_binary_matches() {
assert_eq!(classify("codex-aarch64-a", ""), Some("codex".into()));
assert_eq!(classify("codex", ""), Some("codex".into()));
}
#[test]
fn opencode_exact_command() {
assert_eq!(classify("opencode", "⠹ opencode"), Some("opencode".into()));
}
#[test]
fn kiro_and_copilot_match() {
assert_eq!(classify("kiro-cli", ""), Some("kiro-cli".into()));
assert_eq!(classify("copilot", ""), Some("copilot".into()));
}
#[test]
fn direct_stem_matches_for_known_binaries() {
assert_eq!(classify("claude", ""), Some("claude".into()));
assert_eq!(classify("gemini", ""), Some("gemini".into()));
assert_eq!(classify("pi", ""), Some("pi".into()));
assert_eq!(classify("vibe", ""), Some("vibe".into()));
}
#[test]
fn absolute_path_is_normalized() {
assert_eq!(classify("/usr/local/bin/claude", ""), Some("claude".into()));
assert_eq!(classify("/opt/codex-aarch64-a", ""), Some("codex".into()));
}
#[test]
fn node_with_claude_title() {
assert_eq!(
classify("node", "Claude Code 2.1.0 - foo"),
Some("claude".into())
);
}
#[test]
fn node_with_gemini_title() {
assert_eq!(
classify("node", "\u{25C7} Ready (sidebar-templates)"),
Some("gemini".into())
);
assert_eq!(classify("node", "Gemini - working"), Some("gemini".into()));
}
#[test]
fn node_with_pi_title() {
assert_eq!(
classify("node", "\u{03C0} - sidebar-templates"),
Some("pi".into())
);
}
#[test]
fn python_with_vibe_title() {
assert_eq!(classify("Python", "Vibe"), Some("vibe".into()));
assert_eq!(classify("python3", "Vibe agent"), Some("vibe".into()));
}
#[test]
fn opencode_via_node_title() {
assert_eq!(
classify("node", "⠹ opencode session"),
Some("opencode".into())
);
}
#[test]
fn empty_command_returns_none() {
assert_eq!(classify_agent_kind(None, None), None);
assert_eq!(classify("", ""), None);
assert_eq!(classify("", "Vibe"), None);
}
#[test]
fn unknown_command_returns_none() {
assert_eq!(classify("zsh", ""), None);
assert_eq!(classify("vim", "some title"), None);
assert_eq!(classify("codexploitation", ""), None);
assert_eq!(classify("codex2", ""), None);
}
#[test]
fn generic_interpreter_no_matching_title_returns_none() {
assert_eq!(classify("node", "random title"), None);
assert_eq!(classify("Python", "no match"), None);
}
#[test]
fn version_string_negative_cases() {
assert!(!is_version_string(""));
assert!(!is_version_string("2"));
assert!(!is_version_string("2."));
assert!(!is_version_string(".2"));
assert!(!is_version_string("2..1"));
assert!(!is_version_string("2.1a"));
assert!(is_version_string("2.1"));
assert!(is_version_string("2.1.118"));
}
#[test]
fn every_variant_has_metadata_and_round_trips() {
let all = [
AgentKind::Claude,
AgentKind::Codex,
AgentKind::OpenCode,
AgentKind::Gemini,
AgentKind::Pi,
AgentKind::KiroCli,
AgentKind::Vibe,
AgentKind::Copilot,
];
for kind in all {
assert!(!kind.default_icon().is_empty(), "{:?} icon empty", kind);
assert!(!kind.default_label().is_empty(), "{:?} label empty", kind);
assert_eq!(AgentKind::from_str(kind.as_str()), Some(kind));
}
}
#[test]
fn from_str_rejects_unknown() {
assert_eq!(AgentKind::from_str(""), None);
assert_eq!(AgentKind::from_str("not-a-profile"), None);
assert_eq!(AgentKind::from_str("Claude"), None); }
}