Skip to main content

claude_code_cli_acp/terminal/
recognizers.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
4pub struct PermissionDialog {
5    pub title: String,
6    pub options: Vec<PermissionDialogOption>,
7}
8
9#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
10pub struct PermissionDialogOption {
11    pub ordinal: usize,
12    pub accelerator: Option<String>,
13    pub label: String,
14    pub decision: PermissionDecision,
15}
16
17#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum PermissionDecision {
20    AllowOnce,
21    AllowAlways,
22    Reject,
23}
24
25#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum ScreenStatus {
28    Idle,
29    Thinking,
30    WorkspaceTrust,
31    Permission,
32    Error,
33    Exited,
34    Unknown,
35}
36
37pub fn recognize_screen(text: &str) -> ScreenStatus {
38    if recognize_exit(text) {
39        ScreenStatus::Exited
40    } else if recognize_error(text) {
41        ScreenStatus::Error
42    } else if recognize_workspace_trust(text) {
43        ScreenStatus::WorkspaceTrust
44    } else if recognize_permission(text) {
45        ScreenStatus::Permission
46    } else if recognize_thinking(text) {
47        ScreenStatus::Thinking
48    } else if recognize_idle(text) {
49        ScreenStatus::Idle
50    } else {
51        ScreenStatus::Unknown
52    }
53}
54
55pub fn recognize_idle(text: &str) -> bool {
56    let normalized = normalize(text);
57    normalized.contains("\n>")
58        || normalized.contains("│ >")
59        || normalized.ends_with("> ")
60        || normalized.contains("\n❯")
61        || normalized.ends_with("❯ ")
62}
63
64pub fn recognize_thinking(text: &str) -> bool {
65    let normalized = normalize(text);
66    normalized.contains("thinking")
67        || normalized.contains("working")
68        || normalized.contains("esc to interrupt")
69}
70
71pub fn recognize_permission(text: &str) -> bool {
72    recognize_permission_dialog(text).is_some()
73}
74
75pub fn recognize_permission_dialog(text: &str) -> Option<PermissionDialog> {
76    let normalized = normalize(text);
77    if !(normalized.contains("permission")
78        || normalized.contains("allow this action")
79        || normalized.contains("do you want"))
80    {
81        return None;
82    }
83
84    let options = text
85        .lines()
86        .filter_map(parse_permission_option)
87        .collect::<Vec<_>>();
88    if options.is_empty() {
89        return None;
90    }
91
92    Some(PermissionDialog {
93        title: permission_title(text),
94        options,
95    })
96}
97
98pub fn recognize_workspace_trust(text: &str) -> bool {
99    let normalized = normalize(text);
100    normalized.contains("is this a project you created or one you trust")
101        && normalized.contains("yes, i trust this folder")
102        && normalized.contains("no, exit")
103}
104
105pub fn recognize_error(text: &str) -> bool {
106    let normalized = normalize(text);
107    normalized.contains("error:") || normalized.contains("failed") || normalized.contains("panic")
108}
109
110pub fn recognize_exit(text: &str) -> bool {
111    let normalized = normalize(text);
112    normalized.contains("goodbye")
113        || normalized.contains("session ended")
114        || normalized.contains("exited")
115}
116
117fn normalize(text: &str) -> String {
118    text.to_lowercase()
119}
120
121fn parse_permission_option(line: &str) -> Option<PermissionDialogOption> {
122    let trimmed = line.trim_start_matches([' ', '❯']).trim();
123    let (ordinal, rest) = parse_option_prefix(trimmed)?;
124    let label = rest.trim().to_string();
125    let decision = classify_permission_label(&label)?;
126    Some(PermissionDialogOption {
127        ordinal,
128        accelerator: Some(ordinal.to_string()),
129        label,
130        decision,
131    })
132}
133
134fn parse_option_prefix(line: &str) -> Option<(usize, &str)> {
135    if let Some(rest) = line.strip_prefix('[') {
136        let (number, rest) = rest.split_once(']')?;
137        return Some((number.parse().ok()?, rest));
138    }
139
140    let mut digits_end = 0;
141    for (index, character) in line.char_indices() {
142        if character.is_ascii_digit() {
143            digits_end = index + character.len_utf8();
144        } else {
145            break;
146        }
147    }
148    if digits_end == 0 {
149        return None;
150    }
151    let rest = line[digits_end..].trim_start();
152    let rest = rest.strip_prefix('.').or_else(|| rest.strip_prefix(')'))?;
153    Some((line[..digits_end].parse().ok()?, rest))
154}
155
156fn classify_permission_label(label: &str) -> Option<PermissionDecision> {
157    let normalized = normalize(label);
158    if normalized.contains("always")
159        || normalized.contains("don't ask")
160        || normalized.contains("do not ask")
161        || normalized.contains("remember")
162        || normalized.contains("future")
163        || normalized.contains("session")
164    {
165        Some(PermissionDecision::AllowAlways)
166    } else if normalized.contains("deny")
167        || normalized.contains("no")
168        || normalized.contains("reject")
169        || normalized.contains("decline")
170        || normalized.contains("abort")
171    {
172        Some(PermissionDecision::Reject)
173    } else if normalized.contains("allow")
174        || normalized.contains("yes")
175        || normalized.contains("proceed")
176    {
177        Some(PermissionDecision::AllowOnce)
178    } else {
179        None
180    }
181}
182
183fn permission_title(text: &str) -> String {
184    text.lines()
185        .map(str::trim)
186        .find(|line| {
187            let normalized = normalize(line);
188            normalized.contains("do you want") || normalized.contains("permission")
189        })
190        .unwrap_or("Claude permission request")
191        .to_string()
192}