missiond_core/semantic/
state.rs1use once_cell::sync::Lazy;
6use regex::Regex;
7
8use super::types::{
9 ConfirmType, ParserContext, ParserMeta, State, StateDetectionResult, StateMeta, StateParser,
10};
11
12static 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
21pub struct ClaudeCodeStateParser {
31 meta: ParserMeta,
32}
33
34impl Default for ClaudeCodeStateParser {
35 fn default() -> Self {
36 Self::new()
37 }
38}
39
40impl ClaudeCodeStateParser {
41 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 fn is_running(&self, text: &str) -> bool {
55 text.contains("esc to interrupt")
56 }
57
58 fn is_option_confirm(&self, text: &str) -> bool {
60 OPTION_CONFIRM_PATTERN.is_match(text) && text.contains("Esc to cancel")
61 }
62
63 fn is_yes_no_confirm(&self, text: &str) -> bool {
65 YES_NO_CONFIRM_PATTERN.is_match(text)
66 }
67
68 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 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 let is_running = self.is_running(&text);
99
100 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 if is_running {
122 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 if self.has_prompt(&context.last_lines) && !is_running {
133 return Some(StateDetectionResult::new(State::Idle, 0.9));
134 }
135
136 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 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 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 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 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 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 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 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 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 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}