From aca35ea76f3df15ec68fd07959d939f5e7a78a6c Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 18 Jun 2026 00:50:33 +0300 Subject: [PATCH] fix: scope image paste shortcut to remote refs #647 --- docs/next/CHANGELOG.md | 1 + .../src/content/docs/configuration.mdx | 3 + src/client/mod.rs | 132 ++++++++++++++---- src/config.rs | 26 ++++ src/config/model.rs | 3 + src/main.rs | 1 + 6 files changed, 137 insertions(+), 29 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 3a9d615b..e084fe75 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Fixed +- Local Herdr clients no longer treat raw `Ctrl+V` as a clipboard-image paste trigger, so pane apps such as Vim and Neovim receive block-visual `Ctrl+V` even when the desktop clipboard contains an image. `herdr --remote` keeps `keys.remote_image_paste = "ctrl+v"` by default. (#647) - OMP now reports a native session reference, so an OMP pane reappears in the Agents panel after exiting and rerunning `omp` in the same pane, and Herdr can resume it with `omp --resume=`. Previously the released lifecycle hook stayed suppressed until a server restart. (#614) - Host terminal color query (OSC 10/11) replies that arrive split at their escape introducer no longer leak as text like `11;rgb:...` into the focused pane, most visible when launching agents that probe terminal colors on startup. (#549) diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 28a26223..b879af7f 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -151,6 +151,7 @@ navigate_pane_left = "h" navigate_pane_down = "j" navigate_pane_up = "k" navigate_pane_right = "l" +remote_image_paste = "ctrl+v" new_tab = "prefix+c" previous_tab = "prefix+p" next_tab = "prefix+n" @@ -191,6 +192,8 @@ next_tab = ["prefix+n", "ctrl+alt+]"] `last_pane` switches back to the last focused pane across workspaces and tabs. It is unset by default because the tmux-style pane binding `prefix+l` is already used for pane-right focus. +`remote_image_paste` is only active in `herdr --remote`. It is the local-client shortcut that sends a local clipboard image to the remote pane. Set it to an empty string to disable the raw-key shortcut; terminal paste-image signals still work when the outer terminal sends them. + Key strings accept plain keys, modifier combinations such as `ctrl+a`, `shift+n`, `alt+1`, `cmd+k`, and special keys such as `enter`, `tab`, `esc`, `left`, `right`, `up`, and `down`. Named punctuation such as `minus`, `comma`, `ampersand`, `plus`, and `backtick` is also accepted. Plain direct printable keys such as `n` are unsafe because they intercept typing; use `prefix+n` unless you intentionally want a direct binding. The `navigate_workspace_*` and `navigate_pane_*` fields are navigate-mode-only and may use plain keys such as `j` or `k`; they must not use `prefix+`, `esc`, `enter`, `tab`, `shift+tab`, `left`, `right`, or unmodified `1` through `9`. Left and right arrows are permanent aliases for pane-left and pane-right navigation. These navigate-mode shortcuts are independent from general action bindings such as `focus_pane_down = "prefix+j"`; when both use the same key, the navigate-mode shortcut wins while navigate mode is open. Alt, Cmd/Super, and punctuation with modifiers depend on your terminal and tmux settings. If you have old custom keybindings and want the new defaults, run `herdr config reset-keys`. Herdr backs up `config.toml`, removes `[keys]` and `[[keys.command]]`, and uses built-in v2 defaults after restart or `herdr server reload-config`. diff --git a/src/client/mod.rs b/src/client/mod.rs index 2fc899ce..70d39559 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -49,6 +49,16 @@ static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock>> = OnceLock::ne // Client state // --------------------------------------------------------------------------- +struct ClientLoopConfig { + sound_config: crate::config::SoundConfig, + mouse_scroll_lines: usize, + redraw_on_focus_gained: bool, + kitty_graphics_enabled: bool, + mouse_capture_active: bool, + #[cfg(unix)] + remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, +} + /// State tracking for the thin client. struct ClientState { /// Stateful semantic-frame encoder used when the server sends FrameData. @@ -66,6 +76,9 @@ struct ClientState { /// Rows scrolled for one direct-attach wheel notch. #[cfg(unix)] mouse_scroll_lines: usize, + /// Local-client shortcut that sends a clipboard image to a remote Herdr session. + #[cfg(unix)] + remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, /// Whether outer focus gain should force a full host-terminal redraw. redraw_on_focus_gained: bool, } @@ -434,6 +447,11 @@ fn requested_render_encoding() -> RenderEncoding { } } +#[cfg(unix)] +fn is_remote_client_process() -> bool { + std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok() +} + fn requested_keybindings() -> ClientKeybindings { match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR) .ok() @@ -609,10 +627,20 @@ fn run_client_with_mode( let mouse_capture = loaded_config.config.ui.mouse_capture; let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines(); let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained; - let sound_config = loaded_config.config.ui.sound; let direct_attach_requested = attach_request.is_some(); + #[cfg(unix)] + let remote_image_paste_key = client_remote_image_paste_key(&loaded_config.config); let kitty_graphics_enabled = loaded_config.config.experimental.kitty_graphics && !direct_attach_requested; + let loop_config = ClientLoopConfig { + sound_config: loaded_config.config.ui.sound, + mouse_scroll_lines, + redraw_on_focus_gained, + kitty_graphics_enabled, + mouse_capture_active: mouse_capture, + #[cfg(unix)] + remote_image_paste_key, + }; let socket_path = client_socket_path(); crate::logging::startup("client"); @@ -702,11 +730,7 @@ fn run_client_with_mode( cols, rows, should_quit, - sound_config, - mouse_scroll_lines, - redraw_on_focus_gained, - kitty_graphics_enabled, - mouse_capture, + loop_config, negotiated_encoding, attach_escape, ) @@ -750,27 +774,25 @@ async fn run_client_loop( cols: u16, rows: u16, should_quit: Arc, - sound_config: crate::config::SoundConfig, - mouse_scroll_lines: usize, - redraw_on_focus_gained: bool, - kitty_graphics_enabled: bool, - mouse_capture_active: bool, + config: ClientLoopConfig, negotiated_encoding: RenderEncoding, attach_escape: Option, ) -> Result<(), ClientError> { #[cfg(windows)] - let _ = mouse_scroll_lines; + let _ = config.mouse_scroll_lines; let mut state = ClientState { blit_encoder: render_ansi::BlitEncoder::new(), - mouse_capture_active, + mouse_capture_active: config.mouse_capture_active, reported_size: (cols, rows), - sound_config, - kitty_graphics_enabled, + sound_config: config.sound_config, + kitty_graphics_enabled: config.kitty_graphics_enabled, attach_escape, #[cfg(unix)] - mouse_scroll_lines, - redraw_on_focus_gained, + mouse_scroll_lines: config.mouse_scroll_lines, + #[cfg(unix)] + remote_image_paste_key: config.remote_image_paste_key, + redraw_on_focus_gained: config.redraw_on_focus_gained, }; debug!(?negotiated_encoding, "client render encoding active"); @@ -793,6 +815,7 @@ async fn run_client_loop( // Spawn the resize poller thread. let resize_quit = should_quit.clone(); let resize_tx = event_tx.clone(); + let kitty_graphics_enabled = state.kitty_graphics_enabled; std::thread::spawn(move || { resize_poll_loop(resize_tx, cols, rows, kitty_graphics_enabled, &resize_quit); }); @@ -877,7 +900,7 @@ async fn run_client_loop( } data }; - if should_bridge_clipboard_image_paste(&data) { + if should_bridge_clipboard_image_paste(&data, state.remote_image_paste_key) { if let Some(image) = crate::platform::read_clipboard_image() { if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { warn!( @@ -994,6 +1017,8 @@ async fn run_client_loop( reload_local_client_config( &mut state.sound_config, &mut state.redraw_on_focus_gained, + #[cfg(unix)] + &mut state.remote_image_paste_key, ); } ServerMessage::MouseCapture { enabled } => { @@ -1093,17 +1118,44 @@ fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result< // Notifications // --------------------------------------------------------------------------- +#[cfg(unix)] +fn client_remote_image_paste_key( + config: &crate::config::Config, +) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { + if !is_remote_client_process() { + return None; + } + + match config.remote_image_paste_key() { + Ok(key) => key, + Err(diagnostic) => { + warn!(diagnostic = %diagnostic, "local remote image paste key config diagnostic"); + None + } + } +} + fn reload_local_client_config( sound_config: &mut crate::config::SoundConfig, redraw_on_focus_gained: &mut bool, + #[cfg(unix)] remote_image_paste_key: &mut Option<( + crossterm::event::KeyCode, + crossterm::event::KeyModifiers, + )>, ) { match crate::config::load_live_config() { Ok(loaded) => { for diagnostic in loaded.config.ui.sound.diagnostics() { warn!(diagnostic = %diagnostic, "local sound config diagnostic"); } + #[cfg(unix)] + let loaded_remote_image_paste_key = client_remote_image_paste_key(&loaded.config); *sound_config = loaded.config.ui.sound; *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; + #[cfg(unix)] + { + *remote_image_paste_key = loaded_remote_image_paste_key; + } debug!("reloaded local client config"); } Err(diagnostics) => { @@ -1179,18 +1231,24 @@ fn sound_from_notify_message(message: &str) -> Option { } #[cfg(unix)] -fn should_bridge_clipboard_image_paste(data: &[u8]) -> bool { +fn should_bridge_clipboard_image_paste( + data: &[u8], + remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, +) -> bool { if data == b"\x1b[200~\x1b[201~" { return true; } + let Some(remote_image_paste_key) = remote_image_paste_key else { + return false; + }; + let events = crate::raw_input::parse_raw_input_bytes_sync(data); matches!( events.as_slice(), [crate::raw_input::RawInputEvent::Key(key)] if key.kind == crossterm::event::KeyEventKind::Press - && key.modifiers == crossterm::event::KeyModifiers::CONTROL - && matches!(key.code, crossterm::event::KeyCode::Char('v' | 'V')) + && crate::config::terminal_key_matches_combo(*key, remote_image_paste_key) ) } @@ -1465,14 +1523,23 @@ mod tests { #[cfg(unix)] #[test] - fn clipboard_image_paste_bridge_triggers_on_ctrl_v_and_empty_paste() { - assert!(should_bridge_clipboard_image_paste(&[0x16])); - assert!(should_bridge_clipboard_image_paste(b"\x1b[118;5u")); - assert!(should_bridge_clipboard_image_paste(b"\x1b[200~\x1b[201~")); - assert!(!should_bridge_clipboard_image_paste( - b"\x1b[200~text\x1b[201~" + fn clipboard_image_paste_bridge_triggers_on_configured_key_and_empty_paste() { + let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); + assert!(should_bridge_clipboard_image_paste(&[0x16], Some(ctrl_v))); + assert!(should_bridge_clipboard_image_paste( + b"\x1b[118;5u", + Some(ctrl_v) )); - assert!(!should_bridge_clipboard_image_paste(b"v")); + assert!(should_bridge_clipboard_image_paste( + b"\x1b[200~\x1b[201~", + None + )); + assert!(!should_bridge_clipboard_image_paste( + b"\x1b[200~text\x1b[201~", + Some(ctrl_v) + )); + assert!(!should_bridge_clipboard_image_paste(&[0x16], None)); + assert!(!should_bridge_clipboard_image_paste(b"v", Some(ctrl_v))); } #[test] @@ -1825,8 +1892,15 @@ mod tests { let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string); let mut sound_config = crate::config::SoundConfig::default(); let mut redraw_on_focus_gained = true; + #[cfg(unix)] + let mut remote_image_paste_key = None; - reload_local_client_config(&mut sound_config, &mut redraw_on_focus_gained); + reload_local_client_config( + &mut sound_config, + &mut redraw_on_focus_gained, + #[cfg(unix)] + &mut remote_image_paste_key, + ); assert!(!redraw_on_focus_gained); let _ = std::fs::remove_file(path); diff --git a/src/config.rs b/src/config.rs index bf5fceb4..003178ab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,10 +65,21 @@ impl Config { prefix_diag .into_iter() .chain(keybind_diags) + .chain(self.remote_image_paste_key().err()) .chain(self.ui.sound.diagnostics()) .collect() } + pub(crate) fn remote_image_paste_key(&self) -> Result, String> { + let raw = self.keys.remote_image_paste.trim(); + if raw.is_empty() { + return Ok(None); + } + parse_key_combo(raw).map(Some).ok_or_else(|| { + format!("invalid keybinding: keys.remote_image_paste = {raw:?}; disabling binding") + }) + } + pub fn live_keybinds(&self) -> Result> { let (prefix_diag, prefix, keybind_diags, keybinds) = self.validated_keybinds(); let diagnostics: Vec = prefix_diag.into_iter().chain(keybind_diags).collect(); @@ -120,4 +131,19 @@ command = "lazygit" assert!(!profile.contains("command =")); assert!(!profile.contains("[[keys.command]]")); } + + #[test] + fn remote_image_paste_key_defaults_to_ctrl_v() { + let config = Config::default(); + assert_eq!( + config.remote_image_paste_key().unwrap(), + Some((KeyCode::Char('v'), KeyModifiers::CONTROL)) + ); + } + + #[test] + fn remote_image_paste_key_can_be_disabled() { + let config: Config = toml::from_str("[keys]\nremote_image_paste = ''\n").unwrap(); + assert_eq!(config.remote_image_paste_key().unwrap(), None); + } } diff --git a/src/config/model.rs b/src/config/model.rs index c8fa7f71..7b1e7bb7 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -337,6 +337,8 @@ pub struct KeysConfig { pub next_agent: BindingConfig, /// Focus an agent by index 1-9. Unset by default. pub focus_agent: BindingConfig, + /// Local-client shortcut that sends a clipboard image to a remote Herdr session. Default: "ctrl+v". + pub remote_image_paste: String, /// Create a new tab in the active workspace. Default: "prefix+c" pub new_tab: BindingConfig, /// Rename the active tab. Default: "prefix+shift+t". @@ -572,6 +574,7 @@ impl Default for KeysConfig { previous_agent: BindingConfig::empty(), next_agent: BindingConfig::empty(), focus_agent: BindingConfig::empty(), + remote_image_paste: "ctrl+v".into(), new_tab: BindingConfig::one("prefix+c"), rename_tab: BindingConfig::one("prefix+shift+t"), previous_tab: BindingConfig::one("prefix+p"), diff --git a/src/main.rs b/src/main.rs index 11bd2550..2217bef2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -159,6 +159,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # previous_agent = "" # optional, unset by default # next_agent = "" # optional, unset by default # focus_agent = "" # optional indexed binding, e.g. "prefix+alt+1..9" +# remote_image_paste = "ctrl+v" # only active in herdr --remote; empty disables raw-key image paste # new_tab = "prefix+c" # rename_tab = "prefix+shift+t" # previous_tab = "prefix+p"