Skip to main content

batty_cli/prompt/
mod.rs

1//! Prompt detection patterns for agent PTY output.
2//!
3//! Each agent family has different output conventions. This module provides
4//! compiled regex patterns and a `PromptKind` classification so the supervisor
5//! can decide what to do (auto-answer, escalate, log, etc.).
6//!
7//! ## Design notes
8//!
9//! **Claude Code** and **Codex CLI** use full-screen TUIs (alternate screen
10//! buffer, cursor positioning). Raw PTY scraping sees ANSI escapes, not clean
11//! lines. For reliable automation, prefer Claude's `-p --output-format
12//! stream-json` or Codex's `--full-auto` mode. The patterns here target the
13//! *text content after ANSI stripping* for cases where interactive mode is used.
14//!
15//! **Aider** uses a traditional line-oriented interface (`prompt_toolkit`),
16//! making it the most amenable to PTY pattern matching.
17#![cfg_attr(not(test), allow(dead_code))]
18
19use regex::Regex;
20
21/// What kind of prompt was detected in the agent's output.
22#[derive(Debug, Clone, PartialEq)]
23pub enum PromptKind {
24    /// Agent is asking for permission to run a command or edit a file.
25    Permission { detail: String },
26    /// Agent is asking a yes/no confirmation question.
27    Confirmation { detail: String },
28    /// Agent is asking the user a free-form question.
29    #[allow(dead_code)]
30    Question { detail: String },
31    /// Agent has finished its current turn / task.
32    Completion,
33    /// Agent encountered an error.
34    Error { detail: String },
35    /// Agent is waiting for user input (idle at prompt).
36    #[allow(dead_code)]
37    WaitingForInput,
38}
39
40/// A detected prompt with its kind and the matched text.
41#[derive(Debug, Clone, PartialEq)]
42pub struct DetectedPrompt {
43    pub kind: PromptKind,
44    pub matched_text: String,
45}
46
47/// Compiled prompt detection patterns for a specific agent.
48pub struct PromptPatterns {
49    patterns: Vec<(Regex, PromptClassifier)>,
50}
51
52type PromptClassifier = fn(&str) -> PromptKind;
53
54impl PromptPatterns {
55    /// Scan a line of (ANSI-stripped) PTY output for known prompt patterns.
56    /// Returns the first match found.
57    pub fn detect(&self, line: &str) -> Option<DetectedPrompt> {
58        for (regex, classify) in &self.patterns {
59            if let Some(m) = regex.find(line) {
60                return Some(DetectedPrompt {
61                    kind: classify(m.as_str()),
62                    matched_text: m.as_str().to_string(),
63                });
64            }
65        }
66        None
67    }
68
69    /// Build prompt patterns for Claude Code.
70    ///
71    /// Claude Code uses a full-screen TUI. These patterns target text content
72    /// after ANSI stripping. For production use, prefer `-p --output-format
73    /// stream-json` mode where completion is signaled by `"type":"result"`.
74    pub fn claude_code() -> Self {
75        Self {
76            patterns: vec![
77                // Permission / tool approval
78                (Regex::new(r"(?i)allow\s+tool\b").unwrap(), |s| {
79                    PromptKind::Permission {
80                        detail: s.to_string(),
81                    }
82                }),
83                // Yes/No confirmation
84                (Regex::new(r"(?i)\[y/n\]").unwrap(), |s| {
85                    PromptKind::Confirmation {
86                        detail: s.to_string(),
87                    }
88                }),
89                // Continue prompt
90                (Regex::new(r"(?i)continue\?").unwrap(), |s| {
91                    PromptKind::Confirmation {
92                        detail: s.to_string(),
93                    }
94                }),
95                // JSON stream error (must be before completion — error results are still results)
96                (Regex::new(r#""is_error"\s*:\s*true"#).unwrap(), |s| {
97                    PromptKind::Error {
98                        detail: s.to_string(),
99                    }
100                }),
101                // JSON stream completion (for -p --output-format stream-json)
102                (Regex::new(r#""type"\s*:\s*"result""#).unwrap(), |_| {
103                    PromptKind::Completion
104                }),
105            ],
106        }
107    }
108
109    /// Build prompt patterns for Codex CLI.
110    ///
111    /// Codex CLI uses a full-screen ratatui TUI with alternate screen buffer.
112    /// These patterns target text after ANSI stripping.
113    pub fn codex_cli() -> Self {
114        Self {
115            patterns: vec![
116                // Command execution approval
117                (
118                    Regex::new(r"Would you like to run the following command\?").unwrap(),
119                    |s| PromptKind::Permission {
120                        detail: s.to_string(),
121                    },
122                ),
123                // File edit approval
124                (
125                    Regex::new(r"Would you like to make the following edits\?").unwrap(),
126                    |s| PromptKind::Permission {
127                        detail: s.to_string(),
128                    },
129                ),
130                // Network access approval
131                (
132                    Regex::new(r#"Do you want to approve network access to ".*"\?"#).unwrap(),
133                    |s| PromptKind::Permission {
134                        detail: s.to_string(),
135                    },
136                ),
137                // MCP approval
138                (Regex::new(r".+ needs your approval\.").unwrap(), |s| {
139                    PromptKind::Permission {
140                        detail: s.to_string(),
141                    }
142                }),
143                // Confirm/cancel footer
144                (
145                    Regex::new(r"Press .* to confirm or .* to cancel").unwrap(),
146                    |s| PromptKind::Confirmation {
147                        detail: s.to_string(),
148                    },
149                ),
150                // Context window exceeded
151                (Regex::new(r"(?i)context.?window.?exceeded").unwrap(), |s| {
152                    PromptKind::Error {
153                        detail: s.to_string(),
154                    }
155                }),
156            ],
157        }
158    }
159
160    /// Build prompt patterns for Kiro CLI.
161    ///
162    /// Kiro's installed CLI surface is currently narrower than the published
163    /// docs in this environment, so keep detection conservative and focused on
164    /// generic confirmations and context-limit failures.
165    pub fn kiro_cli() -> Self {
166        Self {
167            patterns: vec![
168                (
169                    Regex::new(r"(?i)context (window|limit).*(exceeded|reached|full)").unwrap(),
170                    |s| PromptKind::Error {
171                        detail: s.to_string(),
172                    },
173                ),
174                (Regex::new(r"(?i)conversation is too long").unwrap(), |s| {
175                    PromptKind::Error {
176                        detail: s.to_string(),
177                    }
178                }),
179                (Regex::new(r"(?i)continue\?").unwrap(), |s| {
180                    PromptKind::Confirmation {
181                        detail: s.to_string(),
182                    }
183                }),
184            ],
185        }
186    }
187
188    /// Build prompt patterns for Aider.
189    ///
190    /// Aider uses a line-oriented interface, making it the most reliable
191    /// target for PTY pattern matching.
192    #[allow(dead_code)]
193    pub fn aider() -> Self {
194        Self {
195            patterns: vec![
196                // Yes/No confirmation prompts: "(Y)es/(N)o [Yes]:"
197                (
198                    Regex::new(r"\(Y\)es/\(N\)o.*\[(Yes|No)\]:\s*$").unwrap(),
199                    |s| PromptKind::Confirmation {
200                        detail: s.to_string(),
201                    },
202                ),
203                // Input prompt: "code> " or "architect> " or "> "
204                (Regex::new(r"^(\w+\s*)?(multi\s+)?>\s$").unwrap(), |_| {
205                    PromptKind::WaitingForInput
206                }),
207                // Edit applied
208                (Regex::new(r"^Applied edit to\s+").unwrap(), |_| {
209                    PromptKind::Completion
210                }),
211                // Token limit exceeded
212                (Regex::new(r"exceeds the .* token limit").unwrap(), |s| {
213                    PromptKind::Error {
214                        detail: s.to_string(),
215                    }
216                }),
217                // Empty LLM response
218                (
219                    Regex::new(r"Empty response received from LLM").unwrap(),
220                    |s| PromptKind::Error {
221                        detail: s.to_string(),
222                    },
223                ),
224                // File errors
225                (
226                    Regex::new(r"(?:unable to read|file not found error|Unable to write)").unwrap(),
227                    |s| PromptKind::Error {
228                        detail: s.to_string(),
229                    },
230                ),
231            ],
232        }
233    }
234}
235
236/// Strip ANSI escape sequences from PTY output.
237pub fn strip_ansi(input: &str) -> String {
238    // Matches CSI sequences (ESC [ ... final byte), OSC sequences (ESC ] ... ST),
239    // and simple two-byte escapes (ESC + one char).
240    static ANSI_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
241        Regex::new(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[^\[\]]").unwrap()
242    });
243    ANSI_RE.replace_all(input, "").to_string()
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    // ── ANSI stripping ──
251
252    #[test]
253    fn strip_ansi_removes_csi() {
254        let input = "\x1b[31mERROR\x1b[0m: something broke";
255        assert_eq!(strip_ansi(input), "ERROR: something broke");
256    }
257
258    #[test]
259    fn strip_ansi_removes_osc() {
260        let input = "\x1b]0;title\x07some text";
261        assert_eq!(strip_ansi(input), "some text");
262    }
263
264    #[test]
265    fn strip_ansi_passthrough_clean_text() {
266        let input = "just normal text";
267        assert_eq!(strip_ansi(input), "just normal text");
268    }
269
270    // ── Claude Code patterns ──
271
272    #[test]
273    fn claude_detects_allow_tool() {
274        let p = PromptPatterns::claude_code();
275        let d = p.detect("Allow tool Read on /home/user/file.rs?").unwrap();
276        assert!(matches!(d.kind, PromptKind::Permission { .. }));
277    }
278
279    #[test]
280    fn claude_detects_yn_prompt() {
281        let p = PromptPatterns::claude_code();
282        let d = p.detect("Continue? [y/n]").unwrap();
283        assert!(matches!(d.kind, PromptKind::Confirmation { .. }));
284    }
285
286    #[test]
287    fn claude_detects_json_completion() {
288        let p = PromptPatterns::claude_code();
289        let line = r#"{"type": "result", "subtype": "success"}"#;
290        let d = p.detect(line).unwrap();
291        assert_eq!(d.kind, PromptKind::Completion);
292    }
293
294    #[test]
295    fn claude_detects_json_error() {
296        let p = PromptPatterns::claude_code();
297        let line = r#"{"type": "result", "is_error": true}"#;
298        let d = p.detect(line).unwrap();
299        assert!(matches!(d.kind, PromptKind::Error { .. }));
300    }
301
302    #[test]
303    fn claude_no_match_on_normal_output() {
304        let p = PromptPatterns::claude_code();
305        assert!(p.detect("Writing function to parse YAML...").is_none());
306    }
307
308    // ── Codex CLI patterns ──
309
310    #[test]
311    fn codex_detects_command_approval() {
312        let p = PromptPatterns::codex_cli();
313        let d = p
314            .detect("Would you like to run the following command?")
315            .unwrap();
316        assert!(matches!(d.kind, PromptKind::Permission { .. }));
317    }
318
319    #[test]
320    fn codex_detects_edit_approval() {
321        let p = PromptPatterns::codex_cli();
322        let d = p
323            .detect("Would you like to make the following edits?")
324            .unwrap();
325        assert!(matches!(d.kind, PromptKind::Permission { .. }));
326    }
327
328    #[test]
329    fn codex_detects_network_approval() {
330        let p = PromptPatterns::codex_cli();
331        let d = p
332            .detect(r#"Do you want to approve network access to "api.example.com"?"#)
333            .unwrap();
334        assert!(matches!(d.kind, PromptKind::Permission { .. }));
335    }
336
337    #[test]
338    fn kiro_detects_context_error() {
339        let p = PromptPatterns::kiro_cli();
340        let d = p
341            .detect("Kiro cannot continue because the context limit was reached.")
342            .unwrap();
343        assert!(matches!(d.kind, PromptKind::Error { .. }));
344    }
345
346    #[test]
347    fn kiro_detects_continue_confirmation() {
348        let p = PromptPatterns::kiro_cli();
349        let d = p.detect("Continue?").unwrap();
350        assert!(matches!(d.kind, PromptKind::Confirmation { .. }));
351    }
352
353    // ── Aider patterns ──
354
355    #[test]
356    fn aider_detects_yn_confirmation() {
357        let p = PromptPatterns::aider();
358        let d = p
359            .detect("Fix lint errors in main.rs? (Y)es/(N)o [Yes]: ")
360            .unwrap();
361        assert!(matches!(d.kind, PromptKind::Confirmation { .. }));
362    }
363
364    #[test]
365    fn aider_detects_input_prompt() {
366        let p = PromptPatterns::aider();
367        let d = p.detect("code> ").unwrap();
368        assert_eq!(d.kind, PromptKind::WaitingForInput);
369    }
370
371    #[test]
372    fn aider_detects_bare_prompt() {
373        let p = PromptPatterns::aider();
374        let d = p.detect("> ").unwrap();
375        assert_eq!(d.kind, PromptKind::WaitingForInput);
376    }
377
378    #[test]
379    fn aider_detects_edit_completion() {
380        let p = PromptPatterns::aider();
381        let d = p.detect("Applied edit to src/main.rs").unwrap();
382        assert_eq!(d.kind, PromptKind::Completion);
383    }
384
385    #[test]
386    fn aider_detects_token_limit_error() {
387        let p = PromptPatterns::aider();
388        let d = p
389            .detect(
390                "Your estimated chat context of 50k tokens exceeds the 32k token limit for gpt-4!",
391            )
392            .unwrap();
393        assert!(matches!(d.kind, PromptKind::Error { .. }));
394    }
395
396    #[test]
397    fn aider_no_match_on_cost_report() {
398        let p = PromptPatterns::aider();
399        // Cost reports are informational, not prompts
400        assert!(
401            p.detect("Tokens: 4.2k sent, 1.1k received. Cost: $0.02 message, $0.05 session.")
402                .is_none()
403        );
404    }
405}