feat(cli): sessions command + 'did you mean' suggestions + honest tab-switch liveness (#29)
- chrome-use sessions: top-level alias for the daemon inventory (the skill advertises sessions, so it's a natural guess that used to error). - Unknown commands now suggest the nearest valid one (Levenshtein + prefix match), staying silent when nothing is close (e.g. 'clik' -> click, 'sesions' -> sessions, 'xyzzy' -> no suggestion). - tab <id>: probe the switched session and show a warning indicator instead of a green check when it isn't responding yet, so a switch onto a re-attaching (churned-tabId) session no longer reports false success. The #24 targetId recovery self-heals within ~6s, hence a warning rather than a hard error.
This commit is contained in:
+71
-3
@@ -30,12 +30,65 @@ pub enum ParseError {
|
|||||||
InvalidSessionName { name: String },
|
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<char> = a.chars().collect();
|
||||||
|
let b: Vec<char> = b.chars().collect();
|
||||||
|
let mut prev: Vec<usize> = (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<String> {
|
||||||
|
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 {
|
impl ParseError {
|
||||||
pub fn format(&self) -> String {
|
pub fn format(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
ParseError::UnknownCommand { command } => {
|
ParseError::UnknownCommand { command } => match nearest_command(command) {
|
||||||
format!("Unknown command: {}", command)
|
Some(suggestion) => format!(
|
||||||
}
|
"Unknown command: {}\nDid you mean: chrome-use {}?",
|
||||||
|
command, suggestion
|
||||||
|
),
|
||||||
|
None => format!("Unknown command: {}", command),
|
||||||
|
},
|
||||||
ParseError::UnknownSubcommand {
|
ParseError::UnknownSubcommand {
|
||||||
subcommand,
|
subcommand,
|
||||||
valid_options,
|
valid_options,
|
||||||
@@ -4813,6 +4866,21 @@ mod tests {
|
|||||||
assert_eq!(cmd["action"], "frames");
|
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]
|
#[test]
|
||||||
fn test_get_text_main() {
|
fn test_get_text_main() {
|
||||||
for variant in ["get text --main", "get text --readable", "text -m"] {
|
for variant in ["get text --main", "get text --readable", "text -m"] {
|
||||||
|
|||||||
@@ -893,6 +893,14 @@ fn main() {
|
|||||||
return;
|
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
|
// Handle close --all: close all active sessions
|
||||||
if matches!(
|
if matches!(
|
||||||
clean.first().map(|s| s.as_str()),
|
clean.first().map(|s| s.as_str()),
|
||||||
|
|||||||
@@ -4561,7 +4561,22 @@ async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
|||||||
state.ref_map.clear();
|
state.ref_map.clear();
|
||||||
state.iframe_sessions.clear();
|
state.iframe_sessions.clear();
|
||||||
state.active_frame_id = None;
|
state.active_frame_id = None;
|
||||||
let result = mgr.tab_switch_by_id(tab_id).await?;
|
let mut result = mgr.tab_switch_by_id(tab_id).await?;
|
||||||
|
|
||||||
|
// Liveness probe: confirm the new session actually answers before we report
|
||||||
|
// success, so `tab <id>` 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;
|
// `--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
|
// bring_to_front acts on the active tab) — for handing a specific tab to the
|
||||||
|
|||||||
+15
-12
@@ -597,19 +597,21 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
// Tab switch
|
// Tab switch
|
||||||
if action == Some("tab_switch") {
|
if action == Some("tab_switch") {
|
||||||
if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_str()) {
|
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()) {
|
let warning = data.get("warning").and_then(|v| v.as_str());
|
||||||
println!(
|
// A non-responding session isn't a real success — show a warning
|
||||||
"{} Switched to tab [{}] ({})",
|
// indicator instead of the green ✓ (issue #29.3).
|
||||||
color::success_indicator(),
|
let indicator = if warning.is_some() {
|
||||||
tab_id,
|
color::warning_indicator()
|
||||||
url
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
println!(
|
color::success_indicator()
|
||||||
"{} Switched to tab [{}]",
|
};
|
||||||
color::success_indicator(),
|
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
||||||
tab_id
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3287,6 +3289,7 @@ Confirmation:
|
|||||||
Sessions:
|
Sessions:
|
||||||
session Show current session name
|
session Show current session name
|
||||||
session list List active sessions
|
session list List active sessions
|
||||||
|
sessions List running session daemons (alias of daemon status)
|
||||||
daemon status List running session daemons (+ relay state)
|
daemon status List running session daemons (+ relay state)
|
||||||
daemon restart Kill all session daemons; keeps the extension relay
|
daemon restart Kill all session daemons; keeps the extension relay
|
||||||
up. Clears stale/cross-leaked state after an upgrade.
|
up. Clears stale/cross-leaked state after an upgrade.
|
||||||
|
|||||||
Reference in New Issue
Block a user