agent_tui/core/vom/
snapshot.rs

1use std::collections::HashMap;
2
3use super::{Component, Rect};
4
5#[derive(Debug, Clone)]
6pub struct SnapshotOptions {
7    pub interactive_only: bool,
8    /// Row threshold for Tab detection (elements on row <= threshold with inverse are Tabs).
9    /// Default: 2
10    pub tab_row_threshold: u16,
11}
12
13impl Default for SnapshotOptions {
14    fn default() -> Self {
15        Self {
16            interactive_only: false,
17            tab_row_threshold: 2,
18        }
19    }
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct Bounds {
24    pub x: u16,
25    pub y: u16,
26    pub width: u16,
27    pub height: u16,
28}
29
30impl From<Rect> for Bounds {
31    fn from(r: Rect) -> Self {
32        Self {
33            x: r.x,
34            y: r.y,
35            width: r.width,
36            height: r.height,
37        }
38    }
39}
40
41/// Reference to a detected UI element.
42///
43/// # Stability
44/// - `ref_id`: Sequential within a snapshot. Not stable across snapshots.
45/// - `visual_hash`: Uses `DefaultHasher`, stable within a session but not across binary versions.
46#[derive(Debug, Clone)]
47pub struct ElementRef {
48    pub role: String,
49    pub name: Option<String>,
50    pub bounds: Bounds,
51    pub visual_hash: u64,
52    pub nth: Option<usize>,
53    pub selected: bool,
54}
55
56#[derive(Debug, Clone, Default)]
57pub struct RefMap {
58    pub refs: HashMap<String, ElementRef>,
59}
60
61impl RefMap {
62    pub fn get(&self, ref_id: &str) -> Option<&ElementRef> {
63        self.refs.get(ref_id)
64    }
65}
66
67#[derive(Debug, Clone)]
68pub struct SnapshotStats {
69    pub total: usize,
70    pub interactive: usize,
71    pub lines: usize,
72}
73
74#[derive(Debug, Clone)]
75pub struct AccessibilitySnapshot {
76    pub tree: String,
77    pub refs: RefMap,
78    pub stats: SnapshotStats,
79}
80
81pub fn format_snapshot(
82    components: &[Component],
83    options: &SnapshotOptions,
84) -> AccessibilitySnapshot {
85    let mut refs = RefMap::default();
86    let mut lines = Vec::with_capacity(components.len());
87    let mut ref_counter = 0usize;
88    let mut interactive_count = 0usize;
89    // Preallocate for typical number of unique roles in a TUI screen
90    let mut role_counts: HashMap<String, usize> = HashMap::with_capacity(16);
91
92    for component in components {
93        if options.interactive_only && !component.role.is_interactive() {
94            continue;
95        }
96
97        ref_counter += 1;
98        let ref_id = format!("e{}", ref_counter);
99
100        if component.role.is_interactive() {
101            interactive_count += 1;
102        }
103
104        let name = component.text_content.trim();
105        let role_str = component.role.to_string();
106
107        // Compute nth (0-indexed ordinal within same role)
108        let entry = role_counts.entry(role_str.clone()).or_insert(0);
109        let nth = *entry;
110        *entry += 1;
111
112        let line = if name.is_empty() {
113            format!("- {} [ref={}]", component.role, ref_id)
114        } else {
115            let escaped = name.replace('"', "\\\"");
116            format!("- {} \"{}\" [ref={}]", component.role, escaped, ref_id)
117        };
118        lines.push(line);
119
120        refs.refs.insert(
121            ref_id,
122            ElementRef {
123                role: role_str,
124                name: (!name.is_empty()).then(|| name.to_string()),
125                bounds: component.bounds.into(),
126                visual_hash: component.visual_hash,
127                nth: Some(nth),
128                selected: component.selected,
129            },
130        );
131    }
132
133    let tree = lines.join("\n");
134    let line_count = lines.len();
135
136    AccessibilitySnapshot {
137        tree,
138        refs,
139        stats: SnapshotStats {
140            total: ref_counter,
141            interactive: interactive_count,
142            lines: line_count,
143        },
144    }
145}
146
147pub fn parse_ref(arg: &str) -> Option<String> {
148    if let Some(stripped) = arg.strip_prefix('@') {
149        Some(stripped.to_string())
150    } else if let Some(stripped) = arg.strip_prefix("ref=") {
151        Some(stripped.to_string())
152    } else if let Some(suffix) = arg.strip_prefix('e') {
153        if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
154            Some(arg.to_string())
155        } else {
156            None
157        }
158    } else {
159        None
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::core::vom::Role;
167    use uuid::Uuid;
168
169    fn make_component(role: Role, text: &str, x: u16, y: u16, width: u16) -> Component {
170        Component {
171            id: Uuid::new_v4(),
172            role,
173            bounds: Rect::new(x, y, width, 1),
174            text_content: text.to_string(),
175            visual_hash: 12345,
176            selected: false,
177        }
178    }
179
180    #[test]
181    fn test_snapshot_text_format_button() {
182        let components = vec![make_component(Role::Button, "[ OK ]", 10, 5, 6)];
183        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
184
185        assert!(snapshot.tree.contains("button"));
186        assert!(snapshot.tree.contains("[ref=e1]"));
187        assert!(snapshot.tree.contains("[ OK ]"));
188    }
189
190    #[test]
191    fn test_snapshot_text_format_multiple() {
192        let components = vec![
193            make_component(Role::Button, "[ OK ]", 10, 5, 6),
194            make_component(Role::Input, ">", 0, 0, 1),
195            make_component(Role::StaticText, "Hello", 0, 1, 5),
196        ];
197        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
198
199        assert!(snapshot.tree.contains("[ref=e1]"));
200        assert!(snapshot.tree.contains("[ref=e2]"));
201        assert!(snapshot.tree.contains("[ref=e3]"));
202    }
203
204    #[test]
205    fn test_snapshot_refs_sequential() {
206        let components = vec![
207            make_component(Role::Button, "A", 0, 0, 1),
208            make_component(Role::Button, "B", 5, 0, 1),
209            make_component(Role::Input, "C", 10, 0, 1),
210        ];
211        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
212
213        assert!(snapshot.refs.get("e1").is_some());
214        assert!(snapshot.refs.get("e2").is_some());
215        assert!(snapshot.refs.get("e3").is_some());
216        assert!(snapshot.refs.get("e4").is_none());
217    }
218
219    #[test]
220    fn test_snapshot_stats() {
221        let components = vec![
222            make_component(Role::Button, "A", 0, 0, 1),
223            make_component(Role::StaticText, "B", 5, 0, 1),
224        ];
225        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
226
227        assert_eq!(snapshot.stats.total, 2);
228        assert_eq!(snapshot.stats.interactive, 1);
229    }
230
231    #[test]
232    fn test_parse_ref_at_prefix() {
233        assert_eq!(parse_ref("@e1"), Some("e1".to_string()));
234        assert_eq!(parse_ref("@e42"), Some("e42".to_string()));
235    }
236
237    #[test]
238    fn test_parse_ref_ref_equals() {
239        assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
240    }
241
242    #[test]
243    fn test_parse_ref_bare() {
244        assert_eq!(parse_ref("e1"), Some("e1".to_string()));
245        assert_eq!(parse_ref("e123"), Some("e123".to_string()));
246    }
247
248    #[test]
249    fn test_parse_ref_invalid() {
250        assert_eq!(parse_ref("button"), None);
251        assert_eq!(parse_ref("1"), None);
252        assert_eq!(parse_ref(""), None);
253    }
254
255    #[test]
256    fn test_refmap_contains_bounds() {
257        let components = vec![make_component(Role::Button, "OK", 10, 5, 6)];
258        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
259
260        let elem = snapshot.refs.get("e1").unwrap();
261        assert_eq!(elem.bounds.x, 10);
262        assert_eq!(elem.bounds.y, 5);
263        assert_eq!(elem.bounds.width, 6);
264        assert_eq!(elem.bounds.height, 1);
265    }
266
267    #[test]
268    fn test_refmap_contains_role() {
269        let components = vec![make_component(Role::Input, ">", 0, 0, 1)];
270        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
271
272        let elem = snapshot.refs.get("e1").unwrap();
273        assert_eq!(elem.role, "input");
274    }
275
276    #[test]
277    fn test_refmap_contains_name() {
278        let components = vec![make_component(Role::Button, "Submit", 0, 0, 6)];
279        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
280
281        let elem = snapshot.refs.get("e1").unwrap();
282        assert_eq!(elem.name, Some("Submit".to_string()));
283    }
284
285    #[test]
286    fn test_refmap_empty_name_is_none() {
287        let components = vec![make_component(Role::Panel, "", 0, 0, 10)];
288        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
289
290        let elem = snapshot.refs.get("e1").unwrap();
291        assert_eq!(elem.name, None);
292    }
293
294    #[test]
295    fn test_refmap_lookup_missing() {
296        let components = vec![make_component(Role::Button, "OK", 0, 0, 2)];
297        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
298
299        assert!(snapshot.refs.get("e999").is_none());
300    }
301
302    #[test]
303    fn test_interactive_filter_excludes_static_text() {
304        let components = vec![
305            make_component(Role::Button, "OK", 0, 0, 2),
306            make_component(Role::StaticText, "Hello", 5, 0, 5),
307            make_component(Role::Input, ">", 0, 1, 1),
308        ];
309        let options = SnapshotOptions {
310            interactive_only: true,
311            ..Default::default()
312        };
313        let snapshot = format_snapshot(&components, &options);
314
315        assert_eq!(snapshot.stats.total, 2);
316        assert!(!snapshot.tree.contains("text"));
317        assert!(snapshot.tree.contains("button"));
318        assert!(snapshot.tree.contains("input"));
319    }
320
321    #[test]
322    fn test_interactive_filter_excludes_panel() {
323        let components = vec![
324            make_component(Role::Panel, "───", 0, 0, 3),
325            make_component(Role::Button, "OK", 5, 0, 2),
326        ];
327        let options = SnapshotOptions {
328            interactive_only: true,
329            ..Default::default()
330        };
331        let snapshot = format_snapshot(&components, &options);
332
333        assert_eq!(snapshot.stats.total, 1);
334        assert!(!snapshot.tree.contains("panel"));
335        assert!(snapshot.tree.contains("button"));
336    }
337
338    #[test]
339    fn test_interactive_filter_excludes_status() {
340        let components = vec![
341            make_component(Role::Status, "⠋ Loading", 0, 0, 10),
342            make_component(Role::Button, "Cancel", 0, 1, 6),
343        ];
344        let options = SnapshotOptions {
345            interactive_only: true,
346            ..Default::default()
347        };
348        let snapshot = format_snapshot(&components, &options);
349
350        assert_eq!(snapshot.stats.total, 1);
351        assert!(!snapshot.tree.contains("status"));
352        assert!(snapshot.tree.contains("button"));
353    }
354
355    #[test]
356    fn test_interactive_filter_includes_all_interactive() {
357        let components = vec![
358            make_component(Role::Button, "OK", 0, 0, 2),
359            make_component(Role::Input, ">", 0, 1, 1),
360            make_component(Role::Checkbox, "[x]", 0, 2, 3),
361            make_component(Role::MenuItem, "> opt", 0, 3, 5),
362            make_component(Role::Tab, "Tab1", 0, 4, 4),
363            make_component(Role::PromptMarker, ">", 0, 5, 1),
364        ];
365        let options = SnapshotOptions {
366            interactive_only: true,
367            ..Default::default()
368        };
369        let snapshot = format_snapshot(&components, &options);
370
371        assert_eq!(snapshot.stats.total, 6);
372        assert_eq!(snapshot.stats.interactive, 6);
373    }
374
375    #[test]
376    fn test_snapshot_escapes_quotes_in_name() {
377        let components = vec![make_component(Role::Button, r#"Say "Hello""#, 0, 0, 12)];
378        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
379        assert!(snapshot.tree.contains(r#"Say \"Hello\""#));
380    }
381
382    #[test]
383    fn test_interactive_filter_refs_renumbered() {
384        let components = vec![
385            make_component(Role::StaticText, "A", 0, 0, 1),
386            make_component(Role::Button, "B", 0, 1, 1),
387            make_component(Role::StaticText, "C", 0, 2, 1),
388            make_component(Role::Input, "D", 0, 3, 1),
389        ];
390        let options = SnapshotOptions {
391            interactive_only: true,
392            ..Default::default()
393        };
394        let snapshot = format_snapshot(&components, &options);
395
396        assert!(snapshot.refs.get("e1").is_some());
397        assert!(snapshot.refs.get("e2").is_some());
398        assert!(snapshot.refs.get("e3").is_none());
399    }
400
401    #[test]
402    fn test_nth_field_populated() {
403        let components = vec![
404            make_component(Role::Button, "A", 0, 0, 1),
405            make_component(Role::StaticText, "text", 5, 0, 4),
406            make_component(Role::Button, "B", 10, 0, 1),
407            make_component(Role::Button, "C", 15, 0, 1),
408        ];
409        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
410
411        // First button: nth=0
412        let e1 = snapshot.refs.get("e1").unwrap();
413        assert_eq!(e1.nth, Some(0));
414
415        // StaticText: nth=0 (first of its role)
416        let e2 = snapshot.refs.get("e2").unwrap();
417        assert_eq!(e2.nth, Some(0));
418
419        // Second button: nth=1
420        let e3 = snapshot.refs.get("e3").unwrap();
421        assert_eq!(e3.nth, Some(1));
422
423        // Third button: nth=2
424        let e4 = snapshot.refs.get("e4").unwrap();
425        assert_eq!(e4.nth, Some(2));
426    }
427
428    #[test]
429    fn test_selected_state_from_inverse() {
430        let mut comp = make_component(Role::MenuItem, "Option 1", 0, 0, 8);
431        comp.selected = true;
432        let components = vec![comp];
433        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
434        let elem = snapshot.refs.get("e1").unwrap();
435        assert!(elem.selected);
436    }
437
438    #[test]
439    fn test_selected_state_default_false() {
440        let comp = make_component(Role::MenuItem, "Option 1", 0, 0, 8);
441        let components = vec![comp];
442        let snapshot = format_snapshot(&components, &SnapshotOptions::default());
443        let elem = snapshot.refs.get("e1").unwrap();
444        assert!(!elem.selected);
445    }
446
447    mod prop_tests {
448        use super::*;
449        use proptest::prelude::*;
450
451        fn arb_role() -> impl Strategy<Value = Role> {
452            prop_oneof![
453                Just(Role::Button),
454                Just(Role::Tab),
455                Just(Role::Input),
456                Just(Role::StaticText),
457                Just(Role::Panel),
458                Just(Role::Checkbox),
459                Just(Role::MenuItem),
460                Just(Role::Status),
461                Just(Role::ToolBlock),
462                Just(Role::PromptMarker),
463            ]
464        }
465
466        fn arb_component() -> impl Strategy<Value = Component> {
467            (
468                arb_role(),
469                "[a-zA-Z0-9 ]{0,20}",
470                0u16..100,
471                0u16..50,
472                1u16..20,
473            )
474                .prop_map(|(role, text, x, y, width)| Component {
475                    id: Uuid::new_v4(),
476                    role,
477                    bounds: Rect::new(x, y, width, 1),
478                    text_content: text,
479                    visual_hash: 12345,
480                    selected: false,
481                })
482        }
483
484        proptest! {
485            #[test]
486            fn snapshot_is_deterministic(
487                components in prop::collection::vec(arb_component(), 1..20)
488            ) {
489                let options = SnapshotOptions::default();
490
491                let snapshot1 = format_snapshot(&components, &options);
492                let snapshot2 = format_snapshot(&components, &options);
493
494                prop_assert_eq!(&snapshot1.tree, &snapshot2.tree);
495                prop_assert_eq!(snapshot1.stats.total, snapshot2.stats.total);
496                prop_assert_eq!(snapshot1.stats.interactive, snapshot2.stats.interactive);
497                prop_assert_eq!(snapshot1.stats.lines, snapshot2.stats.lines);
498                prop_assert_eq!(snapshot1.refs.refs.len(), snapshot2.refs.refs.len());
499            }
500
501            #[test]
502            fn snapshot_ref_count_matches_components(
503                components in prop::collection::vec(arb_component(), 0..20)
504            ) {
505                let options = SnapshotOptions::default();
506                let snapshot = format_snapshot(&components, &options);
507
508                prop_assert_eq!(snapshot.refs.refs.len(), components.len());
509                prop_assert_eq!(snapshot.stats.total, components.len());
510            }
511
512            #[test]
513            fn interactive_filter_reduces_or_maintains_count(
514                components in prop::collection::vec(arb_component(), 0..20)
515            ) {
516                let all_snapshot = format_snapshot(&components, &SnapshotOptions::default());
517                let interactive_snapshot = format_snapshot(
518                    &components,
519                    &SnapshotOptions { interactive_only: true, ..Default::default() }
520                );
521
522                prop_assert!(interactive_snapshot.stats.total <= all_snapshot.stats.total);
523                prop_assert!(interactive_snapshot.refs.refs.len() <= all_snapshot.refs.refs.len());
524            }
525
526            #[test]
527            fn refs_are_sequential_starting_at_e1(
528                components in prop::collection::vec(arb_component(), 1..10)
529            ) {
530                let snapshot = format_snapshot(&components, &SnapshotOptions::default());
531
532                for i in 1..=components.len() {
533                    let ref_key = format!("e{}", i);
534                    prop_assert!(
535                        snapshot.refs.get(&ref_key).is_some(),
536                        "Missing ref: {}", ref_key
537                    );
538                }
539
540                let extra_ref = format!("e{}", components.len() + 1);
541                prop_assert!(snapshot.refs.get(&extra_ref).is_none());
542            }
543
544            #[test]
545            fn tree_contains_all_refs(
546                components in prop::collection::vec(arb_component(), 1..10)
547            ) {
548                let snapshot = format_snapshot(&components, &SnapshotOptions::default());
549
550                for i in 1..=components.len() {
551                    let ref_marker = format!("[ref=e{}]", i);
552                    prop_assert!(
553                        snapshot.tree.contains(&ref_marker),
554                        "Tree missing ref marker: {}", ref_marker
555                    );
556                }
557            }
558
559            #[test]
560            fn nth_is_sequential_per_role(
561                components in prop::collection::vec(arb_component(), 1..20)
562            ) {
563                let snapshot = format_snapshot(&components, &SnapshotOptions::default());
564
565                // Group elements by role
566                let mut by_role: HashMap<String, Vec<usize>> = HashMap::new();
567                for elem in snapshot.refs.refs.values() {
568                    by_role.entry(elem.role.clone())
569                        .or_default()
570                        .push(elem.nth.unwrap());
571                }
572
573                // Each role's nth values should be 0..n
574                for (role, mut nths) in by_role {
575                    nths.sort();
576                    let expected: Vec<usize> = (0..nths.len()).collect();
577                    prop_assert_eq!(nths, expected, "Non-sequential nth for role: {}", role);
578                }
579            }
580        }
581    }
582}