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 #[allow(dead_code)]
30 Question { detail: String },
31 Completion,
33 Error { detail: String },
35 #[allow(dead_code)]
37 WaitingForInput,
38}
39
40#[derive(Debug, Clone, PartialEq)]
42pub struct DetectedPrompt {
43 pub kind: PromptKind,
44 pub matched_text: String,
45}
46
47pub struct PromptPatterns {
49 patterns: Vec<(Regex, PromptClassifier)>,
50}
51
52type PromptClassifier = fn(&str) -> PromptKind;
53
54impl PromptPatterns {
55 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 pub fn claude_code() -> Self {
75 Self {
76 patterns: vec![
77 (Regex::new(r"(?i)allow\s+tool\b").unwrap(), |s| {
79 PromptKind::Permission {
80 detail: s.to_string(),
81 }
82 }),
83 (Regex::new(r"(?i)\[y/n\]").unwrap(), |s| {
85 PromptKind::Confirmation {
86 detail: s.to_string(),
87 }
88 }),
89 (Regex::new(r"(?i)continue\?").unwrap(), |s| {
91 PromptKind::Confirmation {
92 detail: s.to_string(),
93 }
94 }),
95 (Regex::new(r#""is_error"\s*:\s*true"#).unwrap(), |s| {
97 PromptKind::Error {
98 detail: s.to_string(),
99 }
100 }),
101 (Regex::new(r#""type"\s*:\s*"result""#).unwrap(), |_| {
103 PromptKind::Completion
104 }),
105 ],
106 }
107 }
108
109 pub fn codex_cli() -> Self {
114 Self {
115 patterns: vec![
116 (
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 (
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 (
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 (Regex::new(r".+ needs your approval\.").unwrap(), |s| {
139 PromptKind::Permission {
140 detail: s.to_string(),
141 }
142 }),
143 (
145 Regex::new(r"Press .* to confirm or .* to cancel").unwrap(),
146 |s| PromptKind::Confirmation {
147 detail: s.to_string(),
148 },
149 ),
150 (Regex::new(r"(?i)context.?window.?exceeded").unwrap(), |s| {
152 PromptKind::Error {
153 detail: s.to_string(),
154 }
155 }),
156 ],
157 }
158 }
159
160 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 #[allow(dead_code)]
193 pub fn aider() -> Self {
194 Self {
195 patterns: vec![
196 (
198 Regex::new(r"\(Y\)es/\(N\)o.*\[(Yes|No)\]:\s*$").unwrap(),
199 |s| PromptKind::Confirmation {
200 detail: s.to_string(),
201 },
202 ),
203 (Regex::new(r"^(\w+\s*)?(multi\s+)?>\s$").unwrap(), |_| {
205 PromptKind::WaitingForInput
206 }),
207 (Regex::new(r"^Applied edit to\s+").unwrap(), |_| {
209 PromptKind::Completion
210 }),
211 (Regex::new(r"exceeds the .* token limit").unwrap(), |s| {
213 PromptKind::Error {
214 detail: s.to_string(),
215 }
216 }),
217 (
219 Regex::new(r"Empty response received from LLM").unwrap(),
220 |s| PromptKind::Error {
221 detail: s.to_string(),
222 },
223 ),
224 (
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
236pub fn strip_ansi(input: &str) -> String {
238 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 #[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 #[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 #[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 #[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 assert!(
401 p.detect("Tokens: 4.2k sent, 1.1k received. Cost: $0.02 message, $0.05 session.")
402 .is_none()
403 );
404 }
405}