Skip to main content

tmai_core/detectors/
mod.rs

1mod claude_code;
2mod codex;
3mod default;
4mod gemini;
5
6pub use claude_code::ClaudeCodeDetector;
7pub use codex::CodexDetector;
8pub use default::DefaultDetector;
9pub use gemini::GeminiDetector;
10
11use once_cell::sync::Lazy;
12use parking_lot::Mutex;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16use crate::agents::{AgentStatus, AgentType};
17use crate::config::ClaudeSettingsCache;
18
19/// Detection confidence level
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub enum DetectionConfidence {
22    /// Explicit pattern match
23    High,
24    /// Heuristic-based detection
25    Medium,
26    /// Fallback detection
27    Low,
28}
29
30/// Reason for a detection result
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct DetectionReason {
33    /// Rule name (e.g., "user_question_numbered_choices")
34    pub rule: String,
35    /// Confidence level
36    pub confidence: DetectionConfidence,
37    /// Matched text (truncated)
38    pub matched_text: Option<String>,
39}
40
41/// Detection result combining status and reason
42#[derive(Debug, Clone)]
43pub struct DetectionResult {
44    /// Detected agent status
45    pub status: AgentStatus,
46    /// Reason for this detection
47    pub reason: DetectionReason,
48}
49
50impl DetectionResult {
51    /// Create a new detection result
52    pub fn new(status: AgentStatus, rule: &str, confidence: DetectionConfidence) -> Self {
53        Self {
54            status,
55            reason: DetectionReason {
56                rule: rule.to_string(),
57                confidence,
58                matched_text: None,
59            },
60        }
61    }
62
63    /// Add matched text to the detection result
64    pub fn with_matched_text(mut self, text: &str) -> Self {
65        // Truncate to 200 chars
66        let truncated = if text.len() > 200 {
67            format!("{}...", &text[..text.floor_char_boundary(197)])
68        } else {
69            text.to_string()
70        };
71        self.reason.matched_text = Some(truncated);
72        self
73    }
74}
75
76/// Context passed to detectors for additional information
77#[derive(Default)]
78pub struct DetectionContext<'a> {
79    /// Current working directory of the pane
80    pub cwd: Option<&'a str>,
81    /// Settings cache for Claude Code configuration
82    pub settings_cache: Option<&'a ClaudeSettingsCache>,
83}
84
85/// Trait for detecting agent status from pane content and title
86pub trait StatusDetector: Send + Sync {
87    /// Detect the current status of the agent
88    fn detect_status(&self, title: &str, content: &str) -> AgentStatus;
89
90    /// Detect the current status with additional context
91    ///
92    /// Default implementation falls back to `detect_status`.
93    /// Override this for detectors that need context (e.g., ClaudeCodeDetector for spinnerVerbs).
94    fn detect_status_with_context(
95        &self,
96        title: &str,
97        content: &str,
98        _context: &DetectionContext,
99    ) -> AgentStatus {
100        self.detect_status(title, content)
101    }
102
103    /// Get the agent type this detector handles
104    fn agent_type(&self) -> AgentType;
105
106    /// Detect context warning (e.g., "Context left until auto-compact: XX%")
107    /// Returns the percentage remaining if warning is present
108    fn detect_context_warning(&self, _content: &str) -> Option<u8> {
109        None
110    }
111
112    /// Detect status with reason for audit logging
113    ///
114    /// Default implementation wraps `detect_status_with_context` with a legacy fallback reason.
115    fn detect_status_with_reason(
116        &self,
117        title: &str,
118        content: &str,
119        context: &DetectionContext,
120    ) -> DetectionResult {
121        let status = self.detect_status_with_context(title, content, context);
122        DetectionResult::new(status, "legacy_fallback", DetectionConfidence::Low)
123    }
124
125    /// Keys to send for approval (Enter for cursor-based UI)
126    fn approval_keys(&self) -> &str {
127        "Enter"
128    }
129}
130
131// Static detector instances for caching
132static CLAUDE_DETECTOR: Lazy<ClaudeCodeDetector> = Lazy::new(ClaudeCodeDetector::new);
133static CODEX_DETECTOR: Lazy<CodexDetector> = Lazy::new(CodexDetector::new);
134static GEMINI_DETECTOR: Lazy<GeminiDetector> = Lazy::new(GeminiDetector::new);
135static OPENCODE_DETECTOR: Lazy<DefaultDetector> =
136    Lazy::new(|| DefaultDetector::new(AgentType::OpenCode));
137
138/// Cache for custom agent detectors to avoid repeated Box::leak allocations
139static CUSTOM_DETECTORS: Lazy<Mutex<HashMap<String, &'static dyn StatusDetector>>> =
140    Lazy::new(|| Mutex::new(HashMap::new()));
141
142/// Get the appropriate detector for an agent type
143/// Returns a static reference to avoid repeated allocations
144pub fn get_detector(agent_type: &AgentType) -> &'static dyn StatusDetector {
145    match agent_type {
146        AgentType::ClaudeCode => &*CLAUDE_DETECTOR,
147        AgentType::CodexCli => &*CODEX_DETECTOR,
148        AgentType::GeminiCli => &*GEMINI_DETECTOR,
149        AgentType::OpenCode => &*OPENCODE_DETECTOR,
150        AgentType::Custom(name) => {
151            // Use cached detector if available, otherwise create and cache
152            let mut cache = CUSTOM_DETECTORS.lock();
153            if let Some(&detector) = cache.get(name) {
154                detector
155            } else {
156                // Only leak once per unique custom agent name
157                let detector: &'static dyn StatusDetector = Box::leak(Box::new(
158                    DefaultDetector::new(AgentType::Custom(name.clone())),
159                ));
160                cache.insert(name.clone(), detector);
161                detector
162            }
163        }
164    }
165}