agent_tui/detection/
framework.rs

1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2pub enum Framework {
3    Ink,
4    Blessed,
5    BubbleTea,
6    Textual,
7    Ncurses,
8    Inquirer,
9    Prompts,
10    Ratatui,
11    Unknown,
12}
13
14// Character signature constants for framework detection
15mod signatures {
16    pub const BRAILLE_SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
17    pub const CHARM_SPINNERS: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
18    pub const BLOCK_CHARS: &[&str] = &["█", "▓", "▒", "░", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
19    pub const SPARKLINE_CHARS: &[&str] = &["▁", "▂", "▃", "▄", "▅", "▆", "▇"];
20    pub const SELECT_POINTERS: &[char] = &['❯', '›'];
21    pub const INQUIRER_CIRCLES: &[char] = &['◯', '◉'];
22    pub const HEAVY_BORDERS: &[&str] = &["┏", "┓", "┗"];
23}
24
25fn has_any(screen: &str, chars: &[&str]) -> bool {
26    chars.iter().any(|c| screen.contains(c))
27}
28
29fn has_all(screen: &str, chars: &[&str]) -> bool {
30    chars.iter().all(|c| screen.contains(c))
31}
32
33fn count_matches(screen: &str, chars: &[&str]) -> usize {
34    chars.iter().filter(|c| screen.contains(*c)).count()
35}
36
37fn line_starts_with_any(screen: &str, chars: &[char]) -> bool {
38    screen.lines().any(|l| {
39        let trimmed = l.trim();
40        chars.iter().any(|c| trimmed.starts_with(*c))
41    })
42}
43
44type Detector = fn(&str) -> bool;
45
46const DETECTORS: &[(Framework, Detector)] = &[
47    (Framework::Inquirer, is_inquirer),
48    (Framework::Ink, is_ink),
49    (Framework::Prompts, is_prompts),
50    (Framework::BubbleTea, is_bubbletea),
51    (Framework::Textual, is_textual),
52    (Framework::Blessed, is_blessed),
53    (Framework::Ratatui, is_ratatui),
54    (Framework::Ncurses, is_ncurses),
55];
56
57pub fn detect_framework(screen: &str) -> Framework {
58    DETECTORS
59        .iter()
60        .find(|(_, detect)| detect(screen))
61        .map(|(fw, _)| *fw)
62        .unwrap_or(Framework::Unknown)
63}
64
65fn is_ink(screen: &str) -> bool {
66    use signatures::*;
67    let has_spinner = has_any(screen, BRAILLE_SPINNERS);
68    let has_pointer = SELECT_POINTERS.iter().any(|c| screen.contains(*c));
69    let has_select = has_pointer && line_starts_with_any(screen, SELECT_POINTERS);
70    has_spinner || has_select || (screen.contains("?") && has_pointer)
71}
72
73fn is_inquirer(screen: &str) -> bool {
74    use signatures::*;
75    let circle_count = screen
76        .lines()
77        .filter(|l| INQUIRER_CIRCLES.iter().any(|c| l.trim().starts_with(*c)))
78        .count();
79    circle_count >= 2
80        || screen.contains("◻")
81        || screen.contains("◼")
82        || screen.contains("(Y/n)")
83        || screen.contains("(y/N)")
84}
85
86fn is_prompts(screen: &str) -> bool {
87    let toggle = screen.contains("◉") && screen.contains("○");
88    let select = screen.contains("›") && screen.lines().any(|l| l.contains("←") || l.contains("→"));
89    toggle || select
90}
91
92fn is_bubbletea(screen: &str) -> bool {
93    use signatures::*;
94    let has_spinner = has_any(screen, CHARM_SPINNERS);
95    let has_help = ["q: quit", "ctrl+c", "esc: back", "enter: select"]
96        .iter()
97        .any(|s| screen.contains(s));
98    let has_input = screen.contains("│") && screen.lines().any(|l| l.contains(">"));
99    has_spinner || (has_help && has_input)
100}
101
102fn is_textual(screen: &str) -> bool {
103    use signatures::*;
104    let footer_keys = ["^q", "^c", "F1", "ESC"];
105    let has_footer = screen
106        .lines()
107        .last()
108        .is_some_and(|l| footer_keys.iter().any(|k| l.contains(k)));
109    let has_borders = has_all(screen, HEAVY_BORDERS);
110    let has_table = has_all(screen, &["│", "─", "┼"]);
111    has_footer || has_borders || has_table
112}
113
114fn is_blessed(screen: &str) -> bool {
115    let has_scrollbar = screen.contains("▒") || screen.contains("░");
116    let has_input = has_all(screen, &["[", "]"]) && screen.contains("_____");
117    let has_box = has_all(screen, &["┌─", "─┐", "└─"]);
118    (has_scrollbar && has_box) || has_input
119}
120
121fn is_ratatui(screen: &str) -> bool {
122    use signatures::*;
123    count_matches(screen, BLOCK_CHARS) >= 3 || count_matches(screen, SPARKLINE_CHARS) >= 3
124}
125
126fn is_ncurses(screen: &str) -> bool {
127    let has_fkeys = ["F1", "F2", "F10", "^X"].iter().any(|k| screen.contains(k));
128    let has_box = has_all(screen, &["+-", "-+"]) && (screen.contains("|") || screen.contains("│"));
129    let has_menu = screen
130        .lines()
131        .next()
132        .is_some_and(|l| l.contains("File") || l.contains("Help"));
133    (has_fkeys && has_box) || has_menu
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_detect_ink() {
142        let screen = "? Select a color\n  ❯ Red\n    Blue\n    Green";
143        assert_eq!(detect_framework(screen), Framework::Ink);
144    }
145
146    #[test]
147    fn test_detect_inquirer() {
148        let screen = "? Choose an option (Y/n)\n  ◯ Option 1\n  ◉ Option 2";
149        assert_eq!(detect_framework(screen), Framework::Inquirer);
150    }
151
152    #[test]
153    fn test_detect_bubbletea() {
154        let screen = "My App\n\n> Select an item\n\nq: quit | enter: select";
155        assert!(matches!(
156            detect_framework(screen),
157            Framework::BubbleTea | Framework::Unknown
158        ));
159    }
160
161    #[test]
162    fn test_detect_unknown() {
163        let screen = "Hello World";
164        assert_eq!(detect_framework(screen), Framework::Unknown);
165    }
166
167    #[test]
168    fn test_detect_ratatui() {
169        let screen = "Progress: [████████░░░░░░░░] 50%\nSparkline: ▁▂▃▄▅▆▇█";
170        assert_eq!(detect_framework(screen), Framework::Ratatui);
171    }
172}