aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
9 files changed, 512 insertions, 240 deletions
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(),