Skip to main content

missiond_core/semantic/
confirm.rs

1//! Claude Code confirm parser
2//!
3//! Parses Claude Code tool confirmation dialogs.
4
5use std::collections::HashMap;
6
7use once_cell::sync::Lazy;
8use regex::Regex;
9
10use super::types::{
11    ConfirmAction, ConfirmInfo, ConfirmKey, ConfirmOption, ConfirmParser, ConfirmResponse,
12    ConfirmType, ParserContext, ParserMeta, ToolInfo,
13};
14
15/// Regex patterns for confirm parsing
16static OPTION_CONFIRM_PATTERN: Lazy<Regex> =
17    Lazy::new(|| Regex::new(r"(?mi)^[\s❯>]*1\.\s*(Yes|Allow)").unwrap());
18
19static YES_NO_CONFIRM_PATTERN: Lazy<Regex> =
20    Lazy::new(|| Regex::new(r"(?i)\[Y/n\]|\(yes/no\)|Allow\?|Do you want to proceed").unwrap());
21
22/// Tool info pattern: server - tool_name(params) or server - tool_name(params) (MCP)
23static TOOL_INFO_PATTERN: Lazy<Regex> =
24    Lazy::new(|| Regex::new(r"(\S+)\s*-\s*(\w+)\s*\(([^)]*)\)(?:\s*\(MCP\))?").unwrap());
25
26/// Parameter pattern: key: "value" or key: value
27static PARAM_PATTERN: Lazy<Regex> =
28    Lazy::new(|| Regex::new(r#"(\w+):\s*("[^"]*"|[^,)]+)"#).unwrap());
29
30/// Option line pattern: number. label (with optional leading ❯ or > and spaces)
31static OPTION_LINE_PATTERN: Lazy<Regex> =
32    Lazy::new(|| Regex::new(r"^[\s❯>]*(\d+)\.\s*(.+)$").unwrap());
33
34/// Y/n prompt cleanup pattern
35static YN_CLEANUP_PATTERN: Lazy<Regex> =
36    Lazy::new(|| Regex::new(r"(?i)\s*\[Y/n\].*|\s*\(yes/no\).*").unwrap());
37
38/// Claude Code confirm parser
39///
40/// Parses confirmation dialogs and formats responses:
41/// - Options-style: 1. Yes, 2. ..., 3. No (use arrow keys + Enter)
42/// - Y/n style: [Y/n] or (yes/no) prompts
43pub struct ClaudeCodeConfirmParser {
44    meta: ParserMeta,
45}
46
47impl Default for ClaudeCodeConfirmParser {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl ClaudeCodeConfirmParser {
54    /// Create a new Claude Code confirm parser
55    pub fn new() -> Self {
56        Self {
57            meta: ParserMeta {
58                name: "claude-code-confirm".to_string(),
59                description: "Parses Claude Code tool confirmation dialogs".to_string(),
60                priority: 100,
61                version: "1.0.0".to_string(),
62            },
63        }
64    }
65
66    /// Check for options-style confirmation
67    fn is_option_confirm(&self, text: &str) -> bool {
68        OPTION_CONFIRM_PATTERN.is_match(text) && text.contains("Esc to cancel")
69    }
70
71    /// Check for Y/n style confirmation
72    fn is_yes_no_confirm(&self, text: &str) -> bool {
73        YES_NO_CONFIRM_PATTERN.is_match(text)
74    }
75
76    /// Parse tool info from confirmation text
77    ///
78    /// Supports formats:
79    /// - `xjp-mcp - xjp_secret_get(key: "value")`
80    /// - `xjp-mcp - xjp_secret_get(key: "value") (MCP)`
81    fn parse_tool_info(&self, text: &str) -> Option<ToolInfo> {
82        let caps = TOOL_INFO_PATTERN.captures(text)?;
83
84        let mcp_server = caps.get(1)?.as_str().to_string();
85        let name = caps.get(2)?.as_str().to_string();
86        let params_str = caps.get(3)?.as_str();
87
88        // Parse parameters
89        let mut params = HashMap::new();
90        for caps in PARAM_PATTERN.captures_iter(params_str) {
91            if let (Some(key), Some(value)) = (caps.get(1), caps.get(2)) {
92                let key = key.as_str().to_string();
93                let mut value = value.as_str().to_string();
94
95                // Remove quotes if present
96                if value.starts_with('"') && value.ends_with('"') {
97                    value = value[1..value.len() - 1].to_string();
98                }
99
100                params.insert(key, value);
101            }
102        }
103
104        Some(ToolInfo {
105            name,
106            mcp_server: Some(mcp_server),
107            params,
108        })
109    }
110
111    /// Parse options from text
112    fn parse_options(&self, text: &str) -> Option<Vec<ConfirmOption>> {
113        let mut options = Vec::new();
114
115        for line in text.lines() {
116            if let Some(caps) = OPTION_LINE_PATTERN.captures(line) {
117                if let (Some(num_match), Some(label_match)) = (caps.get(1), caps.get(2)) {
118                    if let Ok(num) = num_match.as_str().parse::<u32>() {
119                        options.push(ConfirmOption {
120                            key: ConfirmKey::Number(num),
121                            label: label_match.as_str().trim().to_string(),
122                            is_default: num == 1,
123                        });
124                    }
125                }
126            }
127        }
128
129        if options.is_empty() {
130            None
131        } else {
132            Some(options)
133        }
134    }
135
136    /// Extract the main prompt/question
137    fn extract_prompt(&self, text: &str) -> String {
138        let mut prompt_lines = Vec::new();
139
140        for line in text.lines() {
141            // Stop at options
142            if OPTION_LINE_PATTERN.is_match(line) {
143                break;
144            }
145
146            // Handle Y/n type prompts - extract text before the prompt indicator
147            if YES_NO_CONFIRM_PATTERN.is_match(line) {
148                let cleaned = YN_CLEANUP_PATTERN.replace(line, "");
149                let trimmed = cleaned.trim();
150                if !trimmed.is_empty() {
151                    prompt_lines.push(trimmed.to_string());
152                }
153                break;
154            }
155
156            let trimmed = line.trim();
157            if !trimmed.is_empty() {
158                prompt_lines.push(trimmed.to_string());
159            }
160        }
161
162        prompt_lines.join("\n")
163    }
164}
165
166impl ConfirmParser for ClaudeCodeConfirmParser {
167    fn meta(&self) -> &ParserMeta {
168        &self.meta
169    }
170
171    fn detect_confirm(&self, context: &ParserContext) -> Option<ConfirmInfo> {
172        let text = context.text();
173
174        // Check for options-style confirm (Claude Code tool usage)
175        // Format: "❯ 1. Yes" or "  1. Yes" (with optional leading arrow/spaces)
176        if self.is_option_confirm(&text) {
177            let tool = self.parse_tool_info(&text);
178            let options = self.parse_options(&text);
179
180            return Some(ConfirmInfo {
181                confirm_type: ConfirmType::Options,
182                prompt: self.extract_prompt(&text),
183                options,
184                tool,
185                raw_prompt: text,
186            });
187        }
188
189        // Check for simple Y/n confirm
190        if self.is_yes_no_confirm(&text) {
191            return Some(ConfirmInfo {
192                confirm_type: ConfirmType::YesNo,
193                prompt: self.extract_prompt(&text),
194                options: Some(vec![
195                    ConfirmOption {
196                        key: ConfirmKey::Char("y".to_string()),
197                        label: "Yes".to_string(),
198                        is_default: true,
199                    },
200                    ConfirmOption {
201                        key: ConfirmKey::Char("n".to_string()),
202                        label: "No".to_string(),
203                        is_default: false,
204                    },
205                ]),
206                tool: None,
207                raw_prompt: text,
208            });
209        }
210
211        None
212    }
213
214    fn format_response(&self, info: &ConfirmInfo, response: &ConfirmResponse) -> String {
215        // Claude Code confirmation dialog uses ❯ to mark current selection
216        // Navigate with arrow keys, confirm with Enter
217        // Cannot input numbers directly as they may be intercepted by other dialogs (e.g., feedback)
218        match response.action {
219            ConfirmAction::Confirm => {
220                // First option is selected, just press Enter
221                "\r".to_string()
222            }
223            ConfirmAction::Deny => {
224                match info.confirm_type {
225                    ConfirmType::Options => {
226                        // Move to third option (No) and press Enter: Down Down Enter
227                        // \x1b[B is the ANSI escape code for down arrow
228                        "\x1b[B\x1b[B\r".to_string()
229                    }
230                    ConfirmType::YesNo => {
231                        // Just type 'n' and Enter
232                        "n\r".to_string()
233                    }
234                }
235            }
236            ConfirmAction::Select => {
237                // Move to specified option and press Enter
238                if let Some(option) = response.option {
239                    if option > 1 {
240                        let down_keys = "\x1b[B".repeat((option - 1) as usize);
241                        format!("{}\r", down_keys)
242                    } else {
243                        "\r".to_string()
244                    }
245                } else {
246                    "\r".to_string()
247                }
248            }
249            ConfirmAction::Input => {
250                // Type custom value and press Enter
251                if let Some(ref value) = response.value {
252                    format!("{}\r", value)
253                } else {
254                    "\r".to_string()
255                }
256            }
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn make_context(lines: &[&str]) -> ParserContext {
266        ParserContext::new(lines.iter().map(|s| s.to_string()).collect())
267    }
268
269    #[test]
270    fn test_parse_tool_info() {
271        let parser = ClaudeCodeConfirmParser::new();
272
273        // Basic format
274        let text = r#"xjp-mcp - xjp_secret_get(key: "test_value")"#;
275        let tool = parser.parse_tool_info(text);
276        assert!(tool.is_some());
277        let tool = tool.unwrap();
278        assert_eq!(tool.name, "xjp_secret_get");
279        assert_eq!(tool.mcp_server, Some("xjp-mcp".to_string()));
280        assert_eq!(tool.params.get("key"), Some(&"test_value".to_string()));
281
282        // With (MCP) suffix
283        let text = r#"xjp-mcp - xjp_secret_get(key: "value") (MCP)"#;
284        let tool = parser.parse_tool_info(text);
285        assert!(tool.is_some());
286        assert_eq!(tool.unwrap().name, "xjp_secret_get");
287
288        // Multiple parameters
289        let text = r#"server - tool_name(param1: "val1", param2: "val2")"#;
290        let tool = parser.parse_tool_info(text);
291        assert!(tool.is_some());
292        let tool = tool.unwrap();
293        assert_eq!(tool.params.get("param1"), Some(&"val1".to_string()));
294        assert_eq!(tool.params.get("param2"), Some(&"val2".to_string()));
295    }
296
297    #[test]
298    fn test_parse_options() {
299        let parser = ClaudeCodeConfirmParser::new();
300
301        let text = "❯ 1. Yes, allow this action\n  2. Yes, allow for this session\n  3. No, deny this action";
302        let options = parser.parse_options(text);
303        assert!(options.is_some());
304        let options = options.unwrap();
305        assert_eq!(options.len(), 3);
306
307        assert!(matches!(options[0].key, ConfirmKey::Number(1)));
308        assert!(options[0].label.contains("Yes, allow this action"));
309        assert!(options[0].is_default);
310
311        assert!(matches!(options[1].key, ConfirmKey::Number(2)));
312        assert!(!options[1].is_default);
313
314        assert!(matches!(options[2].key, ConfirmKey::Number(3)));
315        assert!(options[2].label.contains("No"));
316    }
317
318    #[test]
319    fn test_detect_option_confirm() {
320        let parser = ClaudeCodeConfirmParser::new();
321
322        let context = make_context(&[
323            "xjp-mcp - xjp_secret_get(key: \"test\")",
324            "❯ 1. Yes, allow this action",
325            "  2. Yes, allow for this session",
326            "  3. No, deny this action",
327            "Esc to cancel",
328        ]);
329
330        let result = parser.detect_confirm(&context);
331        assert!(result.is_some());
332        let info = result.unwrap();
333
334        assert_eq!(info.confirm_type, ConfirmType::Options);
335        assert!(info.tool.is_some());
336        assert_eq!(info.tool.as_ref().unwrap().name, "xjp_secret_get");
337        assert!(info.options.is_some());
338        assert_eq!(info.options.as_ref().unwrap().len(), 3);
339    }
340
341    #[test]
342    fn test_detect_yesno_confirm() {
343        let parser = ClaudeCodeConfirmParser::new();
344
345        // [Y/n] format
346        let context = make_context(&["Do you want to continue? [Y/n]"]);
347        let result = parser.detect_confirm(&context);
348        assert!(result.is_some());
349        let info = result.unwrap();
350        assert_eq!(info.confirm_type, ConfirmType::YesNo);
351        assert!(info.options.is_some());
352        assert_eq!(info.options.as_ref().unwrap().len(), 2);
353
354        // (yes/no) format
355        let context = make_context(&["Proceed with action? (yes/no)"]);
356        let result = parser.detect_confirm(&context);
357        assert!(result.is_some());
358        assert_eq!(result.unwrap().confirm_type, ConfirmType::YesNo);
359    }
360
361    #[test]
362    fn test_extract_prompt() {
363        let parser = ClaudeCodeConfirmParser::new();
364
365        // Options confirm
366        let text =
367            "Do you want to allow this?\n❯ 1. Yes\n  2. No\nEsc to cancel";
368        let prompt = parser.extract_prompt(text);
369        assert_eq!(prompt, "Do you want to allow this?");
370
371        // Y/n confirm
372        let text = "Continue? [Y/n]";
373        let prompt = parser.extract_prompt(text);
374        assert_eq!(prompt, "Continue?");
375    }
376
377    #[test]
378    fn test_format_response_confirm() {
379        let parser = ClaudeCodeConfirmParser::new();
380
381        let info = ConfirmInfo {
382            confirm_type: ConfirmType::Options,
383            prompt: "Test".to_string(),
384            options: None,
385            tool: None,
386            raw_prompt: "Test".to_string(),
387        };
388
389        // Confirm action
390        let response = ConfirmResponse::confirm();
391        assert_eq!(parser.format_response(&info, &response), "\r");
392    }
393
394    #[test]
395    fn test_format_response_deny_options() {
396        let parser = ClaudeCodeConfirmParser::new();
397
398        let info = ConfirmInfo {
399            confirm_type: ConfirmType::Options,
400            prompt: "Test".to_string(),
401            options: None,
402            tool: None,
403            raw_prompt: "Test".to_string(),
404        };
405
406        // Deny action for options (down down enter)
407        let response = ConfirmResponse::deny();
408        assert_eq!(parser.format_response(&info, &response), "\x1b[B\x1b[B\r");
409    }
410
411    #[test]
412    fn test_format_response_deny_yesno() {
413        let parser = ClaudeCodeConfirmParser::new();
414
415        let info = ConfirmInfo {
416            confirm_type: ConfirmType::YesNo,
417            prompt: "Test".to_string(),
418            options: None,
419            tool: None,
420            raw_prompt: "Test".to_string(),
421        };
422
423        // Deny action for Y/n
424        let response = ConfirmResponse::deny();
425        assert_eq!(parser.format_response(&info, &response), "n\r");
426    }
427
428    #[test]
429    fn test_format_response_select() {
430        let parser = ClaudeCodeConfirmParser::new();
431
432        let info = ConfirmInfo {
433            confirm_type: ConfirmType::Options,
434            prompt: "Test".to_string(),
435            options: None,
436            tool: None,
437            raw_prompt: "Test".to_string(),
438        };
439
440        // Select option 1 (no movement needed)
441        let response = ConfirmResponse::select(1);
442        assert_eq!(parser.format_response(&info, &response), "\r");
443
444        // Select option 2 (one down)
445        let response = ConfirmResponse::select(2);
446        assert_eq!(parser.format_response(&info, &response), "\x1b[B\r");
447
448        // Select option 3 (two downs)
449        let response = ConfirmResponse::select(3);
450        assert_eq!(parser.format_response(&info, &response), "\x1b[B\x1b[B\r");
451    }
452
453    #[test]
454    fn test_format_response_input() {
455        let parser = ClaudeCodeConfirmParser::new();
456
457        let info = ConfirmInfo {
458            confirm_type: ConfirmType::YesNo,
459            prompt: "Test".to_string(),
460            options: None,
461            tool: None,
462            raw_prompt: "Test".to_string(),
463        };
464
465        // Custom input
466        let response = ConfirmResponse::input("custom value");
467        assert_eq!(
468            parser.format_response(&info, &response),
469            "custom value\r"
470        );
471    }
472
473    #[test]
474    fn test_no_detection() {
475        let parser = ClaudeCodeConfirmParser::new();
476
477        let context = make_context(&["random text", "nothing special"]);
478        let result = parser.detect_confirm(&context);
479        assert!(result.is_none());
480    }
481}