From 8e475c1a4b583c599863d25ad6877909e01feec4 Mon Sep 17 00:00:00 2001 From: kipavy Date: Thu, 24 Sep 2026 12:25:13 +0000 Subject: [PATCH] fix(ssh): let typing leave tmux copy-mode after scrolling up Scrolling the wheel up in a persistent session puts our tmux wrapper into copy-mode, whose default key table has no Enter binding, so every keystroke was swallowed until q/Escape (#344). A one-shot exec per persistent connection now binds keys in both copy mode tables to cancel and forward themselves. It runs on its own channel so the size-capped create payload is untouched, waits for the session so a cold server is covered, and re-applies to servers started before this change. tmux 3.4+ gets every printable key; 2.6-3.3 get Enter only, because those versions run a paste's first key through bindings and would drop the bracketed-paste start. q, Escape and C-c keep exiting without forwarding. Also folds the three exec-and-read copies in connect() into exec_collect. --- src-tauri/src/shell_integration.rs | 76 +++++++++++++++++++++++++++ src-tauri/src/ssh/client.rs | 82 +++++++++++++++--------------- 2 files changed, 117 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/shell_integration.rs b/src-tauri/src/shell_integration.rs index dfab98ba1..d14442303 100644 --- a/src-tauri/src/shell_integration.rs +++ b/src-tauri/src/shell_integration.rs @@ -484,6 +484,47 @@ true"#, encode_wrapper(&script) } +/// Binds typed keys to leave copy-mode (which wheel-up enters) and reach the shell. +/// Below 3.4 only Enter: older tmux runs a paste's first key through bindings, breaking the paste. +pub fn persistent_copy_mode_keys_command(session_key: &str) -> String { + let script = format!( + r#"command -v tmux >/dev/null 2>&1 || exit 0 +case "$(tmux -V 2>/dev/null)" in + *"tmux "3.[4-9]*|*"tmux "[4-9]*|*"tmux "[1-9][0-9]*) ALL=1 ;; + *"tmux "3.[0-3]*|*"tmux "2.[6-9]*) ALL= ;; + *) exit 0 ;; +esac +i=0 +until tmux -L {socket} has-session -t {key} 2>/dev/null; do + [ $i -ge 15 ] && exit 0 + sleep 1 + i=$((i+1)) +done +F=$(mktemp 2>/dev/null) || exit 0 +set -f +while read -r l; do + for k in $l; do + [ -n "$ALL" ] || [ "$k" = Enter ] || continue + s=$k + [ "$k" = '\;' ] && s=0x3b + for t in copy-mode copy-mode-vi; do + printf 'bind -T %s %s send -X cancel \\; send-keys %s\n' $t "$k" "$s" + done + done +done > "$F" <<'KEYS' +Enter BSpace Tab Space ! '"' '#' '$' % & "'" ( ) * + , - . / : \; < = > ? @ [ '\' ] ^ _ ` '{{' | '}}' '~' +0 1 2 3 4 5 6 7 8 9 A B C D E F G H I J K L M N O P Q R S T U V W X Y Z +a b c d e f g h i j k l m n o p r s t u v w x y z +KEYS +tmux -L {socket} source-file "$F" +rm -f "$F" +"#, + socket = TMUX_SOCKET, + key = session_key, + ); + encode_wrapper(&script) +} + /// One-shot exec that dumps the scrollback history of a persistent session, /// picking the backend at runtime to mirror `persistent_exec_command`. /// @@ -833,6 +874,41 @@ mod tests { assert!(script.trim_end().ends_with("true")); } + #[test] + fn copy_mode_keys_forward_typing_but_keep_exits() { + let script = decode_bootstrap(&persistent_copy_mode_keys_command("voltius_s1")); + assert!(script.contains("until tmux -L voltius has-session -t voltius_s1")); + // Below 3.4 a paste's first key hits bindings, so only Enter is bound there. + assert!(script.contains(r#"*"tmux "3.[4-9]*|*"tmux "[4-9]*|*"tmux "[1-9][0-9]*) ALL=1 ;;"#)); + assert!(script.contains(r#"*"tmux "3.[0-3]*|*"tmux "2.[6-9]*) ALL= ;;"#)); + assert!(script.contains(r#"[ -n "$ALL" ] || [ "$k" = Enter ] || continue"#)); + assert!(script.contains("for t in copy-mode copy-mode-vi")); + assert!(script.contains(r"send -X cancel \\; send-keys %s")); + assert!(script.contains(r#"[ "$k" = '\;' ] && s=0x3b"#)); + assert!(!script.contains("Any")); + assert!(script.contains("tmux -L voltius source-file")); + let keys: Vec<&str> = script + .split("<<'KEYS'\n") + .nth(1) + .and_then(|s| s.split("\nKEYS").next()) + .expect("key list") + .split_whitespace() + .collect(); + for printable in (b'!'..=b'~').filter(|b| b.is_ascii_alphanumeric()) { + let key = (printable as char).to_string(); + assert_eq!(keys.contains(&key.as_str()), key != "q", "key {key}"); + } + for named in [ + "Enter", "BSpace", "Tab", "Space", r"\;", "'{'", "'}'", "\"'\"", + ] { + assert!(keys.contains(&named), "key {named}"); + } + assert_eq!(keys.len(), 97); + for exit in ["Escape", "C-c"] { + assert!(!keys.contains(&exit)); + } + } + #[test] fn persistent_kill_is_conditional_and_reports() { let key = tmux_session_key("s1"); diff --git a/src-tauri/src/ssh/client.rs b/src-tauri/src/ssh/client.rs index 6a75933ac..bcbebef6b 100644 --- a/src-tauri/src/ssh/client.rs +++ b/src-tauri/src/ssh/client.rs @@ -350,6 +350,30 @@ pub struct ConnectedSession { pub remote_routes: RemoteRouteMap, } +/// Runs `cmd` on `channel` and collects stdout; `None` if the exec request fails, +/// otherwise the output and whether it reached EOF within `limit`. +async fn exec_collect( + channel: russh::Channel, + cmd: &str, + limit: std::time::Duration, +) -> Option<(Vec, bool)> { + channel.exec(true, cmd).await.ok()?; + let mut stream = channel.into_stream(); + let mut out: Vec = Vec::new(); + let completed = tokio::time::timeout(limit, async { + let mut buf = [0u8; 8192]; + loop { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => out.extend_from_slice(&buf[..n]), + } + } + }) + .await + .is_ok(); + Some((out, completed)) +} + async fn bridge_remote_channel(channel: russh::Channel, route: RemoteRoute) { let tcp = match TcpStream::connect((route.target_host.as_str(), route.target_port)).await { Ok(t) => t, @@ -760,20 +784,9 @@ pub async fn connect( let key = crate::shell_integration::tmux_session_key(&session_id); let probe = crate::shell_integration::persistent_probe_command(&key); if let Ok(probe_channel) = final_handle.channel_open_session().await { - if probe_channel.exec(true, probe.as_str()).await.is_ok() { - let mut stream = probe_channel.into_stream(); - let mut out: Vec = Vec::new(); - let completed = tokio::time::timeout(std::time::Duration::from_secs(5), async { - let mut buf = [0u8; 1024]; - loop { - match stream.read(&mut buf).await { - Ok(0) | Err(_) => break, - Ok(n) => out.extend_from_slice(&buf[..n]), - } - } - }) - .await - .is_ok(); + if let Some((out, completed)) = + exec_collect(probe_channel, &probe, std::time::Duration::from_secs(5)).await + { if completed && !String::from_utf8_lossy(&out).contains("VOLTIUS_PRESENT") { return Err("SESSION_ENDED".to_string()); } @@ -811,19 +824,9 @@ pub async fn connect( let key = crate::shell_integration::tmux_session_key(&session_id); let capture = crate::shell_integration::capture_history_command(&key, pty_rows); if let Ok(cap_channel) = final_handle.channel_open_session().await { - if cap_channel.exec(true, capture.as_str()).await.is_ok() { - let mut stream = cap_channel.into_stream(); - let mut history: Vec = Vec::new(); - let _ = tokio::time::timeout(std::time::Duration::from_secs(5), async { - let mut buf = [0u8; 8192]; - loop { - match stream.read(&mut buf).await { - Ok(0) | Err(_) => break, - Ok(n) => history.extend_from_slice(&buf[..n]), - } - } - }) - .await; + if let Some((history, _)) = + exec_collect(cap_channel, &capture, std::time::Duration::from_secs(5)).await + { if !history.iter().all(|b| b.is_ascii_whitespace()) { // capture-pane emits bare LF; the PTY-less exec channel // does no ONLCR translation, so normalize for xterm. @@ -949,6 +952,13 @@ pub async fn connect( let poll_handle = Arc::clone(&handle); let poll_app = app.clone(); let key = crate::shell_integration::tmux_session_key(&session_id); + let keys_handle = Arc::clone(&handle); + let keys_cmd = crate::shell_integration::persistent_copy_mode_keys_command(&key); + tokio::spawn(async move { + if let Ok(channel) = keys_handle.channel_open_session().await { + let _ = exec_collect(channel, &keys_cmd, std::time::Duration::from_secs(30)).await; + } + }); let cwd_cmd = crate::shell_integration::cwd_probe_command(&key); let cwd_event = format!("ssh-cwd-{}", session_id); tokio::spawn(async move { @@ -970,21 +980,11 @@ pub async fn connect( } }; failures = 0; - if channel.exec(true, cwd_cmd.as_str()).await.is_err() { + let Some((out, _)) = + exec_collect(channel, &cwd_cmd, std::time::Duration::from_secs(3)).await + else { continue; - } - let mut stream = channel.into_stream(); - let mut out: Vec = Vec::new(); - let _ = tokio::time::timeout(std::time::Duration::from_secs(3), async { - let mut buf = [0u8; 512]; - loop { - match stream.read(&mut buf).await { - Ok(0) | Err(_) => break, - Ok(n) => out.extend_from_slice(&buf[..n]), - } - } - }) - .await; + }; let path = String::from_utf8_lossy(&out).trim().to_string(); if crate::shell_integration::is_live_probe_cwd(&path) && Some(&path) != last.as_ref()