From a7a59c94f3c39d927d9618b1782e0366dc62ae08 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 23 Mar 2026 16:43:43 -0500 Subject: [PATCH] Fix `press Control+a` and other modifier key chords (#980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- cli/src/native/actions.rs | 109 +++++++++++++++++++++++++++++++++- cli/src/native/interaction.rs | 10 +++- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index c3ad7e8..02c2174 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -2093,10 +2093,53 @@ async fn handle_press(cmd: &Value, state: &mut DaemonState) -> Result (String, Option) { + let parts: Vec<&str> = input.split('+').collect(); + if parts.len() < 2 { + return (input.to_string(), None); + } + + let mut modifiers = 0i32; + let mut key_parts: Vec<&str> = Vec::new(); + + for part in &parts { + match part.to_lowercase().as_str() { + "alt" => modifiers |= 1, + "control" | "ctrl" => modifiers |= 2, + "meta" | "cmd" | "command" => modifiers |= 4, + "shift" => modifiers |= 8, + _ => key_parts.push(part), + } + } + + // If no modifiers were found, the '+' was part of the key name (e.g. "+") + // or the input was something unexpected — treat the whole string as the key. + if modifiers == 0 { + return (input.to_string(), None); + } + + // The actual key is whatever remains after stripping modifiers. + // If nothing remains (e.g. "Control+"), treat the whole string as-is. + let actual_key = if key_parts.is_empty() { + input.to_string() + } else { + key_parts.join("+") + }; + + (actual_key, Some(modifiers)) +} + async fn handle_hover(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); @@ -7279,4 +7322,68 @@ mod tests { selectors explicitly" ); } + + #[test] + fn test_parse_key_chord_plain_key() { + let (key, mods) = parse_key_chord("a"); + assert_eq!(key, "a"); + assert_eq!(mods, None); + } + + #[test] + fn test_parse_key_chord_enter() { + let (key, mods) = parse_key_chord("Enter"); + assert_eq!(key, "Enter"); + assert_eq!(mods, None); + } + + #[test] + fn test_parse_key_chord_control_a() { + let (key, mods) = parse_key_chord("Control+a"); + assert_eq!(key, "a"); + assert_eq!(mods, Some(2)); + } + + #[test] + fn test_parse_key_chord_ctrl_alias() { + let (key, mods) = parse_key_chord("Ctrl+c"); + assert_eq!(key, "c"); + assert_eq!(mods, Some(2)); + } + + #[test] + fn test_parse_key_chord_shift_enter() { + let (key, mods) = parse_key_chord("Shift+Enter"); + assert_eq!(key, "Enter"); + assert_eq!(mods, Some(8)); + } + + #[test] + fn test_parse_key_chord_control_shift_a() { + let (key, mods) = parse_key_chord("Control+Shift+a"); + assert_eq!(key, "a"); + assert_eq!(mods, Some(2 | 8)); + } + + #[test] + fn test_parse_key_chord_meta_a() { + let (key, mods) = parse_key_chord("Meta+a"); + assert_eq!(key, "a"); + assert_eq!(mods, Some(4)); + } + + #[test] + fn test_parse_key_chord_alt_tab() { + let (key, mods) = parse_key_chord("Alt+Tab"); + assert_eq!(key, "Tab"); + assert_eq!(mods, Some(1)); + } + + #[test] + fn test_parse_key_chord_plus_key() { + // A bare "+" should not be confused with a separator + let (key, mods) = parse_key_chord("+"); + assert_eq!(key, "+"); + assert_eq!(mods, None); + } } diff --git a/cli/src/native/interaction.rs b/cli/src/native/interaction.rs index 56c3a96..c18d226 100644 --- a/cli/src/native/interaction.rs +++ b/cli/src/native/interaction.rs @@ -282,7 +282,15 @@ pub async fn press_key_with_modifiers( modifiers: Option, ) -> Result<(), String> { let (key_name, code, key_code) = named_key_info(key); - let text = key_text(&key_name); + + // Suppress text insertion when Control (2) or Meta (4) modifiers are active, + // since these are command chords (e.g. Ctrl+A = select-all), not text input. + let has_command_modifier = modifiers.is_some_and(|m| m & (2 | 4) != 0); + let text = if has_command_modifier { + None + } else { + key_text(&key_name) + }; client .send_command_typed::<_, Value>(