Skip to main content

missiond_core/semantic/
state.rs

1//! Claude Code state parser
2//!
3//! Detects Claude Code CLI states from terminal output.
4
5use once_cell::sync::Lazy;
6use regex::Regex;
7
8use super::types::{
9    ConfirmType, ParserContext, ParserMeta, State, StateDetectionResult, StateMeta, StateParser,
10};
11
12/// Regex patterns for state detection
13static OPTION_CONFIRM_PATTERN: Lazy<Regex> =
14    Lazy::new(|| Regex::new(r"(?mi)^[\s❯>]*1\.\s*(Yes|Allow)").unwrap());
15
16static YES_NO_CONFIRM_PATTERN: Lazy<Regex> =
17    Lazy::new(|| Regex::new(r"(?i)\[Y/n\]|\(yes/no\)|Allow\?|Do you want to proceed").unwrap());
18
19static PROMPT_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[❯>]\s*").unwrap());
20
21/// Claude Code state parser
22///
23/// Detects various CLI states:
24/// - `starting`: Initial startup, may need trust confirmation
25/// - `idle`: Prompt visible, waiting for input
26/// - `thinking`: Processing (esc to interrupt visible)
27/// - `tool_running`: Running a tool
28/// - `confirming`: Waiting for user confirmation
29/// - `error`: Error state
30pub struct ClaudeCodeStateParser {
31    meta: ParserMeta,
32}
33
34impl Default for ClaudeCodeStateParser {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl ClaudeCodeStateParser {
41    /// Create a new Claude Code state parser
42    pub fn new() -> Self {
43        Self {
44            meta: ParserMeta {
45                name: "claude-code-state".to_string(),
46                description: "Detects Claude Code CLI states".to_string(),
47                priority: 100,
48                version: "1.0.0".to_string(),
49            },
50        }
51    }
52
53    /// Check if text indicates running state (spinner visible)
54    fn is_running(&self, text: &str) -> bool {
55        text.contains("esc to interrupt")
56    }
57
58    /// Check for options-style confirmation
59    fn is_option_confirm(&self, text: &str) -> bool {
60        OPTION_CONFIRM_PATTERN.is_match(text) && text.contains("Esc to cancel")
61    }
62
63    /// Check for Y/n style confirmation
64    fn is_yes_no_confirm(&self, text: &str) -> bool {
65        YES_NO_CONFIRM_PATTERN.is_match(text)
66    }
67
68    /// Check if any line has a prompt indicator
69    fn has_prompt(&self, lines: &[String]) -> bool {
70        lines
71            .iter()
72            .any(|line| PROMPT_PATTERN.is_match(line.trim()))
73    }
74}
75
76impl StateParser for ClaudeCodeStateParser {
77    fn meta(&self) -> &ParserMeta {
78        &self.meta
79    }
80
81    fn detect_state(&self, context: &ParserContext) -> Option<StateDetectionResult> {
82        let text = context.text();
83
84        // Check for trust dialog during startup (auto-confirm)
85        if context.current_state == Some(State::Starting)
86            && text.contains("Yes, proceed")
87            && text.contains("Enter to confirm")
88        {
89            return Some(
90                StateDetectionResult::new(State::Starting, 0.95).with_meta(StateMeta {
91                    needs_trust_confirm: Some(true),
92                    confirm_type: None,
93                }),
94            );
95        }
96
97        // Check for running state (spinner visible)
98        let is_running = self.is_running(&text);
99
100        // Check for confirmation dialog
101        // Note: ❯ and > are prompt indicators, need to match them before option number
102        let is_option_confirm = self.is_option_confirm(&text);
103        let is_yes_no_confirm = self.is_yes_no_confirm(&text);
104
105        if is_option_confirm || is_yes_no_confirm {
106            let confirm_type = if is_option_confirm {
107                ConfirmType::Options
108            } else {
109                ConfirmType::YesNo
110            };
111
112            return Some(
113                StateDetectionResult::new(State::Confirming, 0.95).with_meta(StateMeta {
114                    needs_trust_confirm: None,
115                    confirm_type: Some(confirm_type),
116                }),
117            );
118        }
119
120        // Check for busy state (running tools/thinking)
121        if is_running {
122            // Determine if thinking or tool_running
123            // Tool running: "Tool:" or spinner with vertical bar
124            if text.contains("Tool:") || (text.contains('⏺') && text.contains('│')) {
125                return Some(StateDetectionResult::new(State::ToolRunning, 0.85));
126            }
127            return Some(StateDetectionResult::new(State::Thinking, 0.9));
128        }
129
130        // Check for idle state (prompt visible, no running indicator)
131        // Match prompt: ❯ or > at start of line (with optional trailing space/content)
132        if self.has_prompt(&context.last_lines) && !is_running {
133            return Some(StateDetectionResult::new(State::Idle, 0.9));
134        }
135
136        // Check for error state
137        if text.contains("Error:") || text.contains("error:") || text.contains('✖') {
138            return Some(StateDetectionResult::new(State::Error, 0.7));
139        }
140
141        None
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    fn make_context(lines: &[&str]) -> ParserContext {
150        ParserContext::new(lines.iter().map(|s| s.to_string()).collect())
151    }
152
153    fn make_context_with_state(lines: &[&str], state: State) -> ParserContext {
154        ParserContext::new(lines.iter().map(|s| s.to_string()).collect()).with_state(state)
155    }
156
157    #[test]
158    fn test_detect_idle_with_prompt() {
159        let parser = ClaudeCodeStateParser::new();
160
161        // Test with ❯ prompt
162        let context = make_context(&["❯ ", "some previous output"]);
163        let result = parser.detect_state(&context);
164        assert!(result.is_some());
165        let result = result.unwrap();
166        assert_eq!(result.state, State::Idle);
167        assert!(result.confidence >= 0.9);
168
169        // Test with > prompt
170        let context = make_context(&["> ", "some output"]);
171        let result = parser.detect_state(&context);
172        assert!(result.is_some());
173        assert_eq!(result.unwrap().state, State::Idle);
174    }
175
176    #[test]
177    fn test_detect_thinking() {
178        let parser = ClaudeCodeStateParser::new();
179
180        let context = make_context(&["Processing...", "esc to interrupt"]);
181        let result = parser.detect_state(&context);
182        assert!(result.is_some());
183        let result = result.unwrap();
184        assert_eq!(result.state, State::Thinking);
185        assert!(result.confidence >= 0.9);
186    }
187
188    #[test]
189    fn test_detect_tool_running() {
190        let parser = ClaudeCodeStateParser::new();
191
192        // Test with Tool: indicator
193        let context = make_context(&["Tool: Read file", "esc to interrupt"]);
194        let result = parser.detect_state(&context);
195        assert!(result.is_some());
196        assert_eq!(result.unwrap().state, State::ToolRunning);
197
198        // Test with spinner and vertical bar
199        let context = make_context(&["⏺ Running command │ ls -la", "esc to interrupt"]);
200        let result = parser.detect_state(&context);
201        assert!(result.is_some());
202        assert_eq!(result.unwrap().state, State::ToolRunning);
203    }
204
205    #[test]
206    fn test_detect_option_confirm() {
207        let parser = ClaudeCodeStateParser::new();
208
209        let context = make_context(&[
210            "xjp-mcp - xjp_secret_get(key: \"test\")",
211            "❯ 1. Yes, allow this action",
212            "  2. Yes, allow for this session",
213            "  3. No, deny this action",
214            "Esc to cancel",
215        ]);
216        let result = parser.detect_state(&context);
217        assert!(result.is_some());
218        let result = result.unwrap();
219        assert_eq!(result.state, State::Confirming);
220        assert!(result.meta.is_some());
221        assert_eq!(
222            result.meta.unwrap().confirm_type,
223            Some(ConfirmType::Options)
224        );
225    }
226
227    #[test]
228    fn test_detect_yesno_confirm() {
229        let parser = ClaudeCodeStateParser::new();
230
231        // Test [Y/n] format
232        let context = make_context(&["Do you want to continue? [Y/n]"]);
233        let result = parser.detect_state(&context);
234        assert!(result.is_some());
235        let result = result.unwrap();
236        assert_eq!(result.state, State::Confirming);
237        assert_eq!(
238            result.meta.unwrap().confirm_type,
239            Some(ConfirmType::YesNo)
240        );
241
242        // Test (yes/no) format
243        let context = make_context(&["Proceed? (yes/no)"]);
244        let result = parser.detect_state(&context);
245        assert!(result.is_some());
246        assert_eq!(result.unwrap().state, State::Confirming);
247    }
248
249    #[test]
250    fn test_detect_starting_trust_confirm() {
251        let parser = ClaudeCodeStateParser::new();
252
253        let context = make_context_with_state(
254            &[
255                "Do you trust this project?",
256                "Yes, proceed",
257                "Enter to confirm",
258            ],
259            State::Starting,
260        );
261        let result = parser.detect_state(&context);
262        assert!(result.is_some());
263        let result = result.unwrap();
264        assert_eq!(result.state, State::Starting);
265        assert!(result.meta.is_some());
266        assert_eq!(result.meta.unwrap().needs_trust_confirm, Some(true));
267    }
268
269    #[test]
270    fn test_detect_error() {
271        let parser = ClaudeCodeStateParser::new();
272
273        // Test Error:
274        let context = make_context(&["Error: Something went wrong"]);
275        let result = parser.detect_state(&context);
276        assert!(result.is_some());
277        assert_eq!(result.unwrap().state, State::Error);
278
279        // Test error:
280        let context = make_context(&["error: file not found"]);
281        let result = parser.detect_state(&context);
282        assert!(result.is_some());
283        assert_eq!(result.unwrap().state, State::Error);
284
285        // Test ✖
286        let context = make_context(&["✖ Failed to execute command"]);
287        let result = parser.detect_state(&context);
288        assert!(result.is_some());
289        assert_eq!(result.unwrap().state, State::Error);
290    }
291
292    #[test]
293    fn test_no_detection() {
294        let parser = ClaudeCodeStateParser::new();
295
296        let context = make_context(&["random text", "nothing special"]);
297        let result = parser.detect_state(&context);
298        assert!(result.is_none());
299    }
300}