#![cfg_attr(not(test), allow(dead_code))]
use regex::Regex;
#[derive(Debug, Clone, PartialEq)]
pub enum PromptKind {
Permission { detail: String },
Confirmation { detail: String },
Question { detail: String },
Completion,
Error { detail: String },
WaitingForInput,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DetectedPrompt {
pub kind: PromptKind,
pub matched_text: String,
}
pub struct PromptPatterns {
patterns: Vec<(Regex, PromptClassifier)>,
}
type PromptClassifier = fn(&str) -> PromptKind;
impl PromptPatterns {
pub fn detect(&self, line: &str) -> Option<DetectedPrompt> {
for (regex, classify) in &self.patterns {
if let Some(m) = regex.find(line) {
return Some(DetectedPrompt {
kind: classify(m.as_str()),
matched_text: m.as_str().to_string(),
});
}
}
None
}
pub fn claude_code() -> Self {
Self {
patterns: vec![
(Regex::new(r"(?i)allow\s+tool\b").unwrap(), |s| {
PromptKind::Permission {
detail: s.to_string(),
}
}),
(Regex::new(r"(?i)\[y/n\]").unwrap(), |s| {
PromptKind::Confirmation {
detail: s.to_string(),
}
}),
(Regex::new(r"(?i)continue\?").unwrap(), |s| {
PromptKind::Confirmation {
detail: s.to_string(),
}
}),
(Regex::new(r#""is_error"\s*:\s*true"#).unwrap(), |s| {
PromptKind::Error {
detail: s.to_string(),
}
}),
(Regex::new(r#""type"\s*:\s*"result""#).unwrap(), |_| {
PromptKind::Completion
}),
],
}
}
pub fn codex_cli() -> Self {
Self {
patterns: vec![
(
Regex::new(r"Would you like to run the following command\?").unwrap(),
|s| PromptKind::Permission {
detail: s.to_string(),
},
),
(
Regex::new(r"Would you like to make the following edits\?").unwrap(),
|s| PromptKind::Permission {
detail: s.to_string(),
},
),
(
Regex::new(r#"Do you want to approve network access to ".*"\?"#).unwrap(),
|s| PromptKind::Permission {
detail: s.to_string(),
},
),
(Regex::new(r".+ needs your approval\.").unwrap(), |s| {
PromptKind::Permission {
detail: s.to_string(),
}
}),
(
Regex::new(r"Press .* to confirm or .* to cancel").unwrap(),
|s| PromptKind::Confirmation {
detail: s.to_string(),
},
),
(Regex::new(r"(?i)context.?window.?exceeded").unwrap(), |s| {
PromptKind::Error {
detail: s.to_string(),
}
}),
],
}
}
pub fn kiro_cli() -> Self {
Self {
patterns: vec![
(
Regex::new(r"(?i)context (window|limit).*(exceeded|reached|full)").unwrap(),
|s| PromptKind::Error {
detail: s.to_string(),
},
),
(Regex::new(r"(?i)conversation is too long").unwrap(), |s| {
PromptKind::Error {
detail: s.to_string(),
}
}),
(Regex::new(r"(?i)continue\?").unwrap(), |s| {
PromptKind::Confirmation {
detail: s.to_string(),
}
}),
],
}
}
pub fn aider() -> Self {
Self {
patterns: vec![
(
Regex::new(r"\(Y\)es/\(N\)o.*\[(Yes|No)\]:\s*$").unwrap(),
|s| PromptKind::Confirmation {
detail: s.to_string(),
},
),
(Regex::new(r"^(\w+\s*)?(multi\s+)?>\s$").unwrap(), |_| {
PromptKind::WaitingForInput
}),
(Regex::new(r"^Applied edit to\s+").unwrap(), |_| {
PromptKind::Completion
}),
(Regex::new(r"exceeds the .* token limit").unwrap(), |s| {
PromptKind::Error {
detail: s.to_string(),
}
}),
(
Regex::new(r"Empty response received from LLM").unwrap(),
|s| PromptKind::Error {
detail: s.to_string(),
},
),
(
Regex::new(r"(?:unable to read|file not found error|Unable to write)").unwrap(),
|s| PromptKind::Error {
detail: s.to_string(),
},
),
],
}
}
}
pub fn strip_ansi(input: &str) -> String {
static ANSI_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[^\[\]]").unwrap()
});
ANSI_RE.replace_all(input, "").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_ansi_removes_csi() {
let input = "\x1b[31mERROR\x1b[0m: something broke";
assert_eq!(strip_ansi(input), "ERROR: something broke");
}
#[test]
fn strip_ansi_removes_osc() {
let input = "\x1b]0;title\x07some text";
assert_eq!(strip_ansi(input), "some text");
}
#[test]
fn strip_ansi_passthrough_clean_text() {
let input = "just normal text";
assert_eq!(strip_ansi(input), "just normal text");
}
#[test]
fn claude_detects_allow_tool() {
let p = PromptPatterns::claude_code();
let d = p.detect("Allow tool Read on /home/user/file.rs?").unwrap();
assert!(matches!(d.kind, PromptKind::Permission { .. }));
}
#[test]
fn claude_detects_yn_prompt() {
let p = PromptPatterns::claude_code();
let d = p.detect("Continue? [y/n]").unwrap();
assert!(matches!(d.kind, PromptKind::Confirmation { .. }));
}
#[test]
fn claude_detects_json_completion() {
let p = PromptPatterns::claude_code();
let line = r#"{"type": "result", "subtype": "success"}"#;
let d = p.detect(line).unwrap();
assert_eq!(d.kind, PromptKind::Completion);
}
#[test]
fn claude_detects_json_error() {
let p = PromptPatterns::claude_code();
let line = r#"{"type": "result", "is_error": true}"#;
let d = p.detect(line).unwrap();
assert!(matches!(d.kind, PromptKind::Error { .. }));
}
#[test]
fn claude_no_match_on_normal_output() {
let p = PromptPatterns::claude_code();
assert!(p.detect("Writing function to parse YAML...").is_none());
}
#[test]
fn codex_detects_command_approval() {
let p = PromptPatterns::codex_cli();
let d = p
.detect("Would you like to run the following command?")
.unwrap();
assert!(matches!(d.kind, PromptKind::Permission { .. }));
}
#[test]
fn codex_detects_edit_approval() {
let p = PromptPatterns::codex_cli();
let d = p
.detect("Would you like to make the following edits?")
.unwrap();
assert!(matches!(d.kind, PromptKind::Permission { .. }));
}
#[test]
fn codex_detects_network_approval() {
let p = PromptPatterns::codex_cli();
let d = p
.detect(r#"Do you want to approve network access to "api.example.com"?"#)
.unwrap();
assert!(matches!(d.kind, PromptKind::Permission { .. }));
}
#[test]
fn kiro_detects_context_error() {
let p = PromptPatterns::kiro_cli();
let d = p
.detect("Kiro cannot continue because the context limit was reached.")
.unwrap();
assert!(matches!(d.kind, PromptKind::Error { .. }));
}
#[test]
fn kiro_detects_continue_confirmation() {
let p = PromptPatterns::kiro_cli();
let d = p.detect("Continue?").unwrap();
assert!(matches!(d.kind, PromptKind::Confirmation { .. }));
}
#[test]
fn aider_detects_yn_confirmation() {
let p = PromptPatterns::aider();
let d = p
.detect("Fix lint errors in main.rs? (Y)es/(N)o [Yes]: ")
.unwrap();
assert!(matches!(d.kind, PromptKind::Confirmation { .. }));
}
#[test]
fn aider_detects_input_prompt() {
let p = PromptPatterns::aider();
let d = p.detect("code> ").unwrap();
assert_eq!(d.kind, PromptKind::WaitingForInput);
}
#[test]
fn aider_detects_bare_prompt() {
let p = PromptPatterns::aider();
let d = p.detect("> ").unwrap();
assert_eq!(d.kind, PromptKind::WaitingForInput);
}
#[test]
fn aider_detects_edit_completion() {
let p = PromptPatterns::aider();
let d = p.detect("Applied edit to src/main.rs").unwrap();
assert_eq!(d.kind, PromptKind::Completion);
}
#[test]
fn aider_detects_token_limit_error() {
let p = PromptPatterns::aider();
let d = p
.detect(
"Your estimated chat context of 50k tokens exceeds the 32k token limit for gpt-4!",
)
.unwrap();
assert!(matches!(d.kind, PromptKind::Error { .. }));
}
#[test]
fn aider_no_match_on_cost_report() {
let p = PromptPatterns::aider();
assert!(
p.detect("Tokens: 4.2k sent, 1.1k received. Cost: $0.02 message, $0.05 session.")
.is_none()
);
}
}