fix: scope image paste shortcut to remote

refs #647
This commit is contained in:
Ogulcan Celik 2026-06-18 00:50:33 +03:00
parent 54490254f7
commit aca35ea76f
6 changed files with 137 additions and 29 deletions

View File

@ -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=<session>`. 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)

View File

@ -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`.

View File

@ -49,6 +49,16 @@ static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = 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<AtomicBool>,
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<AttachEscapeState>,
) -> 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<crate::sound::Sound> {
}
#[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);

View File

@ -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<Option<(KeyCode, KeyModifiers)>, 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<LiveKeybindConfig, Vec<String>> {
let (prefix_diag, prefix, keybind_diags, keybinds) = self.validated_keybinds();
let diagnostics: Vec<String> = 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);
}
}

View File

@ -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"),

View File

@ -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"