//! 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, Kiro, Droid, Amp, Grok, Hermes, } 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::Kiro => "kiro", Agent::Droid => "droid", Agent::Amp => "amp", Agent::Grok => "grok", Agent::Hermes => "hermes", } } pub fn parse_agent_label(agent: &str) -> Option { 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), "kiro" | "kiro-cli" => Some(Agent::Kiro), "droid" => Some(Agent::Droid), "amp" | "amp-local" => Some(Agent::Amp), "grok" | "grok-build" => Some(Agent::Grok), "hermes" | "hermes-agent" => Some(Agent::Hermes), _ => 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 { 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), "copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot), "kimi" => Some(Agent::Kimi), "kiro" | "kiro-cli" => Some(Agent::Kiro), "droid" => Some(Agent::Droid), "amp" | "amp-local" => Some(Agent::Amp), "grok" | "grok-build" => Some(Agent::Grok), "hermes" | "hermes-agent" => Some(Agent::Hermes), _ => None, } } pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Agent, String)> { if let Some(process) = job .processes .iter() .find(|process| process.pid == job.process_group_id) { let candidate = normalized_process_name(process); if let Some(agent) = identify_agent(&candidate) { return Some((agent, candidate)); } } 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, 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::Kiro => detect_kiro(screen_content), Agent::Droid => detect_droid(screen_content), Agent::Amp => detect_amp(screen_content), Agent::Grok => detect_grok(screen_content), Agent::Hermes => detect_hermes(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 } /// Kiro CLI detection. /// /// Kiro exposes reliable working and idle terminal markers. Confirmation /// prompts currently render as normal inline conversation followed by the input /// prompt, so they are intentionally treated as idle instead of guessing. fn detect_kiro(content: &str) -> AgentState { let lower = content.to_lowercase(); if lower.contains("kiro is working") || (lower.contains("esc to cancel") && has_kiro_tool_spinner(content)) { 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 } /// Grok Build detection. /// /// Blocked permission prompts display a whitelist scope selector with choices /// like "Yes, proceed" and "No, reject". Working turns show a braille spinner /// status line such as "⠋ Waiting… 1.8s" plus live controls like /// "Ctrl+c:cancel" and "Ctrl+Enter:interject". fn detect_grok(content: &str) -> AgentState { let lower = content.to_lowercase(); if lower.contains("use ← → to choose permission whitelist scope") || lower.contains("yes, proceed") || lower.contains("no, reject") || lower.contains("ctrl+o:yolo") || lower.contains(":scope") { return AgentState::Blocked; } if has_braille_spinner(content) && (lower.contains("waiting") || lower.contains("run ") || lower.contains("read ") || lower.contains("search ") || lower.contains("list ")) { return AgentState::Working; } if lower.contains("ctrl+c:cancel") && lower.contains("ctrl+enter:interject") { return AgentState::Working; } AgentState::Idle } /// Hermes Agent detection. /// /// Hermes shows a bottom status bar while turns are active and modal approval /// dialogs for dangerous terminal commands. Prefer the modal controls for /// blocked detection, then the live interrupt/status controls for working. fn detect_hermes(content: &str) -> AgentState { let lower = content.to_lowercase(); let has_approval_options = lower.contains("allow once") && lower.contains("allow for this session") && lower.contains("deny"); let has_approval_controls = lower.contains("enter to confirm") || lower.contains("↑/↓ to select") || lower.contains("show full command"); if (lower.contains("dangerous command") || has_approval_options) && has_approval_controls { return AgentState::Blocked; } if lower.contains("msg=interrupt") || lower.contains("ctrl+c 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 } fn has_kiro_tool_spinner(content: &str) -> bool { content.lines().any(|line| { let trimmed = line.trim_start(); let mut chars = trimmed.chars(); let Some(first) = chars.next() else { return false; }; if !matches!(first, '◔' | '◑' | '◕' | '●') { return false; } let rest = chars.as_str().trim_start(); rest.chars().next().is_some_and(char::is_alphabetic) }) } // --------------------------------------------------------------------------- // 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::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 { 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 { 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 { agent_name_from_path_token(cmdline.split_whitespace().next()?) } fn agent_name_from_path_token(token: &str) -> Option { 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("kiro"), Some(Agent::Kiro)); assert_eq!(identify_agent("kiro-cli"), Some(Agent::Kiro)); assert_eq!(identify_agent("copilot"), Some(Agent::GithubCopilot)); assert_eq!(identify_agent("ghcs"), Some(Agent::GithubCopilot)); assert_eq!(identify_agent("grok"), Some(Agent::Grok)); assert_eq!(identify_agent("grok-build"), Some(Agent::Grok)); assert_eq!(identify_agent("hermes"), Some(Agent::Hermes)); assert_eq!(identify_agent("hermes-agent"), Some(Agent::Hermes)); } #[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)); assert_eq!(parse_agent_label("kiro-cli"), Some(Agent::Kiro)); assert_eq!(parse_agent_label("grok-build"), Some(Agent::Grok)); assert_eq!(parse_agent_label("hermes-agent"), Some(Agent::Hermes)); } #[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"); assert_eq!(agent_label(Agent::Kiro), "kiro"); assert_eq!(agent_label(Agent::Grok), "grok"); assert_eq!(agent_label(Agent::Hermes), "hermes"); } #[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_prefers_recognized_process_group_leader() { let job = crate::platform::ForegroundJob { process_group_id: 42, processes: vec![ crate::platform::ForegroundProcess { pid: 42, name: "claude".to_string(), argv0: None, cmdline: Some("claude".to_string()), }, crate::platform::ForegroundProcess { pid: 43, name: "node".to_string(), argv0: None, cmdline: Some("node /tmp/mcp/bin/codex".to_string()), }, ], }; assert_eq!( identify_agent_in_job(&job), Some((Agent::Claude, "claude".to_string())) ); } #[test] fn identify_agent_in_job_falls_back_when_process_group_leader_is_unrecognized() { let job = crate::platform::ForegroundJob { process_group_id: 42, processes: vec![ crate::platform::ForegroundProcess { pid: 42, name: "bash".to_string(), argv0: None, cmdline: Some("bash".to_string()), }, crate::platform::ForegroundProcess { pid: 43, name: "node".to_string(), argv0: None, cmdline: Some("node /tmp/mcp/bin/codex".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); } // ---- Kiro ---- #[test] fn kiro_working_on_status_bar() { let screen = "◕ Shell\n esc to cancel\n● 1 MCP failure — see /mcp\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6% ~\n\n Kiro is working · type to queue a message"; assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Working); } #[test] fn kiro_working_on_tool_spinner_and_cancel_hint() { let screen = "◕ Shell\n esc to cancel\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6%"; assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Working); } #[test] fn kiro_idle_at_prompt() { let screen = "● 1 MCP failure — see /mcp\n──────────────────────────────────────────────────────────────────────────────────────\nKiro · auto · ◔ 6% ~\n\n ask a question or describe a task ↵\n /copy to clipboard"; assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Idle); } #[test] fn kiro_does_not_treat_stale_failure_spinner_as_working() { let screen = "● 1 MCP failure — see /mcp\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6%\n\n ask a question or describe a task ↵"; assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Idle); } #[test] fn kiro_identified_by_process_name() { assert_eq!(identify_agent("kiro"), Some(Agent::Kiro)); assert_eq!(identify_agent("kiro-cli"), Some(Agent::Kiro)); } // ---- 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)); } // ---- Grok ---- #[test] fn grok_blocked_on_permission_prompt() { let screen = "Show recent commit history for analysis\n\ git -C /home/can/Projects/herdr log --oneline --decorate -n 12\n\ Use ← → to choose permission whitelist scope\n\n\ 1 (○) Always allow: git -C\n\ 2 (●) Yes, proceed\n\ 3 (○) No, reject (type to add feedback)\n\n\ 1/3:select │ ←/→:scope │ Ctrl+o:yolo │ Ctrl+c:cancel"; assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked); } #[test] fn grok_blocked_wins_over_spinner() { let screen = "⠹ Run git 30s\nYes, proceed\nNo, reject (type to add feedback)"; assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked); } #[test] fn grok_working_on_waiting_spinner() { let screen = "⠋ Waiting… 1.8s\nCtrl+c:cancel │ Ctrl+Enter:interject"; assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working); } #[test] fn grok_working_on_tool_spinner() { let screen = "⠼ Run git -C /home/can/Projects/herdr log --oneline 1.0s"; assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working); } #[test] fn grok_idle_after_turn_completed() { let screen = "yo\n\nTurn completed in 1.7s.\n\n╭────╮\n│ ❯ │\n╰─ gpt-5.4 ─╯"; assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle); } // ---- Hermes ---- #[test] fn hermes_identified_by_process_name() { assert_eq!(identify_agent("hermes"), Some(Agent::Hermes)); assert_eq!(identify_agent("hermes-agent"), Some(Agent::Hermes)); } #[test] fn hermes_working_on_interrupt_footer() { let screen = " (⌐■_■) computing...\n\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 2m │ ⏱ 3s\n─────────────────────────────────────────────────────────────────────────────────────────\n⚕ ❯ msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel"; assert_eq!( detect_state(Some(Agent::Hermes), screen), AgentState::Working ); } #[test] fn hermes_idle_ignores_stale_initializing_agent_text() { let screen = "● say exactly READY and stop\nInitializing agent...\n\n╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n READY\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 15s │ ⏲ 2s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯"; assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle); } #[test] fn hermes_blocked_on_dangerous_command_prompt() { let screen = "╭────────────────────────────────────────────────────────────╮\n│ ⚠️ Dangerous Command │\n│ mkdir -p /tmp/herdr-hermes-block-test/subdir && touch │\n│ ❯ 1. Allow once │\n│ 2. Allow for this session │\n│ 3. Add to permanent allowlist │\n│ 4. Deny │\n│ 5. Show full command │\n╰────────────────────────────────────────────────────────────╯\n ↑/↓ to select, Enter to confirm\n⚠ ❯"; assert_eq!( detect_state(Some(Agent::Hermes), screen), AgentState::Blocked ); } #[test] fn hermes_idle_at_prompt_after_response() { let screen = "╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n READY\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 15s │ ⏲ 2s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯\n─────────────────────────────────────────────────────────────────────────────────────────"; assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle); } #[test] fn hermes_denied_message_is_idle() { let screen = "╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n Command was blocked/denied by the safety layer. I did not retry.\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.4K/272K │ [█░░░░░░░░░] 6% │ 2m │ ⏲ 11s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯"; assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle); } // ---- 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); } }