diff options
| author | Dillon Mulroy <dillon.mulroy@gmail.com> | 2026-06-15 11:23:24 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-15 18:23:24 +0300 |
| commit | 16b69b6faa3f6de4ff8ae404442be95887370918 (patch) | |
| tree | 33dc8e62fef1daf29265dfc956e8bc0ca30c73d0 /src | |
| parent | 9805e23a1225f74bb955b703c9457fdcff1bc952 (diff) | |
fix: support key-combo syntax for API send keys (#613)
Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api/panes.rs | 155 | ||||
| -rw-r--r-- | src/app/api_helpers.rs | 26 | ||||
| -rw-r--r-- | src/config.rs | 1 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 2 |
4 files changed, 160 insertions, 24 deletions
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 4bae2f6f..ac8ddf4b 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1798,6 +1798,17 @@ mod tests { (app, public_pane_id) } + fn app_with_send_key_runtime( + capacity: usize, + ) -> (App, String, tokio::sync::mpsc::Receiver<bytes::Bytes>) { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let (runtime, rx) = + crate::terminal::TerminalRuntime::test_with_channel_capacity(80, 24, capacity); + app.state.insert_test_runtime(pane_id, runtime); + (app, public_pane_id, rx) + } + fn metadata_params(pane_id: String) -> PaneReportMetadataParams { PaneReportMetadataParams { pane_id, @@ -1822,6 +1833,131 @@ mod tests { response.error.code } + #[tokio::test] + async fn api_pane_send_keys_accepts_control_navigation_chords() { + let (mut app, pane_id, mut rx) = app_with_send_key_runtime(4); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { + pane_id, + keys: vec![ + "ctrl+h".into(), + "ctrl+j".into(), + "ctrl+k".into(), + "ctrl+l".into(), + ], + }), + }); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.id, "req"); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x08])); + assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x0a])); + assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x0b])); + assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x0c])); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn api_pane_send_keys_preserves_legacy_control_c_aliases() { + let (mut app, pane_id, mut rx) = app_with_send_key_runtime(3); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { + pane_id, + keys: vec!["C-c".into(), "c-c".into(), "ctrl+c".into()], + }), + }); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.id, "req"); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x03])); + assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x03])); + assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x03])); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn api_pane_send_keys_accepts_literal_plus() { + let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { + pane_id, + keys: vec!["+".into()], + }), + }); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.id, "req"); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from_static(b"+")); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn api_pane_send_input_keys_accept_key_combo_chords() { + let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneSendInput(PaneSendInputParams { + pane_id, + text: String::new(), + keys: vec!["ctrl+j".into()], + }), + }); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.id, "req"); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x0a])); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn api_pane_send_keys_rejects_invalid_keys_before_writing() { + let (mut app, pane_id, mut rx) = app_with_send_key_runtime(2); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { + pane_id, + keys: vec!["ctrl+h".into(), "not-a-key".into()], + }), + }); + + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "invalid_key"); + assert_eq!(error.error.message, "unsupported key not-a-key"); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn api_pane_send_input_rejects_prefix_bindings_before_writing_text_or_keys() { + let (mut app, pane_id, mut rx) = app_with_send_key_runtime(4); + let raw_key = " prefix+h ".to_string(); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneSendInput(PaneSendInputParams { + pane_id, + text: "hello".into(), + keys: vec!["ctrl+h".into(), raw_key.clone()], + }), + }); + + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "invalid_key"); + assert_eq!(error.error.message, format!("unsupported key {raw_key}")); + assert!(rx.try_recv().is_err()); + } + fn app_with_linked_worktree() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( @@ -2274,6 +2410,9 @@ mod tests { .clone(); let target = app.state.workspaces[1].tabs[0].root_pane; seed_terminal_states(&mut app); + let source_workspace_id = app.public_workspace_id(0); + let target_workspace_id = app.public_workspace_id(1); + let target_tab_id = app.public_tab_id(1, 0).unwrap(); let source_public = app.public_pane_id(0, source).unwrap(); let target_public = app.public_pane_id(1, target).unwrap(); @@ -2297,9 +2436,9 @@ mod tests { }; assert!(move_result.changed); assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(move_result.closed_workspace_id, Some("w1".into())); - assert_eq!(move_result.pane.workspace_id, "w2"); - assert_eq!(move_result.pane.tab_id, "w2:t1"); + assert_eq!(move_result.closed_workspace_id, Some(source_workspace_id)); + assert_eq!(move_result.pane.workspace_id, target_workspace_id); + assert_eq!(move_result.pane.tab_id, target_tab_id); assert_eq!(move_result.pane.terminal_id, source_terminal.to_string()); assert_eq!( app.state.workspaces[0].tabs[0].terminal_id(source), @@ -2609,9 +2748,10 @@ mod tests { .terminal_id(source) .unwrap() .clone(); + let previous_workspace_id = app.public_workspace_id(0); let context = PaneMoveRecoveryContext { source_ws_idx: 0, - previous_workspace_id: app.public_workspace_id(0), + previous_workspace_id: previous_workspace_id.clone(), previous_workspace_label: app.state.workspaces[0].custom_name.clone(), previous_tab_label: app.state.workspaces[0].tabs[0].custom_name.clone(), previous_worktree_space: app.state.workspaces[0].worktree_space.clone(), @@ -2627,12 +2767,15 @@ mod tests { app.recover_failed_pane_move(context, taken.moved); assert_eq!(app.state.workspaces.len(), 1); - assert_eq!(app.state.workspaces[0].id, "w1"); + assert_eq!(app.state.workspaces[0].id, previous_workspace_id); assert_eq!( app.state.workspaces[0].tabs[0].terminal_id(source), Some(&source_terminal) ); - assert_eq!(app.parse_pane_id("w1:p1"), Some((0, source))); + assert_eq!( + app.parse_pane_id(&format!("{previous_workspace_id}:p1")), + Some((0, source)) + ); } #[test] diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index 1ebb30ef..c07a431d 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -9,24 +9,16 @@ pub(super) fn tab_attention_priority(state: crate::detect::AgentState, seen: boo } fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let normalized = normalize_api_key_alias(key.trim()); + let (code, modifiers) = crate::config::parse_key_combo(normalized)?; + Some(crossterm::event::KeyEvent::new(code, modifiers)) +} - let normalized = key.trim(); - match normalized { - "Enter" | "enter" => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())), - "Tab" | "tab" => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::empty())), - "Esc" | "esc" => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())), - "Backspace" | "backspace" => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty())), - "Up" | "up" => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::empty())), - "Down" | "down" => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::empty())), - "Left" | "left" => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::empty())), - "Right" | "right" => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::empty())), - "C-c" | "c-c" | "ctrl+c" => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), - _ if normalized.len() == 1 => normalized - .chars() - .next() - .map(|ch| KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty())), - _ => None, +fn normalize_api_key_alias(key: &str) -> &str { + match key { + "C-c" | "c-c" => "ctrl+c", + "+" => "plus", + _ => key, } } diff --git a/src/config.rs b/src/config.rs index a01be048..758ff331 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,7 @@ pub use self::{ }; pub(crate) use self::io::upsert_top_level_bool; +pub(crate) use self::keybinds::parse_key_combo; pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH"; pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000; diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 92f13bf8..c3e15a8b 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -967,7 +967,7 @@ fn parse_modifier_combo(s: &str) -> Option<KeyModifiers> { } } -pub(super) fn parse_key_combo(s: &str) -> Option<KeyCombo> { +pub(crate) fn parse_key_combo(s: &str) -> Option<KeyCombo> { let parts: Vec<&str> = s.split('+').collect(); let mut modifiers = KeyModifiers::empty(); let mut key_str: Option<&str> = None; |
