Commit 6feca74
Jay Botte
·
2026-04-21 23:50:01 -0400 EDT
parent 3750464
feat: send raw bytes to pty input This is a wrapper around the `.Input` event. Send delivers bytes to the PTY exactly as-is with no automatic carriage return. The caller is responsible for appending \r when shell command submission is desired.
3 files changed,
+142,
-4
+6,
-4
| ... | ... | @@ -29,7 +29,7 @@ const bash_completions = | |
| 29 | 29 | \\ cur="${COMP_WORDS[COMP_CWORD]}" | |
| 30 | 30 | \\ prev="${COMP_WORDS[COMP_CWORD-1]}" | |
| 31 | 31 | \\ | |
| 32 | - | \\ local commands="attach run detach list completions kill history version help" | |
| 32 | + | \\ local commands="attach run send detach list completions kill history version help" | |
| 33 | 33 | \\ | |
| 34 | 34 | \\ if [[ $COMP_CWORD -eq 1 ]]; then | |
| 35 | 35 | \\ COMPREPLY=($(compgen -W "$commands" -- "$cur")) |
| ... | ... | @@ -37,7 +37,7 @@ const bash_completions = | |
| 37 | 37 | \\ fi | |
| 38 | 38 | \\ | |
| 39 | 39 | \\ case "$prev" in | |
| 40 | - | \\ attach|run|kill|history) | |
| 40 | + | \\ attach|run|send|kill|history) | |
| 41 | 41 | \\ local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ') | |
| 42 | 42 | \\ COMPREPLY=($(compgen -W "$sessions" -- "$cur")) | |
| 43 | 43 | \\ ;; |
| ... | ... | @@ -72,6 +72,7 @@ const zsh_completions = | |
| 72 | 72 | \\ commands=( | |
| 73 | 73 | \\ 'attach:Attach to session, creating if needed' | |
| 74 | 74 | \\ 'run:Send command without attaching' | |
| 75 | + | \\ 'send:Send raw input to session PTY' | |
| 75 | 76 | \\ 'detach:Detach all clients from current session' | |
| 76 | 77 | \\ 'list:List active sessions' | |
| 77 | 78 | \\ 'completions:Shell completion scripts' |
| ... | ... | @@ -84,7 +85,7 @@ const zsh_completions = | |
| 84 | 85 | \\ ;; | |
| 85 | 86 | \\ args) | |
| 86 | 87 | \\ case $words[2] in | |
| 87 | - | \\ attach|a|kill|k|run|r|history|hi) | |
| 88 | + | \\ attach|a|kill|k|run|r|send|s|history|hi) | |
| 88 | 89 | \\ _zmx_sessions | |
| 89 | 90 | \\ ;; | |
| 90 | 91 | \\ completions|c) |
| ... | ... | @@ -125,6 +126,7 @@ const fish_completions = | |
| 125 | 126 | \\# zmx subcommands | |
| 126 | 127 | \\complete -c zmx -n "__fish_is_nth_token 1" -a 'a attach' -d 'Attach to session, creating if needed' | |
| 127 | 128 | \\complete -c zmx -n "__fish_is_nth_token 1" -a 'r run' -d 'Send command without attaching' | |
| 129 | + | \\complete -c zmx -n "__fish_is_nth_token 1" -a 's send' -d 'Send raw input to session PTY' | |
| 128 | 130 | \\complete -c zmx -n "__fish_is_nth_token 1" -a 'wr write' -d 'Write stdin to file_path through the session' | |
| 129 | 131 | \\complete -c zmx -n "__fish_is_nth_token 1" -a 'd detach' -d 'Detach all clients (ctrl+\ for current client)' | |
| 130 | 132 | \\complete -c zmx -n "__fish_is_nth_token 1" -a 'l list' -d 'List active sessions' |
| ... | ... | @@ -137,7 +139,7 @@ const fish_completions = | |
| 137 | 139 | \\complete -c zmx -n "__fish_is_nth_token 1" -a 'h help' -d 'Show help message' | |
| 138 | 140 | \\ | |
| 139 | 141 | \\# Complete session names and shells | |
| 140 | - | \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from a attach r run wr write hi history" -a '(zmx list --short 2>/dev/null)' -d 'Session name' | |
| 142 | + | \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from a attach r run s send wr write hi history" -a '(zmx list --short 2>/dev/null)' -d 'Session name' | |
| 141 | 143 | \\complete -c zmx -n "not __fish_is_nth_token 1; and __fish_seen_subcommand_from k kill w wait t tail" -a '(zmx list --short 2>/dev/null)' -d 'Session name' | |
| 142 | 144 | \\ | |
| 143 | 145 | \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from c completions" -a 'bash zsh fish' -d Shell |
+96,
-0
| ... | ... | @@ -196,6 +196,23 @@ pub fn main() !void { | |
| 196 | 196 | }; | |
| 197 | 197 | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 198 | 198 | return run(&daemon, detached, shell_basename, cmd_args_raw.items); | |
| 199 | + | } else if (std.mem.eql(u8, cmd, "send") or std.mem.eql(u8, cmd, "s")) { | |
| 200 | + | const session_name = args.next() orelse ""; | |
| 201 | + | if (session_name.len == 0) return error.SessionNameRequired; | |
| 202 | + | ||
| 203 | + | var text_parts: std.ArrayList([]const u8) = .empty; | |
| 204 | + | defer text_parts.deinit(alloc); | |
| 205 | + | while (args.next()) |arg| { | |
| 206 | + | try text_parts.append(alloc, arg); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | const sesh = try socket.getSeshName(alloc, session_name); | |
| 210 | + | defer alloc.free(sesh); | |
| 211 | + | const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) { | |
| 212 | + | error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir), | |
| 213 | + | error.OutOfMemory => return err, | |
| 214 | + | }; | |
| 215 | + | return send(&cfg, sesh, socket_path, text_parts.items); | |
| 199 | 216 | } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) { | |
| 200 | 217 | var stderr_buffer: [1024]u8 = undefined; | |
| 201 | 218 | var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer); |
| ... | ... | @@ -1174,6 +1191,7 @@ fn help() !void { | |
| 1174 | 1191 | \\Commands: | |
| 1175 | 1192 | \\ [a]ttach <name> [command...] Attach to session, creating if needed | |
| 1176 | 1193 | \\ [r]un <name> [-d] [--fish] [command...] Send command without attaching | |
| 1194 | + | \\ [s]end <name> <text...> Send raw input to session PTY | |
| 1177 | 1195 | \\ [wr]ite <name> <file_path> Write stdin to file_path through the session | |
| 1178 | 1196 | \\ [d]etach Detach all clients (ctrl+\\ for current client) | |
| 1179 | 1197 | \\ [l]ist [--short] List active sessions |
| ... | ... | @@ -1223,6 +1241,23 @@ fn help() !void { | |
| 1223 | 1241 | \\ zmx run dev grep -r TODO src | |
| 1224 | 1242 | \\ zmx run dev git -c core.pager=cat diff | |
| 1225 | 1243 | \\ | |
| 1244 | + | \\Send: | |
| 1245 | + | \\ Sends raw text to the session's PTY input (fire-and-forget). | |
| 1246 | + | \\ Unlike `run`, no completion marker is appended and no exit code | |
| 1247 | + | \\ is tracked. Useful for TUI applications, interactive prompts, | |
| 1248 | + | \\ or any program that reads stdin directly. | |
| 1249 | + | \\ | |
| 1250 | + | \\ Text is sent byte-for-byte with no automatic carriage return. | |
| 1251 | + | \\ Append \r yourself when you want the shell to execute a command. | |
| 1252 | + | \\ | |
| 1253 | + | \\ Text can also be piped via stdin: | |
| 1254 | + | \\ printf 'ls -la\r' | zmx send dev | |
| 1255 | + | \\ | |
| 1256 | + | \\ Examples: | |
| 1257 | + | \\ printf 'echo hello\r' | zmx send dev | |
| 1258 | + | \\ zmx send dev $(printf '\x03') | |
| 1259 | + | \\ zmx send dev /compact | |
| 1260 | + | \\ | |
| 1226 | 1261 | \\Write: | |
| 1227 | 1262 | \\ Writes stdin to file_path inside the session. Works over SSH. | |
| 1228 | 1263 | \\ file_path can be absolute or relative to the session shell's cwd. |
| ... | ... | @@ -1891,6 +1926,67 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void { | |
| 1891 | 1926 | return error.NoAckReceived; | |
| 1892 | 1927 | } | |
| 1893 | 1928 | ||
| 1929 | + | fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8) !void { | |
| 1930 | + | const alloc = std.heap.c_allocator; | |
| 1931 | + | var buf: [4096]u8 = undefined; | |
| 1932 | + | var w = std.fs.File.stdout().writer(&buf); | |
| 1933 | + | ||
| 1934 | + | var payload = std.ArrayList(u8).empty; | |
| 1935 | + | defer payload.deinit(alloc); | |
| 1936 | + | ||
| 1937 | + | if (text_parts.len > 0) { | |
| 1938 | + | for (text_parts, 0..) |part, i| { | |
| 1939 | + | if (i > 0) try payload.append(alloc, ' '); | |
| 1940 | + | try payload.appendSlice(alloc, part); | |
| 1941 | + | } | |
| 1942 | + | } else { | |
| 1943 | + | // Read from stdin when no text arguments provided. | |
| 1944 | + | const stdin_fd = posix.STDIN_FILENO; | |
| 1945 | + | if (!std.posix.isatty(stdin_fd)) { | |
| 1946 | + | while (true) { | |
| 1947 | + | var tmp: [4096]u8 = undefined; | |
| 1948 | + | const n = posix.read(stdin_fd, &tmp) catch |err| { | |
| 1949 | + | if (err == error.WouldBlock) break; | |
| 1950 | + | return err; | |
| 1951 | + | }; | |
| 1952 | + | if (n == 0) break; | |
| 1953 | + | try payload.appendSlice(alloc, tmp[0..n]); | |
| 1954 | + | } | |
| 1955 | + | // Strip trailing newline from piped input; the caller is | |
| 1956 | + | // responsible for including \r when submission is desired. | |
| 1957 | + | if (payload.items.len > 0 and payload.items[payload.items.len - 1] == '\n') { | |
| 1958 | + | _ = payload.pop(); | |
| 1959 | + | } | |
| 1960 | + | } | |
| 1961 | + | } | |
| 1962 | + | ||
| 1963 | + | if (payload.items.len == 0) return error.TextRequired; | |
| 1964 | + | ||
| 1965 | + | var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{}); | |
| 1966 | + | defer dir.close(); | |
| 1967 | + | ||
| 1968 | + | const probe_result = ipc.probeSession(alloc, socket_path) catch |err| { | |
| 1969 | + | std.log.err("session unresponsive: {s}", .{@errorName(err)}); | |
| 1970 | + | if (err == error.ConnectionRefused) { | |
| 1971 | + | socket.cleanupStaleSocket(dir, session_name); | |
| 1972 | + | try w.interface.print("cleaned up stale session {s}\n", .{session_name}); | |
| 1973 | + | } else { | |
| 1974 | + | try w.interface.print( | |
| 1975 | + | "session {s} is unresponsive ({s})\ndaemon may be busy: try again\n", | |
| 1976 | + | .{ session_name, @errorName(err) }, | |
| 1977 | + | ); | |
| 1978 | + | } | |
| 1979 | + | try w.interface.flush(); | |
| 1980 | + | return; | |
| 1981 | + | }; | |
| 1982 | + | defer posix.close(probe_result.fd); | |
| 1983 | + | ||
| 1984 | + | ipc.send(probe_result.fd, .Input, payload.items) catch |err| switch (err) { | |
| 1985 | + | error.ConnectionResetByPeer, error.BrokenPipe => return, | |
| 1986 | + | else => return err, | |
| 1987 | + | }; | |
| 1988 | + | } | |
| 1989 | + | ||
| 1894 | 1990 | fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args: [][]const u8) !void { | |
| 1895 | 1991 | const alloc = daemon.alloc; | |
| 1896 | 1992 | var buf: [4096]u8 = undefined; |
+40,
-0
| ... | ... | @@ -50,6 +50,46 @@ load test_helper | |
| 50 | 50 | [ "$status" -ne 0 ] | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | + | # ============================================================================ | |
| 54 | + | # Send (raw PTY input) | |
| 55 | + | # ============================================================================ | |
| 56 | + | ||
| 57 | + | @test "send: does not append CR by default" { | |
| 58 | + | "$ZMX" run test-send-raw -d echo ready | |
| 59 | + | wait_for_session test-send-raw | |
| 60 | + | sleep 0.5 | |
| 61 | + | ||
| 62 | + | # Send text without \r — it should NOT execute as a command | |
| 63 | + | run "$ZMX" send test-send-raw "partial-text" | |
| 64 | + | [ "$status" -eq 0 ] | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | @test "send: requires a session name" { | |
| 68 | + | run "$ZMX" send | |
| 69 | + | [ "$status" -ne 0 ] | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | @test "send: requires text argument" { | |
| 73 | + | "$ZMX" run test-send-notext -d true | |
| 74 | + | wait_for_session test-send-notext | |
| 75 | + | ||
| 76 | + | run "$ZMX" send test-send-notext | |
| 77 | + | [ "$status" -ne 0 ] | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | @test "send: accepts piped stdin" { | |
| 81 | + | "$ZMX" run test-send-pipe -d echo ready | |
| 82 | + | wait_for_session test-send-pipe | |
| 83 | + | sleep 0.5 | |
| 84 | + | ||
| 85 | + | run bash -c 'printf "echo piped-marker-xyz789\r" | "$0" send test-send-pipe' "$ZMX" | |
| 86 | + | [ "$status" -eq 0 ] | |
| 87 | + | ||
| 88 | + | sleep 0.5 | |
| 89 | + | run "$ZMX" history test-send-pipe | |
| 90 | + | [[ "$output" == *"piped-marker-xyz789"* ]] | |
| 91 | + | } | |
| 92 | + | ||
| 53 | 93 | # ============================================================================ | |
| 54 | 94 | # Session listing | |
| 55 | 95 | # ============================================================================ |