1#![cfg_attr(not(test), allow(dead_code))]
18
19use regex::Regex;
20
21#[derive(Debug, Clone, PartialEq)]
23pub enum PromptKind {
24 Permission { detail: String },
26 Confirmation { detail: String },
28 Question { detail: String },
30 Completion,
32 Error { detail: String },
34 WaitingForInput,
36}
37
38#[derive(Debug, Clone, PartialEq)]
40pub struct DetectedPrompt {
41 pub kind: PromptKind,
42 pub matched_text: String,
43}
44
45pub struct PromptPatterns {
47 patterns: Vec<(Regex, PromptClassifier)>,
48}
49
50type PromptClassifier = fn(&str) -> PromptKind;
51
52impl PromptPatterns {
53 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 pub fn claude_code() -> Self {
73 Self {
74 patterns: vec![
75 (Regex::new(r"(?i)allow\s+tool\b").unwrap(), |s| {
77 PromptKind::Permission {
78 detail: s.to_string(),
79 }
80 }),
81 (Regex::new(r"(?i)\[y/n\]").unwrap(), |s| {
83 PromptKind::Confirmation {
84 detail: s.to_string(),
85 }
86 }),
87 (Regex::new(r"(?i)continue\?").unwrap(), |s| {
89 PromptKind::Confirmation {
90 detail: s.to_string(),
91 }
92 }),
93 (Regex::new(r#""is_error"\s*:\s*true"#).unwrap(), |s| {
95 PromptKind::Error {
96 detail: s.to_string(),
97 }
98 }),
99 (Regex::new(r#""type"\s*:\s*"result""#).unwrap(), |_| {
101 PromptKind::Completion
102 }),
103 ],
104 }
105 }
106
107 pub fn codex_cli() -> Self {
112 Self {
113 patterns: vec![
114 (
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 (
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 (
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 (Regex::new(r".+ needs your approval\.").unwrap(), |s| {
137 PromptKind::Permission {
138 detail: s.to_string(),
139 }
140 }),
141 (
143 Regex::new(r"Press .* to confirm or .* to cancel").unwrap(),
144 |s| PromptKind::Confirmation {
145 detail: s.to_string(),
146 },
147 ),
148 (Regex::new(r"(?i)context.?window.?exceeded").unwrap(), |s| {
150 PromptKind::Error {
151 detail: s.to_string(),
152 }
153 }),
154 ],
155 }
156 }
157
158 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 pub fn aider() -> Self {
191 Self {
192 patterns: vec![
193 (
195 Regex::new(r"\(Y\)es/\(N\)o.*\[(Yes|No)\]:\s*$").unwrap(),
196 |s| PromptKind::Confirmation {
197 detail: s.to_string(),
198 },
199 ),
200 (Regex::new(r"^(\w+\s*)?(multi\s+)?>\s$").unwrap(), |_| {
202 PromptKind::WaitingForInput
203 }),
204 (Regex::new(r"^Applied edit to\s+").unwrap(), |_| {
206 PromptKind::Completion
207 }),
208 (Regex::new(r"exceeds the .* token limit").unwrap(), |s| {
210 PromptKind::Error {
211 detail: s.to_string(),
212 }
213 }),
214 (
216 Regex::new(r"Empty response received from LLM").unwrap(),
217 |s| PromptKind::Error {
218 detail: s.to_string(),
219 },
220 ),
221 (
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
233pub fn strip_ansi(input: &str) -> String {
235 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 #[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 #[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 #[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 #[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 assert!(
398 p.detect("Tokens: 4.2k sent, 1.1k received. Cost: $0.02 message, $0.05 session.")
399 .is_none()
400 );
401 }
402}