parent
54490254f7
commit
aca35ea76f
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue