Skip to content

Commit a7a59c9

Browse files
authored
Fix press Control+a and other modifier key chords (vercel-labs#980)
The `press` command was not parsing modifier+key chords (e.g. `Control+a`, `Shift+Enter`). It sent the raw string as a single key name, so CDP never applied the modifier — and the `text` field caused the literal character to be inserted instead of triggering the shortcut. Two changes: 1. `actions.rs` — add `parse_key_chord()` to split inputs like `Control+Shift+a` into the base key (`a`) and a CDP modifier bitmask (Alt=1, Ctrl=2, Meta=4, Shift=8), then call `press_key_with_modifiers` instead of `press_key`. 2. `interaction.rs` — suppress the `text` field in `keyDown`/`keyUp` events when Control or Meta modifiers are active, so the browser treats them as command chords rather than text input. Includes unit tests for the chord parser covering plain keys, single modifiers, multi-modifier combos, modifier aliases, and edge cases. Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
1 parent ce1f1f5 commit a7a59c9

File tree

2 files changed

+117
-2
lines changed

2 files changed

+117
-2
lines changed

cli/src/native/actions.rs

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2093,10 +2093,53 @@ async fn handle_press(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
20932093
.and_then(|v| v.as_str())
20942094
.ok_or("Missing 'key' parameter")?;
20952095

2096-
interaction::press_key(&mgr.client, &session_id, key).await?;
2096+
// Parse modifier+key chords like "Control+a", "Shift+Enter", "Control+Shift+a"
2097+
let (actual_key, modifiers) = parse_key_chord(key);
2098+
2099+
interaction::press_key_with_modifiers(&mgr.client, &session_id, &actual_key, modifiers).await?;
20972100
Ok(json!({ "pressed": key }))
20982101
}
20992102

2103+
/// Parse a key chord string like "Control+a" or "Control+Shift+Enter" into
2104+
/// the actual key name and an optional CDP modifier bitmask.
2105+
///
2106+
/// CDP modifier values: 1 = Alt, 2 = Control, 4 = Meta (Cmd), 8 = Shift.
2107+
fn parse_key_chord(input: &str) -> (String, Option<i32>) {
2108+
let parts: Vec<&str> = input.split('+').collect();
2109+
if parts.len() < 2 {
2110+
return (input.to_string(), None);
2111+
}
2112+
2113+
let mut modifiers = 0i32;
2114+
let mut key_parts: Vec<&str> = Vec::new();
2115+
2116+
for part in &parts {
2117+
match part.to_lowercase().as_str() {
2118+
"alt" => modifiers |= 1,
2119+
"control" | "ctrl" => modifiers |= 2,
2120+
"meta" | "cmd" | "command" => modifiers |= 4,
2121+
"shift" => modifiers |= 8,
2122+
_ => key_parts.push(part),
2123+
}
2124+
}
2125+
2126+
// If no modifiers were found, the '+' was part of the key name (e.g. "+")
2127+
// or the input was something unexpected — treat the whole string as the key.
2128+
if modifiers == 0 {
2129+
return (input.to_string(), None);
2130+
}
2131+
2132+
// The actual key is whatever remains after stripping modifiers.
2133+
// If nothing remains (e.g. "Control+"), treat the whole string as-is.
2134+
let actual_key = if key_parts.is_empty() {
2135+
input.to_string()
2136+
} else {
2137+
key_parts.join("+")
2138+
};
2139+
2140+
(actual_key, Some(modifiers))
2141+
}
2142+
21002143
async fn handle_hover(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
21012144
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
21022145
let session_id = mgr.active_session_id()?.to_string();
@@ -7279,4 +7322,68 @@ mod tests {
72797322
selectors explicitly"
72807323
);
72817324
}
7325+
7326+
#[test]
7327+
fn test_parse_key_chord_plain_key() {
7328+
let (key, mods) = parse_key_chord("a");
7329+
assert_eq!(key, "a");
7330+
assert_eq!(mods, None);
7331+
}
7332+
7333+
#[test]
7334+
fn test_parse_key_chord_enter() {
7335+
let (key, mods) = parse_key_chord("Enter");
7336+
assert_eq!(key, "Enter");
7337+
assert_eq!(mods, None);
7338+
}
7339+
7340+
#[test]
7341+
fn test_parse_key_chord_control_a() {
7342+
let (key, mods) = parse_key_chord("Control+a");
7343+
assert_eq!(key, "a");
7344+
assert_eq!(mods, Some(2));
7345+
}
7346+
7347+
#[test]
7348+
fn test_parse_key_chord_ctrl_alias() {
7349+
let (key, mods) = parse_key_chord("Ctrl+c");
7350+
assert_eq!(key, "c");
7351+
assert_eq!(mods, Some(2));
7352+
}
7353+
7354+
#[test]
7355+
fn test_parse_key_chord_shift_enter() {
7356+
let (key, mods) = parse_key_chord("Shift+Enter");
7357+
assert_eq!(key, "Enter");
7358+
assert_eq!(mods, Some(8));
7359+
}
7360+
7361+
#[test]
7362+
fn test_parse_key_chord_control_shift_a() {
7363+
let (key, mods) = parse_key_chord("Control+Shift+a");
7364+
assert_eq!(key, "a");
7365+
assert_eq!(mods, Some(2 | 8));
7366+
}
7367+
7368+
#[test]
7369+
fn test_parse_key_chord_meta_a() {
7370+
let (key, mods) = parse_key_chord("Meta+a");
7371+
assert_eq!(key, "a");
7372+
assert_eq!(mods, Some(4));
7373+
}
7374+
7375+
#[test]
7376+
fn test_parse_key_chord_alt_tab() {
7377+
let (key, mods) = parse_key_chord("Alt+Tab");
7378+
assert_eq!(key, "Tab");
7379+
assert_eq!(mods, Some(1));
7380+
}
7381+
7382+
#[test]
7383+
fn test_parse_key_chord_plus_key() {
7384+
// A bare "+" should not be confused with a separator
7385+
let (key, mods) = parse_key_chord("+");
7386+
assert_eq!(key, "+");
7387+
assert_eq!(mods, None);
7388+
}
72827389
}

cli/src/native/interaction.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,15 @@ pub async fn press_key_with_modifiers(
282282
modifiers: Option<i32>,
283283
) -> Result<(), String> {
284284
let (key_name, code, key_code) = named_key_info(key);
285-
let text = key_text(&key_name);
285+
286+
// Suppress text insertion when Control (2) or Meta (4) modifiers are active,
287+
// since these are command chords (e.g. Ctrl+A = select-all), not text input.
288+
let has_command_modifier = modifiers.is_some_and(|m| m & (2 | 4) != 0);
289+
let text = if has_command_modifier {
290+
None
291+
} else {
292+
key_text(&key_name)
293+
};
286294

287295
client
288296
.send_command_typed::<_, Value>(

0 commit comments

Comments
 (0)