#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentState {
Idle,
Busy,
Waiting,
Unknown,
}
impl AgentState {
pub fn priority(self) -> u8 {
match self {
Self::Unknown => 0,
Self::Idle => 1,
Self::Busy => 2,
Self::Waiting => 3,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agent {
Pi,
Claude,
Codex,
Gemini,
Cursor,
Cline,
OpenCode,
GithubCopilot,
Kimi,
Droid,
Amp,
}
pub fn identify_agent(process_name: &str) -> Option<Agent> {
let name = process_name.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),
"github-copilot" | "ghcs" => Some(Agent::GithubCopilot),
"kimi" => Some(Agent::Kimi),
"droid" => Some(Agent::Droid),
"amp" | "amp-local" => Some(Agent::Amp),
_ => None,
}
}
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),
}
}
pub fn workspace_state(pane_states: &[AgentState]) -> AgentState {
pane_states
.iter()
.max_by_key(|s| s.priority())
.copied()
.unwrap_or(AgentState::Unknown)
}
fn detect_pi(content: &str) -> AgentState {
if content.contains("Working...") {
return AgentState::Busy;
}
AgentState::Idle
}
fn detect_claude(content: &str) -> AgentState {
let lower = content.to_lowercase();
if content.contains("⌕ Search…") {
return AgentState::Idle;
}
if lower.contains("ctrl+r to toggle") {
return AgentState::Idle;
}
if has_confirmation_prompt(&lower) {
return AgentState::Waiting;
}
if has_selection_prompt(content) {
return AgentState::Waiting;
}
if lower.contains("esc to cancel") {
return AgentState::Waiting;
}
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();
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;
}
if has_interrupt_pattern(&lower) {
return AgentState::Busy;
}
AgentState::Idle
}
fn detect_gemini(content: &str) -> AgentState {
let lower = content.to_lowercase();
if lower.contains("waiting for user confirmation") {
return AgentState::Waiting;
}
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;
}
if lower.contains("esc to cancel") {
return AgentState::Busy;
}
AgentState::Idle
}
fn detect_cursor(content: &str) -> AgentState {
let lower = content.to_lowercase();
if lower.contains("(y) (enter)")
|| lower.contains("keep (n)")
|| lower.contains("skip (esc or n)")
{
return AgentState::Waiting;
}
if lower.contains("(y)") && (lower.contains("allow") || lower.contains("run")) {
return AgentState::Waiting;
}
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();
if lower.contains("let cline use this tool") {
return AgentState::Waiting;
}
if (lower.contains("[act mode]") || lower.contains("[plan mode]"))
&& lower.contains("yes")
{
return AgentState::Waiting;
}
if lower.contains("cline is ready for your message") {
return AgentState::Idle;
}
AgentState::Busy
}
fn detect_opencode(content: &str) -> AgentState {
if content.contains("△ Permission required") {
return AgentState::Waiting;
}
if has_interrupt_pattern(&content.to_lowercase()) {
return AgentState::Busy;
}
AgentState::Idle
}
fn detect_github_copilot(content: &str) -> AgentState {
let lower = content.to_lowercase();
if lower.contains("│ do you want") {
return AgentState::Waiting;
}
if lower.contains("confirm with") && lower.contains("enter") {
return AgentState::Waiting;
}
if lower.contains("esc to cancel") {
return AgentState::Busy;
}
AgentState::Idle
}
fn detect_kimi(content: &str) -> AgentState {
let lower = content.to_lowercase();
if lower.contains("allow?")
|| lower.contains("confirm?")
|| lower.contains("approve?")
|| lower.contains("proceed?")
|| lower.contains("[y/n]")
|| lower.contains("(y/n)")
{
return AgentState::Waiting;
}
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
}
fn detect_droid(content: &str) -> AgentState {
let lower = content.to_lowercase();
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;
}
if has_selection_chrome && has_selection_options {
return AgentState::Waiting;
}
if has_braille_spinner(content) && lower.contains("esc to stop") {
return AgentState::Busy;
}
if lower.contains("esc to stop") {
return AgentState::Busy;
}
AgentState::Idle
}
fn detect_amp(content: &str) -> AgentState {
let lower = content.to_lowercase();
if lower.contains("esc to cancel") {
return AgentState::Busy;
}
AgentState::Idle
}
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_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
}
fn has_selection_prompt(content: &str) -> bool {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('❯') {
if trimmed.chars().any(|c| c.is_ascii_digit())
&& trimmed.contains('.')
{
return true;
}
}
}
false
}
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"))
}
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("ing") && rest.contains('\u{2026}') {
return true;
}
}
}
}
false
}
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 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 {
let byte_offset: usize = lines[..i].iter().map(|l| l.len() + 1).sum();
return &content[..byte_offset.min(content.len())];
}
}
}
content
}
pub fn foreground_process_name(child_pid: u32) -> Option<String> {
crate::platform::foreground_process_name(child_pid)
}
#[cfg(test)]
mod tests {
use super::*;
#[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));
}
#[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);
}
#[test]
fn no_agent_returns_unknown() {
assert_eq!(detect_state(None, "anything"), AgentState::Unknown);
}
#[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
);
}
#[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() {
let screen = "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────";
assert_eq!(detect_claude(screen), AgentState::Busy);
}
#[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);
}
#[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);
}
#[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);
}
#[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() {
assert_eq!(detect_cline("some random output"), AgentState::Busy);
}
#[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);
}
#[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);
}
#[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);
}
#[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() {
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() {
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));
}
#[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));
}
#[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…")); }
#[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"));
}
#[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");
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");
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:?}");
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");
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");
let mut writer = pair.master.take_writer().expect("no writer");
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);
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() {
let pid = std::process::id();
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap();
let close_paren = stat.rfind(')').expect("should have closing paren");
let rest = &stat[close_paren + 2..];
let fields: Vec<&str> = rest.split_whitespace().collect();
assert!(fields.len() >= 6, "not enough fields in stat: {}", fields.len());
let state = fields[0];
assert!(
["S", "R", "D", "Z", "T", "t", "W", "X", "I"].contains(&state),
"unexpected state: {state}"
);
let tpgid: i32 = fields[5].parse().expect("tpgid should be a number");
let _ = tpgid;
}
#[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);
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);
}
}