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