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:
leeguooooo
2026-06-15 13:25:16 +09:00
parent 57c52d6517
commit af46490812
4 changed files with 110 additions and 16 deletions
+71 -3
View File
@@ -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"] {
+8
View File
@@ -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()),
+16 -1
View File
@@ -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
View File
@@ -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.