Skip to main content

kiosk_core/agent/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4/// Represents the kind of AI coding agent
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6pub enum AgentKind {
7    ClaudeCode,
8    Codex,
9    CursorAgent,
10    OpenCode,
11    Gemini,
12}
13
14impl fmt::Display for AgentKind {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        match self {
17            AgentKind::ClaudeCode => write!(f, "Claude Code"),
18            AgentKind::Codex => write!(f, "Codex"),
19            AgentKind::CursorAgent => write!(f, "Cursor"),
20            AgentKind::OpenCode => write!(f, "OpenCode"),
21            AgentKind::Gemini => write!(f, "Gemini"),
22        }
23    }
24}
25
26/// Represents the current state of an AI coding agent.
27///
28/// Variants are ordered by attention priority (highest first): a Waiting agent
29/// needs user action most urgently; a Running agent is actively working and
30/// should be surfaced over Idle; an Idle agent may need a nudge.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub enum AgentState {
33    /// Agent is actively working (spinner, processing)
34    Running,
35    /// Agent needs user action (permission prompt, input prompt)
36    Waiting,
37    /// Agent is at prompt, not doing anything
38    Idle,
39    /// Terminal content not yet recognised as any known pattern
40    Unknown,
41}
42
43impl AgentState {
44    /// Attention priority: higher means the user should look at this agent first.
45    fn attention_priority(self) -> u8 {
46        match self {
47            AgentState::Waiting => 3,
48            AgentState::Running => 2,
49            AgentState::Idle => 1,
50            AgentState::Unknown => 0,
51        }
52    }
53}
54
55impl fmt::Display for AgentState {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            AgentState::Running => write!(f, "Running"),
59            AgentState::Waiting => write!(f, "Waiting"),
60            AgentState::Idle => write!(f, "Idle"),
61            AgentState::Unknown => write!(f, "Unknown"),
62        }
63    }
64}
65
66/// Combined agent kind + state, attached to branches with detected agents
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68pub struct AgentStatus {
69    pub kind: AgentKind,
70    pub state: AgentState,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74pub enum KindSource {
75    PaneCommand,
76    ChildProcess,
77    PaneTitle,
78    ContentFallbackHost,
79    ContentFallbackWrapper,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83pub enum StateSource {
84    ContentPattern,
85    ActivityRecency,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct DetectionDebug {
90    pub kind_source: KindSource,
91    pub kind_rule: &'static str,
92    pub state_source: StateSource,
93    pub state_rule: &'static str,
94}
95
96/// Result of agent detection, pairing the status with the pane where
97/// the agent was found. This allows callers (e.g. `kiosk status`) to
98/// display content from the correct pane.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct DetectionResult {
101    pub status: AgentStatus,
102    pub pane_id: String,
103    pub debug: DetectionDebug,
104}
105
106pub mod detect;
107
108/// Detect agent status for a tmux session by inspecting its panes.
109/// Returns `None` if no agent is found in any pane. When multiple agents are
110/// present, returns the one with the highest attention priority (Waiting >
111/// Running > Idle) so the user sees the status that most needs their action.
112pub fn detect_for_session(
113    tmux: &(impl crate::tmux::TmuxProvider + ?Sized),
114    session_name: &str,
115) -> Option<DetectionResult> {
116    // Prefer the batched tmux query so we can use pane titles as an
117    // additional kind signal without extra subprocess calls.
118    let all_pane_data = tmux.list_all_panes_with_activity();
119    if let Some(data) = all_pane_data.get(session_name) {
120        return detect_from_pane_data(tmux, data);
121    }
122
123    let panes = tmux.list_panes_detailed(session_name);
124    let activity = tmux.session_activity(session_name).unwrap_or(0);
125
126    let data = crate::tmux::provider::SessionPaneData {
127        panes,
128        pane_titles: std::collections::HashMap::new(),
129        session_activity: activity,
130    };
131
132    detect_from_pane_data(tmux, &data)
133}
134
135/// Detect agent status for multiple sessions using pre-fetched pane data.
136///
137/// This is the batched counterpart to [`detect_for_session`]. Instead of
138/// calling tmux per-session, the caller passes pre-fetched
139/// [`SessionPaneData`] from a single `list_all_panes_with_activity()` call.
140/// Only `capture_pane_content` still requires per-pane tmux calls.
141///
142/// Returns results for every requested session (in the same order), with
143/// `None` for sessions not found in `all_pane_data` or without agents.
144pub fn detect_for_sessions_batched(
145    tmux: &(impl crate::tmux::TmuxProvider + ?Sized),
146    session_names: &[String],
147    all_pane_data: &std::collections::HashMap<
148        String,
149        crate::tmux::provider::SessionPaneData,
150        impl std::hash::BuildHasher,
151    >,
152) -> Vec<(String, Option<DetectionResult>)> {
153    session_names
154        .iter()
155        .map(|session_name| {
156            let status = all_pane_data
157                .get(session_name)
158                .and_then(|data| detect_from_pane_data(tmux, data));
159            (session_name.clone(), status)
160        })
161        .collect()
162}
163
164/// Core detection logic operating on pre-fetched [`SessionPaneData`].
165///
166/// Shared between [`detect_for_session`] (single-session path) and
167/// [`detect_for_sessions_batched`] (batch path).
168fn detect_from_pane_data(
169    tmux: &(impl crate::tmux::TmuxProvider + ?Sized),
170    data: &crate::tmux::provider::SessionPaneData,
171) -> Option<DetectionResult> {
172    let mut best: Option<DetectionResult> = None;
173
174    for pane in &data.panes {
175        let mut kind_from_content_fallback = false;
176        let command = pane.command.trim();
177        let pane_title = data.pane_titles.get(&pane.pane_id).map(String::as_str);
178        let allow_wrapper_fallback = looks_like_version_command(command);
179        let mut kind_debug = detect::detect_agent_kind(command, None)
180            .map(|kind| (kind, KindSource::PaneCommand, "agent.kind.command_pattern"));
181        if kind_debug.is_none() && (may_host_agent(command) || allow_wrapper_fallback) {
182            kind_debug = get_child_process_args(pane.pid)
183                .as_deref()
184                .and_then(|args| detect::detect_agent_kind(command, Some(args)))
185                .map(|kind| (kind, KindSource::ChildProcess, "agent.kind.child_process"));
186        }
187        if kind_debug.is_none() && allow_wrapper_fallback {
188            kind_debug = pane_title
189                .and_then(detect::detect_agent_kind_from_title)
190                .map(|kind| (kind, KindSource::PaneTitle, "agent.kind.pane_title"));
191        }
192        let mut kind = kind_debug.as_ref().map(|(kind, _, _)| *kind);
193        let mut captured_content: Option<String> = None;
194
195        // Fallback for host commands where process tree lookup may miss:
196        // infer kind from agent-specific UI markers in captured content.
197        if kind.is_none() && (may_host_agent(command) || allow_wrapper_fallback) {
198            captured_content = tmux.capture_pane_content(&pane.pane_id, 30);
199            kind_debug = captured_content.as_deref().and_then(|content| {
200                if allow_wrapper_fallback {
201                    detect::detect_agent_kind_from_wrapper_content(content).map(|kind| {
202                        (
203                            kind,
204                            KindSource::ContentFallbackWrapper,
205                            "agent.kind.content_wrapper",
206                        )
207                    })
208                } else {
209                    detect::detect_agent_kind_from_content(content).map(|kind| {
210                        (
211                            kind,
212                            KindSource::ContentFallbackHost,
213                            "agent.kind.content_fallback",
214                        )
215                    })
216                }
217            });
218            kind = kind_debug.as_ref().map(|(kind, _, _)| *kind);
219            kind_from_content_fallback = kind.is_some();
220        }
221
222        if let Some(kind) = kind
223            && let Some(content) =
224                captured_content.or_else(|| tmux.capture_pane_content(&pane.pane_id, 30))
225        {
226            let mut state = detect::detect_state(&content, kind);
227            let mut state_source = StateSource::ContentPattern;
228            let mut state_rule = detect::detect_state_rule(&content, kind, state);
229
230            // When content-based detection returns Unknown (no recognisable
231            // pattern), use the pre-fetched session_activity timestamp as a
232            // supplementary signal — no extra tmux call needed.
233            //
234            // Skip this for Codex: Codex sessions often include non-agent pane
235            // activity (editor/shell), and session-level activity can cause
236            // false Running while Codex itself is idle.
237            let kind_source = kind_debug
238                .as_ref()
239                .map_or(KindSource::PaneCommand, |(_, source, _)| *source);
240
241            if state == AgentState::Unknown
242                && kind != AgentKind::Codex
243                && !content.trim().is_empty()
244                && kind_source != KindSource::PaneTitle
245            {
246                state = infer_state_from_activity_ts(data.session_activity);
247                if state == AgentState::Running {
248                    state_source = StateSource::ActivityRecency;
249                    state_rule = "agent.state.activity_recent";
250                }
251            }
252            // If kind was inferred only from pane content and state is still
253            // Unknown, treat it as no agent. This avoids sticky stale badges
254            // from historical transcript text in shell panes.
255            if state == AgentState::Unknown && kind_from_content_fallback {
256                continue;
257            }
258
259            let status = AgentStatus { kind, state };
260            let (kind_source, kind_rule) = kind_debug.as_ref().map_or(
261                (KindSource::PaneCommand, "agent.kind.unknown"),
262                |(_, source, rule)| (*source, *rule),
263            );
264            let result = DetectionResult {
265                status,
266                pane_id: pane.pane_id.clone(),
267                debug: DetectionDebug {
268                    kind_source,
269                    kind_rule,
270                    state_source,
271                    state_rule,
272                },
273            };
274            if best.as_ref().is_none_or(|b| {
275                status.state.attention_priority() > b.status.state.attention_priority()
276            }) {
277                best = Some(result);
278            }
279        }
280    }
281
282    best
283}
284
285/// Infer Running from a pre-fetched activity timestamp.
286///
287/// Counterpart to [`infer_state_from_activity`] but takes the timestamp
288/// directly instead of calling tmux.
289fn infer_state_from_activity_ts(activity_ts: u64) -> AgentState {
290    let now = std::time::SystemTime::now()
291        .duration_since(std::time::UNIX_EPOCH)
292        .map(|d| d.as_secs())
293        .unwrap_or(0);
294
295    if now.saturating_sub(activity_ts) <= ACTIVITY_RECENCY_SECS {
296        AgentState::Running
297    } else {
298        AgentState::Unknown
299    }
300}
301
302/// Commands that may host an agent as a child process. We walk the process
303/// tree for these to check if an agent binary is running underneath.
304/// Includes shells (where users launch agents) and `node` (which hosts
305/// Node.js-based agents like `OpenCode` and Cursor Agent).
306const AGENT_HOST_COMMANDS: &[&str] = &[
307    "bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh", "csh", "nu", "nushell", "pwsh", "node",
308];
309
310fn may_host_agent(command: &str) -> bool {
311    AGENT_HOST_COMMANDS
312        .iter()
313        .any(|s| command.eq_ignore_ascii_case(s))
314}
315
316/// Some agent wrappers appear in tmux as a bare semantic version string
317/// (for example `2.1.63`), which is not useful for command-based detection.
318/// Treat those as wrapper commands and allow content-based kind fallback.
319fn looks_like_version_command(command: &str) -> bool {
320    let mut parts = command.split('.');
321    let Some(first) = parts.next() else {
322        return false;
323    };
324    if first.is_empty() || !first.chars().all(|c| c.is_ascii_digit()) {
325        return false;
326    }
327    let mut part_count = 1usize;
328    for part in parts {
329        if part.is_empty() || !part.chars().all(|c| c.is_ascii_digit()) {
330            return false;
331        }
332        part_count += 1;
333    }
334    part_count >= 2
335}
336
337/// How recently (in seconds) the session must have had activity for us to
338/// infer Running from the timestamp alone. Keeps it tight to avoid false
339/// positives on sessions where a human just scrolled or resized.
340const ACTIVITY_RECENCY_SECS: u64 = 3;
341
342/// Maximum depth when recursively walking child processes, to prevent
343/// infinite loops in case of unexpected process tree cycles.
344const MAX_CHILD_DEPTH: usize = 8;
345
346/// Get command-line arguments of all descendant processes for a given PID.
347/// Walks the process tree recursively (depth-first) up to [`MAX_CHILD_DEPTH`].
348/// Portable across Linux (incl. WSL) and macOS.
349fn get_child_process_args(pid: u32) -> Option<String> {
350    let mut args = String::new();
351
352    // Try /proc first (Linux, WSL)
353    if get_child_args_procfs(pid, &mut args, 0) {
354        if !args.is_empty() {
355            return Some(args);
356        }
357    } else {
358        // Fallback: use pgrep + ps (works on Linux and macOS)
359        get_child_args_pgrep(pid, &mut args, 0);
360        if !args.is_empty() {
361            return Some(args);
362        }
363    }
364
365    None
366}
367
368/// Recursively collect descendant command lines via `/proc`.
369/// Returns `true` if `/proc` is available (even if no children found).
370fn get_child_args_procfs(pid: u32, args: &mut String, depth: usize) -> bool {
371    if depth >= MAX_CHILD_DEPTH {
372        return true;
373    }
374    let children_path = format!("/proc/{pid}/task/{pid}/children");
375    let Ok(children) = std::fs::read_to_string(&children_path) else {
376        return false; // /proc not available
377    };
378    for child_pid_str in children.split_whitespace() {
379        if let Ok(raw) = std::fs::read(format!("/proc/{child_pid_str}/cmdline")) {
380            let readable = String::from_utf8_lossy(&raw).replace('\0', " ");
381            args.push_str(&readable);
382            args.push('\n');
383        }
384        // Recurse into this child's children
385        if let Ok(child_pid) = child_pid_str.parse::<u32>() {
386            get_child_args_procfs(child_pid, args, depth + 1);
387        }
388    }
389    true
390}
391
392/// Recursively collect descendant command lines via `pgrep` + `ps`.
393fn get_child_args_pgrep(pid: u32, args: &mut String, depth: usize) {
394    if depth >= MAX_CHILD_DEPTH {
395        return;
396    }
397    let Ok(pgrep_output) = std::process::Command::new("pgrep")
398        .args(["-P", &pid.to_string()])
399        .output()
400    else {
401        return;
402    };
403    if !pgrep_output.status.success() {
404        return;
405    }
406
407    let pgrep_str = String::from_utf8_lossy(&pgrep_output.stdout);
408    let child_pids: Vec<&str> = pgrep_str.lines().filter(|s| !s.is_empty()).collect();
409    if child_pids.is_empty() {
410        return;
411    }
412
413    let mut ps_cmd = std::process::Command::new("ps");
414    ps_cmd.args(["-o", "args="]);
415    for cpid in &child_pids {
416        ps_cmd.args(["-p", cpid]);
417    }
418    if let Ok(output) = ps_cmd.output()
419        && output.status.success()
420    {
421        let text = String::from_utf8_lossy(&output.stdout);
422        if !text.trim().is_empty() {
423            args.push_str(&text);
424        }
425    }
426
427    // Recurse into each child
428    for cpid_str in &child_pids {
429        if let Ok(cpid) = cpid_str.parse::<u32>() {
430            get_child_args_pgrep(cpid, args, depth + 1);
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::tmux::mock::MockTmuxProvider;
439    use crate::tmux::provider::{PaneInfo, TmuxProvider};
440
441    fn mock_with_agent(session: &str, command: &str, pane_content: &str) -> MockTmuxProvider {
442        let mut tmux = MockTmuxProvider::default();
443        let pane_id = "%0";
444        tmux.pane_info.insert(
445            session.to_string(),
446            vec![PaneInfo {
447                pane_id: pane_id.to_string(),
448                command: command.to_string(),
449                pid: 99999, // Fake PID — child process lookup will fail gracefully
450            }],
451        );
452        tmux.pane_content
453            .insert(pane_id.to_string(), pane_content.to_string());
454        tmux
455    }
456
457    #[test]
458    fn detect_claude_code_running() {
459        let tmux = mock_with_agent("my-session", "claude", "⠋ Reading file src/main.rs");
460        let status = detect_for_session(&tmux, "my-session").unwrap().status;
461        assert_eq!(status.kind, AgentKind::ClaudeCode);
462        assert_eq!(status.state, AgentState::Running);
463    }
464
465    #[test]
466    fn detect_claude_code_waiting() {
467        let tmux = mock_with_agent(
468            "my-session",
469            "claude",
470            "Allow write to src/main.rs?\n  Yes, allow\n  No, deny",
471        );
472        let status = detect_for_session(&tmux, "my-session").unwrap().status;
473        assert_eq!(status.kind, AgentKind::ClaudeCode);
474        assert_eq!(status.state, AgentState::Waiting);
475    }
476
477    #[test]
478    fn detect_claude_code_idle() {
479        let tmux = mock_with_agent("my-session", "claude", "❯ \n? for shortcuts");
480        let status = detect_for_session(&tmux, "my-session").unwrap().status;
481        assert_eq!(status.kind, AgentKind::ClaudeCode);
482        assert_eq!(status.state, AgentState::Idle);
483    }
484
485    #[test]
486    fn detect_codex_running() {
487        let tmux = mock_with_agent(
488            "codex-session",
489            "codex",
490            "⠋ Searching codebase\nesc to interrupt",
491        );
492        let status = detect_for_session(&tmux, "codex-session").unwrap().status;
493        assert_eq!(status.kind, AgentKind::Codex);
494        assert_eq!(status.state, AgentState::Running);
495    }
496
497    #[test]
498    fn detect_codex_waiting() {
499        let tmux = mock_with_agent(
500            "codex-session",
501            "codex",
502            "Would you like to run the following command?\n$ touch test.txt\n› 1. Yes, proceed (y)\n  2. Yes, and don't ask again (p)\n  3. No (esc)\n\n  Press enter to confirm or esc to cancel",
503        );
504        let status = detect_for_session(&tmux, "codex-session").unwrap().status;
505        assert_eq!(status.kind, AgentKind::Codex);
506        assert_eq!(status.state, AgentState::Waiting);
507    }
508
509    #[test]
510    fn detect_cursor_agent_running() {
511        // Can't mock child process args, so test state detection directly
512        let state = detect::detect_state(
513            "⠋ Editing file src/main.rs\nesc to interrupt",
514            AgentKind::CursorAgent,
515        );
516        assert_eq!(state, AgentState::Running);
517    }
518
519    #[test]
520    fn detect_cursor_agent_waiting() {
521        let state = detect::detect_state(
522            "Do you trust the contents of this directory?\n\n▶ [a] Trust this workspace\n  [w] Trust without MCP\n  [q] Quit\n\nUse arrow keys to navigate, Enter to select",
523            AgentKind::CursorAgent,
524        );
525        assert_eq!(state, AgentState::Waiting);
526    }
527
528    #[test]
529    fn no_agent_in_regular_shell() {
530        let tmux = mock_with_agent("shell-session", "bash", "$ ls -la\ntotal 42");
531        assert!(detect_for_session(&tmux, "shell-session").is_none());
532    }
533
534    #[test]
535    fn stale_claude_shell_transcript_not_detected() {
536        let tmux = mock_with_agent(
537            "shell-session",
538            "zsh",
539            "⠋ Reading file src/main.rs\nesc to interrupt\n❯ \n? for shortcuts",
540        );
541        assert!(detect_for_session(&tmux, "shell-session").is_none());
542    }
543
544    #[test]
545    fn stale_cursor_shell_transcript_not_detected() {
546        let tmux = mock_with_agent(
547            "shell-session",
548            "zsh",
549            "/ commands\nDo you trust the contents of this directory?\nEnter to select",
550        );
551        assert!(detect_for_session(&tmux, "shell-session").is_none());
552    }
553
554    #[test]
555    fn stale_opencode_shell_transcript_not_detected() {
556        let tmux = mock_with_agent(
557            "shell-session",
558            "zsh",
559            "⬝■■■■■■⬝  esc interrupt  ctrl+t variants  tab agents  ctrl+p commands",
560        );
561        assert!(detect_for_session(&tmux, "shell-session").is_none());
562    }
563
564    #[test]
565    fn stale_gemini_shell_transcript_not_detected() {
566        let tmux = mock_with_agent(
567            "shell-session",
568            "zsh",
569            "Action required\nallow execution\n? for shortcuts",
570        );
571        assert!(detect_for_session(&tmux, "shell-session").is_none());
572    }
573
574    #[test]
575    fn no_panes_returns_none() {
576        let tmux = MockTmuxProvider::default();
577        assert!(detect_for_session(&tmux, "nonexistent").is_none());
578    }
579
580    #[test]
581    fn agent_found_in_second_pane() {
582        let mut tmux = MockTmuxProvider::default();
583        let session = "multi-pane";
584        tmux.pane_info.insert(
585            session.to_string(),
586            vec![
587                PaneInfo {
588                    pane_id: "%0".to_string(),
589                    command: "bash".to_string(),
590                    pid: 11111,
591                },
592                PaneInfo {
593                    pane_id: "%1".to_string(),
594                    command: "claude".to_string(),
595                    pid: 22222,
596                },
597            ],
598        );
599        tmux.pane_content
600            .insert("%0".to_string(), "$ vim file.txt".to_string());
601        tmux.pane_content
602            .insert("%1".to_string(), "Esc to interrupt".to_string());
603
604        let status = detect_for_session(&tmux, session).unwrap().status;
605        assert_eq!(status.kind, AgentKind::ClaudeCode);
606        assert_eq!(status.state, AgentState::Running);
607    }
608
609    #[test]
610    fn agent_with_ansi_codes_in_output() {
611        let tmux = mock_with_agent("ansi-session", "claude", "\x1B[32m⠹ Running tool\x1B[0m");
612        let status = detect_for_session(&tmux, "ansi-session").unwrap().status;
613        assert_eq!(status.state, AgentState::Running);
614    }
615
616    #[test]
617    fn pane_has_agent_command_with_empty_content() {
618        // capture_pane_content returns Some("") — agent detected but state is Unknown
619        let mut tmux = MockTmuxProvider::default();
620        tmux.pane_info.insert(
621            "empty-content".to_string(),
622            vec![PaneInfo {
623                pane_id: "%0".to_string(),
624                command: "claude".to_string(),
625                pid: 44444,
626            }],
627        );
628        tmux.pane_content.insert("%0".to_string(), String::new());
629        let status = detect_for_session(&tmux, "empty-content").unwrap().status;
630        assert_eq!(status.kind, AgentKind::ClaudeCode);
631        assert_eq!(status.state, AgentState::Unknown);
632    }
633
634    #[test]
635    fn pane_has_agent_command_but_no_content() {
636        let mut tmux = MockTmuxProvider::default();
637        tmux.pane_info.insert(
638            "empty-pane".to_string(),
639            vec![PaneInfo {
640                pane_id: "%0".to_string(),
641                command: "claude".to_string(),
642                pid: 33333,
643            }],
644        );
645        // No pane_content entry → capture_pane_content returns None
646        assert!(detect_for_session(&tmux, "empty-pane").is_none());
647    }
648
649    /// Helper: build a mock with multiple agent panes in the same session.
650    fn mock_multi_agent(session: &str, agents: &[(&str, &str)]) -> MockTmuxProvider {
651        let mut tmux = MockTmuxProvider::default();
652        let panes: Vec<PaneInfo> = agents
653            .iter()
654            .enumerate()
655            .map(|(i, (command, _))| PaneInfo {
656                pane_id: format!("%{i}"),
657                command: command.to_string(),
658                pid: 90000 + u32::try_from(i).expect("test has fewer than u32::MAX agents"),
659            })
660            .collect();
661        tmux.pane_info.insert(session.to_string(), panes);
662        for (i, (_, content)) in agents.iter().enumerate() {
663            tmux.pane_content
664                .insert(format!("%{i}"), content.to_string());
665        }
666        tmux
667    }
668
669    #[test]
670    fn multi_agent_waiting_beats_running() {
671        let tmux = mock_multi_agent(
672            "multi",
673            &[
674                ("claude", "⠋ Reading file src/main.rs"),
675                ("claude", "Allow write?\n  Yes, allow\n  No, deny"),
676            ],
677        );
678        let status = detect_for_session(&tmux, "multi").unwrap().status;
679        assert_eq!(status.state, AgentState::Waiting);
680    }
681
682    #[test]
683    fn multi_agent_waiting_beats_idle() {
684        let tmux = mock_multi_agent(
685            "multi",
686            &[
687                ("claude", "❯ \n? for shortcuts"),
688                ("claude", "Allow write?\n  Yes, allow\n  No, deny"),
689            ],
690        );
691        let status = detect_for_session(&tmux, "multi").unwrap().status;
692        assert_eq!(status.state, AgentState::Waiting);
693    }
694
695    #[test]
696    fn multi_agent_running_beats_idle() {
697        let tmux = mock_multi_agent(
698            "multi",
699            &[
700                ("claude", "⠋ Reading file src/main.rs"),
701                ("claude", "❯ \n? for shortcuts"),
702            ],
703        );
704        let status = detect_for_session(&tmux, "multi").unwrap().status;
705        assert_eq!(status.state, AgentState::Running);
706    }
707
708    #[test]
709    fn multi_agent_across_windows() {
710        let mut tmux = MockTmuxProvider::default();
711        let session = "multi-win";
712        tmux.pane_info.insert(
713            session.to_string(),
714            vec![
715                PaneInfo {
716                    pane_id: "%10".to_string(),
717                    command: "claude".to_string(),
718                    pid: 80001,
719                },
720                PaneInfo {
721                    pane_id: "%11".to_string(),
722                    command: "claude".to_string(),
723                    pid: 80002,
724                },
725            ],
726        );
727        tmux.pane_content
728            .insert("%10".to_string(), "⠋ Reading file".to_string());
729        tmux.pane_content
730            .insert("%11".to_string(), "Allow write?\n  Yes, allow".to_string());
731
732        let status = detect_for_session(&tmux, session).unwrap().status;
733        assert_eq!(status.state, AgentState::Waiting);
734    }
735
736    #[test]
737    fn may_host_agent_matches_common_shells() {
738        assert!(super::may_host_agent("bash"));
739        assert!(super::may_host_agent("zsh"));
740        assert!(super::may_host_agent("fish"));
741        assert!(super::may_host_agent("sh"));
742        assert!(super::may_host_agent("dash"));
743        assert!(super::may_host_agent("nu"));
744        assert!(super::may_host_agent("nushell"));
745    }
746
747    #[test]
748    fn may_host_agent_rejects_non_shells() {
749        assert!(!super::may_host_agent("vim"));
750        assert!(!super::may_host_agent("hx"));
751
752        assert!(!super::may_host_agent("python3"));
753        assert!(!super::may_host_agent("cargo"));
754        assert!(!super::may_host_agent("claude"));
755        assert!(!super::may_host_agent("codex"));
756    }
757
758    #[test]
759    fn may_host_agent_case_insensitive() {
760        assert!(super::may_host_agent("Bash"));
761        assert!(super::may_host_agent("ZSH"));
762        assert!(super::may_host_agent("Fish"));
763    }
764
765    #[test]
766    fn looks_like_version_command_matches_semver_like_strings() {
767        assert!(super::looks_like_version_command("2.1.63"));
768        assert!(super::looks_like_version_command("0.106.0"));
769        assert!(!super::looks_like_version_command("node"));
770        assert!(!super::looks_like_version_command("v2.1.63"));
771        assert!(!super::looks_like_version_command("2.1.beta"));
772    }
773
774    #[test]
775    fn attention_priority_ordering() {
776        // Waiting > Running > Idle > Unknown
777        assert!(
778            AgentState::Waiting.attention_priority() > AgentState::Running.attention_priority()
779        );
780        assert!(AgentState::Running.attention_priority() > AgentState::Idle.attention_priority());
781        assert!(AgentState::Idle.attention_priority() > AgentState::Unknown.attention_priority());
782    }
783
784    #[test]
785    fn multi_agent_running_beats_unknown() {
786        // Running should now beat Unknown (they were equal before)
787        let tmux = mock_multi_agent(
788            "multi",
789            &[
790                ("claude", ""),                           // Unknown (empty content)
791                ("claude", "⠋ Reading file src/main.rs"), // Running
792            ],
793        );
794        let status = detect_for_session(&tmux, "multi").unwrap().status;
795        assert_eq!(status.state, AgentState::Running);
796    }
797
798    #[test]
799    fn child_process_skipped_for_non_shell() {
800        // When pane command is "hx" (not a shell), child process walking
801        // should be skipped entirely — no agent should be detected even if
802        // a child process would match. We test this indirectly: "hx" with
803        // no agent content should return None.
804        let tmux = mock_with_agent("editor-session", "hx", "normal mode");
805        assert!(detect_for_session(&tmux, "editor-session").is_none());
806    }
807
808    #[test]
809    fn child_process_checked_for_shell() {
810        // When pane command is a shell like "bash", detection should still
811        // fall through to child process checking. Since we can't mock /proc,
812        // verify that a shell with no agent content and no children returns None.
813        let tmux = mock_with_agent("shell-session", "bash", "$ ls -la");
814        assert!(detect_for_session(&tmux, "shell-session").is_none());
815    }
816
817    #[test]
818    fn detect_opencode_running() {
819        let _tmux = mock_with_agent(
820            "oc-session",
821            "node",
822            "⬝■■■■■■⬝  esc interrupt  ctrl+t variants  tab agents  ctrl+p commands",
823        );
824        // node pane won't match directly — needs child process.
825        // Since we can't mock /proc, test state detection directly:
826        let state = detect::detect_state(
827            "⬝■■■■■■⬝  esc interrupt  ctrl+t variants  tab agents  ctrl+p commands",
828            AgentKind::OpenCode,
829        );
830        assert_eq!(state, AgentState::Running);
831    }
832
833    #[test]
834    fn detect_opencode_idle() {
835        let state = detect::detect_state(
836            "  ┃  Build  GPT-5.3 Codex OpenAI\n  ╹▀▀▀\n                ctrl+t variants  tab agents  ctrl+p commands",
837            AgentKind::OpenCode,
838        );
839        assert_eq!(state, AgentState::Idle);
840    }
841
842    #[test]
843    fn detect_opencode_via_command_name() {
844        let tmux = mock_with_agent(
845            "oc-session",
846            "opencode",
847            "  ctrl+t variants  tab agents  ctrl+p commands",
848        );
849        let status = detect_for_session(&tmux, "oc-session").unwrap().status;
850        assert_eq!(status.kind, AgentKind::OpenCode);
851        assert_eq!(status.state, AgentState::Idle);
852    }
853
854    #[test]
855    fn may_host_agent_includes_node() {
856        assert!(super::may_host_agent("node"));
857        assert!(super::may_host_agent("Node"));
858    }
859
860    #[test]
861    fn detect_codex_kind_from_content_when_process_lookup_misses() {
862        let mut tmux = MockTmuxProvider::default();
863        let session = "content-fallback";
864        tmux.pane_info.insert(
865            session.to_string(),
866            vec![PaneInfo {
867                pane_id: "%0".to_string(),
868                command: "node".to_string(),
869                pid: 99999, // No child args in tests
870            }],
871        );
872        tmux.pane_content.insert(
873            "%0".to_string(),
874            "› \n\
875              gpt-5.3-codex high · 100% left · ~/Development/kiosk"
876                .to_string(),
877        );
878
879        let status = detect_for_session(&tmux, session).unwrap().status;
880        assert_eq!(status.kind, AgentKind::Codex);
881        assert_eq!(status.state, AgentState::Idle);
882    }
883
884    #[test]
885    fn detect_claude_kind_from_wrapper_command_and_content() {
886        let mut tmux = MockTmuxProvider::default();
887        let session = "wrapper-claude";
888        tmux.pane_info.insert(
889            session.to_string(),
890            vec![PaneInfo {
891                pane_id: "%0".to_string(),
892                command: "2.1.63".to_string(),
893                pid: 99999, // No child args in tests
894            }],
895        );
896        tmux.pane_content.insert(
897            "%0".to_string(),
898            "▐▛███▜▌   Claude Code v2.1.63\n\
899             ▝▜█████▛▘  Opus 4.6 · Claude Max\n\
900             Do you want to proceed?\n\
901             ❯ 1. Yes"
902                .to_string(),
903        );
904
905        let status = detect_for_session(&tmux, session).unwrap().status;
906        assert_eq!(status.kind, AgentKind::ClaudeCode);
907        assert_eq!(status.state, AgentState::Waiting);
908    }
909
910    #[test]
911    fn detect_claude_kind_from_wrapper_command_and_pane_title() {
912        let mut tmux = MockTmuxProvider::default();
913        let session = "wrapper-title-claude";
914        tmux.pane_info.insert(
915            session.to_string(),
916            vec![PaneInfo {
917                pane_id: "%0".to_string(),
918                command: "2.1.63".to_string(),
919                pid: 99999, // No child args in tests
920            }],
921        );
922        tmux.pane_titles
923            .insert("%0".to_string(), "✳ Claude Code".to_string());
924        tmux.pane_content.insert(
925            "%0".to_string(),
926            "Bash command\nDo you want to proceed?\n❯ 1. Yes".to_string(),
927        );
928
929        let status = detect_for_session(&tmux, session).unwrap().status;
930        assert_eq!(status.kind, AgentKind::ClaudeCode);
931        assert_eq!(status.state, AgentState::Waiting);
932    }
933
934    #[test]
935    fn pane_title_not_used_for_non_host_command() {
936        let mut tmux = MockTmuxProvider::default();
937        let session = "title-non-host";
938        tmux.pane_info.insert(
939            session.to_string(),
940            vec![PaneInfo {
941                pane_id: "%0".to_string(),
942                command: "vim".to_string(),
943                pid: 99999,
944            }],
945        );
946        tmux.pane_titles
947            .insert("%0".to_string(), "OpenAI Codex".to_string());
948        tmux.pane_content
949            .insert("%0".to_string(), "regular editor text".to_string());
950
951        assert!(
952            detect_for_session(&tmux, session).is_none(),
953            "pane title fallback should be restricted to host/wrapper commands"
954        );
955    }
956
957    #[test]
958    fn pane_title_not_used_for_host_shell_command() {
959        let mut tmux = MockTmuxProvider::default();
960        let session = "title-host-shell";
961        tmux.pane_info.insert(
962            session.to_string(),
963            vec![PaneInfo {
964                pane_id: "%0".to_string(),
965                command: "zsh".to_string(),
966                pid: 99999,
967            }],
968        );
969        tmux.pane_titles
970            .insert("%0".to_string(), "✳ Claude Code".to_string());
971        tmux.pane_content.insert(
972            "%0".to_string(),
973            "stale transcript text\n0 | host $".to_string(),
974        );
975
976        assert!(
977            detect_for_session(&tmux, session).is_none(),
978            "shell panes should not infer agent kind from title alone"
979        );
980    }
981
982    #[test]
983    fn content_fallback_unknown_is_ignored() {
984        let mut tmux = MockTmuxProvider::default();
985        let session = "content-fallback-unknown";
986        tmux.pane_info.insert(
987            session.to_string(),
988            vec![PaneInfo {
989                pane_id: "%0".to_string(),
990                command: "zsh".to_string(),
991                pid: 99999, // No child args in tests
992            }],
993        );
994        tmux.pane_content.insert(
995            "%0".to_string(),
996            "╭────────────────────────────────╮\n\
997             │ >_ OpenAI Codex (v0.106.0) │\n\
998             ╰────────────────────────────────╯"
999                .to_string(),
1000        );
1001
1002        assert!(
1003            detect_for_session(&tmux, session).is_none(),
1004            "Content-only fallback with Unknown state should not report an agent"
1005        );
1006    }
1007
1008    #[test]
1009    fn content_fallback_running_beats_idle_across_panes() {
1010        let mut tmux = MockTmuxProvider::default();
1011        let session = "content-fallback-priority";
1012        tmux.pane_info.insert(
1013            session.to_string(),
1014            vec![
1015                PaneInfo {
1016                    pane_id: "%0".to_string(),
1017                    command: "node".to_string(),
1018                    pid: 90001,
1019                },
1020                PaneInfo {
1021                    pane_id: "%1".to_string(),
1022                    command: "node".to_string(),
1023                    pid: 90002,
1024                },
1025            ],
1026        );
1027        tmux.pane_content.insert(
1028            "%0".to_string(),
1029            "› Write tests for @filename\n\
1030              gpt-5.3-codex high · 100% left · ~/Development/kiosk"
1031                .to_string(),
1032        );
1033        tmux.pane_content.insert(
1034            "%1".to_string(),
1035            "› Please can you sleep in bash for 60s\n\
1036              • Implementing periodic commentary during sleep (46s • esc to interrupt) · 1 background terminal r…\n\
1037              › Improve documentation in @filename\n\
1038              gpt-5.3-codex high · 100% left · ~/Development/dotfiles"
1039                .to_string(),
1040        );
1041
1042        let status = detect_for_session(&tmux, session).unwrap().status;
1043        assert_eq!(status.kind, AgentKind::Codex);
1044        assert_eq!(status.state, AgentState::Running);
1045    }
1046    #[test]
1047    fn unknown_upgrades_to_running_with_recent_activity() {
1048        // When content detection returns Unknown (no recognisable pattern)
1049        // but the session has recent activity, infer Running.
1050        let now = std::time::SystemTime::now()
1051            .duration_since(std::time::UNIX_EPOCH)
1052            .unwrap()
1053            .as_secs();
1054
1055        let mut tmux = MockTmuxProvider::default();
1056        let session = "active-session";
1057        tmux.pane_info.insert(
1058            session.to_string(),
1059            vec![PaneInfo {
1060                pane_id: "%0".to_string(),
1061                command: "claude".to_string(),
1062                pid: 99999,
1063            }],
1064        );
1065        // Content that would normally return Unknown (no patterns match)
1066        tmux.pane_content.insert(
1067            "%0".to_string(),
1068            "Some random output with no indicators".to_string(),
1069        );
1070        // Activity timestamp is NOW (within recency window)
1071        tmux.session_activity_ts.insert(session.to_string(), now);
1072
1073        let status = detect_for_session(&tmux, session).unwrap().status;
1074        assert_eq!(status.kind, AgentKind::ClaudeCode);
1075        assert_eq!(
1076            status.state,
1077            AgentState::Running,
1078            "Unknown + recent activity should infer Running"
1079        );
1080    }
1081
1082    #[test]
1083    fn title_inferred_unknown_not_upgraded_by_activity() {
1084        let now = std::time::SystemTime::now()
1085            .duration_since(std::time::UNIX_EPOCH)
1086            .unwrap()
1087            .as_secs();
1088
1089        let mut tmux = MockTmuxProvider::default();
1090        let session = "title-unknown-active";
1091        tmux.pane_info.insert(
1092            session.to_string(),
1093            vec![PaneInfo {
1094                pane_id: "%0".to_string(),
1095                command: "2.1.63".to_string(),
1096                pid: 99999,
1097            }],
1098        );
1099        tmux.pane_titles
1100            .insert("%0".to_string(), "✳ Claude Code".to_string());
1101        tmux.pane_content.insert(
1102            "%0".to_string(),
1103            "plain shell text without markers".to_string(),
1104        );
1105        tmux.session_activity_ts.insert(session.to_string(), now);
1106
1107        let status = detect_for_session(&tmux, session).unwrap().status;
1108        assert_eq!(status.kind, AgentKind::ClaudeCode);
1109        assert_eq!(
1110            status.state,
1111            AgentState::Unknown,
1112            "title-inferred unknown should not be upgraded by activity"
1113        );
1114    }
1115
1116    #[test]
1117    fn unknown_stays_unknown_with_stale_activity() {
1118        // When content detection returns Unknown and the session has NO
1119        // recent activity, keep it as Unknown.
1120        let stale_ts = std::time::SystemTime::now()
1121            .duration_since(std::time::UNIX_EPOCH)
1122            .unwrap()
1123            .as_secs()
1124            - 60; // 60 seconds ago
1125
1126        let mut tmux = MockTmuxProvider::default();
1127        let session = "stale-session";
1128        tmux.pane_info.insert(
1129            session.to_string(),
1130            vec![PaneInfo {
1131                pane_id: "%0".to_string(),
1132                command: "claude".to_string(),
1133                pid: 99999,
1134            }],
1135        );
1136        tmux.pane_content.insert(
1137            "%0".to_string(),
1138            "Some random output with no indicators".to_string(),
1139        );
1140        tmux.session_activity_ts
1141            .insert(session.to_string(), stale_ts);
1142
1143        let status = detect_for_session(&tmux, session).unwrap().status;
1144        assert_eq!(
1145            status.state,
1146            AgentState::Unknown,
1147            "Unknown + stale activity should stay Unknown"
1148        );
1149    }
1150
1151    #[test]
1152    fn codex_unknown_not_upgraded_by_recent_session_activity() {
1153        // Codex unknown should remain Unknown even with recent session-level
1154        // activity, to avoid false Running caused by non-agent panes.
1155        let now = std::time::SystemTime::now()
1156            .duration_since(std::time::UNIX_EPOCH)
1157            .unwrap()
1158            .as_secs();
1159
1160        let mut tmux = MockTmuxProvider::default();
1161        let session = "codex-unknown-active";
1162        tmux.pane_info.insert(
1163            session.to_string(),
1164            vec![PaneInfo {
1165                pane_id: "%0".to_string(),
1166                command: "codex".to_string(),
1167                pid: 99999,
1168            }],
1169        );
1170        tmux.pane_content.insert(
1171            "%0".to_string(),
1172            "› Review main.py and find all the bugs\n  100% context left".to_string(),
1173        );
1174        tmux.session_activity_ts.insert(session.to_string(), now);
1175
1176        let status = detect_for_session(&tmux, session).unwrap().status;
1177        assert_eq!(status.kind, AgentKind::Codex);
1178        assert_eq!(
1179            status.state,
1180            AgentState::Unknown,
1181            "Codex unknown should not be upgraded by session activity"
1182        );
1183    }
1184
1185    #[test]
1186    fn activity_does_not_override_idle() {
1187        // When content detection returns Idle, recent activity should NOT
1188        // override it (the idle footer is a stronger signal).
1189        let now = std::time::SystemTime::now()
1190            .duration_since(std::time::UNIX_EPOCH)
1191            .unwrap()
1192            .as_secs();
1193
1194        let mut tmux = MockTmuxProvider::default();
1195        let session = "idle-session";
1196        tmux.pane_info.insert(
1197            session.to_string(),
1198            vec![PaneInfo {
1199                pane_id: "%0".to_string(),
1200                command: "claude".to_string(),
1201                pid: 99999,
1202            }],
1203        );
1204        tmux.pane_content
1205            .insert("%0".to_string(), "❯ \n? for shortcuts".to_string());
1206        tmux.session_activity_ts.insert(session.to_string(), now);
1207
1208        let status = detect_for_session(&tmux, session).unwrap().status;
1209        assert_eq!(
1210            status.state,
1211            AgentState::Idle,
1212            "Idle state should not be overridden by recent activity"
1213        );
1214    }
1215    // -- Batched detection ---------------------------------------------------
1216
1217    #[test]
1218    fn batched_matches_single_session_results() {
1219        // Verify that batch detection produces the same results as
1220        // calling detect_for_session individually.
1221        let mut tmux = MockTmuxProvider::default();
1222        let sessions = ["claude-session", "codex-session", "empty-session"];
1223
1224        tmux.pane_info.insert(
1225            "claude-session".to_string(),
1226            vec![PaneInfo {
1227                pane_id: "%0".to_string(),
1228                command: "claude".to_string(),
1229                pid: 10001,
1230            }],
1231        );
1232        tmux.pane_content
1233            .insert("%0".to_string(), "⠋ Reading file".to_string());
1234
1235        tmux.pane_info.insert(
1236            "codex-session".to_string(),
1237            vec![PaneInfo {
1238                pane_id: "%1".to_string(),
1239                command: "codex".to_string(),
1240                pid: 10002,
1241            }],
1242        );
1243        tmux.pane_content
1244            .insert("%1".to_string(), "? for shortcuts".to_string());
1245
1246        // empty-session has no pane_info → not in all_pane_data
1247
1248        let session_names: Vec<String> = sessions.iter().map(ToString::to_string).collect();
1249
1250        // Individual detection
1251        let individual: Vec<Option<AgentStatus>> = session_names
1252            .iter()
1253            .map(|s| detect_for_session(&tmux, s).map(|r| r.status))
1254            .collect();
1255
1256        // Batched detection
1257        let all_pane_data = tmux.list_all_panes_with_activity();
1258        let batched = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1259
1260        // Results should match
1261        for (i, session) in session_names.iter().enumerate() {
1262            let batch_status = batched
1263                .iter()
1264                .find(|(s, _)| s == session)
1265                .map(|(_, r)| r.as_ref().map(|d| d.status));
1266            assert_eq!(
1267                batch_status,
1268                Some(individual[i]),
1269                "Mismatch for session {session}"
1270            );
1271        }
1272    }
1273
1274    #[test]
1275    fn batched_returns_none_for_unknown_sessions() {
1276        let tmux = MockTmuxProvider::default();
1277        let session_names = vec!["nonexistent".to_string()];
1278        let all_pane_data = tmux.list_all_panes_with_activity();
1279
1280        let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1281        assert_eq!(results.len(), 1);
1282        assert_eq!(results[0].0, "nonexistent");
1283        assert!(results[0].1.is_none());
1284    }
1285
1286    #[test]
1287    fn batched_preserves_session_order() {
1288        let mut tmux = MockTmuxProvider::default();
1289        for name in &["z-session", "a-session", "m-session"] {
1290            tmux.pane_info.insert(
1291                name.to_string(),
1292                vec![PaneInfo {
1293                    pane_id: format!("%{}", name.chars().next().unwrap()),
1294                    command: "claude".to_string(),
1295                    pid: 10000,
1296                }],
1297            );
1298            tmux.pane_content.insert(
1299                format!("%{}", name.chars().next().unwrap()),
1300                "❯ \n? for shortcuts".to_string(),
1301            );
1302        }
1303
1304        let session_names: Vec<String> = ["z-session", "a-session", "m-session"]
1305            .iter()
1306            .map(ToString::to_string)
1307            .collect();
1308        let all_pane_data = tmux.list_all_panes_with_activity();
1309        let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1310
1311        assert_eq!(results[0].0, "z-session");
1312        assert_eq!(results[1].0, "a-session");
1313        assert_eq!(results[2].0, "m-session");
1314    }
1315
1316    #[test]
1317    fn batched_activity_inference_uses_prefetched_timestamp() {
1318        // Verify that batched detection uses the pre-fetched session_activity
1319        // timestamp (from list_all_panes_with_activity) instead of making an
1320        // extra tmux call.
1321        let now = std::time::SystemTime::now()
1322            .duration_since(std::time::UNIX_EPOCH)
1323            .unwrap()
1324            .as_secs();
1325
1326        let mut tmux = MockTmuxProvider::default();
1327        let session = "active-session";
1328        tmux.pane_info.insert(
1329            session.to_string(),
1330            vec![PaneInfo {
1331                pane_id: "%0".to_string(),
1332                command: "claude".to_string(),
1333                pid: 99999,
1334            }],
1335        );
1336        // Content with no recognisable patterns → Unknown
1337        tmux.pane_content.insert(
1338            "%0".to_string(),
1339            "Some output with no indicators".to_string(),
1340        );
1341        // Recent activity
1342        tmux.session_activity_ts.insert(session.to_string(), now);
1343
1344        let session_names = vec![session.to_string()];
1345        let all_pane_data = tmux.list_all_panes_with_activity();
1346        let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1347
1348        assert_eq!(
1349            results[0].1.as_ref().unwrap().status.state,
1350            AgentState::Running,
1351            "Should infer Running from pre-fetched recent activity"
1352        );
1353    }
1354
1355    #[test]
1356    fn batched_codex_unknown_not_upgraded_by_prefetched_activity() {
1357        let now = std::time::SystemTime::now()
1358            .duration_since(std::time::UNIX_EPOCH)
1359            .unwrap()
1360            .as_secs();
1361
1362        let mut tmux = MockTmuxProvider::default();
1363        let session = "codex-unknown-active";
1364        tmux.pane_info.insert(
1365            session.to_string(),
1366            vec![PaneInfo {
1367                pane_id: "%0".to_string(),
1368                command: "codex".to_string(),
1369                pid: 99999,
1370            }],
1371        );
1372        tmux.pane_content.insert(
1373            "%0".to_string(),
1374            "› Review main.py and find all the bugs\n  100% context left".to_string(),
1375        );
1376        tmux.session_activity_ts.insert(session.to_string(), now);
1377
1378        let session_names = vec![session.to_string()];
1379        let all_pane_data = tmux.list_all_panes_with_activity();
1380        let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1381
1382        assert_eq!(results[0].1.as_ref().unwrap().status.kind, AgentKind::Codex);
1383        assert_eq!(
1384            results[0].1.as_ref().unwrap().status.state,
1385            AgentState::Unknown,
1386            "Batched detection should keep Codex Unknown despite recent activity"
1387        );
1388    }
1389
1390    #[test]
1391    fn batched_content_fallback_unknown_is_ignored() {
1392        let mut tmux = MockTmuxProvider::default();
1393        let session = "content-fallback-unknown";
1394        tmux.pane_info.insert(
1395            session.to_string(),
1396            vec![PaneInfo {
1397                pane_id: "%0".to_string(),
1398                command: "zsh".to_string(),
1399                pid: 99999,
1400            }],
1401        );
1402        tmux.pane_content.insert(
1403            "%0".to_string(),
1404            "╭────────────────────────────────╮\n\
1405             │ >_ OpenAI Codex (v0.106.0) │\n\
1406             ╰────────────────────────────────╯"
1407                .to_string(),
1408        );
1409
1410        let session_names = vec![session.to_string()];
1411        let all_pane_data = tmux.list_all_panes_with_activity();
1412        let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1413
1414        assert_eq!(results.len(), 1);
1415        assert!(results[0].1.is_none());
1416    }
1417
1418    #[test]
1419    fn batched_empty_sessions_list() {
1420        let tmux = MockTmuxProvider::default();
1421        let all_pane_data = tmux.list_all_panes_with_activity();
1422        let results = super::detect_for_sessions_batched(&tmux, &[], &all_pane_data);
1423        assert!(results.is_empty());
1424    }
1425
1426    #[test]
1427    fn batched_mixed_agents_and_shells() {
1428        // Realistic scenario: some sessions have agents, some are plain shells
1429        let mut tmux = MockTmuxProvider::default();
1430
1431        // Session with an agent
1432        tmux.pane_info.insert(
1433            "dev-kiosk".to_string(),
1434            vec![PaneInfo {
1435                pane_id: "%0".to_string(),
1436                command: "claude".to_string(),
1437                pid: 10001,
1438            }],
1439        );
1440        tmux.pane_content
1441            .insert("%0".to_string(), "❯ \n? for shortcuts".to_string());
1442
1443        // Session with just a shell (no agent)
1444        tmux.pane_info.insert(
1445            "dev-other".to_string(),
1446            vec![PaneInfo {
1447                pane_id: "%1".to_string(),
1448                command: "zsh".to_string(),
1449                pid: 10002,
1450            }],
1451        );
1452        tmux.pane_content
1453            .insert("%1".to_string(), "$ ls -la".to_string());
1454
1455        let session_names = vec!["dev-kiosk".to_string(), "dev-other".to_string()];
1456        let all_pane_data = tmux.list_all_panes_with_activity();
1457        let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1458
1459        assert!(results[0].1.is_some(), "Should detect agent in dev-kiosk");
1460        assert!(
1461            results[1].1.is_none(),
1462            "Should not detect agent in dev-other"
1463        );
1464    }
1465
1466    #[test]
1467    fn batched_multi_pane_priority() {
1468        // Session with multiple panes: Waiting should win over Running
1469        let mut tmux = MockTmuxProvider::default();
1470        tmux.pane_info.insert(
1471            "multi".to_string(),
1472            vec![
1473                PaneInfo {
1474                    pane_id: "%0".to_string(),
1475                    command: "claude".to_string(),
1476                    pid: 10001,
1477                },
1478                PaneInfo {
1479                    pane_id: "%1".to_string(),
1480                    command: "claude".to_string(),
1481                    pid: 10002,
1482                },
1483            ],
1484        );
1485        tmux.pane_content
1486            .insert("%0".to_string(), "⠋ Reading file".to_string());
1487        tmux.pane_content
1488            .insert("%1".to_string(), "Allow write?\n  Yes, allow".to_string());
1489
1490        let session_names = vec!["multi".to_string()];
1491        let all_pane_data = tmux.list_all_panes_with_activity();
1492        let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1493
1494        assert_eq!(
1495            results[0].1.as_ref().unwrap().status.state,
1496            AgentState::Waiting
1497        );
1498    }
1499
1500    #[test]
1501    fn detect_from_pane_data_empty_panes() {
1502        let tmux = MockTmuxProvider::default();
1503        let data = crate::tmux::provider::SessionPaneData {
1504            panes: vec![],
1505            pane_titles: std::collections::HashMap::new(),
1506            session_activity: 0,
1507        };
1508        assert!(super::detect_from_pane_data(&tmux, &data).is_none());
1509    }
1510}