diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-23 00:36:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-27 21:37:05 +0300 |
| commit | a57b97285c3308fbdcc167ab3da9ae1dd78767b6 (patch) | |
| tree | 25a0eb4df100ea9b09c7551fa7cc25f8d86027ef /src/detect.rs | |
initial release
terminal workspace manager for AI coding agents.
single rust binary, runs inside your existing terminal.
workspaces, tiled panes, intelligent agent state detection.
https://herdr.dev
Diffstat (limited to 'src/detect.rs')
| -rw-r--r-- | src/detect.rs | 1163 |
1 files changed, 1163 insertions, 0 deletions
diff --git a/src/detect.rs b/src/detect.rs new file mode 100644 index 00000000..235a1603 --- /dev/null +++ b/src/detect.rs @@ -0,0 +1,1163 @@ +//! Agent state detection via screen content pattern matching. +//! +//! Each pane's `vt100::Screen` content 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. + Busy, + /// Agent needs human input — approval, question, error. + Waiting, + /// Plain shell or unrecognized program. + Unknown, +} + +impl AgentState { + /// Priority for rolling up pane states to a workspace indicator. + /// Higher = more urgent. + pub fn priority(self) -> u8 { + match self { + Self::Unknown => 0, + Self::Idle => 1, + Self::Busy => 2, + Self::Waiting => 3, + } + } +} + +/// 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, +} + +/// 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, + } +} + +/// Detect the state of an agent from the visible screen content. +/// 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), + } +} + +/// Roll up multiple pane states into a single workspace state. +/// Returns the highest-priority state among all panes. +pub fn workspace_state(pane_states: &[AgentState]) -> AgentState { + pane_states + .iter() + .max_by_key(|s| s.priority()) + .copied() + .unwrap_or(AgentState::Unknown) +} + +// --------------------------------------------------------------------------- +// Per-agent detectors +// --------------------------------------------------------------------------- + +fn detect_pi(content: &str) -> AgentState { + // pi shows "Working..." when the agent is processing + if content.contains("Working...") { + return AgentState::Busy; + } + 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; + } + + // --- Waiting detection (full content including prompt box) --- + + // "Do you want" or "Would you like" followed by yes/❯ + if has_confirmation_prompt(&lower) { + return AgentState::Waiting; + } + + // Selection prompt: ❯ followed by numbered option + if has_selection_prompt(content) { + return AgentState::Waiting; + } + + // "esc to cancel" indicates permission/confirmation dialog + if lower.contains("esc to cancel") { + return AgentState::Waiting; + } + + // --- Busy 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::Busy; + } + + if has_spinner_activity(above) { + return AgentState::Busy; + } + + AgentState::Idle +} + +fn detect_codex(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting 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::Waiting; + } + if has_confirmation_prompt(&lower) { + return AgentState::Waiting; + } + + // Busy + if has_interrupt_pattern(&lower) { + return AgentState::Busy; + } + + AgentState::Idle +} + +fn detect_gemini(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting — explicit confirmation + if lower.contains("waiting for user confirmation") { + return AgentState::Waiting; + } + + // Waiting — box-drawing confirmation prompts + if content.contains("│ Apply this change") + || content.contains("│ Allow execution") + || content.contains("│ Do you want to proceed") + { + return AgentState::Waiting; + } + if has_confirmation_prompt(&lower) { + return AgentState::Waiting; + } + + // Busy + if lower.contains("esc to cancel") { + return AgentState::Busy; + } + + AgentState::Idle +} + +fn detect_cursor(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting + if lower.contains("(y) (enter)") + || lower.contains("keep (n)") + || lower.contains("skip (esc or n)") + { + return AgentState::Waiting; + } + // "allow ...(y)" or "run ...(y)" patterns + if lower.contains("(y)") && (lower.contains("allow") || lower.contains("run")) { + return AgentState::Waiting; + } + + // Busy + if lower.contains("ctrl+c to stop") { + return AgentState::Busy; + } + if has_cursor_spinner(content) { + return AgentState::Busy; + } + + AgentState::Idle +} + +fn detect_cline(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting + if lower.contains("let cline use this tool") { + return AgentState::Waiting; + } + // [act mode] or [plan mode] followed by "yes" + if (lower.contains("[act mode]") || lower.contains("[plan mode]")) + && lower.contains("yes") + { + return AgentState::Waiting; + } + + // Idle + if lower.contains("cline is ready for your message") { + return AgentState::Idle; + } + + // Cline defaults to busy (unlike most agents that default to idle) + AgentState::Busy +} + +fn detect_opencode(content: &str) -> AgentState { + // Waiting + if content.contains("△ Permission required") { + return AgentState::Waiting; + } + + // Busy + if has_interrupt_pattern(&content.to_lowercase()) { + return AgentState::Busy; + } + + AgentState::Idle +} + +fn detect_github_copilot(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting + if lower.contains("│ do you want") { + return AgentState::Waiting; + } + if lower.contains("confirm with") && lower.contains("enter") { + return AgentState::Waiting; + } + + // Busy + if lower.contains("esc to cancel") { + return AgentState::Busy; + } + + AgentState::Idle +} + +fn detect_kimi(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Waiting + if lower.contains("allow?") + || lower.contains("confirm?") + || lower.contains("approve?") + || lower.contains("proceed?") + || lower.contains("[y/n]") + || lower.contains("(y/n)") + { + return AgentState::Waiting; + } + + // Busy + 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::Busy; + } + + AgentState::Idle +} + +/// Droid detection. +/// +/// Busy: braille spinner line (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) + "Thinking..." + "(Press ESC to stop)" +/// Waiting: 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(); + + // Waiting: 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::Waiting; + } + // Secondary: selection chrome + options together (no EXECUTE needed) + if has_selection_chrome && has_selection_options { + return AgentState::Waiting; + } + + // Busy: 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::Busy; + } + // Fallback: "ESC to stop" alone is still a strong signal (it's UI chrome) + if lower.contains("esc to stop") { + return AgentState::Busy; + } + + AgentState::Idle +} + +/// Amp (Sourcegraph) detection. +/// +/// Screen layout when busy: +/// ```text +/// ✓ Search Map the core runtime architecture... +/// ⋯ Oracle ▼ +/// ≈ Running tools... Esc to cancel +/// ``` +/// +/// "Esc to cancel" is the reliable busy indicator — it only appears +/// while amp is actively running tools or thinking. +fn detect_amp(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + if lower.contains("esc to cancel") { + return AgentState::Busy; + } + + 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 +} + +/// 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 +/// Pattern: one of ✱✲✳... followed by a word ending in "ing" and "…" +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) { + // Next should be a space, then a word ending in "ing", then "…" + let rest: String = chars.collect(); + if rest.starts_with(' ') && rest.contains("ing") && rest.contains('\u{2026}') { + return true; + } + } + } + } + false +} + +/// 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 +} + +/// 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 process name for a given child PID. +/// Delegates to platform-specific implementation. +pub fn foreground_process_name(child_pid: u32) -> Option<String> { + crate::platform::foreground_process_name(child_pid) +} + +// --------------------------------------------------------------------------- +// 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 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)); + } + + // ---- Workspace state rollup ---- + + #[test] + fn workspace_state_waiting_wins() { + let states = [AgentState::Idle, AgentState::Busy, AgentState::Waiting]; + assert_eq!(workspace_state(&states), AgentState::Waiting); + } + + #[test] + fn workspace_state_busy_over_idle() { + let states = [AgentState::Idle, AgentState::Busy, AgentState::Unknown]; + assert_eq!(workspace_state(&states), AgentState::Busy); + } + + #[test] + fn workspace_state_empty() { + assert_eq!(workspace_state(&[]), AgentState::Unknown); + } + + // ---- No agent → Unknown ---- + + #[test] + fn no_agent_returns_unknown() { + assert_eq!(detect_state(None, "anything"), AgentState::Unknown); + } + + // ---- Pi ---- + + #[test] + fn pi_busy_when_working() { + assert_eq!(detect_pi("some output\nWorking..."), AgentState::Busy); + } + + #[test] + fn pi_busy_working_in_middle() { + assert_eq!( + detect_pi("line1\nWorking...\nline3"), + AgentState::Busy + ); + } + + #[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_busy_esc_to_interrupt() { + let screen = "Reading file src/main.rs\nesc to interrupt\n─────────\n❯ \n─────────"; + assert_eq!(detect_claude(screen), AgentState::Busy); + } + + #[test] + fn claude_busy_ctrl_c_to_interrupt() { + let screen = "Editing code\nctrl+c to interrupt\n─────────\n❯ \n─────────"; + assert_eq!(detect_claude(screen), AgentState::Busy); + } + + #[test] + fn claude_busy_spinner() { + let screen = "✽ Tempering…\n─────────\n❯ \n─────────"; + assert_eq!(detect_claude(screen), AgentState::Busy); + } + + #[test] + fn claude_busy_spinner_with_detail() { + let screen = "✳ Simplifying recompute_tangents…\n─────────\n❯ \n─────────"; + assert_eq!(detect_claude(screen), AgentState::Busy); + } + + #[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::Waiting); + } + + #[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::Waiting); + } + + #[test] + fn claude_waiting_selection_prompt() { + let screen = "Choose an option:\n❯ 1. Apply\n 2. Skip\n 3. Cancel"; + assert_eq!(detect_claude(screen), AgentState::Waiting); + } + + #[test] + fn claude_waiting_esc_to_cancel() { + let screen = "Allow bash: rm -rf /tmp/test?\n\nesc to cancel"; + assert_eq!(detect_claude(screen), AgentState::Waiting); + } + + #[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_busy_not_confused_by_old_prompt() { + // The "esc to interrupt" is ABOVE the prompt box — should be busy + let screen = "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────"; + assert_eq!(detect_claude(screen), AgentState::Busy); + } + + // ---- Codex ---- + + #[test] + fn codex_waiting_confirm() { + assert_eq!( + detect_codex("press enter to confirm or esc to cancel"), + AgentState::Waiting + ); + } + + #[test] + fn codex_waiting_allow_command() { + assert_eq!( + detect_codex("allow command?\n[y/n]"), + AgentState::Waiting + ); + } + + #[test] + fn codex_waiting_submit_answer() { + assert_eq!( + detect_codex("Question about approach\n| enter to submit answer"), + AgentState::Waiting + ); + } + + #[test] + fn codex_busy_interrupt() { + assert_eq!( + detect_codex("generating code\nesc to interrupt"), + AgentState::Busy + ); + } + + #[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::Waiting + ); + } + + #[test] + fn gemini_waiting_apply() { + assert_eq!( + detect_gemini("│ Apply this change\n│ Yes │ No"), + AgentState::Waiting + ); + } + + #[test] + fn gemini_waiting_allow_execution() { + assert_eq!( + detect_gemini("│ Allow execution of: rm test.txt"), + AgentState::Waiting + ); + } + + #[test] + fn gemini_busy() { + assert_eq!( + detect_gemini("thinking...\nesc to cancel"), + AgentState::Busy + ); + } + + #[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::Waiting + ); + } + + #[test] + fn cursor_waiting_allow() { + assert_eq!( + detect_cursor("allow file edit (y)"), + AgentState::Waiting + ); + } + + #[test] + fn cursor_busy_spinner() { + assert_eq!( + detect_cursor("⬡ Grepping.."), + AgentState::Busy + ); + } + + #[test] + fn cursor_busy_ctrl_c() { + assert_eq!( + detect_cursor("processing\nctrl+c to stop"), + AgentState::Busy + ); + } + + #[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::Waiting + ); + } + + #[test] + fn cline_waiting_act_mode() { + assert_eq!( + detect_cline("[act mode] execute command?\nyes"), + AgentState::Waiting + ); + } + + #[test] + fn cline_idle_ready() { + assert_eq!( + detect_cline("cline is ready for your message"), + AgentState::Idle + ); + } + + #[test] + fn cline_defaults_to_busy() { + // Cline's default is busy (unlike other agents) + assert_eq!(detect_cline("some random output"), AgentState::Busy); + } + + // ---- OpenCode ---- + + #[test] + fn opencode_waiting_permission() { + assert_eq!( + detect_opencode("△ Permission required"), + AgentState::Waiting + ); + } + + #[test] + fn opencode_busy() { + assert_eq!( + detect_opencode("running tool\nesc to interrupt"), + AgentState::Busy + ); + } + + #[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::Waiting + ); + } + + #[test] + fn copilot_waiting_do_you_want() { + assert_eq!( + detect_github_copilot("│ do you want to apply?"), + AgentState::Waiting + ); + } + + #[test] + fn copilot_busy() { + assert_eq!( + detect_github_copilot("generating\nesc to cancel"), + AgentState::Busy + ); + } + + #[test] + fn copilot_idle() { + assert_eq!(detect_github_copilot("> "), AgentState::Idle); + } + + // ---- Kimi ---- + + #[test] + fn kimi_waiting_approve() { + assert_eq!(detect_kimi("approve?"), AgentState::Waiting); + } + + #[test] + fn kimi_waiting_yn() { + assert_eq!(detect_kimi("continue? [y/n]"), AgentState::Waiting); + } + + #[test] + fn kimi_busy_thinking() { + assert_eq!(detect_kimi("thinking"), AgentState::Busy); + } + + #[test] + fn kimi_busy_generating() { + assert_eq!(detect_kimi("generating code"), AgentState::Busy); + } + + #[test] + fn kimi_idle() { + assert_eq!(detect_kimi("> "), AgentState::Idle); + } + + // ---- Droid ---- + + #[test] + fn droid_busy_thinking_with_spinner() { + let screen = "> how u doin\n\n⠴ Thinking... (Press ESC to stop)\n\nAuto (Off)"; + assert_eq!(detect_droid(screen), AgentState::Busy); + } + + #[test] + fn droid_busy_esc_to_stop_alone() { + // ESC to stop without spinner is still busy (UI chrome) + let screen = "Processing\n(Press ESC to stop)"; + assert_eq!(detect_droid(screen), AgentState::Busy); + } + + #[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::Waiting); + } + + #[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::Waiting); + } + + #[test] + fn droid_not_waiting_on_options_text_alone() { + // "Yes, allow" in normal conversation should NOT trigger waiting + 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_busy_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::Busy); + } + + #[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("✽ Tempering…")); + assert!(has_spinner_activity("✳ Simplifying recompute_tangents…")); + assert!(has_spinner_activity(" ✶ Reading…")); // with leading whitespace + } + + #[test] + fn spinner_activity_not_false_positive() { + assert!(!has_spinner_activity("normal text")); + assert!(!has_spinner_activity("✽ no ellipsis here")); + 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_process_name_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 name = foreground_process_name(pid); + assert_eq!(name.as_deref(), Some("sleep"), "expected 'sleep', got {name:?}"); + + // Clean up + child.kill().ok(); + child.wait().ok(); + } + + #[cfg(target_os = "linux")] + #[test] + fn foreground_process_name_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 name = foreground_process_name(pid); + // The foreground process should now be "sleep", not "sh" + assert_eq!(name.as_deref(), Some("sleep"), "expected 'sleep', got {name:?}"); + + 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; + } + + // ---- VT100 integration ---- + + #[test] + fn vt100_screen_content_works_with_detection() { + let mut parser = vt100::Parser::new(24, 80, 0); + parser.process(b"Working..."); + let content = parser.screen().contents(); + assert_eq!(detect_pi(&content), AgentState::Busy); + } + + #[test] + fn vt100_screen_with_ansi_colors() { + let mut parser = vt100::Parser::new(24, 80, 0); + // Red "Working..." with ANSI codes — contents() strips formatting + parser.process(b"\x1b[31mWorking...\x1b[0m"); + let content = parser.screen().contents(); + assert_eq!(detect_pi(&content), AgentState::Busy); + } + + #[test] + fn vt100_screen_claude_prompt_box() { + let mut parser = vt100::Parser::new(24, 80, 0); + let screen = "Task complete.\n─────────────\n❯ \n─────────────"; + parser.process(screen.as_bytes()); + let content = parser.screen().contents(); + assert_eq!(detect_claude(&content), AgentState::Idle); + } +} |
