use std::path::Path;
use serde::Deserialize;
use crate::ui::theme::State;
struct KnownAgent {
name: &'static str,
distinct: &'static [&'static str],
ambiguous: &'static [&'static str],
}
const KNOWN_AGENTS: &[KnownAgent] = &[
KnownAgent {
name: "claude",
distinct: &["claude"],
ambiguous: &[],
},
KnownAgent {
name: "codex",
distinct: &["codex"],
ambiguous: &[],
},
KnownAgent {
name: "gemini",
distinct: &["gemini"],
ambiguous: &[],
},
KnownAgent {
name: "aider",
distinct: &["aider"],
ambiguous: &[],
},
KnownAgent {
name: "opencode",
distinct: &["opencode"],
ambiguous: &[],
},
KnownAgent {
name: "copilot",
distinct: &["copilot"],
ambiguous: &[],
},
KnownAgent {
name: "kimi",
distinct: &["kimi"],
ambiguous: &[],
},
KnownAgent {
name: "qwen",
distinct: &["qwen"],
ambiguous: &[],
},
KnownAgent {
name: "kiro",
distinct: &["kiro"],
ambiguous: &[],
},
KnownAgent {
name: "cursor",
distinct: &["cursor-agent"],
ambiguous: &["cursor"],
},
KnownAgent {
name: "amp",
distinct: &[],
ambiguous: &["amp"],
},
KnownAgent {
name: "droid",
distinct: &[],
ambiguous: &["droid"],
},
KnownAgent {
name: "grok",
distinct: &[],
ambiguous: &["grok"],
},
];
#[derive(Clone)]
struct AgentIdent {
name: String,
distinct: Vec<String>,
ambiguous: Vec<String>,
}
impl AgentIdent {
fn all(&self) -> impl Iterator<Item = &String> {
self.distinct.iter().chain(self.ambiguous.iter())
}
}
fn builtin_agents() -> Vec<AgentIdent> {
KNOWN_AGENTS
.iter()
.map(|a| AgentIdent {
name: a.name.to_string(),
distinct: a.distinct.iter().map(|s| s.to_string()).collect(),
ambiguous: a.ambiguous.iter().map(|s| s.to_string()).collect(),
})
.collect()
}
const BLOCKED_PROMPTS: &[&str] = &[
"do you want to proceed",
"do you want to continue",
"waiting for approval",
"waiting for user confirmation",
"waiting for confirmation",
"run this command?",
"allow command?",
"allow this command",
"allow editing file",
"allow creating file",
"allow execution",
"apply this change",
"confirm tool call",
"invoke tool",
"write to this file?",
"proceed (y)",
"run (once) (y)",
"skip (esc or n)",
"esc or n or p",
"reject & propose changes",
"press enter to confirm",
"enter to submit answer",
"yes, allow",
"no, cancel",
"allow all for this session",
"allow all for every session",
"deny with feedback",
"keep (n)",
"(y) (enter)",
"yes (y)",
"(y/n)",
"[y/n]",
"yes/no",
"❯ 1.",
"1. yes",
"press enter to continue",
];
const BLOCKED_TITLES: &[&str] = &["action required", "confirmation needed"];
const WORKING_HINTS: &[&str] = &[
"esc to interrupt",
"esc to cancel",
"esc to stop",
"esc interrupt",
"esc again to cancel",
"ctrl+c to stop",
"ctrl+c to interrupt",
"ctrl-c to interrupt",
"interrupt to stop",
];
#[derive(Clone, Copy, PartialEq)]
enum Region {
Title,
Screen,
}
enum Cond {
Any(Vec<String>),
All(Vec<String>),
Not(Vec<String>),
Spinner,
}
impl Cond {
fn holds(&self, low: &str) -> bool {
match self {
Cond::Any(subs) => subs.iter().any(|s| low.contains(s)),
Cond::All(subs) => subs.iter().all(|s| low.contains(s)),
Cond::Not(subs) => !subs.iter().any(|s| low.contains(s)),
Cond::Spinner => low
.lines()
.any(|l| l.trim_start().chars().next().is_some_and(is_spinner_glyph)),
}
}
}
fn is_spinner_glyph(c: char) -> bool {
('\u{2800}'..='\u{28FF}').contains(&c) || ('\u{1F311}'..='\u{1F318}').contains(&c)
}
struct Rule {
agent: String,
state: State,
priority: i32,
region: Region,
conds: Vec<Cond>,
}
pub struct Manifests {
rules: Vec<Rule>,
agents: Vec<AgentIdent>,
}
impl Manifests {
#[cfg(test)]
pub fn builtin() -> Manifests {
Manifests {
rules: builtin_rules(),
agents: builtin_agents(),
}
}
pub fn load(dir: &Path) -> Manifests {
let mut rules = builtin_rules();
let mut agents = builtin_agents();
for mf in load_dir(dir) {
mf.apply_identity(&mut agents);
rules.extend(mf.into_rules());
}
Manifests { rules, agents }
}
pub fn is_agent(&self, name: &str) -> bool {
let low = name.to_lowercase();
self.agents.iter().any(|a| a.name == low)
}
fn evaluate(&self, agent: &str, regions: &Regions) -> Option<State> {
let mut best: Option<(i32, State)> = None;
for r in &self.rules {
if !(r.agent.is_empty() || r.agent == agent) {
continue;
}
let text = regions.get(r.region);
if r.conds.iter().all(|c| c.holds(text)) && best.is_none_or(|(p, _)| r.priority > p) {
best = Some((r.priority, r.state));
}
}
best.map(|(_, s)| s)
}
}
fn any(subs: &[&str]) -> Cond {
Cond::Any(subs.iter().map(|s| s.to_lowercase()).collect())
}
fn all(subs: &[&str]) -> Cond {
Cond::All(subs.iter().map(|s| s.to_lowercase()).collect())
}
fn builtin_rules() -> Vec<Rule> {
let gen = |state, priority, region, conds| Rule {
agent: String::new(),
state,
priority,
region,
conds,
};
let per = |agent: &str, state, priority, region, conds| Rule {
agent: agent.to_string(),
state,
priority,
region,
conds,
};
vec![
gen(
State::Blocked,
330,
Region::Title,
vec![any(BLOCKED_TITLES)],
),
gen(
State::Blocked,
320,
Region::Screen,
vec![all(&["enter to select", "esc to cancel"])],
),
gen(
State::Blocked,
320,
Region::Screen,
vec![all(&["enter to confirm", "esc to cancel"])],
),
gen(
State::Blocked,
320,
Region::Screen,
vec![all(&["enter select", "esc cancel"])],
),
gen(
State::Blocked,
300,
Region::Screen,
vec![any(BLOCKED_PROMPTS)],
),
gen(State::Working, 120, Region::Title, vec![Cond::Spinner]),
gen(State::Working, 110, Region::Screen, vec![Cond::Spinner]),
gen(
State::Working,
100,
Region::Screen,
vec![any(WORKING_HINTS)],
),
per(
"gemini",
State::Blocked,
310,
Region::Screen,
vec![any(&["│ apply this change", "│ allow execution"])],
),
per(
"droid",
State::Blocked,
310,
Region::Screen,
vec![any(&["> yes, allow", "> no, cancel"])],
),
per(
"cursor",
State::Working,
105,
Region::Screen,
vec![any(&["ctrl+c to stop"])],
),
per(
"kimi",
State::Blocked,
310,
Region::Screen,
vec![all(&["select", "choose", "confirm"])],
),
]
}
#[derive(Deserialize)]
struct ManifestFile {
#[serde(default = "default_generic")]
agent: String,
#[serde(default)]
rule: Vec<RuleSpec>,
#[serde(default)]
identity: Option<IdentitySpec>,
}
#[derive(Deserialize)]
struct IdentitySpec {
#[serde(default)]
distinct: Vec<String>,
#[serde(default)]
ambiguous: Vec<String>,
#[serde(default)]
replace: bool,
}
#[derive(Deserialize)]
struct RuleSpec {
state: String,
#[serde(default)]
priority: i32,
#[serde(default = "default_screen")]
region: String,
#[serde(default)]
any: Vec<String>,
#[serde(default)]
all: Vec<String>,
#[serde(default)]
not: Vec<String>,
#[serde(default)]
spinner: bool,
}
fn default_generic() -> String {
"generic".to_string()
}
fn default_screen() -> String {
"screen".to_string()
}
fn load_dir(dir: &Path) -> Vec<ManifestFile> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
let mut paths: Vec<_> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("toml"))
.collect();
paths.sort();
for path in paths {
let parsed = std::fs::read_to_string(&path)
.ok()
.and_then(|s| toml::from_str::<ManifestFile>(&s).ok());
match parsed {
Some(mf) => out.push(mf),
None => eprintln!(
"bohay: skipping invalid detection manifest {}",
path.display()
),
}
}
out
}
impl ManifestFile {
fn apply_identity(&self, agents: &mut Vec<AgentIdent>) {
let Some(id) = &self.identity else { return };
if self.agent.eq_ignore_ascii_case("generic") {
return; }
let name = self.agent.to_lowercase();
let lc = |v: &Vec<String>| v.iter().map(|s| s.to_lowercase()).collect::<Vec<_>>();
let entry = match agents.iter_mut().find(|a| a.name == name) {
Some(e) => e,
None => {
agents.push(AgentIdent {
name: name.clone(),
distinct: Vec::new(),
ambiguous: Vec::new(),
});
agents.last_mut().expect("just pushed")
}
};
if id.replace {
entry.distinct.clear();
entry.ambiguous.clear();
}
entry.distinct.extend(lc(&id.distinct));
entry.ambiguous.extend(lc(&id.ambiguous));
entry.distinct.dedup();
entry.ambiguous.dedup();
if entry.distinct.is_empty() && entry.ambiguous.is_empty() {
entry.distinct.push(name);
}
}
fn into_rules(self) -> Vec<Rule> {
let agent = if self.agent.eq_ignore_ascii_case("generic") {
String::new()
} else {
self.agent.to_lowercase()
};
self.rule
.into_iter()
.filter_map(|spec| spec.into_rule(&agent))
.collect()
}
}
impl RuleSpec {
fn into_rule(self, agent: &str) -> Option<Rule> {
let state = match self.state.to_lowercase().as_str() {
"working" => State::Working,
"blocked" => State::Blocked,
"idle" => State::Idle,
"done" => State::Done,
_ => return None, };
let region = match self.region.to_lowercase().as_str() {
"title" => Region::Title,
"screen" => Region::Screen,
_ => return None,
};
let lc = |v: Vec<String>| v.into_iter().map(|s| s.to_lowercase()).collect::<Vec<_>>();
let mut conds = Vec::new();
if !self.any.is_empty() {
conds.push(Cond::Any(lc(self.any)));
}
if !self.all.is_empty() {
conds.push(Cond::All(lc(self.all)));
}
if !self.not.is_empty() {
conds.push(Cond::Not(lc(self.not)));
}
if self.spinner {
conds.push(Cond::Spinner);
}
if conds.is_empty() {
return None; }
Some(Rule {
agent: agent.to_string(),
state,
priority: self.priority,
region,
conds,
})
}
}
pub struct Detection {
pub state: State,
pub agent: String,
}
struct Regions {
screen: String,
title: String,
}
impl Regions {
fn get(&self, r: Region) -> &str {
match r {
Region::Title => &self.title,
Region::Screen => &self.screen,
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn classify(
title: Option<&str>,
bottom: &str,
recent_activity: bool,
recent_input: bool,
base_command: &str,
known_agent: &str,
running: &[String],
manifests: &Manifests,
) -> Detection {
let regions = Regions {
screen: bottom.to_lowercase(),
title: title.map(|t| t.to_lowercase()).unwrap_or_default(),
};
let agent = if running.is_empty() {
manifests
.detect_agent(title, ®ions.screen, base_command)
.or_else(|| {
manifests
.is_agent(known_agent)
.then(|| known_agent.to_string())
})
.unwrap_or_else(|| base_command.to_string())
} else {
manifests
.agent_in_processes(running)
.unwrap_or_else(|| base_command.to_string())
};
let fallback = if !manifests.is_agent(&agent) && recent_activity && !recent_input {
State::Working
} else {
State::Idle
};
let state = manifests.evaluate(&agent, ®ions).unwrap_or(fallback);
Detection { state, agent }
}
impl Manifests {
fn agent_in_processes(&self, running: &[String]) -> Option<String> {
for cmd in running {
let low = cmd.to_lowercase();
let mut tokens = low.split_whitespace();
let Some(first) = tokens.next() else { continue };
let first = binary_name(first);
if let Some(a) = self.match_binary(first) {
return Some(a);
}
if is_interpreter(first) {
for t in tokens {
if t.starts_with('-') {
continue;
}
return self.match_binary(binary_name(t));
}
}
}
None
}
fn match_binary(&self, base: &str) -> Option<String> {
self.agents
.iter()
.find(|a| a.all().any(|p| p == base))
.map(|a| a.name.clone())
}
fn detect_agent(
&self,
title: Option<&str>,
low_bottom: &str,
base_command: &str,
) -> Option<String> {
let cmd = base_command.to_lowercase();
let title = title.map(|t| t.to_lowercase()).unwrap_or_default();
for region in [&cmd, &title] {
if let Some(a) = self
.agents
.iter()
.find(|a| a.all().any(|p| contains_agent_word(region, p)))
{
return Some(a.name.clone());
}
}
self.agents
.iter()
.find(|a| {
a.distinct
.iter()
.any(|p| contains_agent_word(low_bottom, p))
})
.map(|a| a.name.clone())
}
}
fn binary_name(token: &str) -> &str {
token
.rsplit(['/', '\\'])
.next()
.unwrap_or(token)
.trim_start_matches('-')
.trim_end_matches(".exe")
}
fn is_interpreter(base: &str) -> bool {
matches!(
base,
"node"
| "nodejs"
| "bun"
| "deno"
| "npx"
| "pnpm"
| "yarn"
| "python"
| "python3"
| "py"
| "uv"
| "uvx"
| "ruby"
| "perl"
| "env"
| "sh"
| "bash"
| "zsh"
| "fish"
)
}
fn contains_agent_word(hay: &str, needle: &str) -> bool {
let boundary = |c: Option<char>| match c {
None => true,
Some(c) => !c.is_alphanumeric() && !matches!(c, '.' | '/' | '\\'),
};
hay.match_indices(needle).any(|(i, _)| {
boundary(hay[..i].chars().next_back()) && boundary(hay[i + needle.len()..].chars().next())
})
}
#[cfg(test)]
mod tests {
use super::*;
fn state(bottom: &str, activity: bool, input: bool) -> State {
classify(
Some("claude"),
bottom,
activity,
input,
"claude",
"claude",
&[],
&Manifests::builtin(),
)
.state
}
#[test]
fn permission_prompt_is_blocked() {
assert_eq!(
state("Do you want to proceed? (y/n)", true, false),
State::Blocked
);
assert_eq!(
state("Run this command? [y/n]", false, false),
State::Blocked
);
}
#[test]
fn selection_menu_is_blocked_not_working() {
let d = state(
"Choose:\n1. Yes\n Enter to select · Esc to cancel",
true,
false,
);
assert_eq!(d, State::Blocked);
}
#[test]
fn spinner_is_working_even_while_typing() {
assert_eq!(
state("⠹ Thinking… (esc to interrupt)", true, true),
State::Working
);
}
#[test]
fn interrupt_hint_is_working() {
assert_eq!(
state("· 1.2k tokens · esc to interrupt", true, false),
State::Working
);
}
#[test]
fn typing_at_prompt_is_idle() {
assert_eq!(state("> write me a function", true, true), State::Idle);
}
#[test]
fn agent_output_without_a_marker_is_idle() {
assert_eq!(state("streaming plain text", true, false), State::Idle);
assert_eq!(
state(
"✻ Welcome to Claude Code!\n\n /help for help\n\n> ",
true,
false
),
State::Idle,
"a launching agent is idle, not working"
);
}
#[test]
fn shell_output_is_working_fallback() {
let d = classify(
None,
"compiling foo v0.1.0",
true,
false,
"zsh",
"",
&[],
&Manifests::builtin(),
);
assert_eq!(d.state, State::Working);
}
#[test]
fn kimi_moon_spinner_is_working() {
let d = classify(
Some("kimi"),
"🌖 running task…",
true,
false,
"kimi",
"kimi",
&[],
&Manifests::builtin(),
);
assert_eq!(d.state, State::Working);
}
#[test]
fn kimi_approval_panel_is_blocked() {
let d = classify(
Some("kimi"),
"Run this command?\n rm -rf build\n ↑/↓ select · 1/2 choose · ↵ confirm",
true,
false,
"kimi",
"kimi",
&[],
&Manifests::builtin(),
);
assert_eq!(d.state, State::Blocked);
}
#[test]
fn known_agent_stays_idle_when_its_name_is_off_screen() {
let d = classify(
None, "> \n ? for shortcuts", true, false, "zsh", "claude", &[],
&Manifests::builtin(),
);
assert_eq!(
d.state,
State::Idle,
"a repaint with no marker must not read as working"
);
}
#[test]
fn quiet_is_idle() {
let d = classify(
None,
"$ ",
false,
false,
"zsh",
"",
&[],
&Manifests::builtin(),
);
assert_eq!(d.state, State::Idle);
assert_eq!(d.agent, "zsh");
}
#[test]
fn user_manifest_rule_overrides_by_priority() {
let toml = r#"
agent = "claude"
[[rule]]
state = "blocked"
priority = 500
region = "screen"
any = ["my custom prompt"]
"#;
let mut m = Manifests::builtin();
m.rules
.extend(toml::from_str::<ManifestFile>(toml).unwrap().into_rules());
let d = classify(
Some("claude"),
"here is my custom prompt >",
true,
false,
"claude",
"claude",
&[],
&m,
);
assert_eq!(d.state, State::Blocked, "user rule applies");
let d2 = classify(
Some("codex"),
"here is my custom prompt >",
true,
false,
"codex",
"codex",
&[],
&m,
);
assert_eq!(d2.state, State::Idle, "user rule is scoped to its agent");
}
#[test]
fn ordinary_words_do_not_name_an_agent() {
let m = Manifests::builtin();
let named = |title: Option<&str>, screen: &str, base: &str| {
classify(title, screen, true, false, base, "", &[], &m).agent
};
for prose in [
"for example, run the build",
"a sample config",
"cargo test --example foo",
"stamped output",
"the implementation examples",
"champion",
] {
assert_eq!(
named(Some("zsh"), prose, "zsh"),
"zsh",
"ordinary prose must not name an agent: {prose:?}"
);
}
assert_eq!(
named(Some("zsh"), ".kiro/settings/mcp.json\n", "zsh"),
"zsh",
"listing ~/.kiro must not turn a shell into the kiro agent"
);
assert_eq!(
named(Some("zsh"), "error: cursor is out of bounds\n", "zsh"),
"zsh",
"a compiler error must not name the pane cursor"
);
assert_eq!(
named(Some("zsh"), "warning: unused variable `droid`\n", "zsh"),
"zsh"
);
assert_eq!(
named(Some("zsh"), "you have to grok it first\n", "zsh"),
"zsh"
);
assert_eq!(named(Some("amp"), "", "zsh"), "amp", "title names it");
assert_eq!(named(Some("zsh"), "", "droid"), "droid", "command names it");
assert_eq!(
named(Some("zsh"), "cursor-agent v1\n", "zsh"),
"cursor",
"the CLI binary is distinctive enough to trust from output"
);
assert_eq!(named(Some("zsh"), "claude\n", "zsh"), "claude");
assert_eq!(named(Some("zsh"), "claude-code v2\n", "zsh"), "claude");
}
#[test]
fn user_manifest_can_refine_and_add_agent_identity() {
let apply = |toml: &str, m: &mut Manifests| {
toml::from_str::<ManifestFile>(toml)
.unwrap()
.apply_identity(&mut m.agents);
};
let mut m = Manifests::builtin();
let named = |m: &Manifests, title: Option<&str>, screen: &str, cmd: &str| {
classify(title, screen, true, false, cmd, "", &[], m).agent
};
assert_eq!(named(&m, Some("cursor"), "", "zsh"), "cursor");
apply(
r#"
agent = "cursor"
[identity]
distinct = ["cursor-agent"]
replace = true
"#,
&mut m,
);
assert_eq!(
named(&m, Some("cursor"), "", "zsh"),
"zsh",
"`replace` drops the built-in ambiguous pattern"
);
assert_eq!(named(&m, Some("cursor-agent"), "", "zsh"), "cursor");
let mut m2 = Manifests::builtin();
assert!(!m2.is_agent("nimbus"));
apply(
r#"
agent = "nimbus"
[identity]
distinct = ["nimbus-cli"]
ambiguous = ["nimbus"]
"#,
&mut m2,
);
assert!(m2.is_agent("nimbus"), "the new agent is recognised");
assert_eq!(
named(&m2, Some("zsh"), "nimbus-cli v3 ready\n", "zsh"),
"nimbus",
"a distinct pattern is trusted from pane output"
);
assert_eq!(
named(&m2, Some("zsh"), "the nimbus is a cloud\n", "zsh"),
"zsh",
"an ambiguous pattern is not trusted from pane output"
);
}
#[test]
fn running_process_outranks_screen_text() {
let m = Manifests::builtin();
let named = |screen: &str, running: &[String]| {
classify(Some("zsh"), screen, true, false, "zsh", "", running, &m).agent
};
let proc = |c: &str| vec![c.to_string()];
assert_eq!(
named("see the claude docs\n", &proc("/opt/homebrew/bin/amp")),
"amp",
"a real amp process beats the word claude on screen"
);
assert_eq!(
named(
"installing opencode from npm\n",
&proc("node /usr/local/bin/claude")
),
"claude"
);
assert_eq!(
named("see the claude docs\n", &proc("-zsh")),
"zsh",
"no agent process means no agent, whatever the screen says"
);
assert_eq!(
named("the copilot subscription page\n", &proc("-zsh")),
"zsh"
);
assert_eq!(named("gemini is a constellation\n", &proc("-zsh")), "zsh");
assert_eq!(named("", &proc("cargo test --example amp")), "zsh");
assert_eq!(named("", &proc("C:\\tools\\droid.exe")), "droid");
assert_eq!(named("claude\n", &[]), "claude");
}
}