diff --git a/cli/src/commands.rs b/cli/src/commands.rs index d62aff0..80a67a2 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -30,12 +30,65 @@ pub enum ParseError { InvalidSessionName { name: String }, } +/// Top-level commands an agent is likely to mistype, used for "did you mean" +/// suggestions on an unknown command (issue #29). Not exhaustive — just the +/// common verbs plus a few known wrong-guesses mapped to the real command. +const KNOWN_COMMANDS: &[&str] = &[ + "open", "navigate", "click", "fill", "type", "press", "snapshot", "screenshot", "eval", "get", + "text", "html", "frames", "find", "wait", "scroll", "hover", "select", "check", "uncheck", + "tab", "tabs", "close", "back", "forward", "reload", "sessions", "status", "daemon", "doctor", + "upgrade", "connect", "cookies", "mouse", "keyboard", "stream", "frame", "profiles", "title", + "url", "is", "drag", "dialog", "upload", +]; + +/// Levenshtein distance, capped — small inputs only (command names). +fn edit_distance(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let mut prev: Vec = (0..=b.len()).collect(); + let mut curr = vec![0usize; b.len() + 1]; + for (i, &ca) in a.iter().enumerate() { + curr[0] = i + 1; + for (j, &cb) in b.iter().enumerate() { + let cost = if ca == cb { 0 } else { 1 }; + curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost); + } + std::mem::swap(&mut prev, &mut curr); + } + prev[b.len()] +} + +/// Closest known command within a small edit distance, or a prefix/substring +/// match — `None` if nothing is close enough to suggest confidently. +fn nearest_command(input: &str) -> Option { + let lower = input.to_lowercase(); + // Exact prefix/substring hits first (e.g. "session" -> "sessions"). + if let Some(c) = KNOWN_COMMANDS + .iter() + .find(|c| c.starts_with(&lower) || lower.starts_with(**c)) + { + return Some(c.to_string()); + } + // Tolerance scales with length: short words get distance 1, longer get 2. + let max_dist = if lower.len() <= 4 { 1 } else { 2 }; + KNOWN_COMMANDS + .iter() + .map(|c| (*c, edit_distance(&lower, c))) + .filter(|(_, d)| *d <= max_dist) + .min_by_key(|(_, d)| *d) + .map(|(c, _)| c.to_string()) +} + impl ParseError { pub fn format(&self) -> String { match self { - ParseError::UnknownCommand { command } => { - format!("Unknown command: {}", command) - } + ParseError::UnknownCommand { command } => match nearest_command(command) { + Some(suggestion) => format!( + "Unknown command: {}\nDid you mean: chrome-use {}?", + command, suggestion + ), + None => format!("Unknown command: {}", command), + }, ParseError::UnknownSubcommand { subcommand, valid_options, @@ -4813,6 +4866,21 @@ mod tests { assert_eq!(cmd["action"], "frames"); } + #[test] + fn test_nearest_command_suggestions() { + assert_eq!(nearest_command("sesions").as_deref(), Some("sessions")); + assert_eq!(nearest_command("session").as_deref(), Some("sessions")); + assert_eq!(nearest_command("clik").as_deref(), Some("click")); + assert_eq!(nearest_command("screenshits").as_deref(), Some("screenshot")); + // Nonsense with no close match stays silent. + assert_eq!(nearest_command("xyzzy"), None); + // The unknown-command error embeds the suggestion. + let err = ParseError::UnknownCommand { + command: "sesions".to_string(), + }; + assert!(err.format().contains("Did you mean: chrome-use sessions?")); + } + #[test] fn test_get_text_main() { for variant in ["get text --main", "get text --readable", "text -m"] { diff --git a/cli/src/main.rs b/cli/src/main.rs index 97e530d..82ebd2b 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -893,6 +893,14 @@ fn main() { return; } + // `sessions` is a natural top-level guess for "list my sessions" (the skill + // advertises sessions as a feature) — route it to the daemon inventory the + // same way `daemon status` does (issue #29). + if clean.first().map(|s| s.as_str()) == Some("sessions") { + run_daemon(&["sessions".to_string(), "status".to_string()], flags.json); + return; + } + // Handle close --all: close all active sessions if matches!( clean.first().map(|s| s.as_str()), diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index b095410..eb436c6 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -4561,7 +4561,22 @@ async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result` doesn't print a misleading ✓ for a session that's + // stale and will fail on the very next command (issue #29.3). On the churned + // -tabId case the ext-0.4.9 targetId recovery (#24) self-heals within ~6s, so + // we surface a warning rather than a hard error to avoid a false failure + // during that window. + if mgr.evaluate("1", None).await.is_err() { + if let Some(obj) = result.as_object_mut() { + obj.insert( + "warning".to_string(), + json!("switched tab is not responding yet (session re-attaching); retry the next command"), + ); + } + } // `--activate`: raise this tab to the foreground (the switch made it active; // bring_to_front acts on the active tab) — for handing a specific tab to the diff --git a/cli/src/output.rs b/cli/src/output.rs index 9d90545..54d2f79 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -597,19 +597,21 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou // Tab switch if action == Some("tab_switch") { if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_str()) { - if let Some(url) = data.get("url").and_then(|v| v.as_str()) { - println!( - "{} Switched to tab [{}] ({})", - color::success_indicator(), - tab_id, - url - ); + let warning = data.get("warning").and_then(|v| v.as_str()); + // A non-responding session isn't a real success — show a warning + // indicator instead of the green ✓ (issue #29.3). + let indicator = if warning.is_some() { + color::warning_indicator() } else { - println!( - "{} Switched to tab [{}]", - color::success_indicator(), - tab_id - ); + color::success_indicator() + }; + if let Some(url) = data.get("url").and_then(|v| v.as_str()) { + println!("{} Switched to tab [{}] ({})", indicator, tab_id, url); + } else { + println!("{} Switched to tab [{}]", indicator, tab_id); + } + if let Some(w) = warning { + eprintln!("{}", color::dim(w)); } return; } @@ -3287,6 +3289,7 @@ Confirmation: Sessions: session Show current session name session list List active sessions + sessions List running session daemons (alias of daemon status) daemon status List running session daemons (+ relay state) daemon restart Kill all session daemons; keeps the extension relay up. Clears stale/cross-leaked state after an upgrade.