Commit 5ebf61b
Eric Bower
·
2026-04-23 23:12:11 -0400 EDT
parent 4164523
feat: print cmd This sends text directly to client's stdout as-is. Typically you'll want to wrap the text in '\r\n' but we leave that up to the end-user
3 files changed,
+102,
-6
+28,
-0
| ... | ... | @@ -79,6 +79,8 @@ Usage: zmx <command> [args...] | |
| 79 | 79 | Commands: | |
| 80 | 80 | [a]ttach <name> [command...] Attach to session, creating if needed | |
| 81 | 81 | [r]un <name> [-d] [--fish] [command...] Send command without attaching | |
| 82 | + | [s]end <name> <text...> Send raw input to session PTY | |
| 83 | + | [p]rint <name> <text...> Inject text into session display | |
| 82 | 84 | [wr]ite <name> <file_path> Write stdin to file_path through the session | |
| 83 | 85 | [d]etach Detach all clients (ctrl+\\ for current client) | |
| 84 | 86 | [l]ist [--short] List active sessions |
| ... | ... | @@ -128,6 +130,32 @@ Run: | |
| 128 | 130 | zmx run dev grep -r TODO src | |
| 129 | 131 | zmx run dev git -c core.pager=cat diff | |
| 130 | 132 | ||
| 133 | + | Send: | |
| 134 | + | Sends raw text to the session's PTY input (fire-and-forget). | |
| 135 | + | Unlike `run`, no completion marker is appended and no exit code | |
| 136 | + | is tracked. Useful for TUI applications, interactive prompts, | |
| 137 | + | or any program that reads stdin directly. | |
| 138 | + | ||
| 139 | + | Text is sent byte-for-byte with no automatic carriage return. | |
| 140 | + | Append \r yourself when you want the shell to execute a command. | |
| 141 | + | ||
| 142 | + | Text can also be piped via stdin: | |
| 143 | + | printf 'ls -la\r' | zmx send dev | |
| 144 | + | ||
| 145 | + | Examples: | |
| 146 | + | printf 'echo hello\r' | zmx send dev | |
| 147 | + | zmx send dev $(printf '\x03') | |
| 148 | + | zmx send dev /compact | |
| 149 | + | ||
| 150 | + | Print: | |
| 151 | + | Injects text directly into the session display and scrollback. | |
| 152 | + | Never touches the PTY input -- the shell sees nothing. | |
| 153 | + | Caller is responsible for newlines (\\r\\n). | |
| 154 | + | ||
| 155 | + | Examples: | |
| 156 | + | printf '\\r\\nhello\\r\\n' | zmx print dev | |
| 157 | + | zmx print dev "$(printf '\\r\\nalert\\r\\n')" | |
| 158 | + | ||
| 131 | 159 | Write: | |
| 132 | 160 | Writes stdin to file_path inside the session. Works over SSH. | |
| 133 | 161 | file_path can be absolute or relative to the session shell's cwd. |
+49,
-6
| ... | ... | @@ -212,7 +212,24 @@ pub fn main() !void { | |
| 212 | 212 | error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir), | |
| 213 | 213 | error.OutOfMemory => return err, | |
| 214 | 214 | }; | |
| 215 | - | return send(&cfg, sesh, socket_path, text_parts.items); | |
| 215 | + | return send(&cfg, sesh, socket_path, text_parts.items, .Input); | |
| 216 | + | } else if (std.mem.eql(u8, cmd, "print") or std.mem.eql(u8, cmd, "p")) { | |
| 217 | + | const session_name = args.next() orelse ""; | |
| 218 | + | if (session_name.len == 0) return error.SessionNameRequired; | |
| 219 | + | ||
| 220 | + | var text_parts: std.ArrayList([]const u8) = .empty; | |
| 221 | + | defer text_parts.deinit(alloc); | |
| 222 | + | while (args.next()) |arg| { | |
| 223 | + | try text_parts.append(alloc, arg); | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | const sesh = try socket.getSeshName(alloc, session_name); | |
| 227 | + | defer alloc.free(sesh); | |
| 228 | + | const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) { | |
| 229 | + | error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir), | |
| 230 | + | error.OutOfMemory => return err, | |
| 231 | + | }; | |
| 232 | + | return send(&cfg, sesh, socket_path, text_parts.items, .Output); | |
| 216 | 233 | } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) { | |
| 217 | 234 | var stderr_buffer: [1024]u8 = undefined; | |
| 218 | 235 | var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer); |
| ... | ... | @@ -1109,6 +1126,20 @@ const Daemon = struct { | |
| 1109 | 1126 | std.log.debug("run command len={d}", .{payload.len}); | |
| 1110 | 1127 | } | |
| 1111 | 1128 | ||
| 1129 | + | pub fn handleOutput(self: *Daemon, payload: []const u8, vt_stream: anytype) !void { | |
| 1130 | + | try vt_stream.nextSlice(payload); | |
| 1131 | + | self.has_pty_output = true; | |
| 1132 | + | for (self.clients.items) |client| { | |
| 1133 | + | try ipc.appendMessage(self.alloc, &client.write_buf, .Output, payload); | |
| 1134 | + | client.has_pending_output = true; | |
| 1135 | + | } | |
| 1136 | + | if (self.clients.items.len > 0) { | |
| 1137 | + | posix.kill(self.pid, posix.SIG.WINCH) catch |err| { | |
| 1138 | + | std.log.warn("failed to send SIGWINCH err={s}", .{@errorName(err)}); | |
| 1139 | + | }; | |
| 1140 | + | } | |
| 1141 | + | } | |
| 1142 | + | ||
| 1112 | 1143 | pub fn handleWrite(self: *Daemon, client: *Client, payload: []const u8) !void { | |
| 1113 | 1144 | // Wire format: [u32 path len][path bytes][file content] | |
| 1114 | 1145 | if (payload.len < @sizeOf(u32)) return error.InvalidPayload; |
| ... | ... | @@ -1191,7 +1222,8 @@ fn help() !void { | |
| 1191 | 1222 | \\Commands: | |
| 1192 | 1223 | \\ [a]ttach <name> [command...] Attach to session, creating if needed | |
| 1193 | 1224 | \\ [r]un <name> [-d] [--fish] [command...] Send command without attaching | |
| 1194 | - | \\ [s]end <name> <text...> Send raw input to session PTY | |
| 1225 | + | \\ [s]end <name> <text...> Send raw input to session PTY | |
| 1226 | + | \\ [p]rint <name> <text...> Inject text into session display | |
| 1195 | 1227 | \\ [wr]ite <name> <file_path> Write stdin to file_path through the session | |
| 1196 | 1228 | \\ [d]etach Detach all clients (ctrl+\\ for current client) | |
| 1197 | 1229 | \\ [l]ist [--short] List active sessions |
| ... | ... | @@ -1258,6 +1290,15 @@ fn help() !void { | |
| 1258 | 1290 | \\ zmx send dev $(printf '\x03') | |
| 1259 | 1291 | \\ zmx send dev /compact | |
| 1260 | 1292 | \\ | |
| 1293 | + | \\Print: | |
| 1294 | + | \\ Injects text directly into the session display and scrollback. | |
| 1295 | + | \\ Never touches the PTY input -- the shell sees nothing. | |
| 1296 | + | \\ Caller is responsible for newlines (\\r\\n). | |
| 1297 | + | \\ | |
| 1298 | + | \\ Examples: | |
| 1299 | + | \\ printf '\\r\\nhello\\r\\n' | zmx print dev | |
| 1300 | + | \\ zmx print dev "$(printf '\\r\\nalert\\r\\n')" | |
| 1301 | + | \\ | |
| 1261 | 1302 | \\Write: | |
| 1262 | 1303 | \\ Writes stdin to file_path inside the session. Works over SSH. | |
| 1263 | 1304 | \\ file_path can be absolute or relative to the session shell's cwd. |
| ... | ... | @@ -1926,7 +1967,7 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void { | |
| 1926 | 1967 | return error.NoAckReceived; | |
| 1927 | 1968 | } | |
| 1928 | 1969 | ||
| 1929 | - | fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8) !void { | |
| 1970 | + | fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8, tag: ipc.Tag) !void { | |
| 1930 | 1971 | const alloc = std.heap.c_allocator; | |
| 1931 | 1972 | var buf: [4096]u8 = undefined; | |
| 1932 | 1973 | var w = std.fs.File.stdout().writer(&buf); |
| ... | ... | @@ -1954,7 +1995,8 @@ fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts | |
| 1954 | 1995 | } | |
| 1955 | 1996 | // Strip trailing newline from piped input; the caller is | |
| 1956 | 1997 | // responsible for including \r when submission is desired. | |
| 1957 | - | if (payload.items.len > 0 and payload.items[payload.items.len - 1] == '\n') { | |
| 1998 | + | // For .Output the caller controls exact bytes, so don't strip. | |
| 1999 | + | if (tag != .Output and payload.items.len > 0 and payload.items[payload.items.len - 1] == '\n') { | |
| 1958 | 2000 | _ = payload.pop(); | |
| 1959 | 2001 | } | |
| 1960 | 2002 | } |
| ... | ... | @@ -1981,7 +2023,7 @@ fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts | |
| 1981 | 2023 | }; | |
| 1982 | 2024 | defer posix.close(probe_result.fd); | |
| 1983 | 2025 | ||
| 1984 | - | ipc.send(probe_result.fd, .Input, payload.items) catch |err| switch (err) { | |
| 2026 | + | ipc.send(probe_result.fd, tag, payload.items) catch |err| switch (err) { | |
| 1985 | 2027 | error.ConnectionResetByPeer, error.BrokenPipe => return, | |
| 1986 | 2028 | else => return err, | |
| 1987 | 2029 | }; |
| ... | ... | @@ -2474,6 +2516,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void { | |
| 2474 | 2516 | while (client.read_buf.next()) |msg| { | |
| 2475 | 2517 | switch (msg.header.tag) { | |
| 2476 | 2518 | .Input => try daemon.handleInput(client, msg.payload), | |
| 2519 | + | .Output => try daemon.handleOutput(msg.payload, &vt_stream), | |
| 2477 | 2520 | .Init => try daemon.handleInit(client, pty_fd, &term, msg.payload), | |
| 2478 | 2521 | .Switch => try daemon.handleSwitch(msg.payload), | |
| 2479 | 2522 | .Resize => try daemon.handleResize(client, pty_fd, &term, msg.payload), |
| ... | ... | @@ -2491,7 +2534,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void { | |
| 2491 | 2534 | .Info => try daemon.handleInfo(client), | |
| 2492 | 2535 | .History => try daemon.handleHistory(client, &term, msg.payload), | |
| 2493 | 2536 | .Run => try daemon.handleRun(client, msg.payload), | |
| 2494 | - | .Output, .Ack, .TaskComplete => {}, | |
| 2537 | + | .Ack, .TaskComplete => {}, | |
| 2495 | 2538 | .Write => try daemon.handleWrite(client, msg.payload), | |
| 2496 | 2539 | _ => std.log.warn( | |
| 2497 | 2540 | "ignoring unknown IPC tag={d}", |
+25,
-0
| ... | ... | @@ -232,3 +232,28 @@ load test_helper | |
| 232 | 232 | [ "$status" -eq 0 ] | |
| 233 | 233 | [ -z "$output" ] | |
| 234 | 234 | } | |
| 235 | + | ||
| 236 | + | ||
| 237 | + | # ============================================================================ | |
| 238 | + | # Print (inject text into terminal state) | |
| 239 | + | # ============================================================================ | |
| 240 | + | ||
| 241 | + | @test "print: text appears in history" { | |
| 242 | + | "$ZMX" run test-print-hist -d $SHELL_FLAG echo ready | |
| 243 | + | wait_for_session test-print-hist | |
| 244 | + | sleep 0.3 | |
| 245 | + | ||
| 246 | + | # Caller is responsible for newlines; trailing \r\n ensures the text | |
| 247 | + | # lands on its own line before SIGWINCH triggers a prompt redraw. | |
| 248 | + | printf "\r\nbats-print-marker-abc123\r\n" | "$ZMX" print test-print-hist | |
| 249 | + | sleep 0.3 | |
| 250 | + | ||
| 251 | + | run "$ZMX" history test-print-hist | |
| 252 | + | [ "$status" -eq 0 ] | |
| 253 | + | [[ "$output" == *"bats-print-marker-abc123"* ]] | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | @test "print: requires a session name" { | |
| 257 | + | run "$ZMX" print | |
| 258 | + | [ "$status" -ne 0 ] | |
| 259 | + | } |