aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 15:17:37 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 15:17:37 +0300
commit06bcd06a17be3bd242e2b8230dd9b803b1969f3c (patch)
treeecec609d7cccd346a81d749c63b094ce3a97a11f
parentdde0ddb3aec0522f2bc4c6fa1cb7d25f99fc8403 (diff)
feat: use local keybindings for remote attach
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/README.md2
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx3
-rw-r--r--docs/next/website/src/content/docs/persistence-remote.mdx2
-rw-r--r--src/client/mod.rs19
-rw-r--r--src/config.rs44
-rw-r--r--src/config/keybinds.rs10
-rw-r--r--src/config/model.rs5
-rw-r--r--src/main.rs6
-rw-r--r--src/remote.rs335
-rw-r--r--src/server/client_transport.rs176
-rw-r--r--src/server/headless.rs139
-rw-r--r--src/server/protocol.rs18
-rw-r--r--tests/api_ping.rs2
-rw-r--r--tests/cli_wrapper.rs6
-rw-r--r--tests/client_mode.rs36
-rw-r--r--tests/cross_area.rs17
-rw-r--r--tests/detach_reattach.rs44
-rw-r--r--tests/multi_client.rs3
-rw-r--r--tests/server_headless.rs17
-rw-r--r--tests/support/mod.rs1
21 files changed, 583 insertions, 303 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index e9b50c5c..4a0f5e9f 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -3,6 +3,7 @@
## Unreleased
### Added
+- Added `--remote-keybindings local|server` for `herdr --remote`. Remote attach now uses the launching client's local keybindings by default without copying config files to the remote host; use `--remote-keybindings server` to keep the remote server's keybindings. The client/server protocol is now version 9.
- Added `experimental.reveal_hidden_cursor_for_cjk_ime = false` (opt-in), `experimental.cjk_ime_agents = []` (optional allow-list), and `experimental.cjk_ime_cursor_shape = "steady_block"` to expose the focused pane's cursor anchor to the outer terminal even when the pane requested `?25l`, restoring macOS IME candidate-window tracking for TUIs that paint their own cursor (Claude Code, pi, codex). When `cjk_ime_agents` is non-empty, the reveal applies only to focused panes whose detected agent matches one of the listed names. When the pane reports no cursor position, the anchor falls back to the pane's top-left so a stable IME hint is always available. Trade-off when enabled: an extra hardware cursor may appear in the outer terminal for apps that hide the cursor without painting a replacement. (#149, thanks @ChihGodlee)
### Fixed
diff --git a/docs/next/README.md b/docs/next/README.md
index 3e730678..53a2a973 100644
--- a/docs/next/README.md
+++ b/docs/next/README.md
@@ -90,7 +90,7 @@ ssh you@yourserver
herdr
```
-or attach from your local terminal through ssh without opening a shell first. your local herdr acts as a thin client, connects over ssh, starts or attaches to the remote herdr server, and streams the ui back to your terminal.
+or attach from your local terminal through ssh without opening a shell first. your local herdr acts as a thin client, connects over ssh, starts or attaches to the remote herdr server, and streams the ui back to your terminal. remote attach uses your local keybindings by default; pass `--remote-keybindings server` to use the remote server config instead.
```bash
herdr --remote workbox
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index 14c36a76..6f10add3 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -12,7 +12,8 @@ Most commands print JSON responses. Use them from scripts when you want determin
```bash
herdr # launch or attach to the default session
herdr --session work # launch or attach to a named session
-herdr --remote workbox # attach through SSH
+herdr --remote workbox # attach through SSH, using local keybindings
+herdr --remote workbox --remote-keybindings server
herdr --no-session # single-process escape hatch
herdr --default-config # print default config
herdr update # download and install the latest version
diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx
index 2452fc9a..d6c02f0a 100644
--- a/docs/next/website/src/content/docs/persistence-remote.mdx
+++ b/docs/next/website/src/content/docs/persistence-remote.mdx
@@ -71,6 +71,8 @@ herdr --remote ssh://you@server:2222
In this mode your local Herdr is a thin client. It connects over SSH, starts or attaches to the remote Herdr server, and streams the UI back to your local terminal. Because the client runs locally, Herdr can bridge local desktop features such as image clipboard paste into the remote session by copying the image to a remote temp file and pasting that path.
+By default, `herdr --remote` uses your local Herdr keybindings for that attach. This keeps local muscle memory even when the remote server has different config. The local keybindings are a snapshot from attach time; detach and reattach after editing local keybindings. Use `--remote-keybindings server` when you want the remote server config instead. Local custom command keybindings are not sent, because those commands would run on the remote host.
+
For repeat targets, use your SSH config:
```text
diff --git a/src/client/mod.rs b/src/client/mod.rs
index ff5c144f..4d962b64 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -32,8 +32,8 @@ use tracing::{debug, info, warn};
use crate::server::headless::client_socket_path;
use crate::server::protocol::{
- self, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD,
- MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
+ self, ClientKeybindings, ClientMessage, NotifyKind, RenderEncoding, ServerMessage,
+ MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
};
static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
@@ -309,6 +309,20 @@ fn requested_render_encoding() -> RenderEncoding {
}
}
+fn requested_keybindings() -> ClientKeybindings {
+ match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR)
+ .ok()
+ .as_deref()
+ {
+ Some("local") => crate::config::Config::load()
+ .config
+ .local_keybindings_profile_toml()
+ .map(|keys_toml| ClientKeybindings::Local { keys_toml })
+ .unwrap_or(ClientKeybindings::Server),
+ _ => ClientKeybindings::Server,
+ }
+}
+
/// Performs the client→server handshake.
///
/// Sends Hello with the terminal size and protocol version, reads the Welcome
@@ -333,6 +347,7 @@ fn do_handshake(
cell_width_px,
cell_height_px,
requested_encoding,
+ keybindings: requested_keybindings(),
};
protocol::write_message(stream, &hello)
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
diff --git a/src/config.rs b/src/config.rs
index 48ebb8f6..b6406874 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -19,7 +19,7 @@ pub use self::{
},
model::{
validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport,
- ConfigReloadStatus, ToastConfig, ToastDelivery,
+ ConfigReloadStatus, KeysConfig, ToastConfig, ToastDelivery,
},
sound::SoundConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig},
@@ -67,4 +67,46 @@ impl Config {
Err(diagnostics)
}
}
+
+ pub(crate) fn local_keybindings_profile_toml(&self) -> Result<String, toml::ser::Error> {
+ let mut keys = self.keys.clone();
+ keys.command.clear();
+
+ #[derive(serde::Serialize)]
+ struct KeysProfile {
+ keys: KeysConfig,
+ }
+
+ toml::to_string_pretty(&KeysProfile { keys })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn local_keybindings_profile_includes_defaults_and_excludes_commands() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+prefix = "ctrl+a"
+new_tab = "prefix+t"
+
+[[keys.command]]
+key = "prefix+g"
+command = "lazygit"
+"#,
+ )
+ .unwrap();
+
+ let profile = config.local_keybindings_profile_toml().unwrap();
+ assert!(profile.contains("[keys]"));
+ assert!(profile.contains("prefix = \"ctrl+a\""));
+ assert!(profile.contains("new_tab = \"prefix+t\""));
+ assert!(profile.contains("next_tab = \"prefix+n\""));
+ assert!(!profile.contains("lazygit"));
+ assert!(!profile.contains("command ="));
+ assert!(!profile.contains("[[keys.command]]"));
+ }
}
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index 3a4cf754..1c9c491b 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -1,7 +1,7 @@
#[cfg(test)]
use crossterm::event::KeyEvent;
use crossterm::event::{KeyCode, KeyModifiers};
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
use tracing::warn;
use super::Config;
@@ -9,13 +9,13 @@ use crate::input::TerminalKey;
pub type KeyCombo = (KeyCode, KeyModifiers);
-#[derive(Debug)]
+#[derive(Debug, Clone)]
pub struct LiveKeybindConfig {
pub prefix: KeyCombo,
pub keybinds: Keybinds,
}
-#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum BindingConfig {
One(String),
@@ -45,7 +45,7 @@ impl BindingConfig {
}
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CommandKeybindType {
#[default]
@@ -53,7 +53,7 @@ pub enum CommandKeybindType {
Pane,
}
-#[derive(Debug, Clone, Deserialize)]
+#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct CommandKeybindConfig {
/// Key that runs a command. Use `prefix+g` for prefix mode or a modified chord for direct mode.
diff --git a/src/config/model.rs b/src/config/model.rs
index 40d425a3..e099d510 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -89,7 +89,7 @@ pub struct LoadedConfig {
pub invalid_sections: Vec<String>,
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct KeysConfig {
/// Prefix key to enter prefix mode (e.g. "ctrl+b", "f12", "esc").
@@ -168,10 +168,11 @@ pub struct KeysConfig {
/// Optional indexed shortcuts expanded over number keys 1-9.
pub indexed: IndexedKeysConfig,
/// Prefix-mode custom command bindings.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
pub command: Vec<CommandKeybindConfig>,
}
-#[derive(Debug, Default, Deserialize)]
+#[derive(Debug, Default, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct IndexedKeysConfig {
/// Modifier combo for tab shortcuts 1-9. Unset by default.
diff --git a/src/main.rs b/src/main.rs
index 17052159..c8a2c26a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -394,6 +394,8 @@ fn main() -> io::Result<()> {
println!(" --no-session Run monolithically (no server/client, escape hatch)");
println!(" --session <name> Use or create a named persistent session");
println!(" --remote <target> Attach through SSH to a remote Herdr server");
+ println!(" --remote-keybindings <local|server>");
+ println!(" Keybindings for --remote app attach (default: local)");
println!(" --default-config Print default configuration and exit");
println!(" --version, -V Print version and exit");
println!(" --help, -h Show this help");
@@ -420,6 +422,7 @@ fn main() -> io::Result<()> {
"--no-session",
"--session",
"--remote",
+ "--remote-keybindings",
"--version",
"-V",
"--default-config",
@@ -427,7 +430,8 @@ fn main() -> io::Result<()> {
"-h",
];
for arg in &args[1..] {
- if arg.starts_with('-') && !known_flags.contains(&arg.as_str()) {
+ let arg_name = arg.split_once('=').map(|(name, _)| name).unwrap_or(arg);
+ if arg.starts_with('-') && !known_flags.contains(&arg_name) {
eprintln!("unknown option: {arg}");
eprintln!("run 'herdr --help' for usage");
std::process::exit(1);
diff --git a/src/remote.rs b/src/remote.rs
index 66b9b6e5..cfe2d49e 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -25,9 +25,35 @@ const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY";
pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND";
+pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum RemoteKeybindings {
+ Local,
+ Server,
+}
+
+impl RemoteKeybindings {
+ fn parse(value: &str) -> Result<Self, String> {
+ match value {
+ "local" => Ok(Self::Local),
+ "server" => Ok(Self::Server),
+ _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()),
+ }
+ }
+
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::Local => "local",
+ Self::Server => "server",
+ }
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RemoteLaunch {
pub(crate) target: String,
+ pub(crate) keybindings: RemoteKeybindings,
}
pub(crate) fn extract_remote_args(
@@ -38,30 +64,49 @@ pub(crate) fn extract_remote_args(
cleaned.push(program.clone());
}
- let mut remote = None;
+ let mut remote_target = None;
+ let mut keybindings = RemoteKeybindings::Local;
+ let mut keybindings_seen = false;
let mut index = 1;
while index < args.len() {
let arg = &args[index];
if arg == "--remote" {
- if remote.is_some() {
+ if remote_target.is_some() {
return Err("--remote can only be specified once".to_string());
}
let Some(value) = args.get(index + 1) else {
return Err("missing value for --remote".to_string());
};
- remote = Some(RemoteLaunch {
- target: validate_remote_target(value)?.to_owned(),
- });
+ remote_target = Some(validate_remote_target(value)?.to_owned());
index += 2;
continue;
}
if let Some(value) = arg.strip_prefix("--remote=") {
- if remote.is_some() {
+ if remote_target.is_some() {
return Err("--remote can only be specified once".to_string());
}
- remote = Some(RemoteLaunch {
- target: validate_remote_target(value)?.to_owned(),
- });
+ remote_target = Some(validate_remote_target(value)?.to_owned());
+ index += 1;
+ continue;
+ }
+ if arg == "--remote-keybindings" {
+ if keybindings_seen {
+ return Err("--remote-keybindings can only be specified once".to_string());
+ }
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --remote-keybindings".to_string());
+ };
+ keybindings = RemoteKeybindings::parse(value)?;
+ keybindings_seen = true;
+ index += 2;
+ continue;
+ }
+ if let Some(value) = arg.strip_prefix("--remote-keybindings=") {
+ if keybindings_seen {
+ return Err("--remote-keybindings can only be specified once".to_string());
+ }
+ keybindings = RemoteKeybindings::parse(value)?;
+ keybindings_seen = true;
index += 1;
continue;
}
@@ -70,6 +115,14 @@ pub(crate) fn extract_remote_args(
index += 1;
}
+ let remote = remote_target.map(|target| RemoteLaunch {
+ target,
+ keybindings,
+ });
+ if remote.is_none() && keybindings_seen {
+ return Err("--remote-keybindings requires --remote".to_string());
+ }
+
Ok((cleaned, remote))
}
@@ -90,7 +143,8 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
let program = std::env::args()
.next()
.unwrap_or_else(|| "herdr".to_string());
- let reattach_command = reattach_command(&program, &remote.target, &session_name);
+ let reattach_command =
+ reattach_command(&program, &remote.target, &session_name, remote.keybindings);
let prepared_remote = prepare_remote_herdr(&remote.target)?;
ensure_remote_server_ready(
&remote.target,
@@ -105,7 +159,7 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
session_name,
)?;
- run_client_process(&local_socket, &reattach_command)
+ run_client_process(&local_socket, &reattach_command, remote.keybindings)
}
pub(crate) fn run_remote_client_bridge() -> io::Result<()> {
@@ -301,7 +355,6 @@ fn prepare_remote_herdr(target: &str) -> io::Result<PreparedRemoteHerdr> {
)));
}
warn_if_remote_bin_not_on_path(target)?;
- maybe_copy_local_keybindings_to_remote(target, &remote_herdr)?;
Ok(PreparedRemoteHerdr {
remote_herdr,
@@ -787,131 +840,6 @@ fn confirm_remote_install(
Ok(())
}
-fn maybe_copy_local_keybindings_to_remote(
- target: &str,
- remote_herdr: &RemoteHerdr,
-) -> io::Result<()> {
- let Some(config_toml) = local_keybindings_config_toml()? else {
- return Ok(());
- };
- let remote_config_path = remote_config_path(target, remote_herdr)?;
- if remote_path_exists(target, &remote_config_path)? {
- return Ok(());
- }
- if !confirm_remote_keybindings_copy(target)? {
- return Ok(());
- }
- upload_remote_config(target, &remote_config_path, config_toml.as_bytes())
-}
-
-fn local_keybindings_config_toml() -> io::Result<Option<String>> {
- let path = crate::config::config_path();
- if !path.exists() {
- return Ok(None);
- }
- let content = fs::read_to_string(&path)?;
- Ok(local_keybindings_config_toml_from_str(&content))
-}
-
-fn local_keybindings_config_toml_from_str(content: &str) -> Option<String> {
- let mut value = content.parse::<toml::Value>().ok()?;
- let root = value.as_table_mut()?;
- let mut keys = root.remove("keys")?.as_table()?.clone();
- keys.remove("command");
- if keys.is_empty() {
- return None;
- }
-
- let mut out = toml::map::Map::new();
- out.insert("keys".to_string(), toml::Value::Table(keys));
- toml::to_string_pretty(&toml::Value::Table(out)).ok()
-}
-
-fn remote_config_path(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<String> {
- let command = format!("{} --help", remote_herdr.shell_path);
- let output = ssh_output(target, &command)?;
- if !output.status.success() {
- return Err(command_failed("remote config path probe failed", &output));
- }
- let stdout = String::from_utf8_lossy(&output.stdout);
- stdout
- .lines()
- .find_map(|line| line.trim().strip_prefix("Config: ").map(str::to_string))
- .ok_or_else(|| io::Error::other("remote config path probe did not print a Config line"))
-}
-
-fn remote_path_exists(target: &str, path: &str) -> io::Result<bool> {
- let command = format!("test -e {}", shell_quote(path));
- let output = ssh_output(target, &command)?;
- Ok(output.status.success())
-}
-
-fn confirm_remote_keybindings_copy(target: &str) -> io::Result<bool> {
- if !io::stdin().is_terminal() {
- return Ok(false);
- }
- eprintln!("remote Herdr config is not present on {target}.");
- eprintln!(
- "Herdr can copy your local [keys] settings so the remote server uses the same keybindings."
- );
- eprintln!("Custom command keybindings are not copied because they run on the remote host.");
- eprint!("Copy local Herdr keybindings to {target}? [Y/n] ");
- io::stderr().flush()?;
-
- let mut answer = String::new();
- io::stdin().read_line(&mut answer)?;
- let answer = answer.trim().to_ascii_lowercase();
- Ok(!(answer == "n" || answer == "no"))
-}
-
-fn upload_remote_config(target: &str, path: &str, content: &[u8]) -> io::Result<()> {
- let script = format!(
- r#"dest={}
-dir="${{dest%/*}}"
-mkdir -p "$dir"
-umask 077
-tmp="${{dest}}.tmp.$$"
-cat > "$tmp"
-mv "$tmp" "$dest"
-"#,
- shell_quote(path)
- );
-
- let mut child = Command::new("ssh")
- .arg("-T")
- .arg(target)
- .arg(format!("sh -eu -c {}", shell_quote(&script)))
- .stdin(Stdio::piped())
- .stdout(Stdio::inherit())
- .stderr(Stdio::inherit())
- .spawn()
- .map_err(|err| {
- io::Error::new(
- err.kind(),
- format!("failed to start ssh config upload: {err}"),
- )
- })?;
-
- let copy_result = if let Some(mut stdin) = child.stdin.take() {
- stdin.write_all(content)
- } else {
- Err(io::Error::new(
- io::ErrorKind::BrokenPipe,
- "ssh config upload stdin missing",
- ))
- };
- let status = child.wait()?;
- copy_result?;
-
- if status.success() {
- Ok(())
- } else {
- Err(io::Error::other(format!(
- "remote config upload exited with {status}"
- )))
- }
-}
-
fn install_remote_herdr(
target: &str,
remote_herdr: &RemoteHerdr,
@@ -978,9 +906,18 @@ fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> Stri
command
}
-fn reattach_command(program: &str, target: &str, session_name: &str) -> String {
+fn reattach_command(
+ program: &str,
+ target: &str,
+ session_name: &str,
+ keybindings: RemoteKeybindings,
+) -> String {
let program = if program.is_empty() { "herdr" } else { program };
let mut command = format!("{} --remote {}", shell_quote(program), shell_quote(target));
+ if keybindings != RemoteKeybindings::Local {
+ command.push_str(" --remote-keybindings ");
+ command.push_str(keybindings.as_str());
+ }
if session_name != crate::session::DEFAULT_SESSION_NAME {
command.push_str(" --session ");
command.push_str(&shell_quote(session_name));
@@ -1149,7 +1086,11 @@ fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::
}
}
-fn run_client_process(local_socket: &Path, reattach_command: &str) -> io::Result<()> {
+fn run_client_process(
+ local_socket: &Path,
+ reattach_command: &str,
+ keybindings: RemoteKeybindings,
+) -> io::Result<()> {
let exe = std::env::current_exe()?;
let status = Command::new(exe)
.arg("client")
@@ -1159,6 +1100,7 @@ fn run_client_process(local_socket: &Path, reattach_command: &str) -> io::Result
)
.env("HERDR_RENDER_ENCODING", "terminal-ansi")
.env(REATTACH_COMMAND_ENV_VAR, reattach_command)
+ .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str())
.env_remove(crate::api::SOCKET_PATH_ENV_VAR)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
@@ -1278,7 +1220,9 @@ mod tests {
];
let (cleaned, remote) = extract_remote_args(&args).unwrap();
assert_eq!(cleaned, vec!["herdr", "--help"]);
- assert_eq!(remote.unwrap().target, "dev");
+ let remote = remote.unwrap();
+ assert_eq!(remote.target, "dev");
+ assert_eq!(remote.keybindings, RemoteKeybindings::Local);
}
#[test]
@@ -1286,7 +1230,56 @@ mod tests {
let args = vec!["herdr".into(), "--remote=user@host".into()];
let (cleaned, remote) = extract_remote_args(&args).unwrap();
assert_eq!(cleaned, vec!["herdr"]);
- assert_eq!(remote.unwrap().target, "user@host");
+ let remote = remote.unwrap();
+ assert_eq!(remote.target, "user@host");
+ assert_eq!(remote.keybindings, RemoteKeybindings::Local);
+ }
+
+ #[test]
+ fn extract_remote_args_accepts_remote_keybindings_server() {
+ let args = vec![
+ "herdr".into(),
+ "--remote".into(),
+ "dev".into(),
+ "--remote-keybindings=server".into(),
+ ];
+ let (cleaned, remote) = extract_remote_args(&args).unwrap();
+ assert_eq!(cleaned, vec!["herdr"]);
+ let remote = remote.unwrap();
+ assert_eq!(remote.target, "dev");
+ assert_eq!(remote.keybindings, RemoteKeybindings::Server);
+ }
+
+ #[test]
+ fn extract_remote_args_accepts_remote_keybindings_space_form() {
+ let args = vec![
+ "herdr".into(),
+ "--remote=dev".into(),
+ "--remote-keybindings".into(),
+ "server".into(),
+ ];
+ let (cleaned, remote) = extract_remote_args(&args).unwrap();
+ assert_eq!(cleaned, vec!["herdr"]);
+ assert_eq!(remote.unwrap().keybindings, RemoteKeybindings::Server);
+ }
+
+ #[test]
+ fn extract_remote_args_rejects_remote_keybindings_without_remote() {
+ let args = vec!["herdr".into(), "--remote-keybindings=server".into()];
+ let err = extract_remote_args(&args).unwrap_err();
+ assert_eq!(err, "--remote-keybindings requires --remote");
+ }
+
+ #[test]
+ fn extract_remote_args_rejects_duplicate_remote_keybindings() {
+ let args = vec![
+ "herdr".into(),
+ "--remote=dev".into(),
+ "--remote-keybindings=local".into(),
+ "--remote-keybindings=server".into(),
+ ];
+ let err = extract_remote_args(&args).unwrap_err();
+ assert_eq!(err, "--remote-keybindings can only be specified once");
}
#[test]
@@ -1346,50 +1339,32 @@ mod tests {
#[test]
fn reattach_command_includes_remote_and_session() {
assert_eq!(
- reattach_command("target/release/herdr", "user@host", "work"),
+ reattach_command(
+ "target/release/herdr",
+ "user@host",
+ "work",
+ RemoteKeybindings::Local,
+ ),
"target/release/herdr --remote user@host --session work"
);
assert_eq!(
- reattach_command("herdr", "host name", crate::session::DEFAULT_SESSION_NAME),
+ reattach_command(
+ "herdr",
+ "host name",
+ crate::session::DEFAULT_SESSION_NAME,
+ RemoteKeybindings::Local,
+ ),
"herdr --remote 'host name'"
);
- }
-
- #[test]
- fn local_keybindings_config_extracts_only_keys_without_commands() {
- let toml = r#"
-[theme]
-name = "one-dark"
-
-[keys]
-prefix = "ctrl+a"
-new_tab = ["prefix+c", "ctrl+alt+n"]
-next_tab = "prefix+n"
-
-[keys.indexed]
-tabs = "ctrl"
-
-[[keys.command]]
-key = "prefix+g"
-type = "pane"
-command = "lazygit"
-"#;
-
- let copied = local_keybindings_config_toml_from_str(toml).expect("copied key config");
- assert!(copied.contains("[keys]"));
- assert!(copied.contains("prefix = \"ctrl+a\""));
- assert!(copied.contains("prefix+c"));
- assert!(copied.contains("ctrl+alt+n"));
- assert!(copied.contains("[keys.indexed]"));
- assert!(copied.contains("tabs = \"ctrl\""));
- assert!(!copied.contains("one-dark"));
- assert!(!copied.contains("lazygit"));
- assert!(!copied.contains("[[keys.command]]"));
- }
-
- #[test]
- fn local_keybindings_config_returns_none_without_keys() {
- assert!(local_keybindings_config_toml_from_str("[theme]\nname = \"one-dark\"\n").is_none());
+ assert_eq!(
+ reattach_command(
+ "herdr",
+ "host",
+ crate::session::DEFAULT_SESSION_NAME,
+ RemoteKeybindings::Server,
+ ),
+ "herdr --remote host --remote-keybindings server"
+ );
}
#[test]
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs
index fc6ab0be..0abea899 100644
--- a/src/server/client_transport.rs
+++ b/src/server/client_transport.rs
@@ -14,8 +14,8 @@ use tokio::sync::mpsc;
use tracing::{debug, warn};
use crate::server::protocol::{
- self, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD,
- MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
+ self, ClientKeybindings, ClientMessage, RenderEncoding, ServerMessage,
+ MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
};
/// Minimum accepted attached client size.
@@ -55,6 +55,7 @@ pub(crate) enum ServerEvent {
cell_width_px: u32,
cell_height_px: u32,
render_encoding: RenderEncoding,
+ keybindings: Option<Box<crate::config::LiveKeybindConfig>>,
writer: ClientWriter,
},
/// A client sent an input message.
@@ -96,6 +97,23 @@ pub(crate) fn clamp_terminal_size(cols: u16, rows: u16) -> (u16, u16) {
(clamped_cols, clamped_rows)
}
+fn parse_client_keybindings(
+ keybindings: ClientKeybindings,
+) -> Result<Option<Box<crate::config::LiveKeybindConfig>>, String> {
+ match keybindings {
+ ClientKeybindings::Server => Ok(None),
+ ClientKeybindings::Local { keys_toml } => {
+ let mut config = toml::from_str::<crate::config::Config>(&keys_toml)
+ .map_err(|err| format!("invalid client keybindings: {err}"))?;
+ config.keys.command.clear();
+ Ok(Some(Box::new(crate::config::LiveKeybindConfig {
+ prefix: config.prefix_key(),
+ keybinds: config.keybinds(),
+ })))
+ }
+ }
+}
+
/// Handles the client handshake on a blocking thread.
///
/// Reads the `Hello` message, validates the version, sends `Welcome`,
@@ -130,52 +148,68 @@ pub(crate) fn handle_client_handshake(
}
};
- let (client_cols, client_rows, cell_width_px, cell_height_px, render_encoding) = match hello {
- ClientMessage::Hello {
- version,
- cols,
- rows,
- cell_width_px,
- cell_height_px,
- requested_encoding,
- } => {
- // Version check.
- match protocol::check_client_version(version) {
- protocol::VersionCheck::Compatible => {}
- protocol::VersionCheck::Incompatible(reason) => {
- // Send rejection Welcome.
- let welcome = ServerMessage::Welcome {
- version: PROTOCOL_VERSION,
- encoding: RenderEncoding::SemanticFrame,
- error: Some(reason),
- };
- let _ = protocol::write_message(&mut stream, &welcome);
- return Ok(());
- }
- }
-
- // Clamp size.
- let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
- (
- clamped_cols,
- clamped_rows,
+ let (client_cols, client_rows, cell_width_px, cell_height_px, render_encoding, keybindings) =
+ match hello {
+ ClientMessage::Hello {
+ version,
+ cols,
+ rows,
cell_width_px,
cell_height_px,
requested_encoding,
- )
- }
- _ => {
- // First message must be Hello.
- debug!(client_id, "first message was not Hello, closing");
- let welcome = ServerMessage::Welcome {
- version: PROTOCOL_VERSION,
- encoding: RenderEncoding::SemanticFrame,
- error: Some("expected Hello as first message".to_owned()),
- };
- let _ = protocol::write_message(&mut stream, &welcome);
- return Ok(());
- }
- };
+ keybindings,
+ } => {
+ // Version check.
+ match protocol::check_client_version(version) {
+ protocol::VersionCheck::Compatible => {}
+ protocol::VersionCheck::Incompatible(reason) => {
+ // Send rejection Welcome.
+ let welcome = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
+ error: Some(reason),
+ };
+ let _ = protocol::write_message(&mut stream, &welcome);
+ return Ok(());
+ }
+ }
+
+ let keybindings = match parse_client_keybindings(keybindings) {
+ Ok(keybindings) => keybindings,
+ Err(error) => {
+ let welcome = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
+ error: Some(error),
+ };
+ let _ = protocol::write_message(&mut stream, &welcome);
+ return Ok(());
+ }
+ };
+
+ // Clamp size.
+ let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
+ (
+ clamped_cols,
+ clamped_rows,
+ cell_width_px,
+ cell_height_px,
+ requested_encoding,
+ keybindings,
+ )
+ }
+ _ => {
+ // First message must be Hello.
+ debug!(client_id, "first message was not Hello, closing");
+ let welcome = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
+ error: Some("expected Hello as first message".to_owned()),
+ };
+ let _ = protocol::write_message(&mut stream, &welcome);
+ return Ok(());
+ }
+ };
// Send Welcome.
let welcome = ServerMessage::Welcome {
@@ -204,6 +238,7 @@ pub(crate) fn handle_client_handshake(
cell_width_px,
cell_height_px,
render_encoding,
+ keybindings,
writer,
});
@@ -447,6 +482,54 @@ mod tests {
}
#[test]
+ fn parse_client_keybindings_accepts_local_profile() {
+ let keybindings = parse_client_keybindings(ClientKeybindings::Local {
+ keys_toml: r#"
+[keys]
+prefix = "ctrl+a"
+new_tab = "prefix+t"
+
+[[keys.command]]
+key = "prefix+g"
+command = "lazygit"
+"#
+ .to_owned(),
+ })
+ .expect("valid client keybindings")
+ .expect("local profile");
+
+ assert_eq!(keybindings.prefix.0, crossterm::event::KeyCode::Char('a'));
+ assert!(keybindings
+ .keybinds
+ .new_tab
+ .bindings
+ .iter()
+ .any(|binding| binding.label == "prefix+t"));
+ assert!(keybindings.keybinds.custom_commands.is_empty());
+ }
+
+ #[test]
+ fn parse_client_keybindings_tolerates_disabled_bindings() {
+ let keybindings = parse_client_keybindings(ClientKeybindings::Local {
+ keys_toml: r#"
+[keys]
+new_tab = "ctrl+notakey"
+"#
+ .to_owned(),
+ })
+ .expect("diagnostic-only client keybindings should be accepted")
+ .expect("local profile");
+
+ assert!(keybindings.keybinds.new_tab.bindings.is_empty());
+ assert!(keybindings
+ .keybinds
+ .next_tab
+ .bindings
+ .iter()
+ .any(|binding| binding.label == "prefix+n"));
+ }
+
+ #[test]
fn handshake_negotiates_terminal_ansi_encoding() {
let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair");
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
@@ -465,6 +548,7 @@ mod tests {
cell_width_px: 8,
cell_height_px: 16,
requested_encoding: RenderEncoding::TerminalAnsi,
+ keybindings: ClientKeybindings::Server,
},
)
.expect("write hello");
@@ -495,12 +579,14 @@ mod tests {
cell_width_px,
cell_height_px,
render_encoding,
+ keybindings,
writer,
} => {
assert_eq!(client_id, 42);
assert_eq!((cols, rows), (100, 30));
assert_eq!((cell_width_px, cell_height_px), (8, 16));
assert_eq!(render_encoding, RenderEncoding::TerminalAnsi);
+ assert!(keybindings.is_none());
drop(writer);
}
other => panic!("expected ClientConnected, got {other:?}"),
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 539892e0..ddfd631c 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -209,6 +209,19 @@ fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf {
parent.join(format!("{stem}-client.sock"))
}
+fn app_keybindings(app: &app::App) -> crate::config::LiveKeybindConfig {
+ crate::config::LiveKeybindConfig {
+ prefix: (app.state.prefix_code, app.state.prefix_mods),
+ keybinds: app.state.keybinds.clone(),
+ }
+}
+
+fn apply_keybindings(app: &mut app::App, keybindings: &crate::config::LiveKeybindConfig) {
+ app.state.prefix_code = keybindings.prefix.0;
+ app.state.prefix_mods = keybindings.prefix.1;
+ app.state.keybinds = keybindings.keybinds.clone();
+}
+
// ---------------------------------------------------------------------------
// Connected client state
// ---------------------------------------------------------------------------
@@ -231,6 +244,8 @@ type RenderTarget = (
struct ClientConnection {
/// Whether this connection is the full app client or a direct terminal attach.
mode: ClientConnectionMode,
+ /// Client-local app keybindings. None means use the server's keybindings.
+ keybindings: Option<Box<crate::config::LiveKeybindConfig>>,
/// The client's terminal size (after clamping).
terminal_size: (u16, u16),
/// Pixel size of one client terminal cell.
@@ -270,6 +285,7 @@ impl ClientConnection {
) -> Self {
Self::new_with_mode(
ClientConnectionMode::App,
+ None,
terminal_size,
cell_size,
host_terminal_theme,
@@ -282,6 +298,7 @@ impl ClientConnection {
fn new_with_mode(
mode: ClientConnectionMode,
+ keybindings: Option<Box<crate::config::LiveKeybindConfig>>,
terminal_size: (u16, u16),
cell_size: crate::kitty_graphics::HostCellSize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
@@ -292,6 +309,7 @@ impl ClientConnection {
) -> Self {
Self {
mode,
+ keybindings,
terminal_size,
cell_size,
host_terminal_theme,
@@ -349,8 +367,10 @@ pub struct HeadlessServer {
client_socket_path: PathBuf,
clients: HashMap<u64, ClientConnection>,
next_client_id: u64,
- /// The client currently driving the shared pane runtime size and theme.
+ /// The client currently driving the shared pane runtime size, theme, and input keybindings.
foreground_client_id: Option<u64>,
+ /// Server-owned keybindings, restored when foreground clients use server mode.
+ server_keybindings: crate::config::LiveKeybindConfig,
/// Writable direct attach owner per terminal id string.
terminal_attach_owners: HashMap<String, u64>,
/// Monotonic activity counter used to pick the most recently active client.
@@ -390,6 +410,7 @@ impl HeadlessServer {
// Channel for server events from client threads.
let (server_event_tx, server_event_rx) = mpsc::channel(64);
+ let server_keybindings = app_keybindings(&app);
Ok(Self {
app,
@@ -398,6 +419,7 @@ impl HeadlessServer {
clients: HashMap::new(),
next_client_id: 1,
foreground_client_id: None,
+ server_keybindings,
terminal_attach_owners: HashMap::new(),
next_activity_stamp: 1,
effective_size: (MIN_COLS, MIN_ROWS),
@@ -498,7 +520,7 @@ impl HeadlessServer {
if self.app.state.request_reload_config {
self.app.state.request_reload_config = false;
- self.app.reload_config();
+ self.reload_server_config();
needs_render = true;
}
@@ -611,17 +633,27 @@ impl HeadlessServer {
let Some(client_id) = self.foreground_client_id else {
self.effective_size = (MIN_COLS, MIN_ROWS);
self.app.state.outer_terminal_focus = None;
+ let server_keybindings = self.server_keybindings.clone();
+ apply_keybindings(&mut self.app, &server_keybindings);
return;
};
let Some(client) = self.clients.get(&client_id) else {
self.foreground_client_id = None;
self.effective_size = (MIN_COLS, MIN_ROWS);
self.app.state.outer_terminal_focus = None;
+ let server_keybindings = self.server_keybindings.clone();
+ apply_keybindings(&mut self.app, &server_keybindings);
return;
};
self.effective_size = client.terminal_size;
self.app.state.outer_terminal_focus = client.outer_terminal_focus;
+ let keybindings = client
+ .keybindings
+ .as_deref()
+ .unwrap_or(&self.server_keybindings)
+ .clone();
+ apply_keybindings(&mut self.app, &keybindings);
if client.outer_terminal_focus == Some(true) {
self.app.state.mark_active_tab_seen();
}
@@ -630,6 +662,15 @@ impl HeadlessServer {
}
}
+ fn reload_server_config(&mut self) -> crate::config::ConfigReloadReport {
+ let server_keybindings = self.server_keybindings.clone();
+ apply_keybindings(&mut self.app, &server_keybindings);
+ let report = self.app.reload_config();
+ self.server_keybindings = app_keybindings(&self.app);
+ self.sync_foreground_client_state();
+ report
+ }
+
fn foreground_client_outer_focus(&self) -> Option<bool> {
let client_id = self.foreground_client_id?;
self.clients.get(&client_id)?.outer_terminal_focus
@@ -1393,6 +1434,7 @@ impl HeadlessServer {
rows,
cell_width_px,
cell_height_px,
+ keybindings,
writer,
render_encoding,
} => {
@@ -1410,6 +1452,7 @@ impl HeadlessServer {
client_id,
ClientConnection::new_with_mode(
ClientConnectionMode::App,
+ keybindings,
(cols, rows),
crate::kitty_graphics::HostCellSize {
width_px: cell_width_px,
@@ -1675,7 +1718,31 @@ impl HeadlessServer {
};
self.sync_foreground_client_state();
- let response = self.app.handle_api_request(msg.request);
+ let response = if matches!(
+ &msg.request.method,
+ api::schema::Method::ServerReloadConfig(_)
+ ) {
+ let report = self.reload_server_config();
+ serde_json::to_string(&api::schema::SuccessResponse {
+ id: msg.request.id.clone(),
+ result: api::schema::ResponseResult::ConfigReload {
+ status: report.status,
+ diagnostics: report.diagnostics,
+ },
+ })
+ .unwrap_or_else(|err| {
+ serde_json::to_string(&api::schema::ErrorResponse {
+ id: String::new(),
+ error: api::schema::ErrorBody {
+ code: "serialization_error".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap_or_else(|_| "{}".to_string())
+ })
+ } else {
+ self.app.handle_api_request(msg.request)
+ };
let _ = msg.respond_to.send(response);
// Forward new toast state only when a client-local delivery mode is selected.
@@ -2373,6 +2440,7 @@ mod tests {
.set_nonblocking(true)
.expect("set listener nonblocking");
let (server_event_tx, server_event_rx) = mpsc::channel(64);
+ let server_keybindings = app_keybindings(&app);
HeadlessServer {
app,
@@ -2381,6 +2449,7 @@ mod tests {
clients: HashMap::new(),
next_client_id: 1,
foreground_client_id: None,
+ server_keybindings,
terminal_attach_owners: HashMap::new(),
next_activity_stamp: 1,
effective_size: (MIN_COLS, MIN_ROWS),
@@ -2428,6 +2497,68 @@ mod tests {
}
#[test]
+ fn foreground_client_applies_client_keybindings() {
+ let mut server = test_headless_server();
+ let local_config: crate::config::Config = toml::from_str(
+ r#"
+[keys]
+prefix = "ctrl+a"
+new_tab = "prefix+t"
+"#,
+ )
+ .unwrap();
+ let local_keybindings = local_config.live_keybinds().unwrap();
+ let (writer_a, _control_a, _render_a) = test_client_writer();
+ let (writer_b, _control_b, _render_b) = test_client_writer();
+
+ assert!(server.handle_server_event(ServerEvent::ClientConnected {
+ client_id: 1,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ render_encoding: RenderEncoding::SemanticFrame,
+ keybindings: Some(Box::new(local_keybindings)),
+ writer: writer_a,
+ }));
+ assert_eq!(
+ server.app.state.prefix_code,
+ crossterm::event::KeyCode::Char('a')
+ );
+ assert!(server
+ .app
+ .state
+ .keybinds
+ .new_tab
+ .bindings
+ .iter()
+ .any(|binding| binding.label == "prefix+t"));
+
+ assert!(server.handle_server_event(ServerEvent::ClientConnected {
+ client_id: 2,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 0,
+ cell_height_px: 0,
+ render_encoding: RenderEncoding::SemanticFrame,
+ keybindings: None,
+ writer: writer_b,
+ }));
+ assert_eq!(
+ server.app.state.prefix_code,
+ crossterm::event::KeyCode::Char('b')
+ );
+ assert!(server
+ .app
+ .state
+ .keybinds
+ .new_tab
+ .bindings
+ .iter()
+ .any(|binding| binding.label == "prefix+c"));
+ }
+
+ #[test]
fn terminal_attach_rejects_missing_terminal_and_removes_client() {
let mut server = test_headless_server();
let (writer, control_rx, _render_rx) = test_client_writer();
@@ -2439,6 +2570,7 @@ mod tests {
cell_width_px: 0,
cell_height_px: 0,
render_encoding: RenderEncoding::TerminalAnsi,
+ keybindings: None,
writer,
}));
assert!(server.clients.contains_key(&7));
@@ -2479,6 +2611,7 @@ mod tests {
cell_width_px: 0,
cell_height_px: 0,
render_encoding: RenderEncoding::TerminalAnsi,
+ keybindings: None,
writer,
}));
assert!(
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
index 6710bc80..33a5f513 100644
--- a/src/server/protocol.rs
+++ b/src/server/protocol.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 8;
+pub const PROTOCOL_VERSION: u32 = 9;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
@@ -43,6 +43,15 @@ pub enum RenderEncoding {
TerminalAnsi,
}
+/// Keybinding profile requested by an attached app client.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientKeybindings {
+ /// Use the server's own keybinding config.
+ Server,
+ /// Use this attached client's normalized local `[keys]` config.
+ Local { keys_toml: String },
+}
+
/// Messages sent from the client to the server over the client protocol socket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientMessage {
@@ -60,6 +69,8 @@ pub enum ClientMessage {
cell_height_px: u32,
/// Render encoding requested by the client.
requested_encoding: RenderEncoding,
+ /// Keybinding profile requested by the client.
+ keybindings: ClientKeybindings,
},
/// Raw input bytes read from the client's stdin.
@@ -608,6 +619,7 @@ mod tests {
cell_width_px: 8,
cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
+ keybindings: ClientKeybindings::Server,
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
let (decoded, _): (ClientMessage, _) =
@@ -892,6 +904,7 @@ mod tests {
cell_width_px: 8,
cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
+ keybindings: ClientKeybindings::Server,
};
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
@@ -964,6 +977,7 @@ mod tests {
cell_width_px: 8,
cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
+ keybindings: ClientKeybindings::Server,
},
1 => ClientMessage::Input {
data: vec![(i % 256) as u8; (i as usize % 50) + 1],
@@ -1398,6 +1412,7 @@ mod tests {
cell_width_px: 8,
cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
+ keybindings: ClientKeybindings::Server,
};
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
@@ -1431,6 +1446,7 @@ mod tests {
cell_width_px: 8,
cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
+ keybindings: ClientKeybindings::Server,
},
ClientMessage::Input {
data: b"hello world".to_vec(),
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index 7e00c315..a8012ea6 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -227,7 +227,7 @@ fn ping_over_socket_returns_version() {
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
// Intentionally hardcoded so wire protocol bumps require updating this test.
// Changing this value means old clients/servers are no longer compatible.
- assert_eq!(value["result"]["protocol"], 8);
+ assert_eq!(value["result"]["protocol"], 9);
cleanup_spawned_herdr(child, base);
}
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 4d06e618..efe730ec 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -968,7 +968,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {full_stdout}"
);
assert!(
- full_stdout.contains(" protocol: 8"),
+ full_stdout.contains(" protocol: 9"),
"stdout: {full_stdout}"
);
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
@@ -1001,7 +1001,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {server_stdout}"
);
assert!(
- server_stdout.contains("protocol: 8"),
+ server_stdout.contains("protocol: 9"),
"stdout: {server_stdout}"
);
@@ -1013,7 +1013,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {client_stdout}"
);
assert!(
- client_stdout.contains("protocol: 8"),
+ client_stdout.contains("protocol: 9"),
"stdout: {client_stdout}"
);
assert!(
diff --git a/tests/client_mode.rs b/tests/client_mode.rs
index c6d8a3ec..6a9546a3 100644
--- a/tests/client_mode.rs
+++ b/tests/client_mode.rs
@@ -274,8 +274,8 @@ fn client_connects_and_receives_frame() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8, "server should report protocol version 8");
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9, "server should report protocol version 9");
assert!(
error.is_none(),
"handshake should not have error: {:?}",
@@ -342,8 +342,8 @@ fn client_sees_headless_startup_config_diagnostic() {
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
stream
@@ -391,8 +391,8 @@ fn client_input_forwarded_to_pane() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Send an Input message containing "echo hello\n".
@@ -445,8 +445,8 @@ fn client_resize_sends_message() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Drain the initial frame(s).
@@ -504,8 +504,8 @@ fn server_shutdown_sends_message_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Send SIGINT so the server takes the graceful shutdown path and
@@ -736,8 +736,8 @@ fn client_receives_frame_after_pane_output() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
read_next_frame_payload(&mut stream, Duration::from_secs(10))
@@ -783,8 +783,8 @@ fn navigate_mode_keybind_dispatch_in_server() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -901,8 +901,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
@@ -1000,8 +1000,8 @@ fn client_receives_notify_on_agent_state_change() {
// Connect as a client and perform handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
diff --git a/tests/cross_area.rs b/tests/cross_area.rs
index 5b051277..5bd37efe 100644
--- a/tests/cross_area.rs
+++ b/tests/cross_area.rs
@@ -427,6 +427,7 @@ fn client_handshake(stream: &mut UnixStream, version: u32, cols: u16, rows: u16)
payload.extend_from_slice(&encode_varint_u32(8)); // cell_width_px
payload.extend_from_slice(&encode_varint_u32(16)); // cell_height_px
payload.extend_from_slice(&encode_varint_u32(0)); // RenderEncoding::SemanticFrame
+ payload.extend_from_slice(&encode_varint_u32(0)); // ClientKeybindings::Server
stream
.write_all(&frame_message(&payload))
@@ -685,7 +686,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Local attach (client A).
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 8, 100, 30);
+ client_handshake(&mut client_a, 9, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
// Use herdr: create a workspace and write output into its pane.
@@ -722,7 +723,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Reattach from another terminal/session (client B).
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 8, 80, 24);
+ client_handshake(&mut client_b, 9, 80, 24);
assert!(
wait_for_frame(&mut client_b, Duration::from_secs(5)),
"reattached client should receive frame"
@@ -778,7 +779,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 8, 100, 30);
+ client_handshake(&mut client_a, 9, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
let created = workspace_create(&api_socket, "agent-persist");
@@ -831,7 +832,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
// Reattach and ensure client-side state reflects the persisted working status.
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 8, 80, 24);
+ client_handshake(&mut client_b, 9, 80, 24);
let saw_working_on_client =
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
frame_contains_text(frame, "working")
@@ -876,7 +877,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client = UnixStream::connect(&client_socket).expect("client should connect");
- client_handshake(&mut client, 8, 100, 30);
+ client_handshake(&mut client, 9, 100, 30);
assert!(wait_for_frame(&mut client, Duration::from_secs(2)));
drain_server_messages(&mut client, Duration::from_millis(300));
@@ -939,9 +940,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 8, 110, 30);
+ client_handshake(&mut client_a, 9, 110, 30);
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 8, 100, 30);
+ client_handshake(&mut client_b, 9, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
assert!(wait_for_frame(&mut client_b, Duration::from_secs(2)));
@@ -1110,7 +1111,7 @@ fn cross_area_server_kill_then_restart_and_reconnect() {
let mut reconnect_client =
UnixStream::connect(&client_socket).expect("new client should connect after restart");
- client_handshake(&mut reconnect_client, 8, 80, 24);
+ client_handshake(&mut reconnect_client, 9, 80, 24);
assert!(
wait_for_frame(&mut reconnect_client, Duration::from_secs(5)),
"new client should receive frame after restart"
diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs
index 9b1e3681..1d17739a 100644
--- a/tests/detach_reattach.rs
+++ b/tests/detach_reattach.rs
@@ -276,8 +276,8 @@ fn navigate_q_detaches_client_and_server_persists() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -338,8 +338,8 @@ fn explicit_detach_message_causes_clean_disconnect() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -397,8 +397,8 @@ fn reattach_after_detach_shows_current_state() {
// --- Client A ---
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
let (version, error) =
- client_handshake(&mut stream_a, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream_a, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -436,8 +436,8 @@ fn reattach_after_detach_shows_current_state() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
let (version, error) =
- client_handshake(&mut stream_b, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream_b, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(
error.is_none(),
"reattach handshake should succeed: {:?}",
@@ -516,8 +516,8 @@ fn processes_survive_during_and_after_detach() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -555,8 +555,8 @@ fn processes_survive_during_and_after_detach() {
// Reattach — verify we can connect and receive a frame.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
let (version, error) =
- client_handshake(&mut stream_b, 8, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream_b, 9, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Verify the reattached client receives a frame.
@@ -604,8 +604,8 @@ fn server_persists_after_client_connection_drop() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -631,8 +631,8 @@ fn server_persists_after_client_connection_drop() {
// Reattach — verify we can connect and handshake again.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
let (version, error) =
- client_handshake(&mut stream_b, 8, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream_b, 9, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "reattach should succeed: {:?}", error);
cleanup_spawned_herdr(spawned, base);
@@ -653,8 +653,8 @@ fn detached_output_preserves_last_attached_pty_size() {
let mut stream = UnixStream::connect(&client_socket).expect("client should connect");
let (version, error) =
- client_handshake(&mut stream, 8, 120, 40).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream, 9, 120, 40).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
drain_messages(&mut stream);
@@ -722,8 +722,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// Connect and handshake client A.
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
let (version, error) =
- client_handshake(&mut stream_a, 8, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream_a, 9, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Detach client A immediately.
@@ -780,8 +780,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
let (version, error) =
- client_handshake(&mut stream_b, 8, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 8);
+ client_handshake(&mut stream_b, 9, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 9);
assert!(error.is_none(), "{:?}", error);
// Client B should receive a frame with the current state.
diff --git a/tests/multi_client.rs b/tests/multi_client.rs
index ba45bde6..21a6dddc 100644
--- a/tests/multi_client.rs
+++ b/tests/multi_client.rs
@@ -499,6 +499,7 @@ fn client_handshake(
&encode_varint_u32(8), // cell_width_px
&encode_varint_u32(16), // cell_height_px
&encode_varint_u32(0), // RenderEncoding::SemanticFrame
+ &encode_varint_u32(0), // ClientKeybindings::Server
],
);
stream
@@ -549,7 +550,7 @@ fn client_handshake(
fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream {
let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket");
- client_handshake(&mut stream, 8, cols, rows).expect("handshake should succeed");
+ client_handshake(&mut stream, 9, cols, rows).expect("handshake should succeed");
stream
}
diff --git a/tests/server_headless.rs b/tests/server_headless.rs
index 8f0234f0..93531324 100644
--- a/tests/server_headless.rs
+++ b/tests/server_headless.rs
@@ -179,6 +179,7 @@ fn client_handshake(
&encode_varint_u32(8), // cell_width_px
&encode_varint_u32(16), // cell_height_px
&encode_varint_u32(0), // RenderEncoding::SemanticFrame
+ &encode_varint_u32(0), // ClientKeybindings::Server
],
);
let framed = frame_message(&hello_payload);
@@ -591,11 +592,11 @@ fn client_handshake_succeeds() {
// Connect to the client socket and perform a handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- // Send Hello with version 8, 80 cols, 24 rows.
+ // Send Hello with the current protocol version, 80 cols, 24 rows.
let (version, error) =
- client_handshake(&mut stream, 8, 80, 24).expect("handshake should succeed");
+ client_handshake(&mut stream, 9, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 8, "server should report protocol version 8");
+ assert_eq!(version, 9, "server should report protocol version 9");
assert!(
error.is_none(),
"handshake should not have an error: {:?}",
@@ -624,7 +625,7 @@ fn client_handshake_rejects_incompatible_version() {
let (version, error) = client_handshake(&mut stream, 0, 80, 24)
.expect("should read Welcome response even on rejection");
- assert_eq!(version, 8, "server should report its version 8");
+ assert_eq!(version, 9, "server should report its version 9");
assert!(
error.is_some(),
"version 0 should be rejected with an error"
@@ -649,10 +650,10 @@ fn client_handshake_clamps_small_terminal_size() {
// Send Hello with 0x0 terminal size — should be clamped.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- let (version, error) = client_handshake(&mut stream, 8, 0, 0)
+ let (version, error) = client_handshake(&mut stream, 9, 0, 0)
.expect("handshake with 0x0 should succeed (server clamps)");
- assert_eq!(version, 8);
+ assert_eq!(version, 9);
assert!(
error.is_none(),
"0x0 size should be accepted (clamped): {:?}",
@@ -712,9 +713,9 @@ fn no_hello_client_closed_within_five_seconds() {
// Verify the server is still healthy — a proper client can still connect.
let mut good_stream =
UnixStream::connect(&client_socket).expect("should connect after no-hello client");
- let (version, error) = client_handshake(&mut good_stream, 8, 80, 24)
+ let (version, error) = client_handshake(&mut good_stream, 9, 80, 24)
.expect("proper handshake should still work after no-hello client");
- assert_eq!(version, 8);
+ assert_eq!(version, 9);
assert!(error.is_none());
// API should still work.
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index e81afd57..e810b843 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -223,6 +223,7 @@ pub fn client_handshake(
&encode_varint_u32(8), // cell_width_px
&encode_varint_u32(16), // cell_height_px
&encode_varint_u32(0), // RenderEncoding::SemanticFrame
+ &encode_varint_u32(0), // ClientKeybindings::Server
],
);
let framed = frame_message(&hello_payload);