claude_code_cli_acp/terminal/
recognizers.rs1use 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}