Skip to main content

limit_cli/tui/commands/
browser.rs

1//! Browser command for TUI
2//!
3//! Provides `/browser` command for browser automation in the TUI.
4
5use super::{Command, CommandContext, CommandResult};
6use crate::error::CliError;
7use crate::tools::browser::client_ext::{InteractionExt, NavigationExt, QueryExt};
8use crate::tools::browser::{BrowserClient, BrowserConfig};
9use std::sync::Arc;
10use tokio::sync::Mutex;
11
12/// Browser automation command
13pub struct BrowserCommand {
14    client: Arc<Mutex<BrowserClient>>,
15}
16
17impl BrowserCommand {
18    /// Create a new browser command
19    pub fn new() -> Self {
20        let client = BrowserClient::with_default_config();
21        Self {
22            client: Arc::new(Mutex::new(client)),
23        }
24    }
25
26    /// Create a browser command with custom config
27    pub fn with_config(config: BrowserConfig) -> Self {
28        use crate::tools::browser::executor::CliExecutor;
29        let executor = Arc::new(CliExecutor::new(config));
30        let client = BrowserClient::new(executor);
31        Self {
32            client: Arc::new(Mutex::new(client)),
33        }
34    }
35
36    /// Show help for the browser command
37    fn show_help(&self, ctx: &mut CommandContext) -> CommandResult {
38        let help_text = r#"Browser automation commands:
39
40Navigation:
41  /browser open <url>        Open a URL in the browser
42  /browser back              Navigate back in history
43  /browser forward           Navigate forward in history
44  /browser reload            Reload the current page
45
46Page Interaction:
47  /browser snapshot          Take an accessibility snapshot
48  /browser click <selector>  Click an element
49  /browser fill <sel> <text> Fill a form field (instant)
50  /browser type <sel> <text> Type text character by character
51  /browser hover <selector>  Hover over an element
52  /browser select <sel> <val> Select option in dropdown
53  /browser press <key>       Press a keyboard key
54
55State & Info:
56  /browser screenshot <path> Save a screenshot
57  /browser get <what>        Get page content (text, html, url, title)
58  /browser scroll <dir> [px] Scroll page (up/down/left/right)
59  /browser is <what> <sel>   Check element state (visible, enabled, etc.)
60  /browser close             Close the browser
61  /browser help              Show this help
62
63Examples:
64  /browser open https://example.com
65  /browser snapshot
66  /browser click "button.submit"
67  /browser fill "input[name=email]" "test@example.com"
68  /browser type "input[name=password]" "secret"
69  /browser hover "@e5"
70  /browser press Enter
71  /browser scroll down 100
72  /browser is visible "@e3"
73  /browser screenshot /tmp/page.png
74  /browser get title"#;
75
76        ctx.add_system_message(help_text.to_string());
77        CommandResult::Continue
78    }
79
80    /// Parse arguments from the input string
81    fn parse_args(&self, input: &str) -> Vec<String> {
82        // Simple argument parsing - handle quoted strings
83        let mut args = Vec::new();
84        let mut current = String::new();
85        let mut in_quotes = false;
86        let mut quote_char = ' ';
87
88        for ch in input.chars() {
89            match ch {
90                '"' | '\'' if !in_quotes => {
91                    in_quotes = true;
92                    quote_char = ch;
93                }
94                c if c == quote_char && in_quotes => {
95                    in_quotes = false;
96                    quote_char = ' ';
97                }
98                ' ' if !in_quotes => {
99                    if !current.is_empty() {
100                        args.push(current.clone());
101                        current.clear();
102                    }
103                }
104                _ => {
105                    current.push(ch);
106                }
107            }
108        }
109
110        if !current.is_empty() {
111            args.push(current);
112        }
113
114        args
115    }
116}
117
118impl Default for BrowserCommand {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl Command for BrowserCommand {
125    fn name(&self) -> &str {
126        "browser"
127    }
128
129    fn aliases(&self) -> Vec<&str> {
130        vec!["b"]
131    }
132
133    fn description(&self) -> &str {
134        "Browser automation for testing, scraping, and screenshots"
135    }
136
137    fn usage(&self) -> Vec<&str> {
138        vec![
139            "/browser open <url>",
140            "/browser close",
141            "/browser snapshot",
142            "/browser click <selector>",
143            "/browser fill <selector> <text>",
144            "/browser type <selector> <text>",
145            "/browser hover <selector>",
146            "/browser press <key>",
147            "/browser select <selector> <value>",
148            "/browser screenshot <path>",
149            "/browser get <text|html|url|title>",
150            "/browser back",
151            "/browser forward",
152            "/browser reload",
153            "/browser scroll <up|down|left|right> [pixels]",
154            "/browser is <visible|hidden|enabled|disabled|editable> <selector>",
155        ]
156    }
157
158    fn execute(&self, args: &str, ctx: &mut CommandContext) -> Result<CommandResult, CliError> {
159        let args = args.trim();
160
161        // Show help if no args or explicit help request
162        if args.is_empty() || args == "help" {
163            return Ok(self.show_help(ctx));
164        }
165
166        let parsed = self.parse_args(args);
167        if parsed.is_empty() {
168            return Ok(self.show_help(ctx));
169        }
170
171        let action = parsed[0].to_lowercase();
172        let client = self.client.clone();
173
174        // Create a tokio runtime for async operations
175        let rt = tokio::runtime::Runtime::new()
176            .map_err(|e| CliError::Other(format!("Failed to create runtime: {}", e)))?;
177
178        let result = rt.block_on(async {
179            let client = client.lock().await;
180
181            match action.as_str() {
182                "open" => {
183                    if parsed.len() < 2 {
184                        return Err(CliError::Other("Usage: /browser open <url>".to_string()));
185                    }
186                    let url = &parsed[1];
187                    client
188                        .open(url)
189                        .await
190                        .map_err(|e| CliError::Other(format!("Failed to open URL: {}", e)))?;
191                    ctx.add_system_message(format!("Opened: {}", url));
192                    Ok(CommandResult::Continue)
193                }
194
195                "close" => {
196                    client
197                        .close()
198                        .await
199                        .map_err(|e| CliError::Other(format!("Failed to close browser: {}", e)))?;
200                    ctx.add_system_message("Browser closed".to_string());
201                    Ok(CommandResult::Continue)
202                }
203
204                "snapshot" => {
205                    let result = client
206                        .snapshot()
207                        .await
208                        .map_err(|e| CliError::Other(format!("Failed to take snapshot: {}", e)))?;
209
210                    let mut msg = String::new();
211                    if let Some(title) = &result.title {
212                        msg.push_str(&format!("Title: {}\n", title));
213                    }
214                    if let Some(url) = &result.url {
215                        msg.push_str(&format!("URL: {}\n", url));
216                    }
217                    msg.push_str("\n--- Snapshot ---\n");
218                    msg.push_str(&result.content);
219
220                    ctx.add_system_message(msg);
221                    Ok(CommandResult::Continue)
222                }
223
224                "click" => {
225                    if parsed.len() < 2 {
226                        return Err(CliError::Other(
227                            "Usage: /browser click <selector>".to_string(),
228                        ));
229                    }
230                    let selector = &parsed[1];
231                    client
232                        .click(selector)
233                        .await
234                        .map_err(|e| CliError::Other(format!("Failed to click: {}", e)))?;
235                    ctx.add_system_message(format!("Clicked: {}", selector));
236                    Ok(CommandResult::Continue)
237                }
238
239                "fill" => {
240                    if parsed.len() < 3 {
241                        return Err(CliError::Other(
242                            "Usage: /browser fill <selector> <text>".to_string(),
243                        ));
244                    }
245                    let selector = &parsed[1];
246                    let text = &parsed[2];
247                    client
248                        .fill(selector, text)
249                        .await
250                        .map_err(|e| CliError::Other(format!("Failed to fill: {}", e)))?;
251                    ctx.add_system_message(format!("Filled {} with text", selector));
252                    Ok(CommandResult::Continue)
253                }
254
255                "screenshot" => {
256                    if parsed.len() < 2 {
257                        return Err(CliError::Other(
258                            "Usage: /browser screenshot <path>".to_string(),
259                        ));
260                    }
261                    let path = &parsed[1];
262                    client.screenshot(path).await.map_err(|e| {
263                        CliError::Other(format!("Failed to take screenshot: {}", e))
264                    })?;
265                    ctx.add_system_message(format!("Screenshot saved to: {}", path));
266                    Ok(CommandResult::Continue)
267                }
268
269                "get" => {
270                    if parsed.len() < 2 {
271                        return Err(CliError::Other(
272                            "Usage: /browser get <text|html|url|title>".to_string(),
273                        ));
274                    }
275                    let what = &parsed[1];
276                    let content = client
277                        .get(what)
278                        .await
279                        .map_err(|e| CliError::Other(format!("Failed to get {}: {}", what, e)))?;
280                    ctx.add_system_message(format!("{}: {}", what, content));
281                    Ok(CommandResult::Continue)
282                }
283
284                // Navigation commands
285                "back" => {
286                    client
287                        .back()
288                        .await
289                        .map_err(|e| CliError::Other(format!("Failed to navigate back: {}", e)))?;
290                    ctx.add_system_message("Navigated back".to_string());
291                    Ok(CommandResult::Continue)
292                }
293
294                "forward" => {
295                    client
296                        .forward()
297                        .await
298                        .map_err(|e| CliError::Other(format!("Failed to navigate forward: {}", e)))?;
299                    ctx.add_system_message("Navigated forward".to_string());
300                    Ok(CommandResult::Continue)
301                }
302
303                "reload" => {
304                    client
305                        .reload()
306                        .await
307                        .map_err(|e| CliError::Other(format!("Failed to reload: {}", e)))?;
308                    ctx.add_system_message("Page reloaded".to_string());
309                    Ok(CommandResult::Continue)
310                }
311
312                // Input commands
313                "type" => {
314                    if parsed.len() < 3 {
315                        return Err(CliError::Other(
316                            "Usage: /browser type <selector> <text>".to_string(),
317                        ));
318                    }
319                    let selector = &parsed[1];
320                    let text = &parsed[2];
321                    client
322                        .type_text(selector, text)
323                        .await
324                        .map_err(|e| CliError::Other(format!("Failed to type: {}", e)))?;
325                    ctx.add_system_message(format!("Typed text into {}", selector));
326                    Ok(CommandResult::Continue)
327                }
328
329                "press" => {
330                    if parsed.len() < 2 {
331                        return Err(CliError::Other("Usage: /browser press <key>".to_string()));
332                    }
333                    let key = &parsed[1];
334                    client
335                        .press(key)
336                        .await
337                        .map_err(|e| CliError::Other(format!("Failed to press key: {}", e)))?;
338                    ctx.add_system_message(format!("Pressed: {}", key));
339                    Ok(CommandResult::Continue)
340                }
341
342                "hover" => {
343                    if parsed.len() < 2 {
344                        return Err(CliError::Other(
345                            "Usage: /browser hover <selector>".to_string(),
346                        ));
347                    }
348                    let selector = &parsed[1];
349                    client
350                        .hover(selector)
351                        .await
352                        .map_err(|e| CliError::Other(format!("Failed to hover: {}", e)))?;
353                    ctx.add_system_message(format!("Hovered over: {}", selector));
354                    Ok(CommandResult::Continue)
355                }
356
357                "select" => {
358                    if parsed.len() < 3 {
359                        return Err(CliError::Other(
360                            "Usage: /browser select <selector> <value>".to_string(),
361                        ));
362                    }
363                    let selector = &parsed[1];
364                    let value = &parsed[2];
365                    client
366                        .select_option(selector, value)
367                        .await
368                        .map_err(|e| CliError::Other(format!("Failed to select: {}", e)))?;
369                    ctx.add_system_message(format!("Selected '{}' in {}", value, selector));
370                    Ok(CommandResult::Continue)
371                }
372
373                // State commands
374                "scroll" => {
375                    if parsed.len() < 2 {
376                        return Err(CliError::Other(
377                            "Usage: /browser scroll <up|down|left|right> [pixels]".to_string(),
378                        ));
379                    }
380                    let direction = &parsed[1];
381                    let pixels = parsed.get(2).and_then(|s| s.parse::<u32>().ok());
382                    client
383                        .scroll(direction, pixels)
384                        .await
385                        .map_err(|e| CliError::Other(format!("Failed to scroll: {}", e)))?;
386                    ctx.add_system_message(format!("Scrolled {}", direction));
387                    Ok(CommandResult::Continue)
388                }
389
390                "is" => {
391                    if parsed.len() < 3 {
392                        return Err(CliError::Other(
393                            "Usage: /browser is <visible|hidden|enabled|disabled|editable> <selector>".to_string(),
394                        ));
395                    }
396                    let what = &parsed[1];
397                    let selector = &parsed[2];
398                    let result = client
399                        .is_(what, selector)
400                        .await
401                        .map_err(|e| CliError::Other(format!("Failed to check state: {}", e)))?;
402                    ctx.add_system_message(format!("Element {} is {}: {}", selector, what, result));
403                    Ok(CommandResult::Continue)
404                }
405
406                _ => {
407                    ctx.add_system_message(format!("Unknown browser action: {}", action));
408                    ctx.add_system_message("Type /browser help for available commands".to_string());
409                    Ok(CommandResult::Continue)
410                }
411            }
412        });
413
414        result
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_browser_command_name() {
424        let cmd = BrowserCommand::new();
425        assert_eq!(cmd.name(), "browser");
426    }
427
428    #[test]
429    fn test_browser_command_aliases() {
430        let cmd = BrowserCommand::new();
431        assert_eq!(cmd.aliases(), vec!["b"]);
432    }
433
434    #[test]
435    fn test_browser_command_description() {
436        let cmd = BrowserCommand::new();
437        assert!(!cmd.description().is_empty());
438    }
439
440    #[test]
441    fn test_parse_args_simple() {
442        let cmd = BrowserCommand::new();
443        let args = cmd.parse_args("open https://example.com");
444        assert_eq!(args, vec!["open", "https://example.com"]);
445    }
446
447    #[test]
448    fn test_parse_args_quoted() {
449        let cmd = BrowserCommand::new();
450        let args = cmd.parse_args("fill 'input[name=email]' \"test text\"");
451        assert_eq!(args, vec!["fill", "input[name=email]", "test text"]);
452    }
453
454    #[test]
455    fn test_parse_args_empty() {
456        let cmd = BrowserCommand::new();
457        let args = cmd.parse_args("");
458        assert!(args.is_empty());
459    }
460}