- commit
- 5ebf61b
- parent
- 4164523
- author
- Eric Bower
- date
- 2026-04-23 23:12:11 -0400 EDT
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
1@@ -79,6 +79,8 @@ Usage: zmx <command> [args...]
2 Commands:
3 [a]ttach <name> [command...] Attach to session, creating if needed
4 [r]un <name> [-d] [--fish] [command...] Send command without attaching
5+ [s]end <name> <text...> Send raw input to session PTY
6+ [p]rint <name> <text...> Inject text into session display
7 [wr]ite <name> <file_path> Write stdin to file_path through the session
8 [d]etach Detach all clients (ctrl+\\ for current client)
9 [l]ist [--short] List active sessions
10@@ -128,6 +130,32 @@ Run:
11 zmx run dev grep -r TODO src
12 zmx run dev git -c core.pager=cat diff
13
14+Send:
15+ Sends raw text to the session's PTY input (fire-and-forget).
16+ Unlike `run`, no completion marker is appended and no exit code
17+ is tracked. Useful for TUI applications, interactive prompts,
18+ or any program that reads stdin directly.
19+
20+ Text is sent byte-for-byte with no automatic carriage return.
21+ Append \r yourself when you want the shell to execute a command.
22+
23+ Text can also be piped via stdin:
24+ printf 'ls -la\r' | zmx send dev
25+
26+ Examples:
27+ printf 'echo hello\r' | zmx send dev
28+ zmx send dev $(printf '\x03')
29+ zmx send dev /compact
30+
31+Print:
32+ Injects text directly into the session display and scrollback.
33+ Never touches the PTY input -- the shell sees nothing.
34+ Caller is responsible for newlines (\\r\\n).
35+
36+ Examples:
37+ printf '\\r\\nhello\\r\\n' | zmx print dev
38+ zmx print dev "$(printf '\\r\\nalert\\r\\n')"
39+
40 Write:
41 Writes stdin to file_path inside the session. Works over SSH.
42 file_path can be absolute or relative to the session shell's cwd.
+49,
-6
1@@ -212,7 +212,24 @@ pub fn main() !void {
2 error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir),
3 error.OutOfMemory => return err,
4 };
5- return send(&cfg, sesh, socket_path, text_parts.items);
6+ return send(&cfg, sesh, socket_path, text_parts.items, .Input);
7+ } else if (std.mem.eql(u8, cmd, "print") or std.mem.eql(u8, cmd, "p")) {
8+ const session_name = args.next() orelse "";
9+ if (session_name.len == 0) return error.SessionNameRequired;
10+
11+ var text_parts: std.ArrayList([]const u8) = .empty;
12+ defer text_parts.deinit(alloc);
13+ while (args.next()) |arg| {
14+ try text_parts.append(alloc, arg);
15+ }
16+
17+ const sesh = try socket.getSeshName(alloc, session_name);
18+ defer alloc.free(sesh);
19+ const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) {
20+ error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir),
21+ error.OutOfMemory => return err,
22+ };
23+ return send(&cfg, sesh, socket_path, text_parts.items, .Output);
24 } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
25 var stderr_buffer: [1024]u8 = undefined;
26 var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
27@@ -1109,6 +1126,20 @@ const Daemon = struct {
28 std.log.debug("run command len={d}", .{payload.len});
29 }
30
31+ pub fn handleOutput(self: *Daemon, payload: []const u8, vt_stream: anytype) !void {
32+ try vt_stream.nextSlice(payload);
33+ self.has_pty_output = true;
34+ for (self.clients.items) |client| {
35+ try ipc.appendMessage(self.alloc, &client.write_buf, .Output, payload);
36+ client.has_pending_output = true;
37+ }
38+ if (self.clients.items.len > 0) {
39+ posix.kill(self.pid, posix.SIG.WINCH) catch |err| {
40+ std.log.warn("failed to send SIGWINCH err={s}", .{@errorName(err)});
41+ };
42+ }
43+ }
44+
45 pub fn handleWrite(self: *Daemon, client: *Client, payload: []const u8) !void {
46 // Wire format: [u32 path len][path bytes][file content]
47 if (payload.len < @sizeOf(u32)) return error.InvalidPayload;
48@@ -1191,7 +1222,8 @@ fn help() !void {
49 \\Commands:
50 \\ [a]ttach <name> [command...] Attach to session, creating if needed
51 \\ [r]un <name> [-d] [--fish] [command...] Send command without attaching
52- \\ [s]end <name> <text...> Send raw input to session PTY
53+ \\ [s]end <name> <text...> Send raw input to session PTY
54+ \\ [p]rint <name> <text...> Inject text into session display
55 \\ [wr]ite <name> <file_path> Write stdin to file_path through the session
56 \\ [d]etach Detach all clients (ctrl+\\ for current client)
57 \\ [l]ist [--short] List active sessions
58@@ -1258,6 +1290,15 @@ fn help() !void {
59 \\ zmx send dev $(printf '\x03')
60 \\ zmx send dev /compact
61 \\
62+ \\Print:
63+ \\ Injects text directly into the session display and scrollback.
64+ \\ Never touches the PTY input -- the shell sees nothing.
65+ \\ Caller is responsible for newlines (\\r\\n).
66+ \\
67+ \\ Examples:
68+ \\ printf '\\r\\nhello\\r\\n' | zmx print dev
69+ \\ zmx print dev "$(printf '\\r\\nalert\\r\\n')"
70+ \\
71 \\Write:
72 \\ Writes stdin to file_path inside the session. Works over SSH.
73 \\ file_path can be absolute or relative to the session shell's cwd.
74@@ -1926,7 +1967,7 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
75 return error.NoAckReceived;
76 }
77
78-fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8) !void {
79+fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8, tag: ipc.Tag) !void {
80 const alloc = std.heap.c_allocator;
81 var buf: [4096]u8 = undefined;
82 var w = std.fs.File.stdout().writer(&buf);
83@@ -1954,7 +1995,8 @@ fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts
84 }
85 // Strip trailing newline from piped input; the caller is
86 // responsible for including \r when submission is desired.
87- if (payload.items.len > 0 and payload.items[payload.items.len - 1] == '\n') {
88+ // For .Output the caller controls exact bytes, so don't strip.
89+ if (tag != .Output and payload.items.len > 0 and payload.items[payload.items.len - 1] == '\n') {
90 _ = payload.pop();
91 }
92 }
93@@ -1981,7 +2023,7 @@ fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts
94 };
95 defer posix.close(probe_result.fd);
96
97- ipc.send(probe_result.fd, .Input, payload.items) catch |err| switch (err) {
98+ ipc.send(probe_result.fd, tag, payload.items) catch |err| switch (err) {
99 error.ConnectionResetByPeer, error.BrokenPipe => return,
100 else => return err,
101 };
102@@ -2474,6 +2516,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
103 while (client.read_buf.next()) |msg| {
104 switch (msg.header.tag) {
105 .Input => try daemon.handleInput(client, msg.payload),
106+ .Output => try daemon.handleOutput(msg.payload, &vt_stream),
107 .Init => try daemon.handleInit(client, pty_fd, &term, msg.payload),
108 .Switch => try daemon.handleSwitch(msg.payload),
109 .Resize => try daemon.handleResize(client, pty_fd, &term, msg.payload),
110@@ -2491,7 +2534,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
111 .Info => try daemon.handleInfo(client),
112 .History => try daemon.handleHistory(client, &term, msg.payload),
113 .Run => try daemon.handleRun(client, msg.payload),
114- .Output, .Ack, .TaskComplete => {},
115+ .Ack, .TaskComplete => {},
116 .Write => try daemon.handleWrite(client, msg.payload),
117 _ => std.log.warn(
118 "ignoring unknown IPC tag={d}",
+25,
-0
1@@ -232,3 +232,28 @@ load test_helper
2 [ "$status" -eq 0 ]
3 [ -z "$output" ]
4 }
5+
6+
7+# ============================================================================
8+# Print (inject text into terminal state)
9+# ============================================================================
10+
11+@test "print: text appears in history" {
12+ "$ZMX" run test-print-hist -d $SHELL_FLAG echo ready
13+ wait_for_session test-print-hist
14+ sleep 0.3
15+
16+ # Caller is responsible for newlines; trailing \r\n ensures the text
17+ # lands on its own line before SIGWINCH triggers a prompt redraw.
18+ printf "\r\nbats-print-marker-abc123\r\n" | "$ZMX" print test-print-hist
19+ sleep 0.3
20+
21+ run "$ZMX" history test-print-hist
22+ [ "$status" -eq 0 ]
23+ [[ "$output" == *"bats-print-marker-abc123"* ]]
24+}
25+
26+@test "print: requires a session name" {
27+ run "$ZMX" print
28+ [ "$status" -ne 0 ]
29+}