agent_tui/detection/
pattern.rs

1use super::ElementType;
2use regex::Regex;
3use std::collections::HashSet;
4use std::sync::OnceLock;
5
6struct PatternRegexes {
7    button: [Regex; 2],
8    input: [Regex; 2],
9    checkbox: [Regex; 2],
10    radio: [Regex; 1],
11    select: [Regex; 2],
12    menu_item: [Regex; 2],
13    list_item: [Regex; 2],
14    spinner: [Regex; 2],
15    progress: [Regex; 2],
16}
17
18fn get_patterns() -> &'static PatternRegexes {
19    static PATTERNS: OnceLock<PatternRegexes> = OnceLock::new();
20    PATTERNS.get_or_init(|| PatternRegexes {
21        button: [
22            Regex::new(r"\[\s*([^\[\]_]+?)\s*\]").unwrap(),
23            Regex::new(r"<\s*([^<>]+?)\s*>").unwrap(),
24        ],
25        input: [
26            Regex::new(r"([A-Za-z\s]+):\s*\[([^\]]*?)_*\]").unwrap(),
27            Regex::new(r"\[([^\]]*?)_{3,}\]").unwrap(),
28        ],
29        checkbox: [
30            Regex::new(r"\[([xX✓✔\s])\]\s*(.+?)(?:\s{2,}|$)").unwrap(),
31            Regex::new(r"([◉◯●○✓✔])\s+(.+?)(?:\s{2,}|$)").unwrap(),
32        ],
33        radio: [Regex::new(r"\(([•o*\s])\)\s*(.+?)(?:\s{2,}|$)").unwrap()],
34        select: [
35            Regex::new(r"([A-Za-z\s]+):\s*\[?([^\]▼▾\n]+?)\s*[▼▾]\]?").unwrap(),
36            Regex::new(r"([^\s]+)\s+[▼▾]").unwrap(),
37        ],
38        menu_item: [
39            Regex::new(r"^\s*([>❯›▸►●•])\s+(.+?)$").unwrap(),
40            Regex::new(r"^\s*(\d+)\.\s+(.+?)$").unwrap(),
41        ],
42        list_item: [
43            Regex::new(r"^\s*[-*]\s+(.+?)$").unwrap(),
44            Regex::new(r"^\s*•\s+(.+?)$").unwrap(),
45        ],
46        spinner: [
47            Regex::new(r"[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]").unwrap(),
48            Regex::new(r"[\|/\-\\]\s+(.+)").unwrap(),
49        ],
50        progress: [
51            Regex::new(r"\[([█▓▒░=\->#\s]+)\]").unwrap(),
52            Regex::new(r"(\d+)\s*%").unwrap(),
53        ],
54    })
55}
56
57#[derive(Debug, Clone)]
58pub struct PatternMatch {
59    pub element_type: ElementType,
60    pub label: Option<String>,
61    pub value: Option<String>,
62    pub row: u16,
63    pub col: u16,
64    pub width: u16,
65    pub checked: Option<bool>,
66}
67
68pub fn detect_by_pattern(screen_text: &str) -> Vec<PatternMatch> {
69    let mut matches = Vec::new();
70    let lines: Vec<&str> = screen_text.lines().collect();
71
72    let patterns = get_patterns();
73
74    for (row_idx, line) in lines.iter().enumerate() {
75        let row = row_idx as u16;
76
77        for pattern in &patterns.button {
78            for cap in pattern.captures_iter(line) {
79                let full_match = cap.get(0).unwrap();
80                let label = cap.get(1).map(|m| m.as_str().trim().to_string());
81
82                let text = full_match.as_str();
83                if text.contains('_')
84                    || text.starts_with("[x]")
85                    || text.starts_with("[ ]")
86                    || text.starts_with("[X]")
87                    || text.starts_with("[✓]")
88                    || text.starts_with("[✔]")
89                {
90                    continue;
91                }
92
93                if let Some(ref l) = label {
94                    if l.len() < 2 {
95                        continue;
96                    }
97                }
98
99                matches.push(PatternMatch {
100                    element_type: ElementType::Button,
101                    label,
102                    value: None,
103                    row,
104                    col: full_match.start() as u16,
105                    width: full_match.as_str().len() as u16,
106                    checked: None,
107                });
108            }
109        }
110
111        for pattern in &patterns.input {
112            for cap in pattern.captures_iter(line) {
113                let full_match = cap.get(0).unwrap();
114                let label = cap.get(1).map(|m| m.as_str().trim().to_string());
115                let value = cap
116                    .get(2)
117                    .map(|m| m.as_str().trim_end_matches('_').trim().to_string())
118                    .filter(|v| !v.is_empty());
119
120                matches.push(PatternMatch {
121                    element_type: ElementType::Input,
122                    label,
123                    value,
124                    row,
125                    col: full_match.start() as u16,
126                    width: full_match.as_str().len() as u16,
127                    checked: None,
128                });
129            }
130        }
131
132        for pattern in &patterns.checkbox {
133            for cap in pattern.captures_iter(line) {
134                let full_match = cap.get(0).unwrap();
135                let marker = cap.get(1).map(|m| m.as_str()).unwrap_or("");
136                let label = cap.get(2).map(|m| m.as_str().trim().to_string());
137
138                let is_checked = matches!(marker, "x" | "X" | "✓" | "✔" | "◉" | "●");
139
140                matches.push(PatternMatch {
141                    element_type: ElementType::Checkbox,
142                    label,
143                    value: Some(if is_checked { "checked" } else { "unchecked" }.to_string()),
144                    row,
145                    col: full_match.start() as u16,
146                    width: full_match.as_str().len() as u16,
147                    checked: Some(is_checked),
148                });
149            }
150        }
151
152        for pattern in &patterns.radio {
153            for cap in pattern.captures_iter(line) {
154                let full_match = cap.get(0).unwrap();
155                let marker = cap.get(1).map(|m| m.as_str()).unwrap_or("");
156                let label = cap.get(2).map(|m| m.as_str().trim().to_string());
157
158                let is_selected = marker != " ";
159
160                matches.push(PatternMatch {
161                    element_type: ElementType::Radio,
162                    label,
163                    value: Some(
164                        if is_selected {
165                            "selected"
166                        } else {
167                            "unselected"
168                        }
169                        .to_string(),
170                    ),
171                    row,
172                    col: full_match.start() as u16,
173                    width: full_match.as_str().len() as u16,
174                    checked: Some(is_selected),
175                });
176            }
177        }
178
179        for pattern in &patterns.select {
180            for cap in pattern.captures_iter(line) {
181                let full_match = cap.get(0).unwrap();
182                let label = cap.get(1).map(|m| m.as_str().trim().to_string());
183                let value = cap.get(2).map(|m| m.as_str().trim().to_string());
184
185                matches.push(PatternMatch {
186                    element_type: ElementType::Select,
187                    label,
188                    value,
189                    row,
190                    col: full_match.start() as u16,
191                    width: full_match.as_str().len() as u16,
192                    checked: None,
193                });
194            }
195        }
196
197        for pattern in &patterns.menu_item {
198            for cap in pattern.captures_iter(line) {
199                let full_match = cap.get(0).unwrap();
200                let label = cap
201                    .get(2)
202                    .or_else(|| cap.get(1))
203                    .map(|m| m.as_str().trim().to_string());
204
205                matches.push(PatternMatch {
206                    element_type: ElementType::MenuItem,
207                    label,
208                    value: None,
209                    row,
210                    col: full_match.start() as u16,
211                    width: full_match.as_str().len() as u16,
212                    checked: None,
213                });
214            }
215        }
216
217        for pattern in &patterns.list_item {
218            for cap in pattern.captures_iter(line) {
219                let full_match = cap.get(0).unwrap();
220                let label = cap.get(1).map(|m| m.as_str().trim().to_string());
221
222                matches.push(PatternMatch {
223                    element_type: ElementType::ListItem,
224                    label,
225                    value: None,
226                    row,
227                    col: full_match.start() as u16,
228                    width: full_match.as_str().len() as u16,
229                    checked: None,
230                });
231            }
232        }
233
234        for pattern in &patterns.spinner {
235            for cap in pattern.captures_iter(line) {
236                let full_match = cap.get(0).unwrap();
237
238                matches.push(PatternMatch {
239                    element_type: ElementType::Spinner,
240                    label: None,
241                    value: None,
242                    row,
243                    col: full_match.start() as u16,
244                    width: full_match.as_str().len() as u16,
245                    checked: None,
246                });
247            }
248        }
249
250        for pattern in &patterns.progress {
251            for cap in pattern.captures_iter(line) {
252                let full_match = cap.get(0).unwrap();
253                let value = cap.get(1).map(|m| m.as_str().trim().to_string());
254
255                matches.push(PatternMatch {
256                    element_type: ElementType::Progress,
257                    label: None,
258                    value,
259                    row,
260                    col: full_match.start() as u16,
261                    width: full_match.as_str().len() as u16,
262                    checked: None,
263                });
264            }
265        }
266    }
267
268    deduplicate_matches(matches)
269}
270
271fn type_priority(t: &ElementType) -> i32 {
272    match t {
273        ElementType::Input => 10,
274        ElementType::Checkbox => 9,
275        ElementType::Radio => 9,
276        ElementType::Select => 8,
277        ElementType::Button => 7,
278        ElementType::MenuItem => 6,
279        ElementType::ListItem => 5,
280        ElementType::Spinner => 4,
281        ElementType::Progress => 3,
282    }
283}
284
285pub fn deduplicate_matches(mut matches: Vec<PatternMatch>) -> Vec<PatternMatch> {
286    matches.sort_by(|a, b| {
287        let priority_cmp = type_priority(&b.element_type).cmp(&type_priority(&a.element_type));
288        if priority_cmp != std::cmp::Ordering::Equal {
289            return priority_cmp;
290        }
291        if a.row != b.row {
292            return a.row.cmp(&b.row);
293        }
294        a.col.cmp(&b.col)
295    });
296
297    let mut result = Vec::new();
298    let mut occupied: HashSet<(u16, u16)> = HashSet::new();
299
300    for m in matches {
301        let mut overlaps = false;
302        for c in m.col..(m.col + m.width) {
303            if occupied.contains(&(m.row, c)) {
304                overlaps = true;
305                break;
306            }
307        }
308
309        if !overlaps {
310            for c in m.col..(m.col + m.width) {
311                occupied.insert((m.row, c));
312            }
313            result.push(m);
314        }
315    }
316
317    result
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn test_detect_button() {
326        let screen = "[Submit] [Cancel]";
327        let matches = detect_by_pattern(screen);
328
329        assert_eq!(matches.len(), 2);
330        assert!(matches
331            .iter()
332            .all(|m| m.element_type == ElementType::Button));
333    }
334
335    #[test]
336    fn test_detect_checkbox() {
337        let screen = "[x] Accept terms\n[ ] Subscribe to newsletter";
338        let matches = detect_by_pattern(screen);
339
340        let checkboxes: Vec<_> = matches
341            .iter()
342            .filter(|m| m.element_type == ElementType::Checkbox)
343            .collect();
344
345        assert_eq!(checkboxes.len(), 2);
346        assert_eq!(checkboxes[0].checked, Some(true));
347        assert_eq!(checkboxes[1].checked, Some(false));
348    }
349
350    #[test]
351    fn test_detect_input() {
352        let screen = "Name: [John Doe___]";
353        let matches = detect_by_pattern(screen);
354
355        let inputs: Vec<_> = matches
356            .iter()
357            .filter(|m| m.element_type == ElementType::Input)
358            .collect();
359
360        assert_eq!(inputs.len(), 1);
361        assert_eq!(inputs[0].label, Some("Name".to_string()));
362        assert_eq!(inputs[0].value, Some("John Doe".to_string()));
363    }
364
365    #[test]
366    fn test_detect_menu_item() {
367        let screen = "  > Option 1\n    Option 2\n    Option 3";
368        let matches = detect_by_pattern(screen);
369
370        let menu_items: Vec<_> = matches
371            .iter()
372            .filter(|m| m.element_type == ElementType::MenuItem)
373            .collect();
374
375        assert!(!menu_items.is_empty());
376    }
377
378    #[test]
379    fn test_deduplication() {
380        let screen = "[value___]";
381        let matches = detect_by_pattern(screen);
382
383        assert_eq!(matches.len(), 1);
384        assert_eq!(matches[0].element_type, ElementType::Input);
385    }
386}