agent_tui/detection/
mod.rs

1mod framework;
2mod frameworks;
3pub mod pattern;
4mod registry;
5mod traits;
6
7use crate::terminal::ScreenBuffer;
8use regex::Regex;
9use std::collections::HashSet;
10use std::sync::OnceLock;
11
12fn legacy_ref_regex() -> &'static Regex {
13    static RE: OnceLock<Regex> = OnceLock::new();
14    RE.get_or_init(|| Regex::new(r"^@([a-z]+)(\d+)$").unwrap())
15}
16
17pub use framework::{detect_framework, Framework};
18pub use registry::FrameworkDetector;
19pub use traits::{DetectionContext, ElementDetectorImpl};
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22pub enum ElementType {
23    Button,
24    Input,
25    Checkbox,
26    Radio,
27    Select,
28    MenuItem,
29    ListItem,
30    Spinner,
31    Progress,
32}
33
34impl ElementType {
35    pub fn as_str(&self) -> &'static str {
36        match self {
37            ElementType::Button => "button",
38            ElementType::Input => "input",
39            ElementType::Checkbox => "checkbox",
40            ElementType::Radio => "radio",
41            ElementType::Select => "select",
42            ElementType::MenuItem => "menuitem",
43            ElementType::ListItem => "listitem",
44            ElementType::Spinner => "spinner",
45            ElementType::Progress => "progress",
46        }
47    }
48}
49
50#[derive(Debug, Clone)]
51pub struct Position {
52    pub row: u16,
53    pub col: u16,
54    pub width: Option<u16>,
55    pub height: Option<u16>,
56}
57
58#[derive(Debug, Clone)]
59pub struct Element {
60    pub element_ref: String,
61    pub element_type: ElementType,
62    pub label: Option<String>,
63    pub value: Option<String>,
64    pub position: Position,
65    pub focused: bool,
66    pub selected: bool,
67    pub checked: Option<bool>,
68    pub disabled: Option<bool>,
69    pub hint: Option<String>,
70}
71
72impl Element {
73    pub fn new(
74        element_ref: String,
75        element_type: ElementType,
76        row: u16,
77        col: u16,
78        width: u16,
79    ) -> Self {
80        Self {
81            element_ref,
82            element_type,
83            label: None,
84            value: None,
85            position: Position {
86                row,
87                col,
88                width: Some(width),
89                height: Some(1),
90            },
91            focused: false,
92            selected: false,
93            checked: None,
94            disabled: None,
95            hint: None,
96        }
97    }
98
99    pub fn is_interactive(&self) -> bool {
100        matches!(
101            self.element_type,
102            ElementType::Button
103                | ElementType::Input
104                | ElementType::Checkbox
105                | ElementType::Radio
106                | ElementType::Select
107                | ElementType::MenuItem
108        )
109    }
110
111    pub fn has_content(&self) -> bool {
112        self.label
113            .as_ref()
114            .map(|l| !l.trim().is_empty())
115            .unwrap_or(false)
116            || self
117                .value
118                .as_ref()
119                .map(|v| !v.trim().is_empty())
120                .unwrap_or(false)
121    }
122}
123
124pub struct ElementDetector {
125    ref_counter: usize,
126    used_refs: HashSet<String>,
127}
128
129impl Default for ElementDetector {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135impl ElementDetector {
136    pub fn new() -> Self {
137        Self {
138            ref_counter: 0,
139            used_refs: HashSet::new(),
140        }
141    }
142
143    pub fn detect(
144        &mut self,
145        screen_text: &str,
146        screen_buffer: Option<&ScreenBuffer>,
147    ) -> Vec<Element> {
148        self.detect_with_framework(screen_text, screen_buffer, None)
149    }
150
151    pub fn detect_with_framework(
152        &mut self,
153        screen_text: &str,
154        screen_buffer: Option<&ScreenBuffer>,
155        framework_detector: Option<&FrameworkDetector>,
156    ) -> Vec<Element> {
157        use traits::ElementDetectorImpl;
158
159        self.ref_counter = 0;
160        self.used_refs.clear();
161
162        let mut elements = Vec::new();
163
164        let ctx = DetectionContext::new(screen_text, screen_buffer);
165
166        let default_detector;
167        let detector = match framework_detector {
168            Some(d) => d,
169            None => {
170                default_detector = FrameworkDetector::detect(&ctx);
171                &default_detector
172            }
173        };
174
175        let pattern_matches = detector.detect_patterns(&ctx);
176
177        for m in pattern_matches {
178            let focused = if let Some(buffer) = screen_buffer {
179                self.is_focused_by_style(buffer, m.row, m.col, m.width)
180            } else {
181                false
182            };
183
184            let element_ref = self.generate_ref(
185                &m.element_type,
186                m.label.as_deref(),
187                m.value.as_deref(),
188                m.row,
189                m.col,
190            );
191
192            let mut element = Element::new(element_ref, m.element_type, m.row, m.col, m.width);
193            element.label = m.label;
194            element.value = m.value;
195            element.focused = focused;
196            element.selected = m.checked.unwrap_or(false);
197            element.checked = m.checked;
198
199            elements.push(element);
200        }
201
202        elements.sort_by(|a, b| {
203            if a.position.row != b.position.row {
204                a.position.row.cmp(&b.position.row)
205            } else {
206                a.position.col.cmp(&b.position.col)
207            }
208        });
209
210        elements
211    }
212
213    fn is_focused_by_style(&self, buffer: &ScreenBuffer, row: u16, col: u16, width: u16) -> bool {
214        use crate::terminal::Color;
215
216        let row_idx = row as usize;
217        if row_idx >= buffer.cells.len() {
218            return false;
219        }
220
221        let row_cells = &buffer.cells[row_idx];
222        let start = col as usize;
223        let end = (col + width) as usize;
224
225        row_cells
226            .iter()
227            .take(end.min(row_cells.len()))
228            .skip(start)
229            .any(|cell| {
230                let style = &cell.style;
231
232                if style.inverse {
233                    return true;
234                }
235
236                if style.bold {
237                    let has_colored_fg = match &style.fg_color {
238                        Some(Color::Indexed(idx)) => *idx != 7 && *idx != 15,
239                        Some(Color::Rgb(_, _, _)) => true,
240                        Some(Color::Default) | None => false,
241                    };
242                    if has_colored_fg {
243                        return true;
244                    }
245                }
246
247                let has_highlight_bg = match &style.bg_color {
248                    Some(Color::Indexed(idx)) => *idx != 0 && *idx != 16,
249                    Some(Color::Rgb(r, g, b)) => *r > 20 || *g > 20 || *b > 20,
250                    Some(Color::Default) | None => false,
251                };
252                if has_highlight_bg && cell.char != ' ' {
253                    return true;
254                }
255
256                if style.underline && cell.char != ' ' && cell.char != '_' {
257                    return true;
258                }
259
260                false
261            })
262    }
263
264    fn generate_ref(
265        &mut self,
266        _element_type: &ElementType,
267        _label: Option<&str>,
268        _value: Option<&str>,
269        _row: u16,
270        _col: u16,
271    ) -> String {
272        self.ref_counter += 1;
273        let seq_ref = format!("@e{}", self.ref_counter);
274        self.used_refs.insert(seq_ref.clone());
275        seq_ref
276    }
277
278    pub fn find_by_ref<'a>(&self, elements: &'a [Element], ref_str: &str) -> Option<&'a Element> {
279        let normalized = if ref_str.starts_with('@') {
280            ref_str.to_string()
281        } else {
282            format!("@{}", ref_str)
283        };
284
285        if let Some(el) = elements.iter().find(|e| e.element_ref == normalized) {
286            return Some(el);
287        }
288
289        if let Some(caps) = legacy_ref_regex().captures(&normalized) {
290            let prefix = caps.get(1).map(|m| m.as_str()).unwrap_or("");
291            let index: usize = caps
292                .get(2)
293                .and_then(|m| m.as_str().parse().ok())
294                .unwrap_or(0);
295
296            if index > 0 && prefix != "e" {
297                let target_type = match prefix {
298                    "btn" => Some("button"),
299                    "inp" => Some("input"),
300                    "cb" => Some("checkbox"),
301                    "rb" => Some("radio"),
302                    "sel" => Some("select"),
303                    "mi" => Some("menuitem"),
304                    "li" => Some("listitem"),
305                    "lnk" => Some("link"),
306                    _ => None,
307                };
308
309                if let Some(type_str) = target_type {
310                    let matching: Vec<_> = elements
311                        .iter()
312                        .filter(|e| e.element_type.as_str() == type_str)
313                        .collect();
314
315                    if index <= matching.len() {
316                        return Some(matching[index - 1]);
317                    }
318                }
319            }
320        }
321
322        None
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_generate_sequential_ref() {
332        let mut detector = ElementDetector::new();
333        let ref1 = detector.generate_ref(&ElementType::Button, Some("Submit"), None, 5, 10);
334        let ref2 = detector.generate_ref(&ElementType::Input, Some("Name"), None, 6, 10);
335        let ref3 = detector.generate_ref(&ElementType::Button, Some("Cancel"), None, 7, 10);
336
337        assert_eq!(ref1, "@e1");
338        assert_eq!(ref2, "@e2");
339        assert_eq!(ref3, "@e3");
340    }
341
342    #[test]
343    fn test_refs_reset_on_detect() {
344        let mut detector = ElementDetector::new();
345
346        let elements1 = detector.detect("[Submit] [Cancel]", None);
347        assert!(elements1.iter().any(|e| e.element_ref == "@e1"));
348        assert!(elements1.iter().any(|e| e.element_ref == "@e2"));
349
350        let elements2 = detector.detect("[OK]", None);
351        assert!(elements2.iter().any(|e| e.element_ref == "@e1"));
352    }
353
354    #[test]
355    fn test_find_by_sequential_ref() {
356        let mut detector = ElementDetector::new();
357        let elements = detector.detect("[Submit] [Cancel]", None);
358
359        assert!(detector.find_by_ref(&elements, "@e1").is_some());
360        assert!(detector.find_by_ref(&elements, "@e2").is_some());
361        assert!(detector.find_by_ref(&elements, "@e3").is_none());
362    }
363
364    #[test]
365    fn test_find_by_legacy_ref() {
366        let mut detector = ElementDetector::new();
367        let elements = detector.detect("[Submit] [Cancel]", None);
368
369        let btn1 = detector.find_by_ref(&elements, "@btn1");
370        assert!(btn1.is_some());
371        assert_eq!(btn1.unwrap().element_type.as_str(), "button");
372
373        let btn2 = detector.find_by_ref(&elements, "@btn2");
374        assert!(btn2.is_some());
375        assert_eq!(btn2.unwrap().element_type.as_str(), "button");
376    }
377}