1508 lines
48 KiB
Rust
1508 lines
48 KiB
Rust
//! Agent state detection via terminal tail pattern matching.
|
||
//!
|
||
//! Each pane's live bottom-of-buffer text is read periodically and matched
|
||
//! against known agent output patterns to determine state.
|
||
|
||
/// The detected state of a terminal pane.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum AgentState {
|
||
/// Agent finished, prompt visible, nothing happening.
|
||
Idle,
|
||
/// Agent is actively working/processing.
|
||
Working,
|
||
/// Agent needs human input and is blocked on a response.
|
||
Blocked,
|
||
/// Plain shell or unrecognized program.
|
||
Unknown,
|
||
}
|
||
|
||
/// Which agent we detected running in a pane.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Agent {
|
||
Pi,
|
||
Claude,
|
||
Codex,
|
||
Gemini,
|
||
Cursor,
|
||
Cline,
|
||
OpenCode,
|
||
GithubCopilot,
|
||
Kimi,
|
||
Droid,
|
||
Amp,
|
||
}
|
||
|
||
pub fn agent_label(agent: Agent) -> &'static str {
|
||
match agent {
|
||
Agent::Pi => "pi",
|
||
Agent::Claude => "claude",
|
||
Agent::Codex => "codex",
|
||
Agent::Gemini => "gemini",
|
||
Agent::Cursor => "cursor",
|
||
Agent::Cline => "cline",
|
||
Agent::OpenCode => "opencode",
|
||
Agent::GithubCopilot => "copilot",
|
||
Agent::Kimi => "kimi",
|
||
Agent::Droid => "droid",
|
||
Agent::Amp => "amp",
|
||
}
|
||
}
|
||
|
||
pub fn parse_agent_label(agent: &str) -> Option<Agent> {
|
||
let name = agent.trim().to_lowercase();
|
||
match name.as_str() {
|
||
"pi" => Some(Agent::Pi),
|
||
"claude" | "claude-code" => Some(Agent::Claude),
|
||
"codex" => Some(Agent::Codex),
|
||
"gemini" => Some(Agent::Gemini),
|
||
"cursor" => Some(Agent::Cursor),
|
||
"cline" => Some(Agent::Cline),
|
||
"opencode" | "open-code" => Some(Agent::OpenCode),
|
||
"copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot),
|
||
"kimi" => Some(Agent::Kimi),
|
||
"droid" => Some(Agent::Droid),
|
||
"amp" | "amp-local" => Some(Agent::Amp),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Identify which agent is running from the process name.
|
||
/// Returns `None` for plain shells or unrecognized programs.
|
||
pub fn identify_agent(process_name: &str) -> Option<Agent> {
|
||
let name = process_name.to_lowercase();
|
||
// Match against known binary names
|
||
match name.as_str() {
|
||
"pi" => Some(Agent::Pi),
|
||
"claude" | "claude-code" => Some(Agent::Claude),
|
||
"codex" => Some(Agent::Codex),
|
||
"gemini" => Some(Agent::Gemini),
|
||
"cursor" => Some(Agent::Cursor),
|
||
"cline" => Some(Agent::Cline),
|
||
"opencode" | "open-code" => Some(Agent::OpenCode),
|
||
"github-copilot" | "ghcs" => Some(Agent::GithubCopilot),
|
||
"kimi" => Some(Agent::Kimi),
|
||
"droid" => Some(Agent::Droid),
|
||
"amp" | "amp-local" => Some(Agent::Amp),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Agent, String)> {
|
||
let mut best: Option<(u8, Agent, String)> = None;
|
||
|
||
for process in &job.processes {
|
||
let candidate = normalized_process_name(process);
|
||
let Some(agent) = identify_agent(&candidate) else {
|
||
continue;
|
||
};
|
||
let score = process_priority(process, &candidate);
|
||
|
||
match &best {
|
||
Some((best_score, _, _)) if *best_score >= score => {}
|
||
_ => best = Some((score, agent, candidate)),
|
||
}
|
||
}
|
||
|
||
best.map(|(_, agent, name)| (agent, name))
|
||
}
|
||
|
||
/// Detect the state of an agent from the live terminal tail snapshot.
|
||
/// If `agent` is `None`, returns `Unknown`.
|
||
pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState {
|
||
let Some(agent) = agent else {
|
||
return AgentState::Unknown;
|
||
};
|
||
match agent {
|
||
Agent::Pi => detect_pi(screen_content),
|
||
Agent::Claude => detect_claude(screen_content),
|
||
Agent::Codex => detect_codex(screen_content),
|
||
Agent::Gemini => detect_gemini(screen_content),
|
||
Agent::Cursor => detect_cursor(screen_content),
|
||
Agent::Cline => detect_cline(screen_content),
|
||
Agent::OpenCode => detect_opencode(screen_content),
|
||
Agent::GithubCopilot => detect_github_copilot(screen_content),
|
||
Agent::Kimi => detect_kimi(screen_content),
|
||
Agent::Droid => detect_droid(screen_content),
|
||
Agent::Amp => detect_amp(screen_content),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Per-agent detectors
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn detect_pi(content: &str) -> AgentState {
|
||
// pi shows "Working..." when the agent is processing
|
||
if content.contains("Working...") {
|
||
return AgentState::Working;
|
||
}
|
||
AgentState::Idle
|
||
}
|
||
|
||
/// Claude Code detection. The most complex — it has a structured prompt box UI.
|
||
///
|
||
/// Screen layout:
|
||
/// ```text
|
||
/// (agent output / tool results)
|
||
/// ───────────────────────── (top border)
|
||
/// ❯ _ (prompt line)
|
||
/// ───────────────────────── (bottom border)
|
||
/// ```
|
||
fn detect_claude(content: &str) -> AgentState {
|
||
let lower = content.to_lowercase();
|
||
|
||
// Search prompt is always idle
|
||
if content.contains("⌕ Search…") {
|
||
return AgentState::Idle;
|
||
}
|
||
|
||
// ctrl+r toggle — don't change state
|
||
// (we return Idle as a safe default since we don't have previous state here)
|
||
if lower.contains("ctrl+r to toggle") {
|
||
return AgentState::Idle;
|
||
}
|
||
|
||
// --- Blocked detection (full content including prompt box) ---
|
||
|
||
if has_claude_blocked_prompt(content, &lower) {
|
||
return AgentState::Blocked;
|
||
}
|
||
|
||
// --- Working detection (content above the prompt box) ---
|
||
|
||
let above = content_above_prompt_box(content);
|
||
let above_lower = above.to_lowercase();
|
||
|
||
if above_lower.contains("esc to interrupt") || above_lower.contains("ctrl+c to interrupt") {
|
||
return AgentState::Working;
|
||
}
|
||
|
||
if has_spinner_activity(above) {
|
||
return AgentState::Working;
|
||
}
|
||
|
||
AgentState::Idle
|
||
}
|
||
|
||
fn detect_codex(content: &str) -> AgentState {
|
||
let lower = content.to_lowercase();
|
||
|
||
// Blocked patterns
|
||
if lower.contains("press enter to confirm or esc to cancel")
|
||
|| lower.contains("enter to submit answer")
|
||
|| lower.contains("allow command?")
|
||
|| lower.contains("[y/n]")
|
||
|| lower.contains("yes (y)")
|
||
{
|
||
return AgentState::Blocked;
|
||
}
|
||
if has_confirmation_prompt(&lower) {
|
||
return AgentState::Blocked;
|
||
}
|
||
|
||
// Working
|
||
if has_interrupt_pattern(&lower) || has_codex_working_header(content) {
|
||
return AgentState::Working;
|
||
}
|
||
|
||
AgentState::Idle
|
||
}
|
||
|
||
fn detect_gemini(content: &str) -> AgentState {
|
||
let lower = content.to_lowercase();
|
||
|
||
// Blocked — explicit confirmation
|
||
if lower.contains("waiting for user confirmation") {
|
||
return AgentState::Blocked;
|
||
}
|
||
|
||
// Blocked — box-drawing confirmation prompts
|
||
if content.contains("│ Apply this change")
|
||
|| content.contains("│ Allow execution")
|
||
|| content.contains("│ Do you want to proceed")
|
||
{
|
||
return AgentState::Blocked;
|
||
}
|
||
if has_confirmation_prompt(&lower) {
|
||
return AgentState::Blocked;
|
||
}
|
||
|
||
// Working
|
||
if lower.contains("esc to cancel") {
|
||
return AgentState::Working;
|
||
}
|
||
|
||
AgentState::Idle
|
||
}
|
||
|
||
fn detect_cursor(content: &str) -> AgentState {
|
||
let lower = content.to_lowercase();
|
||
|
||
// Blocked
|
||
if lower.contains("(y) (enter)")
|
||
|| lower.contains("keep (n)")
|
||
|| lower.contains("skip (esc or n)")
|
||
{
|
||
return AgentState::Blocked;
|
||
}
|
||
// "allow ...(y)" or "run ...(y)" patterns
|
||
if lower.contains("(y)") && (lower.contains("allow") || lower.contains("run")) {
|
||
return AgentState::Blocked;
|
||
}
|
||
|
||
// Working
|
||
if lower.contains("ctrl+c to stop") {
|
||
return AgentState::Working;
|
||
}
|
||
if has_cursor_spinner(content) {
|
||
return AgentState::Working;
|
||
}
|
||
|
||
AgentState::Idle
|
||
}
|
||
|
||
fn detect_cline(content: &str) -> AgentState {
|
||
let lower = content.to_lowercase();
|
||
|
||
// Blocked
|
||
if lower.contains("let cline use this tool") {
|
||
return AgentState::Blocked;
|
||
}
|
||
// [act mode] or [plan mode] followed by "yes"
|
||
if (lower.contains("[act mode]") || lower.contains("[plan mode]")) && lower.contains("yes") {
|
||
return AgentState::Blocked;
|
||
}
|
||
|
||
// Idle
|
||
if lower.contains("cline is ready for your message") {
|
||
return AgentState::Idle;
|
||
}
|
||
|
||
// Cline defaults to working (unlike most agents that default to idle)
|
||
AgentState::Working
|
||
}
|
||
|
||
fn detect_opencode(content: &str) -> AgentState {
|
||
// Blocked
|
||
if content.contains("△ Permission required") || has_opencode_question_prompt(content) {
|
||
return AgentState::Blocked;
|
||
}
|
||
|
||
// Working
|
||
if has_interrupt_pattern(&content.to_lowercase()) {
|
||
return AgentState::Working;
|
||
}
|
||
|
||
AgentState::Idle
|
||
}
|
||
|
||
fn detect_github_copilot(content: &str) -> AgentState {
|
||
let lower = content.to_lowercase();
|
||
|
||
// Blocked
|
||
if lower.contains("│ do you want") {
|
||
return AgentState::Blocked;
|
||
}
|
||
if lower.contains("confirm with") && lower.contains("enter") {
|
||
return AgentState::Blocked;
|
||
}
|
||
|
||
// Working
|
||
if lower.contains("esc to cancel") {
|
||
return AgentState::Working;
|
||
}
|
||
|
||
AgentState::Idle
|
||
}
|
||
|
||
fn detect_kimi(content: &str) -> AgentState {
|
||
let lower = content.to_lowercase();
|
||
|
||
// Blocked
|
||
if lower.contains("allow?")
|
||
|| lower.contains("confirm?")
|
||
|| lower.contains("approve?")
|
||
|| lower.contains("proceed?")
|
||
|| lower.contains("[y/n]")
|
||
|| lower.contains("(y/n)")
|
||
{
|
||
return AgentState::Blocked;
|
||
}
|
||
|
||
// Working
|
||
if lower.contains("thinking")
|
||
|| lower.contains("processing")
|
||
|| lower.contains("generating")
|
||
|| lower.contains("waiting for response")
|
||
|| lower.contains("ctrl+c to cancel")
|
||
|| lower.contains("ctrl-c to cancel")
|
||
{
|
||
return AgentState::Working;
|
||
}
|
||
|
||
AgentState::Idle
|
||
}
|
||
|
||
/// Droid detection.
|
||
///
|
||
/// Working: braille spinner line (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) + "Thinking..." + "(Press ESC to stop)"
|
||
/// Blocked: EXECUTE prompt with selection box ("Yes, allow" / "No, cancel") +
|
||
/// "Use ↑↓ to navigate, Enter to select"
|
||
/// Idle: prompt box visible, no spinner, no selection prompt
|
||
fn detect_droid(content: &str) -> AgentState {
|
||
let lower = content.to_lowercase();
|
||
|
||
// Blocked: EXECUTE approval prompt with selection UI chrome
|
||
// Primary (AND): structural keyword + chrome text = certain
|
||
let has_execute = content.contains("EXECUTE");
|
||
let has_selection_chrome = lower.contains("enter to select")
|
||
|| lower.contains("↑↓ to navigate")
|
||
|| lower.contains("esc to cancel");
|
||
let has_selection_options = lower.contains("> yes, allow") || lower.contains("> no, cancel");
|
||
|
||
if has_execute && (has_selection_chrome || has_selection_options) {
|
||
return AgentState::Blocked;
|
||
}
|
||
// Secondary: selection chrome + options together (no EXECUTE needed)
|
||
if has_selection_chrome && has_selection_options {
|
||
return AgentState::Blocked;
|
||
}
|
||
|
||
// Working: braille spinner character at start of a line + "Thinking..."
|
||
// The braille chars (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) are very specific — won't appear in normal content
|
||
if has_braille_spinner(content) && lower.contains("esc to stop") {
|
||
return AgentState::Working;
|
||
}
|
||
// Fallback: "ESC to stop" alone is still a strong signal (it's UI chrome)
|
||
if lower.contains("esc to stop") {
|
||
return AgentState::Working;
|
||
}
|
||
|
||
AgentState::Idle
|
||
}
|
||
|
||
/// Amp (Sourcegraph) detection.
|
||
///
|
||
/// Blocked approval prompts use a shared footer with options like
|
||
/// "Approve", "Allow All for This Session", "Allow All for Every Session",
|
||
/// "Allow File for Every Session", and "Deny with feedback". The header varies
|
||
/// by approval type, for example "Invoke tool ...?", "Run this command?",
|
||
/// "Allow editing file:", or "Allow creating file:".
|
||
///
|
||
/// Working layout:
|
||
/// ```text
|
||
/// ✓ Search Map the core runtime architecture...
|
||
/// ⋯ Oracle ▼
|
||
/// ≈ Running tools... Esc to cancel
|
||
/// ```
|
||
fn detect_amp(content: &str) -> AgentState {
|
||
let lower = content.to_lowercase();
|
||
|
||
let has_waiting_for_approval = lower.contains("waiting for approval");
|
||
let has_approval_header = lower.contains("invoke tool")
|
||
|| lower.contains("run this command?")
|
||
|| lower.contains("allow editing file:")
|
||
|| lower.contains("allow creating file:")
|
||
|| lower.contains("confirm tool call");
|
||
let has_approval_actions = lower.contains("approve")
|
||
&& (lower.contains("allow all for this session")
|
||
|| lower.contains("allow all for every session")
|
||
|| lower.contains("allow file for every session")
|
||
|| lower.contains("deny with feedback"));
|
||
|
||
if has_approval_actions && (has_waiting_for_approval || has_approval_header) {
|
||
return AgentState::Blocked;
|
||
}
|
||
|
||
if lower.contains("esc to cancel") {
|
||
return AgentState::Working;
|
||
}
|
||
|
||
AgentState::Idle
|
||
}
|
||
|
||
/// Check for braille spinner characters at the start of a line.
|
||
/// These are the Unicode braille pattern dots used by CLI spinners.
|
||
fn has_braille_spinner(content: &str) -> bool {
|
||
for line in content.lines() {
|
||
let trimmed = line.trim();
|
||
if let Some(c) = trimmed.chars().next() {
|
||
if ('\u{2800}'..='\u{28FF}').contains(&c) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Check for "do you want"/"would you like" followed by "yes" or "❯"
|
||
fn has_confirmation_prompt(lower_content: &str) -> bool {
|
||
if let Some(pos) = lower_content
|
||
.find("do you want")
|
||
.or_else(|| lower_content.find("would you like"))
|
||
{
|
||
let after = &lower_content[pos..];
|
||
return after.contains("yes") || after.contains('❯');
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Claude uses the same generic Select and Dialog widgets for both
|
||
/// permission flows and ordinary slash/settings menus. Match only the
|
||
/// permission and interview prompts that actually need user input.
|
||
fn has_claude_blocked_prompt(content: &str, lower_content: &str) -> bool {
|
||
has_confirmation_prompt(lower_content)
|
||
|| lower_content.contains("do you want to proceed?")
|
||
|| lower_content.contains("would you like to proceed?")
|
||
|| lower_content.contains("waiting for permission")
|
||
|| lower_content.contains("do you want to allow this connection?")
|
||
|| lower_content.contains("tab to amend")
|
||
|| lower_content.contains("ctrl+e to explain")
|
||
|| lower_content.contains("chat about this")
|
||
|| lower_content.contains("review your answers")
|
||
|| lower_content.contains("skip interview and plan immediately")
|
||
|| (has_selection_prompt(content) && has_claude_yes_no_choice(content))
|
||
}
|
||
|
||
fn has_claude_yes_no_choice(content: &str) -> bool {
|
||
content.lines().any(|line| {
|
||
let trimmed = line
|
||
.trim()
|
||
.trim_start_matches('❯')
|
||
.trim_start()
|
||
.to_lowercase();
|
||
trimmed == "yes"
|
||
|| trimmed == "no"
|
||
|| trimmed.starts_with("1. yes")
|
||
|| trimmed.starts_with("2. no")
|
||
|| trimmed.starts_with("yes, and ")
|
||
|| trimmed.starts_with("no, and tell claude")
|
||
})
|
||
}
|
||
|
||
/// Check for "❯" followed by numbered options like "1."
|
||
fn has_selection_prompt(content: &str) -> bool {
|
||
for line in content.lines() {
|
||
let trimmed = line.trim();
|
||
if trimmed.starts_with('❯') {
|
||
// Check if there's a digit followed by a dot nearby
|
||
if trimmed.chars().any(|c| c.is_ascii_digit()) && trimmed.contains('.') {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Check for "esc" + "interrupt" pattern
|
||
fn has_interrupt_pattern(lower_content: &str) -> bool {
|
||
lower_content.contains("esc to interrupt")
|
||
|| lower_content.contains("ctrl+c to interrupt")
|
||
|| (lower_content.contains("esc") && lower_content.contains("interrupt"))
|
||
}
|
||
|
||
/// Claude Code spinner characters + activity label.
|
||
/// The verb changes frequently ("Processing…", "Pouncing…", etc.), so rely
|
||
/// on the spinner glyph + trailing ellipsis rather than specific wording.
|
||
/// Include Claude's narrow-pane middle-dot frame too.
|
||
fn has_spinner_activity(content: &str) -> bool {
|
||
const SPINNER_CHARS: &str = "·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆";
|
||
for line in content.lines() {
|
||
let trimmed = line.trim();
|
||
let mut chars = trimmed.chars();
|
||
if let Some(first) = chars.next() {
|
||
if SPINNER_CHARS.contains(first) {
|
||
let rest: String = chars.collect();
|
||
if rest.starts_with(' ')
|
||
&& rest.contains('\u{2026}')
|
||
&& rest.chars().any(|c| c.is_alphanumeric())
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
fn has_codex_working_header(content: &str) -> bool {
|
||
content.lines().any(|line| {
|
||
let trimmed = line.trim_start();
|
||
trimmed.starts_with('•') && trimmed.contains("Working (")
|
||
})
|
||
}
|
||
|
||
/// Cursor spinner: ⬡ or ⬢ followed by a word ending in "ing"
|
||
fn has_cursor_spinner(content: &str) -> bool {
|
||
for line in content.lines() {
|
||
let trimmed = line.trim();
|
||
if (trimmed.starts_with('⬡') || trimmed.starts_with('⬢')) && trimmed.contains("ing") {
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
fn has_opencode_question_prompt(content: &str) -> bool {
|
||
let lower = content.to_lowercase();
|
||
let has_enter_action = lower.contains("enter confirm")
|
||
|| lower.contains("enter submit")
|
||
|| lower.contains("enter toggle");
|
||
let has_question_nav = content.contains("↑↓ select") || content.contains("⇆ tab");
|
||
|
||
lower.contains("esc dismiss") && has_enter_action && has_question_nav
|
||
}
|
||
|
||
/// Extract content above Claude's prompt box.
|
||
/// The prompt box is two ─── border lines with ❯ between them.
|
||
fn content_above_prompt_box(content: &str) -> &str {
|
||
let lines: Vec<&str> = content.lines().collect();
|
||
let mut border_count = 0;
|
||
|
||
for i in (0..lines.len()).rev() {
|
||
let trimmed = lines[i].trim();
|
||
if !trimmed.is_empty() && trimmed.chars().all(|c| c == '─') {
|
||
border_count += 1;
|
||
if border_count == 2 {
|
||
// Return everything above this border
|
||
let byte_offset: usize = lines[..i].iter().map(|l| l.len() + 1).sum();
|
||
return &content[..byte_offset.min(content.len())];
|
||
}
|
||
}
|
||
}
|
||
|
||
// No prompt box found, return all content
|
||
content
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Process identification (platform-specific)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Get the foreground job for a given child PID.
|
||
/// Delegates to platform-specific implementation.
|
||
pub fn foreground_job(child_pid: u32) -> Option<crate::platform::ForegroundJob> {
|
||
crate::platform::foreground_job(child_pid)
|
||
}
|
||
|
||
/// Get the foreground process group for a pane shell PID.
|
||
/// This is cheaper than collecting every process in the foreground job.
|
||
pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> {
|
||
crate::platform::foreground_process_group_id(child_pid)
|
||
}
|
||
|
||
fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> String {
|
||
let effective = process.argv0.as_deref().unwrap_or(&process.name);
|
||
let lower_effective = effective.to_lowercase();
|
||
|
||
if is_generic_runtime_or_shell(&lower_effective) {
|
||
if let Some(wrapped_agent) =
|
||
wrapped_agent_name_from_cmdline(process.cmdline.as_deref().unwrap_or_default())
|
||
{
|
||
return wrapped_agent;
|
||
}
|
||
}
|
||
|
||
if identify_agent(effective).is_some() {
|
||
return effective.to_string();
|
||
}
|
||
|
||
if let Some(wrapped_agent) =
|
||
cmdline_argv0_agent_name(process.cmdline.as_deref().unwrap_or_default())
|
||
{
|
||
return wrapped_agent;
|
||
}
|
||
|
||
effective.to_string()
|
||
}
|
||
|
||
fn wrapped_agent_name_from_cmdline(cmdline: &str) -> Option<String> {
|
||
for token in cmdline.split_whitespace() {
|
||
if let Some(agent_name) = agent_name_from_path_token(token) {
|
||
return Some(agent_name);
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn cmdline_argv0_agent_name(cmdline: &str) -> Option<String> {
|
||
agent_name_from_path_token(cmdline.split_whitespace().next()?)
|
||
}
|
||
|
||
fn agent_name_from_path_token(token: &str) -> Option<String> {
|
||
let trimmed = token.trim_matches(|c| matches!(c, '"' | '\''));
|
||
if trimmed.is_empty() || trimmed.starts_with('-') {
|
||
return None;
|
||
}
|
||
|
||
let basename = std::path::Path::new(trimmed)
|
||
.file_name()
|
||
.and_then(|name| name.to_str())
|
||
.unwrap_or(trimmed);
|
||
let agent = parse_agent_label(basename)?;
|
||
Some(agent_label(agent).to_string())
|
||
}
|
||
|
||
fn process_priority(process: &crate::platform::ForegroundProcess, normalized_name: &str) -> u8 {
|
||
let lower_name = normalized_name.to_lowercase();
|
||
if lower_name != process.name.to_lowercase() {
|
||
return 3;
|
||
}
|
||
if !is_generic_runtime_or_shell(&lower_name) {
|
||
return 2;
|
||
}
|
||
1
|
||
}
|
||
|
||
fn is_generic_runtime_or_shell(name: &str) -> bool {
|
||
matches!(
|
||
name,
|
||
"sh" | "bash" | "zsh" | "fish" | "tmux" | "node" | "bun" | "python" | "python3"
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// ---- Agent identification ----
|
||
|
||
#[test]
|
||
fn identify_known_agents() {
|
||
assert_eq!(identify_agent("pi"), Some(Agent::Pi));
|
||
assert_eq!(identify_agent("claude"), Some(Agent::Claude));
|
||
assert_eq!(identify_agent("claude-code"), Some(Agent::Claude));
|
||
assert_eq!(identify_agent("codex"), Some(Agent::Codex));
|
||
assert_eq!(identify_agent("gemini"), Some(Agent::Gemini));
|
||
assert_eq!(identify_agent("cursor"), Some(Agent::Cursor));
|
||
assert_eq!(identify_agent("cline"), Some(Agent::Cline));
|
||
assert_eq!(identify_agent("opencode"), Some(Agent::OpenCode));
|
||
assert_eq!(identify_agent("kimi"), Some(Agent::Kimi));
|
||
assert_eq!(identify_agent("ghcs"), Some(Agent::GithubCopilot));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_known_agent_labels() {
|
||
assert_eq!(parse_agent_label("pi"), Some(Agent::Pi));
|
||
assert_eq!(parse_agent_label("claude"), Some(Agent::Claude));
|
||
assert_eq!(parse_agent_label("copilot"), Some(Agent::GithubCopilot));
|
||
assert_eq!(
|
||
parse_agent_label("github-copilot"),
|
||
Some(Agent::GithubCopilot)
|
||
);
|
||
assert_eq!(parse_agent_label("amp-local"), Some(Agent::Amp));
|
||
}
|
||
|
||
#[test]
|
||
fn agent_labels_use_display_names() {
|
||
assert_eq!(agent_label(Agent::Pi), "pi");
|
||
assert_eq!(agent_label(Agent::GithubCopilot), "copilot");
|
||
assert_eq!(agent_label(Agent::OpenCode), "opencode");
|
||
}
|
||
|
||
#[test]
|
||
fn identify_unknown_processes() {
|
||
assert_eq!(identify_agent("bash"), None);
|
||
assert_eq!(identify_agent("zsh"), None);
|
||
assert_eq!(identify_agent("vim"), None);
|
||
assert_eq!(identify_agent("node"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn identify_case_insensitive() {
|
||
assert_eq!(identify_agent("Pi"), Some(Agent::Pi));
|
||
assert_eq!(identify_agent("CLAUDE"), Some(Agent::Claude));
|
||
assert_eq!(identify_agent("Codex"), Some(Agent::Codex));
|
||
}
|
||
|
||
#[test]
|
||
fn identify_agent_in_job_prefers_wrapped_codex() {
|
||
let job = crate::platform::ForegroundJob {
|
||
process_group_id: 123,
|
||
processes: vec![
|
||
crate::platform::ForegroundProcess {
|
||
pid: 1,
|
||
name: "node".to_string(),
|
||
argv0: None,
|
||
cmdline: Some("node /path/to/bin/codex".to_string()),
|
||
},
|
||
crate::platform::ForegroundProcess {
|
||
pid: 2,
|
||
name: "bash".to_string(),
|
||
argv0: None,
|
||
cmdline: Some("bash".to_string()),
|
||
},
|
||
],
|
||
};
|
||
|
||
assert_eq!(
|
||
identify_agent_in_job(&job),
|
||
Some((Agent::Codex, "codex".to_string()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn identify_agent_in_job_detects_nix_wrapped_codex_from_cmdline_argv0() {
|
||
let job = crate::platform::ForegroundJob {
|
||
process_group_id: 123,
|
||
processes: vec![crate::platform::ForegroundProcess {
|
||
pid: 1,
|
||
name: ".codex-wrapped".to_string(),
|
||
argv0: None,
|
||
cmdline: Some("/etc/profiles/per-user/user/bin/codex --model gpt-5".to_string()),
|
||
}],
|
||
};
|
||
|
||
assert_eq!(
|
||
identify_agent_in_job(&job),
|
||
Some((Agent::Codex, "codex".to_string()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn identify_agent_in_job_canonicalizes_nix_wrapped_aliases_from_cmdline_argv0() {
|
||
let job = crate::platform::ForegroundJob {
|
||
process_group_id: 123,
|
||
processes: vec![crate::platform::ForegroundProcess {
|
||
pid: 1,
|
||
name: ".claude-code-wrapped".to_string(),
|
||
argv0: None,
|
||
cmdline: Some("/nix/store/example/bin/claude-code".to_string()),
|
||
}],
|
||
};
|
||
|
||
assert_eq!(
|
||
identify_agent_in_job(&job),
|
||
Some((Agent::Claude, "claude".to_string()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn identify_agent_in_job_detects_shell_wrapped_pi() {
|
||
let job = crate::platform::ForegroundJob {
|
||
process_group_id: 123,
|
||
processes: vec![crate::platform::ForegroundProcess {
|
||
pid: 1,
|
||
name: "sh".to_string(),
|
||
argv0: None,
|
||
cmdline: Some("/bin/sh /tmp/test-bin/pi".to_string()),
|
||
}],
|
||
};
|
||
|
||
assert_eq!(
|
||
identify_agent_in_job(&job),
|
||
Some((Agent::Pi, "pi".to_string()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wrapped_agent_name_from_cmdline_ignores_plain_shell_flags() {
|
||
assert_eq!(wrapped_agent_name_from_cmdline("bash -lc"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn cmdline_argv0_agent_name_canonicalizes_known_aliases() {
|
||
assert_eq!(
|
||
cmdline_argv0_agent_name("/nix/store/example/bin/ghcs"),
|
||
Some("copilot".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cmdline_argv0_agent_name_requires_exact_agent_basename() {
|
||
assert_eq!(cmdline_argv0_agent_name("/tmp/my-codex-helper"), None);
|
||
}
|
||
|
||
// ---- Workspace state rollup ----
|
||
|
||
// ---- No agent → Unknown ----
|
||
|
||
#[test]
|
||
fn no_agent_returns_unknown() {
|
||
assert_eq!(detect_state(None, "anything"), AgentState::Unknown);
|
||
}
|
||
|
||
// ---- Pi ----
|
||
|
||
#[test]
|
||
fn pi_working_when_working() {
|
||
assert_eq!(detect_pi("some output\nWorking..."), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn pi_working_working_in_middle() {
|
||
assert_eq!(detect_pi("line1\nWorking...\nline3"), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn pi_idle_at_prompt() {
|
||
assert_eq!(detect_pi("❯ "), AgentState::Idle);
|
||
}
|
||
|
||
#[test]
|
||
fn pi_idle_no_working_text() {
|
||
assert_eq!(detect_pi("some output\n\n> ready"), AgentState::Idle);
|
||
}
|
||
|
||
// ---- Claude Code ----
|
||
|
||
#[test]
|
||
fn claude_working_esc_to_interrupt() {
|
||
let screen = "Reading file src/main.rs\nesc to interrupt\n─────────\n❯ \n─────────";
|
||
assert_eq!(detect_claude(screen), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_working_ctrl_c_to_interrupt() {
|
||
let screen = "Editing code\nctrl+c to interrupt\n─────────\n❯ \n─────────";
|
||
assert_eq!(detect_claude(screen), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_working_spinner() {
|
||
let screen = "✽ Tempering…\n─────────\n❯ \n─────────";
|
||
assert_eq!(detect_claude(screen), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_working_middle_dot_spinner() {
|
||
let screen = "· Thinking…\n─────────\n❯ \n─────────";
|
||
assert_eq!(detect_claude(screen), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_working_spinner_with_detail() {
|
||
let screen = "✳ Simplifying recompute_tangents…\n─────────\n❯ \n─────────";
|
||
assert_eq!(detect_claude(screen), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_waiting_do_you_want() {
|
||
let screen = "Do you want to run this command?\n\nYes No";
|
||
assert_eq!(detect_claude(screen), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_waiting_would_you_like() {
|
||
let screen = "Would you like to apply these changes?\n\n❯ Yes";
|
||
assert_eq!(detect_claude(screen), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_waiting_selection_prompt() {
|
||
let screen = "Do you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend";
|
||
assert_eq!(detect_claude(screen), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_waiting_esc_to_cancel() {
|
||
let screen = "Allow bash: rm -rf /tmp/test?\n\nDo you want to proceed?\n\nesc to cancel";
|
||
assert_eq!(detect_claude(screen), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_waiting_ask_user_question_menu() {
|
||
let screen =
|
||
"Which approach should I take?\n❯ 1. Minimal change\n 2. Bigger refactor\n3. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel";
|
||
assert_eq!(detect_claude(screen), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_idle_hooks_menu() {
|
||
let screen = "Hooks\n0 hooks configured\nℹ This menu is read-only. To add or modify hooks, edit settings.json directly or ask Claude. Learn more\n\n❯ 1. PreToolUse\n 2. PostToolUse\n 3. PostToolUseFailure\n\nEnter to confirm · Esc to cancel";
|
||
assert_eq!(detect_claude(screen), AgentState::Idle);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_idle_theme_menu() {
|
||
let screen = "Theme\nChoose the text style that looks best with your terminal\n\n❯ 1. Dark mode ✔\n 2. Light mode\n 3. Dark mode (colorblind-friendly)\n\nSyntax theme: Monokai Extended (ctrl+t to disable)\n\nEnter to select · Esc to cancel";
|
||
assert_eq!(detect_claude(screen), AgentState::Idle);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_idle_prompt_box() {
|
||
let screen = "Task complete.\n─────────────\n❯ \n─────────────";
|
||
assert_eq!(detect_claude(screen), AgentState::Idle);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_idle_search() {
|
||
let screen = "⌕ Search…\nsome content";
|
||
assert_eq!(detect_claude(screen), AgentState::Idle);
|
||
}
|
||
|
||
#[test]
|
||
fn claude_working_not_confused_by_old_prompt() {
|
||
// The "esc to interrupt" is ABOVE the prompt box — should be working
|
||
let screen = "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────";
|
||
assert_eq!(detect_claude(screen), AgentState::Working);
|
||
}
|
||
|
||
// ---- Codex ----
|
||
|
||
#[test]
|
||
fn codex_waiting_confirm() {
|
||
assert_eq!(
|
||
detect_codex("press enter to confirm or esc to cancel"),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_waiting_allow_command() {
|
||
assert_eq!(detect_codex("allow command?\n[y/n]"), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_waiting_submit_answer() {
|
||
assert_eq!(
|
||
detect_codex("Question about approach\n| enter to submit answer"),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_waiting_submit_answer_wrapped_footer() {
|
||
assert_eq!(
|
||
detect_codex(
|
||
"Question 1/2\nChoose an option.\nenter to submit answer\nesc to interrupt"
|
||
),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_working_interrupt() {
|
||
assert_eq!(
|
||
detect_codex("generating code\nesc to interrupt"),
|
||
AgentState::Working
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_working_truncated_status_header() {
|
||
assert_eq!(detect_codex("• Working (0s • esc…"), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_idle() {
|
||
assert_eq!(detect_codex("❯ "), AgentState::Idle);
|
||
}
|
||
|
||
// ---- Gemini ----
|
||
|
||
#[test]
|
||
fn gemini_waiting_confirmation() {
|
||
assert_eq!(
|
||
detect_gemini("waiting for user confirmation"),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn gemini_waiting_apply() {
|
||
assert_eq!(
|
||
detect_gemini("│ Apply this change\n│ Yes │ No"),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn gemini_waiting_allow_execution() {
|
||
assert_eq!(
|
||
detect_gemini("│ Allow execution of: rm test.txt"),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn gemini_working() {
|
||
assert_eq!(
|
||
detect_gemini("thinking...\nesc to cancel"),
|
||
AgentState::Working
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn gemini_idle() {
|
||
assert_eq!(detect_gemini("❯ "), AgentState::Idle);
|
||
}
|
||
|
||
// ---- Cursor ----
|
||
|
||
#[test]
|
||
fn cursor_waiting_accept() {
|
||
assert_eq!(
|
||
detect_cursor("Apply changes? (y) (enter) or keep (n)"),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_waiting_allow() {
|
||
assert_eq!(detect_cursor("allow file edit (y)"), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_working_spinner() {
|
||
assert_eq!(detect_cursor("⬡ Grepping.."), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_working_ctrl_c() {
|
||
assert_eq!(
|
||
detect_cursor("processing\nctrl+c to stop"),
|
||
AgentState::Working
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_idle() {
|
||
assert_eq!(detect_cursor("> "), AgentState::Idle);
|
||
}
|
||
|
||
// ---- Cline ----
|
||
|
||
#[test]
|
||
fn cline_waiting_tool_use() {
|
||
assert_eq!(detect_cline("let cline use this tool"), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn cline_waiting_act_mode() {
|
||
assert_eq!(
|
||
detect_cline("[act mode] execute command?\nyes"),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cline_idle_ready() {
|
||
assert_eq!(
|
||
detect_cline("cline is ready for your message"),
|
||
AgentState::Idle
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cline_defaults_to_working() {
|
||
// Cline's default is working (unlike other agents)
|
||
assert_eq!(detect_cline("some random output"), AgentState::Working);
|
||
}
|
||
|
||
// ---- OpenCode ----
|
||
|
||
#[test]
|
||
fn opencode_waiting_permission() {
|
||
assert_eq!(
|
||
detect_opencode("△ Permission required"),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn opencode_working() {
|
||
assert_eq!(
|
||
detect_opencode("running tool\nesc to interrupt"),
|
||
AgentState::Working
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn opencode_waiting_question_prompt() {
|
||
assert_eq!(
|
||
detect_opencode(
|
||
"Goal Detail Confirm\n\
|
||
What do you want help with right now?\n\
|
||
1. Code change\n\
|
||
5. Type your own answer\n\
|
||
⇆ tab ↑↓ select enter confirm esc dismiss",
|
||
),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn opencode_waiting_question_confirm_tab() {
|
||
assert_eq!(
|
||
detect_opencode("Goal Detail Confirm\nReview\n⇆ tab enter submit esc dismiss"),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn opencode_idle() {
|
||
assert_eq!(detect_opencode("> "), AgentState::Idle);
|
||
}
|
||
|
||
// ---- GitHub Copilot ----
|
||
|
||
#[test]
|
||
fn copilot_waiting_confirm() {
|
||
assert_eq!(
|
||
detect_github_copilot("confirm with enter"),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn copilot_waiting_do_you_want() {
|
||
assert_eq!(
|
||
detect_github_copilot("│ do you want to apply?"),
|
||
AgentState::Blocked
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn copilot_working() {
|
||
assert_eq!(
|
||
detect_github_copilot("generating\nesc to cancel"),
|
||
AgentState::Working
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn copilot_idle() {
|
||
assert_eq!(detect_github_copilot("> "), AgentState::Idle);
|
||
}
|
||
|
||
// ---- Kimi ----
|
||
|
||
#[test]
|
||
fn kimi_waiting_approve() {
|
||
assert_eq!(detect_kimi("approve?"), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn kimi_waiting_yn() {
|
||
assert_eq!(detect_kimi("continue? [y/n]"), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn kimi_working_thinking() {
|
||
assert_eq!(detect_kimi("thinking"), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn kimi_working_generating() {
|
||
assert_eq!(detect_kimi("generating code"), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn kimi_idle() {
|
||
assert_eq!(detect_kimi("> "), AgentState::Idle);
|
||
}
|
||
|
||
// ---- Droid ----
|
||
|
||
#[test]
|
||
fn droid_working_thinking_with_spinner() {
|
||
let screen = "> how u doin\n\n⠴ Thinking... (Press ESC to stop)\n\nAuto (Off)";
|
||
assert_eq!(detect_droid(screen), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn droid_working_esc_to_stop_alone() {
|
||
// ESC to stop without spinner is still working (UI chrome)
|
||
let screen = "Processing\n(Press ESC to stop)";
|
||
assert_eq!(detect_droid(screen), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn droid_waiting_execute_approval() {
|
||
let screen = concat!(
|
||
"⛬ I'll create some folders.\n\n",
|
||
" EXECUTE (mkdir -p /tmp/test, impact: medium)\n\n",
|
||
"╭────────────────────╮\n",
|
||
"│ > Yes, allow │\n",
|
||
"│ Yes, always allow │\n",
|
||
"│ No, cancel │\n",
|
||
"╰────────────────────╯\n",
|
||
" Use ↑↓ to navigate, Enter to select, Esc to cancel\n",
|
||
);
|
||
assert_eq!(detect_droid(screen), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn droid_waiting_selection_with_chrome() {
|
||
let screen = "│ > Yes, allow │\n│ No, cancel │\n Use ↑↓ to navigate, Enter to select, Esc to cancel";
|
||
assert_eq!(detect_droid(screen), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn droid_not_waiting_on_options_text_alone() {
|
||
// "Yes, allow" in normal conversation should NOT trigger blocked
|
||
let screen = "The user said > Yes, allow the changes";
|
||
assert_eq!(detect_droid(screen), AgentState::Idle);
|
||
}
|
||
|
||
#[test]
|
||
fn droid_idle_prompt() {
|
||
let screen =
|
||
"╭──────────────────╮\n│ > Try something │\n╰──────────────────╯\n? for help";
|
||
assert_eq!(detect_droid(screen), AgentState::Idle);
|
||
}
|
||
|
||
#[test]
|
||
fn droid_idle_after_response() {
|
||
let screen =
|
||
"⛬ Doing well, thanks!\n\nAuto (Off)\n╭──────────╮\n│ > │\n╰──────────╯";
|
||
assert_eq!(detect_droid(screen), AgentState::Idle);
|
||
}
|
||
|
||
#[test]
|
||
fn droid_braille_spinner_detected() {
|
||
assert!(has_braille_spinner("⠴ Thinking..."));
|
||
assert!(has_braille_spinner(" ⠧ Loading..."));
|
||
assert!(has_braille_spinner("text\n⠋ Working\nmore"));
|
||
}
|
||
|
||
#[test]
|
||
fn droid_braille_spinner_no_false_positive() {
|
||
assert!(!has_braille_spinner("normal text"));
|
||
assert!(!has_braille_spinner("Thinking..."));
|
||
assert!(!has_braille_spinner("some ⠴ in middle of text"));
|
||
}
|
||
|
||
#[test]
|
||
fn droid_identified_by_process_name() {
|
||
assert_eq!(identify_agent("droid"), Some(Agent::Droid));
|
||
}
|
||
|
||
// ---- Amp ----
|
||
|
||
#[test]
|
||
fn amp_blocked_waiting_for_approval() {
|
||
let screen = "Invoke tool shell_command?\n▸● Approve [Alt+1]\n ○ Allow All for This Session [Alt+2]\n ○ Allow All for Every Session [Alt+3]\n ○ Deny with feedback [Alt+4]\nWaiting for approval...";
|
||
assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn amp_blocked_run_this_command() {
|
||
let screen = "Run this command?\nrg --files\n▸● Approve [Alt+1]\n ○ Allow All for This Session [Alt+2]\n ○ Allow All for Every Session [Alt+3]\n ○ Deny with feedback [Alt+4]";
|
||
assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn amp_blocked_allow_editing_file() {
|
||
let screen = "Allow editing file:\nsrc/detect.rs\n▸● Approve [Alt+1]\n ○ Allow File for Every Session [Alt+2]\n ○ Allow All for This Session [Alt+3]\n ○ Deny with feedback [Alt+4]";
|
||
assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn amp_blocked_allow_creating_file() {
|
||
let screen = "Allow creating file:\nsrc/new_file.rs\n▸● Approve [Alt+1]\n ○ Allow File for Every Session [Alt+2]\n ○ Allow All for This Session [Alt+3]\n ○ Deny with feedback [Alt+4]";
|
||
assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked);
|
||
}
|
||
|
||
#[test]
|
||
fn amp_working_running_tools() {
|
||
let screen = " ✓ Search Map the core runtime architecture\n ⋯ Oracle ▼\n ≈ Running tools... Esc to cancel";
|
||
assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn amp_idle() {
|
||
let screen = " Response complete.\n\n╭─100% of 272k · $1.20─────────────────────────╮\n│ │\n╰───────────────────────~/Projects/herdr (master)╯";
|
||
assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Idle);
|
||
}
|
||
|
||
#[test]
|
||
fn amp_identified_by_process_name() {
|
||
assert_eq!(identify_agent("amp"), Some(Agent::Amp));
|
||
assert_eq!(identify_agent("amp-local"), Some(Agent::Amp));
|
||
}
|
||
|
||
// ---- Helpers ----
|
||
|
||
#[test]
|
||
fn content_above_prompt_box_extracts_correctly() {
|
||
let screen = "line1\nline2\n──────\n❯ \n──────";
|
||
let above = content_above_prompt_box(screen);
|
||
assert!(above.contains("line1"));
|
||
assert!(above.contains("line2"));
|
||
assert!(!above.contains('❯'));
|
||
}
|
||
|
||
#[test]
|
||
fn content_above_prompt_box_no_box() {
|
||
let screen = "just some text\nno borders here";
|
||
let above = content_above_prompt_box(screen);
|
||
assert_eq!(above, screen);
|
||
}
|
||
|
||
#[test]
|
||
fn spinner_activity_detected() {
|
||
assert!(has_spinner_activity("· Thinking…"));
|
||
assert!(has_spinner_activity("✽ Tempering…"));
|
||
assert!(has_spinner_activity("✳ Simplifying recompute_tangents…"));
|
||
assert!(has_spinner_activity(" ✶ Reading…")); // with leading whitespace
|
||
assert!(has_spinner_activity("✻ Pouncing…"));
|
||
assert!(has_spinner_activity("✽ Processing…"));
|
||
}
|
||
|
||
#[test]
|
||
fn spinner_activity_not_false_positive() {
|
||
assert!(!has_spinner_activity("normal text"));
|
||
assert!(!has_spinner_activity("✽ no ellipsis here"));
|
||
assert!(!has_spinner_activity("✽ …"));
|
||
assert!(!has_spinner_activity("some ✽ in the middle"));
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_spinner_detected() {
|
||
assert!(has_cursor_spinner("⬡ Grepping.."));
|
||
assert!(has_cursor_spinner("⬢ Reading…"));
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_spinner_not_false_positive() {
|
||
assert!(!has_cursor_spinner("normal text"));
|
||
assert!(!has_cursor_spinner("some ⬡ in middle"));
|
||
}
|
||
|
||
// ---- Process identification (real PTY) ----
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn foreground_job_detects_sleep() {
|
||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||
|
||
let pty_system = native_pty_system();
|
||
let pair = pty_system
|
||
.openpty(PtySize {
|
||
rows: 24,
|
||
cols: 80,
|
||
pixel_width: 0,
|
||
pixel_height: 0,
|
||
})
|
||
.expect("failed to open pty");
|
||
|
||
// Spawn "sleep 999" — a known, deterministic process
|
||
let mut cmd = CommandBuilder::new("sleep");
|
||
cmd.arg("999");
|
||
let mut child = pair.slave.spawn_command(cmd).expect("failed to spawn");
|
||
let pid = child.process_id().expect("no pid");
|
||
|
||
// Give the process a moment to become the foreground group
|
||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||
|
||
let job = foreground_job(pid).expect("expected foreground job");
|
||
assert!(
|
||
job.processes.iter().any(|p| p.name == "sleep"),
|
||
"expected sleep in {job:?}"
|
||
);
|
||
assert_eq!(
|
||
identify_agent_in_job(&job),
|
||
None,
|
||
"sleep should not map to an agent"
|
||
);
|
||
|
||
// Clean up
|
||
child.kill().ok();
|
||
child.wait().ok();
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn foreground_job_detects_shell_running_command() {
|
||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||
use std::io::Write;
|
||
|
||
let pty_system = native_pty_system();
|
||
let pair = pty_system
|
||
.openpty(PtySize {
|
||
rows: 24,
|
||
cols: 80,
|
||
pixel_width: 0,
|
||
pixel_height: 0,
|
||
})
|
||
.expect("failed to open pty");
|
||
|
||
// Spawn a shell, then run a command inside it
|
||
let cmd = CommandBuilder::new("sh");
|
||
let mut child = pair.slave.spawn_command(cmd).expect("failed to spawn");
|
||
let pid = child.process_id().expect("no pid");
|
||
|
||
// Write a command to the shell
|
||
let mut writer = pair.master.take_writer().expect("no writer");
|
||
// Use exec so sleep replaces sh as the foreground process
|
||
writer.write_all(b"exec sleep 999\n").ok();
|
||
drop(writer);
|
||
|
||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||
|
||
let job = foreground_job(pid).expect("expected foreground job");
|
||
assert!(
|
||
job.processes.iter().any(|p| p.name == "sleep"),
|
||
"expected sleep in {job:?}"
|
||
);
|
||
assert_eq!(
|
||
identify_agent_in_job(&job),
|
||
None,
|
||
"sleep should not map to an agent"
|
||
);
|
||
|
||
child.kill().ok();
|
||
child.wait().ok();
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn proc_stat_parsing_handles_spaces_in_comm() {
|
||
// Verify our /proc/pid/stat parser correctly extracts fields
|
||
// even when (comm) could contain spaces.
|
||
let pid = std::process::id();
|
||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap();
|
||
|
||
// Our parsing: find last ')' then split the rest
|
||
let close_paren = stat.rfind(')').expect("should have closing paren");
|
||
let rest = &stat[close_paren + 2..];
|
||
let fields: Vec<&str> = rest.split_whitespace().collect();
|
||
|
||
// We should have enough fields (at least 6 for tpgid)
|
||
assert!(
|
||
fields.len() >= 6,
|
||
"not enough fields in stat: {}",
|
||
fields.len()
|
||
);
|
||
|
||
// Field 0 should be a valid state char (S, R, D, etc.)
|
||
let state = fields[0];
|
||
assert!(
|
||
["S", "R", "D", "Z", "T", "t", "W", "X", "I"].contains(&state),
|
||
"unexpected state: {state}"
|
||
);
|
||
|
||
// Field 5 (tpgid) should parse as i32 (can be -1 if no controlling terminal)
|
||
let tpgid: i32 = fields[5].parse().expect("tpgid should be a number");
|
||
// In CI/test environments without a terminal, tpgid is typically -1
|
||
let _ = tpgid;
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_tail_content_works_with_detection() {
|
||
assert_eq!(detect_pi("Working..."), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn ansi_colored_content_still_detects_working() {
|
||
assert_eq!(detect_pi("\x1b[31mWorking...\x1b[0m"), AgentState::Working);
|
||
}
|
||
|
||
#[test]
|
||
fn visible_claude_prompt_box_is_idle() {
|
||
let screen = "Task complete.\n─────────────\n❯ \n─────────────";
|
||
assert_eq!(detect_claude(screen), AgentState::Idle);
|
||
}
|
||
}
|