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...]
7979 Commands:
8080 [a]ttach <name> [command...] Attach to session, creating if needed
8181 [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
8284 [wr]ite <name> <file_path> Write stdin to file_path through the session
8385 [d]etach Detach all clients (ctrl+\\ for current client)
8486 [l]ist [--short] List active sessions
......@@ -128,6 +130,32 @@ Run:
128130 zmx run dev grep -r TODO src
129131 zmx run dev git -c core.pager=cat diff
130132
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+
131159 Write:
132160 Writes stdin to file_path inside the session. Works over SSH.
133161 file_path can be absolute or relative to the session shell's cwd.
+49, -6
......@@ -212,7 +212,24 @@ pub fn main() !void {
212212 error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir),
213213 error.OutOfMemory => return err,
214214 };
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);
216233 } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
217234 var stderr_buffer: [1024]u8 = undefined;
218235 var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
......@@ -1109,6 +1126,20 @@ const Daemon = struct {
11091126 std.log.debug("run command len={d}", .{payload.len});
11101127 }
11111128
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+
11121143 pub fn handleWrite(self: *Daemon, client: *Client, payload: []const u8) !void {
11131144 // Wire format: [u32 path len][path bytes][file content]
11141145 if (payload.len < @sizeOf(u32)) return error.InvalidPayload;
......@@ -1191,7 +1222,8 @@ fn help() !void {
11911222 \\Commands:
11921223 \\ [a]ttach <name> [command...] Attach to session, creating if needed
11931224 \\ [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
11951227 \\ [wr]ite <name> <file_path> Write stdin to file_path through the session
11961228 \\ [d]etach Detach all clients (ctrl+\\ for current client)
11971229 \\ [l]ist [--short] List active sessions
......@@ -1258,6 +1290,15 @@ fn help() !void {
12581290 \\ zmx send dev $(printf '\x03')
12591291 \\ zmx send dev /compact
12601292 \\
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+ \\
12611302 \\Write:
12621303 \\ Writes stdin to file_path inside the session. Works over SSH.
12631304 \\ 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 {
19261967 return error.NoAckReceived;
19271968 }
19281969
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 {
19301971 const alloc = std.heap.c_allocator;
19311972 var buf: [4096]u8 = undefined;
19321973 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
19541995 }
19551996 // Strip trailing newline from piped input; the caller is
19561997 // 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') {
19582000 _ = payload.pop();
19592001 }
19602002 }
......@@ -1981,7 +2023,7 @@ fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts
19812023 };
19822024 defer posix.close(probe_result.fd);
19832025
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) {
19852027 error.ConnectionResetByPeer, error.BrokenPipe => return,
19862028 else => return err,
19872029 };
......@@ -2474,6 +2516,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
24742516 while (client.read_buf.next()) |msg| {
24752517 switch (msg.header.tag) {
24762518 .Input => try daemon.handleInput(client, msg.payload),
2519+ .Output => try daemon.handleOutput(msg.payload, &vt_stream),
24772520 .Init => try daemon.handleInit(client, pty_fd, &term, msg.payload),
24782521 .Switch => try daemon.handleSwitch(msg.payload),
24792522 .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 {
24912534 .Info => try daemon.handleInfo(client),
24922535 .History => try daemon.handleHistory(client, &term, msg.payload),
24932536 .Run => try daemon.handleRun(client, msg.payload),
2494- .Output, .Ack, .TaskComplete => {},
2537+ .Ack, .TaskComplete => {},
24952538 .Write => try daemon.handleWrite(client, msg.payload),
24962539 _ => std.log.warn(
24972540 "ignoring unknown IPC tag={d}",
+25, -0
......@@ -232,3 +232,28 @@ load test_helper
232232 [ "$status" -eq 0 ]
233233 [ -z "$output" ]
234234 }
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+}