Commit 186393f

Matthias Bertschy  ·  2026-09-28 04:18:47 -0400 EDT
parent 0a18bee
feat: zmx as a zig package

We added the ability to import zmx as a zig package.  The zmx module is really
a thin wrapper around our ipc wire protocol for communicating with the daemon.

We also made embedding the daemon (and by proxy, libghostty) optional so consumers
could import zmx as only the ipc protocol. The daemon is embedded by default,
with the build option `-Ddaemon=false` to disable it.

See `./examples/` folder for examples of how to use zmx as a package.
9 files changed,  +2207, -1856
+56, -21
......@@ -18,10 +18,13 @@ pub fn build(b: *std.Build) void {
1818 const version = b.option([]const u8, "version", "Version string for release") orelse
1919 @as([]const u8, build_zig_zon.version);
2020
21+ const enable_daemon = b.option(bool, "daemon", "Include daemon support and Ghostty VT in libzmx") orelse true;
22+
2123 const options = b.addOptions();
2224 options.addOption([]const u8, "version", version);
2325 const ghostty_ver = build_zig_zon.dependencies.ghostty.hash;
2426 options.addOption([]const u8, "ghostty_version", ghostty_ver);
27+ options.addOption(bool, "has_daemon", enable_daemon);
2528
2629 const exe_mod = b.createModule(.{
2730 .root_source_file = b.path("src/main.zig"),
......@@ -31,7 +34,7 @@ pub fn build(b: *std.Build) void {
3134 });
3235 exe_mod.addOptions("build_options", options);
3336
34- const dep = b.dependency("ghostty", .{
37+ const dep = if (enable_daemon) b.lazyDependency("ghostty", .{
3538 .target = target,
3639 .optimize = optimize,
3740 .@"emit-lib-vt" = true,
......@@ -40,11 +43,37 @@ pub fn build(b: *std.Build) void {
4043 // configure time and breaks builds without full Xcode.
4144 .@"emit-xcframework" = false,
4245 .@"emit-macos-app" = false,
46+ }) else null;
47+ if (dep) |d| {
48+ exe_mod.addImport(
49+ "ghostty-vt",
50+ d.module("ghostty-vt"),
51+ );
52+ }
53+
54+ // libzmx module: programmatic interface for embedding or external tools
55+ const libzmx = b.addModule("libzmx", .{
56+ .root_source_file = b.path("src/lib.zig"),
57+ .target = target,
58+ .optimize = optimize,
59+ .link_libc = true,
4360 });
44- exe_mod.addImport(
45- "ghostty-vt",
46- dep.module("ghostty-vt"),
47- );
61+ libzmx.addOptions("build_options", options);
62+ if (dep) |d| {
63+ libzmx.addImport("ghostty-vt", d.module("ghostty-vt"));
64+ }
65+
66+ // Also export as "zmx" for packages that depend on the package name
67+ const zmx_pkg_mod = b.addModule("zmx", .{
68+ .root_source_file = b.path("src/lib.zig"),
69+ .target = target,
70+ .optimize = optimize,
71+ .link_libc = true,
72+ });
73+ zmx_pkg_mod.addOptions("build_options", options);
74+ if (dep) |d| {
75+ zmx_pkg_mod.addImport("ghostty-vt", d.module("ghostty-vt"));
76+ }
4877
4978 // Run
5079 {
......@@ -56,11 +85,13 @@ pub fn build(b: *std.Build) void {
5685 .root_module = exe_mod,
5786 });
5887
59- b.installArtifact(exe);
60- const run_cmd = b.addRunArtifact(exe);
61- run_cmd.step.dependOn(b.getInstallStep());
62- if (b.args) |args| run_cmd.addArgs(args);
63- run_step.dependOn(&run_cmd.step);
88+ if (enable_daemon) {
89+ b.installArtifact(exe);
90+ const run_cmd = b.addRunArtifact(exe);
91+ run_cmd.step.dependOn(b.getInstallStep());
92+ if (b.args) |args| run_cmd.addArgs(args);
93+ run_step.dependOn(&run_cmd.step);
94+ }
6495 }
6596
6697 // Test
......@@ -72,6 +103,7 @@ pub fn build(b: *std.Build) void {
72103 .optimize = optimize,
73104 .link_libc = true,
74105 });
106+ test_module.addOptions("build_options", options);
75107 const test_dep = b.dependency("ghostty", .{
76108 .target = target,
77109 .optimize = optimize,
......@@ -96,17 +128,20 @@ pub fn build(b: *std.Build) void {
96128 // Check for LSP integration
97129 {
98130 const check = b.step("check", "Check if zmx compiles");
99- const exe_check = b.addExecutable(.{
100- .name = "zmx",
101- // .use_llvm = true,
102- // .use_lld = !is_macos,
103- .root_module = exe_mod,
104- });
105-
106- // Finally we add the "check" step which will be detected
107- // by ZLS and automatically enable Build-On-Save.
108- // If you copy this into your `build.zig`, make sure to rename 'foo'
109- check.dependOn(&exe_check.step);
131+ if (enable_daemon) {
132+ const exe_check = b.addExecutable(.{
133+ .name = "zmx",
134+ .root_module = exe_mod,
135+ });
136+ check.dependOn(&exe_check.step);
137+ } else {
138+ const lib_check = b.addLibrary(.{
139+ .name = "zmx",
140+ .root_module = libzmx,
141+ .linkage = .static,
142+ });
143+ check.dependOn(&lib_check.step);
144+ }
110145 }
111146
112147 // Release step - cross-compile to all targets from any host
+876, -0
......@@ -0,0 +1,876 @@
1+//! libzmx - programmatic client interface for zmx IPC.
2+const std = @import("std");
3+const Allocator = std.mem.Allocator;
4+const Io = std.Io;
5+const build_options = @import("build_options");
6+
7+pub const has_daemon = if (@hasDecl(build_options, "has_daemon")) build_options.has_daemon else true;
8+
9+const ipc = @import("ipc.zig");
10+const socket = @import("socket.zig");
11+const Cfg = @import("cfg.zig");
12+const util = @import("util.zig");
13+const label = @import("label.zig");
14+const lib_posix = @import("posix.zig");
15+const cross = @import("cross.zig");
16+const signal = @import("signal.zig");
17+const loop = @import("loop.zig");
18+const Daemon = if (has_daemon) loop.Daemon else struct {};
19+
20+pub const HistoryFormat = util.HistoryFormat;
21+pub const SessionEntry = util.SessionEntry;
22+
23+pub const CreateOptions = struct {
24+ command: ?[]const []const u8 = null,
25+ cwd: ?[]const u8 = null,
26+ shell: ?[]const u8 = null,
27+ labels: ?[]const u8 = null,
28+ is_task_mode: bool = false,
29+ quiet: bool = false,
30+};
31+
32+pub const AttachOptions = struct {
33+ command: ?[]const []const u8 = null,
34+ cwd: ?[]const u8 = null,
35+ shell: ?[]const u8 = null,
36+ labels: ?[]const u8 = null,
37+ tracked_env: ?[]const u8 = null,
38+};
39+
40+pub const RunOptions = struct {
41+ detached: bool = false,
42+ cwd: ?[]const u8 = null,
43+ shell: ?[]const u8 = null,
44+ stdin_bytes: ?[]const u8 = null,
45+ output_writer: ?*Io.Writer = null,
46+};
47+
48+pub const KillOptions = struct {
49+ force: bool = false,
50+};
51+
52+/// Ensure a session daemon is running in the background without attaching.
53+/// Resolves socket_dir and session prefix automatically.
54+/// If the session does not exist yet, forks the daemon internally (and exits
55+/// the daemon child process when the session ends so it never returns to caller).
56+/// Returns true if a new session was created, false if it already existed.
57+pub fn create(alloc: Allocator, io: Io, name: []const u8, opts: CreateOptions) !bool {
58+ if (!has_daemon) @compileError("create() requires -Ddaemon=true");
59+ signal.ignoreSigpipe();
60+ const sesh = try socket.getSeshName(alloc, name);
61+ defer alloc.free(sesh);
62+
63+ var cfg = try Cfg.init(alloc, io);
64+ defer cfg.deinit(alloc);
65+
66+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
67+ defer alloc.free(socket_path);
68+
69+ var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
70+ defer dir.close(io);
71+
72+ const exists = try socket.sessionExists(io, dir, sesh);
73+ var should_create = !exists;
74+ if (exists) {
75+ if (ipc.connectSession(socket_path)) |fd| {
76+ lib_posix.close(fd);
77+ return false;
78+ } else |err| switch (err) {
79+ error.ConnectionRefused => {
80+ socket.cleanupStaleSocket(io, dir, sesh);
81+ should_create = true;
82+ },
83+ else => return false,
84+ }
85+ }
86+
87+ if (!should_create) return false;
88+
89+ var daemon = Daemon.init(io, &cfg, sesh, socket_path);
90+ daemon.command = opts.command;
91+ if (opts.cwd) |cwd| daemon.setCwd(cwd);
92+ if (opts.shell) |sh| daemon.shell = sh;
93+ daemon.is_task_mode = opts.is_task_mode;
94+ daemon.quiet = opts.quiet;
95+
96+ const is_daemon_proc = try daemon.ensureSession(io);
97+ if (is_daemon_proc) {
98+ std.process.exit(0);
99+ }
100+
101+ if (opts.labels) |kvs| {
102+ try set(alloc, io, sesh, kvs);
103+ }
104+
105+ return true;
106+}
107+
108+/// Connect to an existing session's Unix domain socket and return the socket fd.
109+pub fn connect(alloc: Allocator, io: Io, name: []const u8) !i32 {
110+ const sesh = try socket.getSeshName(alloc, name);
111+ defer alloc.free(sesh);
112+
113+ var cfg = try Cfg.init(alloc, io);
114+ defer cfg.deinit(alloc);
115+
116+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
117+ defer alloc.free(socket_path);
118+
119+ return socket.sessionConnect(socket_path);
120+}
121+
122+/// Attach the current process's stdin/stdout TTY to a session (creating if needed).
123+pub fn attach(alloc: Allocator, io: Io, name: []const u8, opts: AttachOptions) !void {
124+ signal.ignoreSigpipe();
125+ const cur_sesh = socket.getSeshNameFromEnv();
126+ if (cur_sesh.len > 0) {
127+ var cfg = try Cfg.init(alloc, io);
128+ defer cfg.deinit(alloc);
129+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, cur_sesh);
130+ defer alloc.free(socket_path);
131+ const fd = try socket.sessionConnect(socket_path);
132+ defer lib_posix.close(fd);
133+ const target_sesh = try socket.getSeshName(alloc, name);
134+ defer alloc.free(target_sesh);
135+ ipc.send(fd, .Switch, target_sesh) catch |err| switch (err) {
136+ error.BrokenPipe, error.ConnectionResetByPeer => return,
137+ else => return err,
138+ };
139+ return;
140+ }
141+
142+ const sesh = try socket.getSeshName(alloc, name);
143+ defer alloc.free(sesh);
144+
145+ var cfg = try Cfg.init(alloc, io);
146+ defer cfg.deinit(alloc);
147+
148+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
149+ defer alloc.free(socket_path);
150+
151+ if (has_daemon) {
152+ var daemon = Daemon.init(io, &cfg, sesh, socket_path);
153+ daemon.command = opts.command;
154+ if (opts.cwd) |cwd| daemon.setCwd(cwd);
155+ if (opts.shell) |sh| daemon.shell = sh;
156+ const is_daemon_proc = try daemon.ensureSession(io);
157+ if (is_daemon_proc) std.process.exit(0);
158+ }
159+
160+ if (opts.labels) |kvs| {
161+ try set(alloc, io, sesh, kvs);
162+ }
163+
164+ const client_sock = try socket.sessionConnect(socket_path);
165+
166+ var orig_termios: cross.c.termios = undefined;
167+ const stdin_is_tty = cross.c.tcgetattr(lib_posix.STDIN_FILENO, &orig_termios) == 0;
168+ const restore_seq = "\x1bc\x1b]110\x1b\\\x1b]111\x1b\\\x1b]112\x1b\\";
169+
170+ defer {
171+ if (stdin_is_tty) {
172+ _ = cross.c.tcsetattr(lib_posix.STDIN_FILENO, cross.c.TCSAFLUSH, &orig_termios);
173+ }
174+ _ = lib_posix.write(lib_posix.STDOUT_FILENO, restore_seq) catch {};
175+ }
176+
177+ if (stdin_is_tty) {
178+ var raw_termios = orig_termios;
179+ cross.c.cfmakeraw(&raw_termios);
180+ raw_termios.c_cc[cross.c.VLNEXT] = cross.c._POSIX_VDISABLE;
181+ raw_termios.c_cc[cross.c.VQUIT] = cross.c._POSIX_VDISABLE;
182+ raw_termios.c_cc[cross.c.VMIN] = 1;
183+ raw_termios.c_cc[cross.c.VTIME] = 0;
184+ _ = cross.c.tcsetattr(lib_posix.STDIN_FILENO, cross.c.TCSANOW, &raw_termios);
185+ }
186+
187+ const clear_seq = "\x1b[2J\x1b[H";
188+ _ = try lib_posix.write(lib_posix.STDOUT_FILENO, clear_seq);
189+
190+ const env_str = opts.tracked_env orelse "";
191+ const looper = try loop.clientLoop(client_sock, env_str);
192+ switch (looper.kind) {
193+ .detach => return,
194+ .switch_session => {
195+ if (looper.session_name) |target_name| {
196+ _ = lib_posix.write(lib_posix.STDOUT_FILENO, restore_seq) catch {};
197+ var new_opts = opts;
198+ if (looper.cwd) |new_cwd| new_opts.cwd = new_cwd;
199+ return attach(alloc, io, target_name, new_opts);
200+ }
201+ },
202+ }
203+}
204+
205+/// Detach all clients from the specified session (or current ZMX_SESSION if "." or empty).
206+pub fn detach(alloc: Allocator, io: Io, name: []const u8) !void {
207+ signal.ignoreSigpipe();
208+ const raw_name = if (name.len == 0 or std.mem.eql(u8, name, "."))
209+ socket.getSeshNameFromEnv()
210+ else
211+ name;
212+ if (raw_name.len == 0) return error.SessionNameRequired;
213+
214+ const sesh = try socket.getSeshName(alloc, raw_name);
215+ defer alloc.free(sesh);
216+
217+ var cfg = try Cfg.init(alloc, io);
218+ defer cfg.deinit(alloc);
219+
220+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
221+ defer alloc.free(socket_path);
222+
223+ var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
224+ defer dir.close(io);
225+
226+ const fd = ipc.connectSession(socket_path) catch |err| {
227+ if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, sesh);
228+ return error.SessionUnresponsive;
229+ };
230+ defer lib_posix.close(fd);
231+
232+ ipc.send(fd, .DetachAll, "") catch |err| switch (err) {
233+ error.BrokenPipe, error.ConnectionResetByPeer => return,
234+ else => return err,
235+ };
236+}
237+
238+/// List all active sessions. Caller owns the returned slice and entries.
239+pub fn list(alloc: Allocator, io: Io) ![]SessionEntry {
240+ var cfg = try Cfg.init(alloc, io);
241+ defer cfg.deinit(alloc);
242+
243+ var sessions = try util.get_session_entries(alloc, io, cfg.socket_dir);
244+ return sessions.toOwnedSlice(alloc);
245+}
246+
247+pub const KillStatus = enum {
248+ killed,
249+ cleaned_stale,
250+};
251+
252+/// Kill a session and wait for its socket to close.
253+pub fn kill(alloc: Allocator, io: Io, name: []const u8, opts: KillOptions) !KillStatus {
254+ signal.ignoreSigpipe();
255+ const sesh = try socket.getSeshName(alloc, name);
256+ defer alloc.free(sesh);
257+
258+ var cfg = try Cfg.init(alloc, io);
259+ defer cfg.deinit(alloc);
260+
261+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
262+ defer alloc.free(socket_path);
263+
264+ var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
265+ defer dir.close(io);
266+
267+ const exists = try socket.sessionExists(io, dir, sesh);
268+ if (!exists) return error.SessionNotFound;
269+
270+ const fd = ipc.connectSession(socket_path) catch |err| {
271+ if (opts.force or err == error.ConnectionRefused) {
272+ socket.cleanupStaleSocket(io, dir, sesh);
273+ return .cleaned_stale;
274+ }
275+ return error.SessionUnresponsive;
276+ };
277+ defer lib_posix.close(fd);
278+
279+ ipc.send(fd, .Kill, "") catch |err| switch (err) {
280+ error.BrokenPipe, error.ConnectionResetByPeer => return .killed,
281+ else => return err,
282+ };
283+
284+ var drain: [256]u8 = undefined;
285+ while (true) {
286+ const n = lib_posix.read(fd, &drain) catch break;
287+ if (n == 0) break;
288+ }
289+ return .killed;
290+}
291+
292+fn tailInternal(
293+ alloc: Allocator,
294+ client_socket_fds: []const i32,
295+ detached: bool,
296+ is_run_cmd: bool,
297+ writer: ?*Io.Writer,
298+) !u8 {
299+ var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(alloc, client_socket_fds.len + 1);
300+ defer poll_fds.deinit(alloc);
301+
302+ var read_buf = try ipc.SocketBuffer.init(alloc);
303+ defer read_buf.deinit();
304+
305+ var out_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
306+ defer out_buf.deinit(alloc);
307+
308+ var is_first_line = true;
309+ var task_complete_code: ?u8 = null;
310+
311+ while (true) {
312+ poll_fds.clearRetainingCapacity();
313+
314+ for (client_socket_fds) |fd| {
315+ try poll_fds.append(alloc, .{
316+ .fd = fd,
317+ .events = lib_posix.POLL.IN,
318+ .revents = 0,
319+ });
320+ }
321+
322+ if (out_buf.items.len > 0 and writer == null) {
323+ try poll_fds.append(alloc, .{
324+ .fd = lib_posix.STDOUT_FILENO,
325+ .events = lib_posix.POLL.OUT,
326+ .revents = 0,
327+ });
328+ }
329+
330+ _ = lib_posix.poll(poll_fds.items, -1) catch |err| {
331+ if (err == error.Interrupted) continue;
332+ return err;
333+ };
334+
335+ for (poll_fds.items) |*poll_fd| {
336+ if (poll_fd.revents & lib_posix.POLL.IN != 0) {
337+ const n = read_buf.read(poll_fd.fd) catch |err| {
338+ if (err == error.WouldBlock) continue;
339+ if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) return 1;
340+ return err;
341+ };
342+ if (n == 0) {
343+ if (task_complete_code) |exit_code| return exit_code;
344+ return 0;
345+ }
346+
347+ while (read_buf.next()) |msg| {
348+ switch (msg.header.tag) {
349+ .Ack => {
350+ if (detached) {
351+ if (writer) |w| {
352+ try w.writeAll("command sent!\n");
353+ } else {
354+ _ = lib_posix.write(lib_posix.STDOUT_FILENO, "command sent!\n") catch {};
355+ }
356+ return 0;
357+ }
358+ },
359+ .Output => {
360+ if (msg.payload.len > 0) {
361+ var payload = msg.payload;
362+ if (!detached and is_run_cmd and is_first_line) {
363+ if (std.mem.indexOfScalar(u8, payload, '\n')) |nl| {
364+ is_first_line = false;
365+ payload = payload[nl + 1 ..];
366+ } else {
367+ is_first_line = false;
368+ payload = payload[payload.len..];
369+ }
370+ }
371+
372+ if (payload.len > 0) {
373+ const plain = util.stripAnsi(alloc, payload) catch continue;
374+ defer alloc.free(plain);
375+ if (plain.len > 0) {
376+ if (writer) |w| {
377+ try w.writeAll(plain);
378+ } else {
379+ try out_buf.appendSlice(alloc, plain);
380+ }
381+ }
382+ }
383+ }
384+ },
385+ .TaskComplete => {
386+ task_complete_code = if (msg.payload.len > 0) msg.payload[0] else 0;
387+ },
388+ else => {},
389+ }
390+ }
391+ }
392+ }
393+
394+ if (task_complete_code) |exit_code| {
395+ if (writer == null) {
396+ while (out_buf.items.len > 0) {
397+ const n = lib_posix.write(lib_posix.STDOUT_FILENO, out_buf.items) catch break;
398+ try out_buf.replaceRange(alloc, 0, n, &[_]u8{});
399+ }
400+ }
401+ return exit_code;
402+ }
403+
404+ if (writer == null and out_buf.items.len > 0) {
405+ const n = lib_posix.write(lib_posix.STDOUT_FILENO, out_buf.items) catch |err| blk: {
406+ if (err == error.WouldBlock) break :blk 0;
407+ return err;
408+ };
409+ if (n > 0) {
410+ try out_buf.replaceRange(alloc, 0, n, &[_]u8{});
411+ }
412+ }
413+
414+ for (poll_fds.items) |poll_fd| {
415+ if (poll_fd.revents & (lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL) != 0) {
416+ return 0;
417+ }
418+ }
419+ }
420+}
421+
422+/// Run a command inside a session in task mode (creating if needed).
423+/// Returns the command's exit code (or 0 immediately if `opts.detached` is true).
424+pub fn run(
425+ alloc: Allocator,
426+ io: Io,
427+ name: []const u8,
428+ command: []const []const u8,
429+ opts: RunOptions,
430+) !u8 {
431+ signal.ignoreSigpipe();
432+ const sesh = try socket.getSeshName(alloc, name);
433+ defer alloc.free(sesh);
434+
435+ var cfg = try Cfg.init(alloc, io);
436+ defer cfg.deinit(alloc);
437+
438+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
439+ defer alloc.free(socket_path);
440+
441+ if (has_daemon) {
442+ var daemon = Daemon.init(io, &cfg, sesh, socket_path);
443+ daemon.is_task_mode = true;
444+ if (opts.cwd) |cwd| daemon.setCwd(cwd);
445+ if (opts.shell) |sh| daemon.shell = sh;
446+ const is_daemon_proc = try daemon.ensureSession(io);
447+ if (is_daemon_proc) std.process.exit(0);
448+ }
449+
450+ var cmd_to_send: ?[]const u8 = null;
451+ var allocated_cmd: ?[]u8 = null;
452+ defer if (allocated_cmd) |c| alloc.free(c);
453+
454+ if (command.len > 0) {
455+ var cmd_list = std.ArrayList(u8).empty;
456+ defer cmd_list.deinit(alloc);
457+
458+ for (command, 0..) |arg, i| {
459+ if (i > 0) try cmd_list.append(alloc, ' ');
460+ if (util.shellNeedsQuoting(arg)) {
461+ const quoted = try util.shellQuote(alloc, arg);
462+ defer alloc.free(quoted);
463+ try cmd_list.appendSlice(alloc, quoted);
464+ } else {
465+ try cmd_list.appendSlice(alloc, arg);
466+ }
467+ }
468+ try cmd_list.append(alloc, '\r');
469+ cmd_to_send = try cmd_list.toOwnedSlice(alloc);
470+ allocated_cmd = @constCast(cmd_to_send.?);
471+ } else if (opts.stdin_bytes) |bytes| {
472+ if (bytes.len > 0) {
473+ var buf = try std.ArrayList(u8).initCapacity(alloc, bytes.len + 1);
474+ defer buf.deinit(alloc);
475+ try buf.appendSlice(alloc, bytes);
476+ if (buf.items[buf.items.len - 1] == '\n') {
477+ buf.items[buf.items.len - 1] = '\r';
478+ } else {
479+ try buf.append(alloc, '\r');
480+ }
481+ cmd_to_send = try buf.toOwnedSlice(alloc);
482+ allocated_cmd = @constCast(cmd_to_send.?);
483+ }
484+ }
485+
486+ if (cmd_to_send == null) return error.CommandRequired;
487+
488+ const client_sock = try socket.sessionConnect(socket_path);
489+ defer lib_posix.close(client_sock);
490+
491+ const term_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
492+ ipc.send(client_sock, .Resize, std.mem.asBytes(&term_size)) catch {};
493+
494+ ipc.send(client_sock, .Run, cmd_to_send.?) catch |err| switch (err) {
495+ error.ConnectionResetByPeer, error.BrokenPipe => return 0,
496+ else => return err,
497+ };
498+
499+ const fds = [_]i32{client_sock};
500+ return tailInternal(alloc, &fds, opts.detached, true, opts.output_writer);
501+}
502+
503+/// Wait for one or more task-mode sessions to complete and return aggregate exit code.
504+pub fn wait(alloc: Allocator, io: Io, names: []const []const u8) !u8 {
505+ if (names.len == 0) return error.SessionNameRequired;
506+ var cfg = try Cfg.init(alloc, io);
507+ defer cfg.deinit(alloc);
508+
509+ var matchers = try std.ArrayList(socket.SessionMatch).initCapacity(alloc, names.len);
510+ defer {
511+ for (matchers.items) |m| alloc.free(m.name);
512+ matchers.deinit(alloc);
513+ }
514+ for (names) |name| {
515+ try matchers.append(alloc, try socket.parseSessionArg(alloc, name));
516+ }
517+
518+ var agg_exit_code: u8 = 0;
519+ while (true) {
520+ agg_exit_code = 0;
521+ var sessions = try util.get_session_entries(alloc, io, cfg.socket_dir);
522+ defer {
523+ for (sessions.items) |s| s.deinit(alloc);
524+ sessions.deinit(alloc);
525+ }
526+
527+ var total: usize = 0;
528+ var done: usize = 0;
529+
530+ for (sessions.items) |session| {
531+ var matched = false;
532+ for (matchers.items) |m| {
533+ if (m.matches(session.name)) {
534+ matched = true;
535+ break;
536+ }
537+ }
538+ if (!matched) continue;
539+ total += 1;
540+ if (session.is_error) {
541+ agg_exit_code = 1;
542+ done += 1;
543+ } else if (session.task_ended_at != null) {
544+ if (session.task_exit_code) |ec| {
545+ if (ec != 0) agg_exit_code = ec;
546+ }
547+ done += 1;
548+ }
549+ }
550+
551+ if (total > 0 and done == total) return agg_exit_code;
552+ std.time.sleep(50 * std.time.ns_per_ms);
553+ }
554+}
555+
556+/// Follow live output from one or more sessions, writing plain text to `writer`.
557+pub fn tail(alloc: Allocator, io: Io, names: []const []const u8, writer: ?*Io.Writer) !u8 {
558+ if (names.len == 0) return error.SessionNameRequired;
559+ var cfg = try Cfg.init(alloc, io);
560+ defer cfg.deinit(alloc);
561+
562+ var matchers = try std.ArrayList(socket.SessionMatch).initCapacity(alloc, names.len);
563+ defer {
564+ for (matchers.items) |m| alloc.free(m.name);
565+ matchers.deinit(alloc);
566+ }
567+ for (names) |name| {
568+ try matchers.append(alloc, try socket.parseSessionArg(alloc, name));
569+ }
570+
571+ var resolved_names: std.ArrayList([]const u8) = .empty;
572+ defer {
573+ for (resolved_names.items) |n| alloc.free(n);
574+ resolved_names.deinit(alloc);
575+ }
576+
577+ var any_prefix = false;
578+ for (matchers.items) |m| {
579+ if (m.is_prefix) {
580+ any_prefix = true;
581+ break;
582+ }
583+ }
584+
585+ if (any_prefix) {
586+ var sessions = try util.get_session_entries(alloc, io, cfg.socket_dir);
587+ defer {
588+ for (sessions.items) |s| s.deinit(alloc);
589+ sessions.deinit(alloc);
590+ }
591+ for (sessions.items) |s| {
592+ for (matchers.items) |m| {
593+ if (m.matches(s.name)) {
594+ try resolved_names.append(alloc, try alloc.dupe(u8, s.name));
595+ break;
596+ }
597+ }
598+ }
599+ }
600+
601+ for (matchers.items) |m| {
602+ if (!m.is_prefix) {
603+ try resolved_names.append(alloc, try alloc.dupe(u8, m.name));
604+ }
605+ }
606+
607+ if (resolved_names.items.len == 0) return error.SessionNotFound;
608+
609+ var fds = try std.ArrayList(i32).initCapacity(alloc, resolved_names.items.len);
610+ defer {
611+ for (fds.items) |fd| lib_posix.close(fd);
612+ fds.deinit(alloc);
613+ }
614+
615+ for (resolved_names.items) |session_name| {
616+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name);
617+ defer alloc.free(socket_path);
618+ const sock = try socket.sessionConnect(socket_path);
619+ try fds.append(alloc, sock);
620+ }
621+
622+ return tailInternal(alloc, fds.items, false, false, writer);
623+}
624+
625+/// Send raw input bytes to a session's PTY (fire-and-forget, like `zmx send`).
626+pub fn send(alloc: Allocator, io: Io, name: []const u8, bytes: []const u8) !void {
627+ signal.ignoreSigpipe();
628+ const sesh = try socket.getSeshName(alloc, name);
629+ defer alloc.free(sesh);
630+
631+ var cfg = try Cfg.init(alloc, io);
632+ defer cfg.deinit(alloc);
633+
634+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
635+ defer alloc.free(socket_path);
636+
637+ var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
638+ defer dir.close(io);
639+
640+ const probe_result = ipc.probeSession(alloc, socket_path) catch |err| {
641+ if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, sesh);
642+ return error.SessionUnresponsive;
643+ };
644+ defer probe_result.deinit();
645+
646+ ipc.send(probe_result.fd, .Send, bytes) catch |err| switch (err) {
647+ error.ConnectionResetByPeer, error.BrokenPipe => return,
648+ else => return err,
649+ };
650+}
651+
652+/// Inject raw bytes directly into a session's display/scrollback (like `zmx print`).
653+pub fn print(alloc: Allocator, io: Io, name: []const u8, bytes: []const u8) !void {
654+ signal.ignoreSigpipe();
655+ const sesh = try socket.getSeshName(alloc, name);
656+ defer alloc.free(sesh);
657+
658+ var cfg = try Cfg.init(alloc, io);
659+ defer cfg.deinit(alloc);
660+
661+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
662+ defer alloc.free(socket_path);
663+
664+ var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
665+ defer dir.close(io);
666+
667+ const probe_result = ipc.probeSession(alloc, socket_path) catch |err| {
668+ if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, sesh);
669+ return error.SessionUnresponsive;
670+ };
671+ defer probe_result.deinit();
672+
673+ ipc.send(probe_result.fd, .Output, bytes) catch |err| switch (err) {
674+ error.ConnectionResetByPeer, error.BrokenPipe => return,
675+ else => return err,
676+ };
677+}
678+
679+/// Write `content` to `remote_path` inside the session (like `zmx write`).
680+pub fn write(alloc: Allocator, io: Io, name: []const u8, remote_path: []const u8, content: []const u8) !void {
681+ signal.ignoreSigpipe();
682+ const sesh = try socket.getSeshName(alloc, name);
683+ defer alloc.free(sesh);
684+
685+ var cfg = try Cfg.init(alloc, io);
686+ defer cfg.deinit(alloc);
687+
688+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
689+ defer alloc.free(socket_path);
690+
691+ var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
692+ defer dir.close(io);
693+
694+ const result = ipc.probeSession(alloc, socket_path) catch |err| {
695+ if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, sesh);
696+ return error.SessionUnresponsive;
697+ };
698+ defer result.deinit();
699+
700+ var wire_buf = try std.ArrayList(u8).initCapacity(alloc, @sizeOf(u32) + remote_path.len + content.len);
701+ defer wire_buf.deinit(alloc);
702+ const path_len: u32 = @intCast(remote_path.len);
703+ try wire_buf.appendSlice(alloc, std.mem.asBytes(&path_len));
704+ try wire_buf.appendSlice(alloc, remote_path);
705+ try wire_buf.appendSlice(alloc, content);
706+
707+ ipc.send(result.fd, .Write, wire_buf.items) catch |err| switch (err) {
708+ error.BrokenPipe, error.ConnectionResetByPeer => return,
709+ else => return err,
710+ };
711+
712+ var sb = try ipc.SocketBuffer.init(alloc);
713+ defer sb.deinit();
714+
715+ const n = sb.read(result.fd) catch return error.ReadFailed;
716+ if (n == 0) return error.ConnectionClosed;
717+
718+ while (sb.next()) |msg| {
719+ if (msg.header.tag == .Ack) return;
720+ }
721+ return error.NoAckReceived;
722+}
723+
724+/// Fetch session scrollback with optional row count.
725+pub fn historyRange(alloc: Allocator, io: Io, name: []const u8, format: HistoryFormat, scrollback_rows: ?u32) ![]u8 {
726+ signal.ignoreSigpipe();
727+ const sesh = try socket.getSeshName(alloc, name);
728+ defer alloc.free(sesh);
729+
730+ var cfg = try Cfg.init(alloc, io);
731+ defer cfg.deinit(alloc);
732+
733+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
734+ defer alloc.free(socket_path);
735+
736+ var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
737+ defer dir.close(io);
738+
739+ const exists = try socket.sessionExists(io, dir, sesh);
740+ if (!exists) return error.SessionNotFound;
741+
742+ const fd = ipc.connectSession(socket_path) catch |err| {
743+ if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, sesh);
744+ return error.SessionUnresponsive;
745+ };
746+ defer lib_posix.close(fd);
747+
748+ const reply_tag: ipc.Tag = if (scrollback_rows != null) .Capture else .History;
749+ if (scrollback_rows) |rows| {
750+ var request = std.mem.zeroes(ipc.Capture);
751+ request.format = @intFromEnum(format);
752+ request.rows = rows;
753+ try ipc.send(fd, .Capture, std.mem.asBytes(&request));
754+ try ipc.send(fd, .Info, "");
755+ } else {
756+ const format_byte = [_]u8{@intFromEnum(format)};
757+ try ipc.send(fd, .History, &format_byte);
758+ }
759+
760+ var sb = try ipc.SocketBuffer.init(alloc);
761+ defer sb.deinit();
762+
763+ while (true) {
764+ var poll_fds = [_]lib_posix.pollfd{.{ .fd = fd, .events = lib_posix.POLL.IN, .revents = 0 }};
765+ const poll_result = lib_posix.poll(&poll_fds, 5000) catch return error.Timeout;
766+ if (poll_result == 0) return error.Timeout;
767+
768+ const n = sb.read(fd) catch return error.ReadFailed;
769+ if (n == 0) return error.ConnectionClosed;
770+
771+ while (sb.next()) |msg| {
772+ if (msg.header.tag == reply_tag) {
773+ return alloc.dupe(u8, msg.payload);
774+ }
775+ if (scrollback_rows != null and msg.header.tag == .Info) {
776+ return error.CaptureNotSupported;
777+ }
778+ }
779+ }
780+}
781+
782+/// Fetch session scrollback in `.plain`, `.vt`, or `.html` format. Caller owns slice.
783+pub fn history(alloc: Allocator, io: Io, name: []const u8, format: HistoryFormat) ![]u8 {
784+ return historyRange(alloc, io, name, format, null);
785+}
786+
787+/// Get session labels (all `k=v` pairs if `key == null`, or single value). Caller owns slice.
788+pub fn get(alloc: Allocator, io: Io, name: []const u8, key: ?[]const u8) ![]u8 {
789+ signal.ignoreSigpipe();
790+ const sesh = try socket.getSeshName(alloc, name);
791+ defer alloc.free(sesh);
792+
793+ var cfg = try Cfg.init(alloc, io);
794+ defer cfg.deinit(alloc);
795+
796+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
797+ defer alloc.free(socket_path);
798+
799+ const payload = try ipc.roundTripForTag(alloc, socket_path, .LabelGet, "", .LabelData);
800+ errdefer alloc.free(payload);
801+
802+ if (key == null) return payload;
803+
804+ const val = try label.getLabelValueFromPairs(key.?, payload);
805+ const result = try alloc.dupe(u8, val);
806+ alloc.free(payload);
807+ return result;
808+}
809+
810+/// Set or unset (`k=`) space-separated session labels.
811+pub fn set(alloc: Allocator, io: Io, name: []const u8, kvs: []const u8) !void {
812+ signal.ignoreSigpipe();
813+ var it = label.LabelIterator.init(kvs);
814+ while (it.next()) |kv| {
815+ try label.assertLabel(kv.key, kv.value);
816+ }
817+
818+ const sesh = try socket.getSeshName(alloc, name);
819+ defer alloc.free(sesh);
820+
821+ var cfg = try Cfg.init(alloc, io);
822+ defer cfg.deinit(alloc);
823+
824+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
825+ defer alloc.free(socket_path);
826+
827+ _ = try ipc.roundTripForTag(alloc, socket_path, .LabelSet, kvs, .Ack);
828+}
829+
830+/// Clear all labels on a session.
831+pub fn clear(alloc: Allocator, io: Io, name: []const u8) !void {
832+ signal.ignoreSigpipe();
833+ const sesh = try socket.getSeshName(alloc, name);
834+ defer alloc.free(sesh);
835+
836+ var cfg = try Cfg.init(alloc, io);
837+ defer cfg.deinit(alloc);
838+
839+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
840+ defer alloc.free(socket_path);
841+
842+ _ = try ipc.roundTripForTag(alloc, socket_path, .LabelClear, "", .Ack);
843+}
844+
845+/// Get tracked environment variables for the session leader (`zmx print-env`).
846+pub fn printEnv(alloc: Allocator, io: Io, name: []const u8, key: ?[]const u8) ![]u8 {
847+ signal.ignoreSigpipe();
848+ const sesh = try socket.getSeshName(alloc, name);
849+ defer alloc.free(sesh);
850+
851+ var cfg = try Cfg.init(alloc, io);
852+ defer cfg.deinit(alloc);
853+
854+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
855+ defer alloc.free(socket_path);
856+
857+ const payload = try ipc.roundTripForTag(alloc, socket_path, .EnvGet, "", .EnvData);
858+ errdefer alloc.free(payload);
859+
860+ if (key == null) return payload;
861+
862+ // Search for key in payload: lines formatted as "KEY=VALUE\n"
863+ var lines = std.mem.splitScalar(u8, payload, '\n');
864+ while (lines.next()) |line| {
865+ if (std.mem.startsWith(u8, line, key.?)) {
866+ if (line.len > key.?.len and line[key.?.len] == '=') {
867+ const val = line[key.?.len + 1 ..];
868+ const result = try alloc.dupe(u8, val);
869+ alloc.free(payload);
870+ return result;
871+ }
872+ }
873+ }
874+ alloc.free(payload);
875+ return error.EnvVarNotFound;
876+}
+9, -7
......@@ -16,14 +16,16 @@ pub fn createCmdZ(def_shell: []const u8, is_task_mode: bool, command: ?[]const [
1616 const gpa = std.heap.c_allocator;
1717
1818 if (command) |cmd_args| {
19- const argv = try gpa.allocSentinel(?[*:0]const u8, cmd_args.len, null);
20- for (cmd_args, 0..) |arg, i| {
21- argv[i] = try gpa.dupeZ(u8, arg);
19+ if (cmd_args.len > 0) {
20+ const argv = try gpa.allocSentinel(?[*:0]const u8, cmd_args.len, null);
21+ for (cmd_args, 0..) |arg, i| {
22+ argv[i] = try gpa.dupeZ(u8, arg);
23+ }
24+ return .{
25+ .file = argv[0].?,
26+ .argv_ptr = argv.ptr,
27+ };
2228 }
23- return .{
24- .file = argv[0].?,
25- .argv_ptr = argv.ptr,
26- };
2729 }
2830
2931 const z = try std.fmt.allocPrintSentinel(gpa, "{s}", .{def_shell}, 0);
+51, -0
......@@ -0,0 +1,51 @@
1+//! libzmx - Public programmatic interface for zmx.
2+const std = @import("std");
3+const build_options = @import("build_options");
4+const Allocator = std.mem.Allocator;
5+const Io = std.Io;
6+
7+pub const has_daemon = if (@hasDecl(build_options, "has_daemon")) build_options.has_daemon else true;
8+
9+// Publicly exported locked-in IPC wire protocol
10+pub const ipc = @import("ipc.zig");
11+
12+const client = @import("client.zig");
13+const util = @import("util.zig");
14+
15+// ============================================================================
16+// Public Types
17+// ============================================================================
18+
19+pub const HistoryFormat = util.HistoryFormat;
20+pub const SessionEntry = util.SessionEntry;
21+
22+pub const CreateOptions = client.CreateOptions;
23+pub const AttachOptions = client.AttachOptions;
24+pub const RunOptions = client.RunOptions;
25+pub const KillOptions = client.KillOptions;
26+pub const KillStatus = client.KillStatus;
27+
28+// ============================================================================
29+// CLI-Mirrored Top-Level Functions
30+// ============================================================================
31+
32+pub const connect = client.connect;
33+pub const attach = client.attach;
34+pub const detach = client.detach;
35+pub const list = client.list;
36+pub const kill = client.kill;
37+pub const run = client.run;
38+pub const wait = client.wait;
39+pub const tail = client.tail;
40+pub const send = client.send;
41+pub const print = client.print;
42+pub const write = client.write;
43+pub const history = client.history;
44+pub const historyRange = client.historyRange;
45+pub const get = client.get;
46+pub const set = client.set;
47+pub const clear = client.clear;
48+pub const printEnv = client.printEnv;
49+
50+// Export create() when daemon is enabled, or provide compile error
51+pub const create = if (has_daemon) client.create else @compileError("create() requires -Ddaemon=true");
+13, -6
......@@ -1,5 +1,9 @@
11 const std = @import("std");
2-const ghostty_vt = @import("ghostty-vt");
2+const build_options = @import("build_options");
3+const has_daemon = if (@hasDecl(build_options, "has_daemon")) build_options.has_daemon else true;
4+const ghostty_vt = if (has_daemon) @import("ghostty-vt") else struct {
5+ pub const Terminal = struct {};
6+};
37 const ipc = @import("ipc.zig");
48 const log = @import("log.zig");
59 const util = @import("util.zig");
......@@ -625,6 +629,7 @@ pub const Daemon = struct {
625629 task_ended_at: ?u64 = null, // timestamp when task exited
626630 pty_fd: i32 = -1, // set by daemonLoop so handleRun can probe the foreground process
627631 shell: []const u8 = "/bin/sh",
632+ quiet: bool = false,
628633
629634 /// Create a Daemon. Caller is responsible for freeing all variables passed
630635 /// into the init fn.
......@@ -759,7 +764,7 @@ pub const Daemon = struct {
759764 fn run(self: *Daemon, io: std.Io, dir: std.Io.Dir, sesh_name: []const u8) !bool {
760765 std.log.info("creating session={s}", .{sesh_name});
761766 const server_sock_fd: lib_posix.socket_t = try socket.createSocket(self.socket_path);
762- const log_fd = log.log_system.file.?.handle;
767+ const log_fd = if (log.log_system.file) |f| f.handle else -1;
763768
764769 var keep_fds_open = [_]i32{ server_sock_fd, dir.handle, log_fd };
765770 const cmd = try daemonize.createCmdZ(self.shell, self.is_task_mode, self.command);
......@@ -788,10 +793,12 @@ pub const Daemon = struct {
788793 switch (err) {
789794 error.IsClientProc => {
790795 // send a msg to the client that the session was created.
791- var w_buf: [2048]u8 = undefined;
792- var w = std.Io.File.stdout().writer(io, &w_buf);
793- try w.interface.print("session \"{s}\" created\n", .{sesh_name});
794- try w.interface.flush();
796+ if (!self.quiet) {
797+ var w_buf: [2048]u8 = undefined;
798+ var w = std.Io.File.stdout().writer(io, &w_buf);
799+ try w.interface.print("session \"{s}\" created\n", .{sesh_name});
800+ try w.interface.flush();
801+ }
795802 lib_posix.close(server_sock_fd);
796803 return false;
797804 },
+153, -854
......@@ -1,20 +1,16 @@
11 const std = @import("std");
2+const zmx = @import("lib.zig");
23 const Environ = std.process.Environ;
34 const build_options = @import("build_options");
4-const ghostty_vt = @import("ghostty-vt");
55 const ipc = @import("ipc.zig");
66 const log = @import("log.zig");
77 const completions = @import("completions.zig");
88 const util = @import("util.zig");
9-const cross = @import("cross.zig");
109 const socket = @import("socket.zig");
1110 const label = @import("label.zig");
1211 const lib_posix = @import("posix.zig");
1312 const signal = @import("signal.zig");
1413 const Cfg = @import("cfg.zig");
15-const loop = @import("loop.zig");
16-const Client = loop.Client;
17-const Daemon = loop.Daemon;
1814 const version = build_options.version;
1915 const ghostty_version = build_options.ghostty_version;
2016
......@@ -181,23 +177,20 @@ pub fn main(init: std.process.Init) !void {
181177 const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
182178 const cwd = cwd_buf[0..cwd_len];
183179
184- const sesh = try socket.getSeshName(gpa, parsed.session_name);
185- defer gpa.free(sesh);
186- const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
187- error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
188- error.OutOfMemory => return err,
189- };
190- var daemon = Daemon.init(io, &cfg, sesh, socket_path);
191- daemon.command = command;
192- daemon.setCwd(cwd);
193- daemon.shell = shell_env;
194- std.log.info("socket path={s}", .{daemon.socket_path});
195-
196180 const env_keys = lib_posix.getenv("ZMX_TRACK_ENV") orelse cfg.tracked_envs;
197181 const env_str = try getTrackedEnvStr(gpa, env_keys, init.environ_map);
198182 defer gpa.free(env_str);
199183
200- return attach(gpa, io, &daemon, env_str, parsed.labels);
184+ zmx.attach(gpa, io, parsed.session_name, .{
185+ .command = command,
186+ .cwd = cwd,
187+ .shell = shell_env,
188+ .labels = parsed.labels,
189+ .tracked_env = env_str,
190+ }) catch |err| {
191+ return printError(io, "cannot connect to session \"{s}\": {s}", .{ parsed.session_name, @errorName(err) });
192+ };
193+ return;
201194 } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
202195 const session_name = args.next() orelse "";
203196 if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
......@@ -220,19 +213,42 @@ pub fn main(init: std.process.Init) !void {
220213 const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
221214 const cwd = cwd_buf[0..cwd_len];
222215
223- const sesh = try socket.getSeshName(gpa, session_name);
224- defer gpa.free(sesh);
225- const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
226- error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
227- error.OutOfMemory => return err,
216+ var stdin_bytes: ?[]const u8 = null;
217+ var stdin_buf = std.ArrayList(u8).empty;
218+ defer stdin_buf.deinit(gpa);
219+
220+ if (cmd_args_raw.items.len == 0) {
221+ const stdin_file = std.Io.File.stdin();
222+ defer stdin_file.close(io);
223+ var stdbuf: [4096]u8 = undefined;
224+ var reader = stdin_file.reader(io, &stdbuf);
225+ if (!try stdin_file.isTty(io)) {
226+ while (true) {
227+ var dest: [1024]u8 = undefined;
228+ const n = try reader.interface.readSliceShort(&dest);
229+ if (n == 0) break;
230+ try stdin_buf.appendSlice(gpa, dest[0..n]);
231+ }
232+ if (stdin_buf.items.len > 0) {
233+ stdin_bytes = stdin_buf.items;
234+ }
235+ }
236+ }
237+
238+ if (cmd_args_raw.items.len == 0 and stdin_bytes == null) {
239+ return printError(io, "run requires a command (or pipe input via stdin)", .{});
240+ }
241+
242+ const exit_code = zmx.run(gpa, io, session_name, cmd_args_raw.items, .{
243+ .detached = detached,
244+ .cwd = cwd,
245+ .shell = shell_env,
246+ .stdin_bytes = stdin_bytes,
247+ }) catch |err| switch (err) {
248+ error.CommandRequired => return printError(io, "run requires a command (or pipe input via stdin)", .{}),
249+ else => return printError(io, "session not ready: {s}", .{@errorName(err)}),
228250 };
229- defer gpa.free(socket_path);
230- var daemon = Daemon.init(io, &cfg, sesh, socket_path);
231- daemon.setCwd(cwd);
232- daemon.is_task_mode = true;
233- daemon.shell = shell_env;
234- std.log.info("socket path={s}", .{daemon.socket_path});
235- return run(gpa, io, &daemon, detached, cmd_args_raw.items);
251+ lib_posix.exit(exit_code);
236252 } else if (std.mem.eql(u8, cmd, "send") or std.mem.eql(u8, cmd, "s")) {
237253 const session_name = args.next() orelse "";
238254 if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
......@@ -248,13 +264,7 @@ pub fn main(init: std.process.Init) !void {
248264 try text_parts.append(gpa, arg);
249265 }
250266
251- const sesh = try socket.getSeshName(gpa, session_name);
252- defer gpa.free(sesh);
253- const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
254- error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
255- error.OutOfMemory => return err,
256- };
257- send(gpa, io, &cfg, sesh, socket_path, text_parts.items, .Send) catch |err| {
267+ send(gpa, io, session_name, text_parts.items, .Send) catch |err| {
258268 if (err == error.SessionUnresponsive) std.process.exit(1);
259269 return printError(io, "send failed: {s}", .{@errorName(err)});
260270 };
......@@ -273,13 +283,7 @@ pub fn main(init: std.process.Init) !void {
273283 try text_parts.append(gpa, arg);
274284 }
275285
276- const sesh = try socket.getSeshName(gpa, session_name);
277- defer gpa.free(sesh);
278- const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
279- error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
280- error.OutOfMemory => return err,
281- };
282- send(gpa, io, &cfg, sesh, socket_path, text_parts.items, .Output) catch |err| {
286+ send(gpa, io, session_name, text_parts.items, .Output) catch |err| {
283287 if (err == error.SessionUnresponsive) std.process.exit(1);
284288 return printError(io, "print failed: {s}", .{@errorName(err)});
285289 };
......@@ -310,16 +314,16 @@ pub fn main(init: std.process.Init) !void {
310314 if (matchers.items.len == 0) {
311315 return printError(io, "session name required", .{});
312316 }
313- var sessions = try util.get_session_entries(gpa, io, cfg.socket_dir);
317+ const sessions = try zmx.list(gpa, io);
314318 defer {
315- for (sessions.items) |session| {
319+ for (sessions) |session| {
316320 session.deinit(gpa);
317321 }
318- sessions.deinit(gpa);
322+ gpa.free(sessions);
319323 }
320324
321325 var killed_any = false;
322- for (sessions.items) |session| {
326+ for (sessions) |session| {
323327 for (matchers.items) |m| {
324328 if (!m.matches(session.name)) continue;
325329 kill(gpa, io, &cfg, session.name, force) catch |err| {
......@@ -368,88 +372,21 @@ pub fn main(init: std.process.Init) !void {
368372 }
369373 return wait(gpa, io, &cfg, matchers);
370374 } else if (std.mem.eql(u8, cmd, "tail") or std.mem.eql(u8, cmd, "t")) {
371- var matchers: std.ArrayList(socket.SessionMatch) = .empty;
372- defer {
373- for (matchers.items) |m| {
374- gpa.free(m.name);
375- }
376- matchers.deinit(gpa);
377- }
375+ var names: std.ArrayList([]const u8) = .empty;
376+ defer names.deinit(gpa);
378377 while (args.next()) |session_name| {
379378 if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
380379 return help(io);
381380 }
382- const m = try socket.parseSessionArg(gpa, session_name);
383- try matchers.append(gpa, m);
381+ try names.append(gpa, session_name);
384382 }
385- if (matchers.items.len == 0) {
383+ if (names.items.len == 0) {
386384 return printError(io, "session name required", .{});
387385 }
388-
389- // Resolve matchers against session list to get actual session names.
390- var resolved_names: std.ArrayList([]const u8) = .empty;
391- defer {
392- for (resolved_names.items) |name| {
393- gpa.free(name);
394- }
395- resolved_names.deinit(gpa);
396- }
397-
398- var any_prefix = false;
399- for (matchers.items) |m| {
400- if (m.is_prefix) {
401- any_prefix = true;
402- break;
403- }
404- }
405-
406- if (any_prefix) {
407- var sessions = try util.get_session_entries(gpa, io, cfg.socket_dir);
408- defer {
409- for (sessions.items) |session| {
410- session.deinit(gpa);
411- }
412- sessions.deinit(gpa);
413- }
414- for (sessions.items) |session| {
415- for (matchers.items) |m| {
416- if (m.matches(session.name)) {
417- try resolved_names.append(gpa, try gpa.dupe(u8, session.name));
418- break;
419- }
420- }
421- }
422- }
423- // Add exact-match names directly.
424- for (matchers.items) |m| {
425- if (!m.is_prefix) {
426- try resolved_names.append(gpa, try gpa.dupe(u8, m.name));
427- }
428- }
429-
430- if (resolved_names.items.len == 0) {
431- return printError(io, "no matching sessions found", .{});
432- }
433-
434- var client_socket_fds = try std.ArrayList(i32).initCapacity(gpa, resolved_names.items.len);
435- defer {
436- for (client_socket_fds.items) |client_fd| {
437- lib_posix.close(client_fd);
438- }
439- client_socket_fds.deinit(gpa);
440- }
441-
442- for (resolved_names.items) |session_name| {
443- const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, session_name) catch |err| switch (err) {
444- error.NameTooLong => return socket.printSessionNameTooLong(init.io, session_name, cfg.socket_dir),
445- error.OutOfMemory => return err,
446- };
447- const client_sock = socket.sessionConnect(socket_path) catch |err| {
448- return printError(io, "cannot connect to session \"{s}\": {s}", .{ session_name, @errorName(err) });
449- };
450- try client_socket_fds.append(gpa, client_sock);
451- }
452- _ = try tail(gpa, client_socket_fds, false, false);
386+ _ = zmx.tail(gpa, io, names.items, null) catch |err| switch (err) {
387+ error.SessionNotFound => return printError(io, "no matching sessions found", .{}),
388+ else => return err,
389+ };
453390 } else if (std.mem.eql(u8, cmd, "write") or std.mem.eql(u8, cmd, "wr")) {
454391 const session_name = args.next() orelse "";
455392 if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
......@@ -469,18 +406,8 @@ pub fn main(init: std.process.Init) !void {
469406 var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
470407 const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
471408 const cwd = cwd_buf[0..cwd_len];
472- const sesh = try socket.getSeshName(gpa, session_name);
473- defer gpa.free(sesh);
474- const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
475- error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
476- error.OutOfMemory => return err,
477- };
478- var daemon = Daemon.init(io, &cfg, sesh, socket_path);
479- daemon.is_task_mode = true;
480- daemon.setCwd(cwd);
481- daemon.shell = shell_env;
482- std.log.info("socket path={s}", .{daemon.socket_path});
483- writeFile(gpa, io, &daemon, file_path) catch |err| {
409+
410+ writeFile(gpa, io, session_name, file_path, cwd, shell_env) catch |err| {
484411 if (err == error.SessionUnresponsive) std.process.exit(1);
485412 return printError(io, "write failed: {s}", .{@errorName(err)});
486413 };
......@@ -701,161 +628,8 @@ fn printCompletions(io: std.Io, shell: completions.Shell) !void {
701628 try w.interface.flush();
702629 }
703630
704-fn tail(alloc: std.mem.Allocator, client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool) !u8 {
705- var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(alloc, 4);
706- defer poll_fds.deinit(alloc);
707-
708- var read_buf = try ipc.SocketBuffer.init(alloc);
709- defer read_buf.deinit();
710-
711- var stdout_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
712- defer stdout_buf.deinit(alloc);
713-
714- var is_first_line = true;
715- var task_complete_code: ?u8 = null;
716-
717- while (true) {
718- poll_fds.clearRetainingCapacity();
719-
720- // Poll socket for read
721- for (client_socket_fds.items) |client_sock_fd| {
722- try poll_fds.append(alloc, .{
723- .fd = client_sock_fd,
724- .events = lib_posix.POLL.IN,
725- .revents = 0,
726- });
727- }
728-
729- // Poll for write if we have pending data
730- if (stdout_buf.items.len > 0) {
731- try poll_fds.append(alloc, .{
732- .fd = lib_posix.STDOUT_FILENO,
733- .events = lib_posix.POLL.OUT,
734- .revents = 0,
735- });
736- }
737-
738- _ = lib_posix.poll(poll_fds.items, -1) catch |err| {
739- if (err == error.Interrupted) continue; // EINTR from signal, loop again
740- return err;
741- };
742-
743- // Handle socket read (incoming Output messages from daemon)
744- for (poll_fds.items) |*poll_fd| {
745- if (poll_fd.revents & lib_posix.POLL.IN != 0) {
746- const n = read_buf.read(poll_fd.fd) catch |err| {
747- if (err == error.WouldBlock) continue;
748- if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
749- return 1;
750- }
751- std.log.err("daemon read err={s}", .{@errorName(err)});
752- return err;
753- };
754- if (n == 0) {
755- // Server closed connection. If we got task completion,
756- // return the exit code. Otherwise fall back to 0.
757- if (task_complete_code) |exit_code| {
758- return exit_code;
759- }
760- return 0;
761- }
762-
763- while (read_buf.next()) |msg| {
764- switch (msg.header.tag) {
765- .Ack => {
766- if (detached) {
767- _ = lib_posix.write(lib_posix.STDOUT_FILENO, "command sent!\n") catch |err| blk: {
768- if (err == error.WouldBlock) break :blk 0;
769- return err;
770- };
771- return 0;
772- }
773- },
774- .Output => {
775- if (msg.payload.len > 0) {
776- // TODO: figure out how to bring this back
777- // Fallback: scan output for task exit marker in case
778- // .TaskComplete was lost (e.g. daemon exited before
779- // flushing). This ensures we detect completion even
780- // when the IPC message doesn't arrive.
781- // if (task_complete_code == null and is_run_cmd) {
782- // if (util.findTaskExitMarker(msg.payload)) |ec| {
783- // task_complete_code = ec;
784- // }
785- // }
786-
787- // Strip the first line (command echo) for run mode.
788- var payload = msg.payload;
789- if (!detached and is_run_cmd and is_first_line) {
790- if (std.mem.indexOfScalar(u8, payload, '\n')) |nl| {
791- is_first_line = false;
792- payload = payload[nl + 1 ..];
793- } else {
794- is_first_line = false;
795- payload = payload[payload.len..]; // consume entire echo line
796- }
797- }
798-
799- if (payload.len > 0) {
800- // Strip ANSI escape sequences to produce plain text.
801- // This prevents shell prompts, colors, cursor movements,
802- // and other VT sequences from corrupting the caller's terminal.
803- const plain = util.stripAnsi(alloc, payload) catch |err| {
804- std.log.warn("stripAnsi failed: {s}", .{@errorName(err)});
805- continue;
806- };
807- defer alloc.free(plain);
808- if (plain.len > 0) {
809- try stdout_buf.appendSlice(alloc, plain);
810- }
811- }
812- }
813- },
814- .TaskComplete => {
815- task_complete_code = if (msg.payload.len > 0) msg.payload[0] else 0;
816- },
817- else => {},
818- }
819- }
820- }
821- }
822-
823- // Check for task completion after processing socket messages.
824- // This must be outside the stdout write block because .TaskComplete
825- // can arrive after all output has already been flushed, leaving
826- // stdout_buf empty. Without this check, tail() would poll forever.
827- if (task_complete_code) |exit_code| {
828- // Flush any remaining output before returning
829- flush_loop: while (stdout_buf.items.len > 0) {
830- const n = lib_posix.write(lib_posix.STDOUT_FILENO, stdout_buf.items) catch |err| {
831- if (err == error.WouldBlock) break :flush_loop;
832- return err;
833- };
834- try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{});
835- }
836- return exit_code;
837- }
838-
839- if (stdout_buf.items.len > 0) {
840- const n = lib_posix.write(lib_posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: {
841- if (err == error.WouldBlock) break :blk 0;
842- return err;
843- };
844- if (n > 0) {
845- try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{});
846- }
847- }
848-
849- // Check for HUP/ERR on any socket
850- for (poll_fds.items) |poll_fd| {
851- if (poll_fd.revents & (lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL) != 0) {
852- return 0;
853- }
854- }
855- }
856-}
857-
858631 fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList(socket.SessionMatch)) !void {
632+ _ = cfg;
859633 var stdout_buffer: [1024]u8 = undefined;
860634 var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer);
861635 const stdout = &stdout_writer.interface;
......@@ -875,11 +649,17 @@ fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList
875649 var prev_done: i32 = 0;
876650 while (true) {
877651 agg_exit_code = 0;
878- var sessions = try util.get_session_entries(alloc, io, cfg.socket_dir);
652+ const sessions = try zmx.list(alloc, io);
653+ defer {
654+ for (sessions) |session| {
655+ session.deinit(alloc);
656+ }
657+ alloc.free(sessions);
658+ }
879659 var total: i32 = 0;
880660 var done: i32 = 0;
881661
882- for (sessions.items) |session| {
662+ for (sessions) |session| {
883663 var found = false;
884664 for (matchers.items) |m| {
885665 if (m.matches(session.name)) {
......@@ -887,9 +667,7 @@ fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList
887667 break;
888668 }
889669 }
890- if (!found) {
891- continue;
892- }
670+ if (!found) continue;
893671
894672 total += 1;
895673 if (session.is_error) {
......@@ -936,11 +714,6 @@ fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList
936714 done += 1;
937715 }
938716
939- for (sessions.items) |session| {
940- session.deinit(alloc);
941- }
942- sessions.deinit(alloc);
943-
944717 // Check disappearance BEFORE completion: if one of N sessions
945718 // crashed and the remaining N-1 happen to be done, total==done
946719 // would be a false success.
......@@ -984,8 +757,14 @@ fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList
984757 }
985758 try stdout.flush();
986759
987- const sessions = try util.get_session_entries(alloc, io, cfg.socket_dir);
988- for (sessions.items) |session| {
760+ const sessions = try zmx.list(alloc, io);
761+ defer {
762+ for (sessions) |session| {
763+ session.deinit(alloc);
764+ }
765+ alloc.free(sessions);
766+ }
767+ for (sessions) |session| {
989768 var found = false;
990769 for (matchers.items) |m| {
991770 if (m.matches(session.name)) {
......@@ -993,9 +772,7 @@ fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList
993772 break;
994773 }
995774 }
996- if (!found) {
997- continue;
998- }
775+ if (!found) continue;
999776 if (session.task_exit_code.? > 0) {
1000777 try stdout.print("---\n", .{});
1001778 try stdout.print("[{d}] failed task={s} exit_status={d}\n", .{
......@@ -1006,7 +783,7 @@ fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList
1006783
1007784 // Fetch and print the last 20 lines of history for debugging
1008785 const history_lines: usize = 20;
1009- const history_text = fetchHistory(alloc, io, cfg, session.name) catch null;
786+ const history_text = zmx.history(alloc, io, session.name, .plain) catch null;
1010787 if (history_text) |text| {
1011788 defer alloc.free(text);
1012789 try stdout.print("\nLast {d} lines of {s} history:\n", .{ history_lines, session.name });
......@@ -1041,15 +818,15 @@ fn list(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, short: bool) !void {
1041818 const current_session = socket.getSeshNameFromEnv();
1042819 var buf: [4096]u8 = undefined;
1043820 var stdout = std.Io.File.stdout().writer(io, &buf);
1044- var sessions = try util.get_session_entries(alloc, io, cfg.socket_dir);
821+ const sessions = try zmx.list(alloc, io);
1045822 defer {
1046- for (sessions.items) |session| {
823+ for (sessions) |session| {
1047824 session.deinit(alloc);
1048825 }
1049- sessions.deinit(alloc);
826+ alloc.free(sessions);
1050827 }
1051828
1052- if (sessions.items.len == 0) {
829+ if (sessions.len == 0) {
1053830 if (short) return;
1054831 var errbuf: [4096]u8 = undefined;
1055832 var stderr = std.Io.File.stderr().writer(io, &errbuf);
......@@ -1058,91 +835,43 @@ fn list(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, short: bool) !void {
1058835 return;
1059836 }
1060837
1061- std.mem.sort(util.SessionEntry, sessions.items, {}, util.SessionEntry.lessThan);
1062-
1063- for (sessions.items) |session| {
1064- if (session.is_error) {
1065- try util.writeSessionLine(&stdout.interface, session, short, current_session);
1066- try stdout.interface.flush();
1067- continue;
1068- }
838+ std.mem.sort(util.SessionEntry, sessions, {}, util.SessionEntry.lessThan);
1069839
840+ for (sessions) |session| {
1070841 try util.writeSessionLine(&stdout.interface, session, short, current_session);
1071842 try stdout.interface.flush();
1072843 }
1073844 }
1074845
1075846 fn detachAll(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg) !void {
847+ _ = cfg;
1076848 const session_name = socket.getSeshNameFromEnv();
1077849 if (session_name.len == 0) {
1078850 return printError(io, "not inside a zmx session (ZMX_SESSION not set)", .{});
1079851 }
1080852 std.log.info("detach all session={s}", .{session_name});
1081-
1082- var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
1083- defer dir.close(io);
1084-
1085- const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1086- error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1087- error.OutOfMemory => return err,
1088- };
1089- defer alloc.free(socket_path);
1090- const fd = ipc.connectSession(socket_path) catch |err| {
1091- std.log.err("session unresponsive: {s}", .{@errorName(err)});
1092- if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, session_name);
1093- return;
1094- };
1095- defer lib_posix.close(fd);
1096- ipc.send(fd, .DetachAll, "") catch |err| switch (err) {
853+ zmx.detach(alloc, io, session_name) catch |err| switch (err) {
1097854 error.BrokenPipe, error.ConnectionResetByPeer => return,
1098855 else => return err,
1099856 };
1100857 }
1101858
1102859 fn kill(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, force: bool) !void {
860+ _ = cfg;
1103861 std.log.info("kill session={s}", .{session_name});
1104- const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1105- error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1106- error.OutOfMemory => return err,
1107- };
1108- defer alloc.free(socket_path);
1109-
1110- var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
1111- defer dir.close(io);
1112-
1113- const exists = try socket.sessionExists(io, dir, session_name);
1114- if (!exists) {
1115- return error.SessionNotFound;
1116- }
1117- const fd = ipc.connectSession(socket_path) catch |err| {
1118- std.log.err("session unresponsive: {s}", .{@errorName(err)});
1119- if (force or err == error.ConnectionRefused) {
1120- socket.cleanupStaleSocket(io, dir, session_name);
1121- var ebuf: [4096]u8 = undefined;
1122- var ew = std.Io.File.stderr().writer(io, &ebuf);
1123- ew.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
1124- ew.interface.flush() catch {};
1125- return;
1126- } else {
1127- return error.SessionUnresponsive;
1128- }
1129- };
1130-
1131- defer lib_posix.close(fd);
1132- ipc.send(fd, .Kill, "") catch |err| switch (err) {
862+ const status = zmx.kill(alloc, io, session_name, .{ .force = force }) catch |err| switch (err) {
863+ error.SessionNotFound => return error.SessionNotFound,
864+ error.SessionUnresponsive => return error.SessionUnresponsive,
1133865 error.BrokenPipe, error.ConnectionResetByPeer => return,
1134866 else => return err,
1135867 };
1136868
1137- // Block until the daemon hangs up. The daemon's shutdown defer closes
1138- // and unlinks the listen socket before it closes client connections,
1139- // so by the time we read EOF here the session name is free for reuse
1140- // and a subsequent `zmx run <name>` can't land in the dying daemon's
1141- // accept backlog.
1142- var drain: [256]u8 = undefined;
1143- while (true) {
1144- const n = lib_posix.read(fd, &drain) catch break;
1145- if (n == 0) break;
869+ if (status == .cleaned_stale) {
870+ var ebuf: [4096]u8 = undefined;
871+ var ew = std.Io.File.stderr().writer(io, &ebuf);
872+ ew.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
873+ ew.interface.flush() catch {};
874+ return;
1146875 }
1147876
1148877 var buf: [100]u8 = undefined;
......@@ -1173,30 +902,20 @@ fn printLabelError(io: std.Io, session_name: []const u8, err: anyerror) noreturn
1173902 }
1174903
1175904 fn labelGet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, single_kv: []const u8) !void {
905+ _ = cfg;
1176906 std.log.info("label get session={s}", .{session_name});
1177907
1178- const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1179- error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1180- error.OutOfMemory => return err,
1181- };
1182- defer alloc.free(socket_path);
1183-
1184- const payload = ipc.roundTripForTag(alloc, socket_path, .LabelGet, "", .LabelData) catch |err| {
908+ const key = if (single_kv.len == 0) null else single_kv;
909+ const val = zmx.get(alloc, io, session_name, key) catch |err| {
910+ if (err == error.LabelKeyNotFound) {
911+ return printError(io, "label key \"{s}\" not found in session \"{s}\"", .{ single_kv, session_name });
912+ }
1185913 printLabelError(io, session_name, err);
1186914 };
1187- defer alloc.free(payload);
915+ defer alloc.free(val);
1188916
1189917 var buf: [4096]u8 = undefined;
1190918 var stdout = std.Io.File.stdout().writer(io, &buf);
1191- if (single_kv.len == 0) {
1192- try stdout.interface.print("{s}", .{payload});
1193- try stdout.interface.flush();
1194- return;
1195- }
1196-
1197- const val = label.getLabelValueFromPairs(single_kv, payload) catch |err| switch (err) {
1198- error.LabelKeyNotFound => return printError(io, "label key \"{s}\" not found in session \"{s}\"", .{ single_kv, session_name }),
1199- };
1200919 try stdout.interface.print("{s}", .{val});
1201920 try stdout.interface.flush();
1202921 }
......@@ -1310,15 +1029,11 @@ fn printEnvError(io: std.Io, session_name: []const u8, err: anyerror) noreturn {
13101029 }
13111030
13121031 fn envGet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, single_kv: []const u8, shell_mode: bool) !void {
1032+ _ = cfg;
13131033 std.log.info("env get session={s}", .{session_name});
13141034
1315- const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1316- error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1317- error.OutOfMemory => return err,
1318- };
1319- defer alloc.free(socket_path);
1320-
1321- const payload = ipc.roundTripForTag(alloc, socket_path, .EnvGet, "", .EnvData) catch |err| {
1035+ const key = if (single_kv.len > 0) single_kv else null;
1036+ const payload = zmx.printEnv(alloc, io, session_name, key) catch |err| {
13221037 printEnvError(io, session_name, err);
13231038 };
13241039 defer alloc.free(payload);
......@@ -1326,10 +1041,7 @@ fn envGet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const
13261041 var buf: [4096]u8 = undefined;
13271042 var stdout = std.Io.File.stdout().writer(io, &buf);
13281043 if (single_kv.len > 0) {
1329- const val = getEnvValue(single_kv, payload) catch |err| {
1330- printEnvError(io, session_name, err);
1331- };
1332- try stdout.interface.print("{s}\n", .{val});
1044+ try stdout.interface.print("{s}\n", .{payload});
13331045 try stdout.interface.flush();
13341046 return;
13351047 }
......@@ -1360,201 +1072,42 @@ fn envGet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const
13601072 }
13611073
13621074 fn labelSet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, labels: []const u8) !void {
1075+ _ = cfg;
13631076 std.log.info("label set session={s}", .{session_name});
13641077
13651078 assertLabels(io, labels);
1366-
1367- const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1368- error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1369- error.OutOfMemory => return err,
1370- };
1371- defer alloc.free(socket_path);
1372-
1373- _ = ipc.roundTripForTag(alloc, socket_path, .LabelSet, labels, .Ack) catch |err| {
1079+ zmx.set(alloc, io, session_name, labels) catch |err| {
13741080 printLabelError(io, session_name, err);
13751081 };
13761082 }
13771083
13781084 fn labelClear(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8) !void {
1085+ _ = cfg;
13791086 std.log.info("label clear session={s}", .{session_name});
13801087
1381- const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1382- error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1383- error.OutOfMemory => return err,
1384- };
1385- defer alloc.free(socket_path);
1386-
1387- _ = ipc.roundTripForTag(alloc, socket_path, .LabelClear, "", .Ack) catch |err| {
1088+ zmx.clear(alloc, io, session_name) catch |err| {
13881089 printLabelError(io, session_name, err);
13891090 };
13901091 }
13911092
1392-/// Fetch terminal history from a session socket, returning it as an allocated
1393-/// string. Caller owns the returned memory and must free it.
1394-fn fetchHistory(
1395- alloc: std.mem.Allocator,
1396- io: std.Io,
1397- cfg: *Cfg,
1398- session_name: []const u8,
1399-) ![]const u8 {
1400- std.log.info("fetch history session={s}", .{session_name});
1401- const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1402- error.NameTooLong => return error.NameTooLong,
1403- error.OutOfMemory => return err,
1404- };
1405- defer alloc.free(socket_path);
1406-
1407- var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
1408- defer dir.close(io);
1409-
1410- const exists = try socket.sessionExists(io, dir, session_name);
1411- if (!exists) {
1412- return error.SessionNotFound;
1413- }
1414-
1415- const fd = ipc.connectSession(socket_path) catch |err| {
1416- if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, session_name);
1417- return err;
1418- };
1419- defer lib_posix.close(fd);
1420-
1421- const format_byte: u8 = @intFromEnum(util.HistoryFormat.plain);
1422- const payload = [_]u8{format_byte};
1423- ipc.send(fd, .History, &payload) catch |err| switch (err) {
1424- error.BrokenPipe, error.ConnectionResetByPeer => return error.SessionUnresponsive,
1425- else => return err,
1426- };
1427-
1428- var sb = try ipc.SocketBuffer.init(alloc);
1429- defer sb.deinit();
1430-
1431- var result = std.ArrayList(u8).initCapacity(alloc, 4096) catch return error.OutOfMemory;
1432- errdefer result.deinit(alloc);
1433-
1434- while (true) {
1435- var poll_fds = [_]lib_posix.pollfd{.{ .fd = fd, .events = lib_posix.POLL.IN, .revents = 0 }};
1436- const poll_result = lib_posix.poll(&poll_fds, 5000) catch return error.Timeout;
1437- if (poll_result == 0) {
1438- return error.Timeout;
1439- }
1440-
1441- const n = sb.read(fd) catch return error.ReadFailed;
1442- if (n == 0) break;
1443-
1444- while (sb.next()) |msg| {
1445- if (msg.header.tag == .History) {
1446- try result.appendSlice(alloc, msg.payload);
1447- return result.toOwnedSlice(alloc);
1448- }
1449- }
1450- }
1451-
1452- return error.NoHistoryResponse;
1453-}
1454-
14551093 fn history(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat, scrollback_rows: ?u32) !void {
1094+ _ = cfg;
14561095 std.log.info("history session={s}", .{session_name});
14571096
1458- const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1459- error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1460- error.OutOfMemory => return err,
1461- };
1462- defer alloc.free(socket_path);
1463-
1464- var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
1465- defer dir.close(io);
1466-
1467- const exists = try socket.sessionExists(io, dir, session_name);
1468- if (!exists) {
1469- return printError(io, "session \"{s}\" does not exist", .{session_name});
1470- }
1471- const fd = ipc.connectSession(socket_path) catch |err| {
1472- std.log.err("session unresponsive: {s}", .{@errorName(err)});
1473- if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, session_name);
1474- return printError(io, "session \"{s}\" is unresponsive ({s})", .{ session_name, @errorName(err) });
1475- };
1476- defer lib_posix.close(fd);
1477-
1478- const reply_tag: ipc.Tag = if (scrollback_rows != null) .Capture else .History;
1479- if (scrollback_rows) |rows| {
1480- // zeroes() so asBytes() doesn't ship packed struct padding.
1481- var request = std.mem.zeroes(ipc.Capture);
1482- request.format = @intFromEnum(format);
1483- request.rows = rows;
1484- ipc.send(fd, .Capture, std.mem.asBytes(&request)) catch |err| switch (err) {
1485- error.BrokenPipe, error.ConnectionResetByPeer => return,
1486- else => return err,
1487- };
1488- // Requests are handled in connection order. Info is a compatibility
1489- // barrier: old daemons ignore Capture but still answer Info.
1490- ipc.send(fd, .Info, "") catch |err| switch (err) {
1491- error.BrokenPipe, error.ConnectionResetByPeer => return,
1492- else => return err,
1493- };
1494- } else {
1495- const format_byte = [_]u8{@intFromEnum(format)};
1496- ipc.send(fd, .History, &format_byte) catch |err| switch (err) {
1497- error.BrokenPipe, error.ConnectionResetByPeer => return,
1498- else => return err,
1499- };
1500- }
1501-
1502- var sb = try ipc.SocketBuffer.init(alloc);
1503- defer sb.deinit();
1504-
1505- while (true) {
1506- var poll_fds = [_]lib_posix.pollfd{.{ .fd = fd, .events = lib_posix.POLL.IN, .revents = 0 }};
1507- const poll_result = lib_posix.poll(&poll_fds, 5000) catch return;
1508- if (poll_result == 0) {
1097+ const data = zmx.historyRange(alloc, io, session_name, format, scrollback_rows) catch |err| switch (err) {
1098+ error.SessionNotFound => return printError(io, "session \"{s}\" does not exist", .{session_name}),
1099+ error.SessionUnresponsive => return printError(io, "session \"{s}\" is unresponsive (SessionUnresponsive)", .{session_name}),
1100+ error.CaptureNotSupported => return printError(io, "session \"{s}\" does not support --screen/--scrollback (daemon too old?)", .{session_name}),
1101+ error.Timeout => {
15091102 std.log.err("timeout waiting for history response", .{});
15101103 return;
1511- }
1512-
1513- const n = sb.read(fd) catch return;
1514- if (n == 0) return;
1515-
1516- while (sb.next()) |msg| {
1517- if (msg.header.tag == reply_tag) {
1518- _ = lib_posix.write(lib_posix.STDOUT_FILENO, msg.payload) catch return;
1519- return;
1520- }
1521- if (scrollback_rows != null and msg.header.tag == .Info) {
1522- return printError(io, "session \"{s}\" does not support --screen/--scrollback (daemon too old?)", .{session_name});
1523- }
1524- }
1525- }
1526-}
1527-
1528-fn switchSesh(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, current_sesh: []const u8) !void {
1529- // we want daemon.session_name because that's the session name the user provided during zmx attach
1530- // instead of the name of the session they are currently inside of.
1531- const next_session = daemon.session_name;
1532- std.log.info("switch session cur={s} next={s}", .{ current_sesh, next_session });
1533-
1534- const socket_path = socket.getSocketPath(gpa, daemon.cfg.socket_dir, current_sesh) catch |err| switch (err) {
1535- error.NameTooLong => return socket.printSessionNameTooLong(io, current_sesh, daemon.cfg.socket_dir),
1536- error.OutOfMemory => return err,
1537- };
1538- defer gpa.free(socket_path);
1539-
1540- var dir = try std.Io.Dir.openDirAbsolute(io, daemon.cfg.socket_dir, .{});
1541- defer dir.close(io);
1542-
1543- const exists = try socket.sessionExists(io, dir, current_sesh);
1544- if (!exists) {
1545- return printError(io, "session \"{s}\" does not exist", .{current_sesh});
1546- }
1547- const fd = ipc.connectSession(socket_path) catch |err| {
1548- std.log.err("session unresponsive: {s}", .{@errorName(err)});
1549- if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, current_sesh);
1550- return printError(io, "session \"{s}\" is unresponsive ({s})", .{ current_sesh, @errorName(err) });
1551- };
1552- defer lib_posix.close(fd);
1553-
1554- ipc.send(fd, .Switch, next_session) catch |err| switch (err) {
1555- error.BrokenPipe, error.ConnectionResetByPeer => return,
1104+ },
1105+ error.ConnectionClosed, error.ReadFailed => return,
15561106 else => return err,
15571107 };
1108+ defer alloc.free(data);
1109+
1110+ _ = lib_posix.write(lib_posix.STDOUT_FILENO, data) catch return;
15581111 }
15591112
15601113 const AttachArgs = struct {
......@@ -1605,115 +1158,8 @@ fn parseAttachArgs(argv: []const []const u8) AttachArgs {
16051158 return parsed;
16061159 }
16071160
1608-fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, env_str: []const u8, labels: ?[]const u8) !void {
1609- const sesh = socket.getSeshNameFromEnv();
1610- if (sesh.len > 0) {
1611- return switchSesh(gpa, io, daemon, sesh);
1612- }
1613-
1614- const is_daemon_proc = try daemon.ensureSession(io);
1615- if (is_daemon_proc) return;
1616-
1617- // The session exists now, so labels land before the client takes over the
1618- // terminal. Doing it here rather than in a follow-up `zmx set` keeps a
1619- // supervisor from leaving an unlabelled session behind if it dies in
1620- // between the two calls.
1621- if (labels) |kvs| {
1622- try labelSet(gpa, io, daemon.cfg, daemon.session_name, kvs);
1623- }
1624-
1625- const client_sock = socket.sessionConnect(daemon.socket_path) catch |err| {
1626- return printError(io, "cannot connect to session \"{s}\": {s}", .{ daemon.session_name, @errorName(err) });
1627- };
1628- std.log.info("attached session={s}", .{daemon.session_name});
1629- // This is typically used with tcsetattr() to modify terminal settings.
1630- // - you first get the current settings with tcgetattr()
1631- // - modify the desired attributes in the termios structure
1632- // - then apply the changes with tcsetattr().
1633- // This prevents unintended side effects by preserving other settings.
1634- // restore stdin fd to its original state after exiting.
1635- // Use TCSAFLUSH to discard any unread input, preventing stale input after detach.
1636- //
1637- // tcgetattr fails when stdin is not a TTY (e.g. piped). In that case,
1638- // skip terminal setup entirely rather than applying undefined stack bytes
1639- // via tcsetattr.
1640- var orig_termios: cross.c.termios = undefined;
1641- const stdin_is_tty = cross.c.tcgetattr(lib_posix.STDIN_FILENO, &orig_termios) == 0;
1642-
1643- // RIS, OSC 10/11/12
1644- const restore_seq = "\x1bc\x1b]110\x1b\\\x1b]111\x1b\\\x1b]112\x1b\\";
1645-
1646- defer {
1647- if (stdin_is_tty) {
1648- _ = cross.c.tcsetattr(lib_posix.STDIN_FILENO, cross.c.TCSAFLUSH, &orig_termios);
1649- }
1650- // Reset terminal modes on detach
1651- _ = lib_posix.write(lib_posix.STDOUT_FILENO, restore_seq) catch {};
1652- }
1653-
1654- if (stdin_is_tty) {
1655- var raw_termios = orig_termios;
1656- // set raw mode after successful connection.
1657- // disables canonical mode (line buffering), input echoing, signal generation from
1658- // control characters (like Ctrl+C), and flow control.
1659- cross.c.cfmakeraw(&raw_termios);
1660-
1661- // Additional granular raw mode settings for precise control
1662- // (matches what abduco and shpool do)
1663- raw_termios.c_cc[cross.c.VLNEXT] = cross.c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V)
1664- // We want to intercept Ctrl+\ (SIGQUIT) so we can use it as a detach key
1665- raw_termios.c_cc[cross.c.VQUIT] = cross.c._POSIX_VDISABLE; // Disable SIGQUIT (Ctrl+\)
1666- raw_termios.c_cc[cross.c.VMIN] = 1; // Minimum chars to read: return after 1 byte
1667- raw_termios.c_cc[cross.c.VTIME] = 0; // Read timeout: no timeout, return immediately
1668-
1669- _ = cross.c.tcsetattr(lib_posix.STDIN_FILENO, cross.c.TCSANOW, &raw_termios);
1670- }
1671-
1672- // Clear screen before attaching. This provides a clean slate before
1673- // the session restore.
1674- const clear_seq = "\x1b[2J\x1b[H";
1675- _ = try lib_posix.write(lib_posix.STDOUT_FILENO, clear_seq);
1676-
1677- const looper = try loop.clientLoop(client_sock, env_str);
1678- switch (looper.kind) {
1679- .detach => return,
1680- .switch_session => {
1681- if (looper.session_name) |session_name| {
1682- // Reset terminal modes when switching sessions
1683- _ = lib_posix.write(lib_posix.STDOUT_FILENO, restore_seq) catch {};
1684-
1685- const target_path = socket.getSocketPath(
1686- gpa,
1687- daemon.cfg.socket_dir,
1688- session_name,
1689- ) catch |err| switch (err) {
1690- error.NameTooLong => return socket.printSessionNameTooLong(
1691- io,
1692- session_name,
1693- daemon.cfg.socket_dir,
1694- ),
1695- error.OutOfMemory => return err,
1696- };
1697-
1698- var target_daemon = Daemon.init(io, daemon.cfg, session_name, target_path);
1699- // Use the cwd from the previous daemon if available (sent by the daemon),
1700- // otherwise fall back to the client's original cwd
1701- const switch_cwd = looper.cwd orelse daemon.cwd;
1702- std.log.info("switching to new session cwd={s}", .{switch_cwd});
1703- target_daemon.setCwd(switch_cwd);
1704- target_daemon.shell = daemon.shell;
1705- return attach(gpa, io, &target_daemon, env_str, null);
1706- }
1707- },
1708- }
1709-}
1710-
1711-fn writeFile(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, file_path: []const u8) !void {
1712- const is_daemon_proc = try daemon.ensureSession(io);
1713- if (is_daemon_proc) return;
1714-
1715- var buf: [4096]u8 = undefined;
1716- var w = std.Io.File.stdout().writer(io, &buf);
1161+fn writeFile(gpa: std.mem.Allocator, io: std.Io, session_name: []const u8, file_path: []const u8, cwd: []const u8, shell: ?[]const u8) !void {
1162+ _ = try zmx.create(gpa, io, session_name, .{ .cwd = cwd, .shell = shell });
17171163
17181164 const stdin_fd = lib_posix.STDIN_FILENO;
17191165 var stdin_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
......@@ -1729,73 +1175,24 @@ fn writeFile(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, file_path: []c
17291175 try stdin_buf.appendSlice(gpa, tmp[0..n]);
17301176 }
17311177
1732- const socket_path = socket.getSocketPath(
1733- gpa,
1734- daemon.cfg.socket_dir,
1735- daemon.session_name,
1736- ) catch |err| switch (err) {
1737- error.NameTooLong => return socket.printSessionNameTooLong(
1738- io,
1739- daemon.session_name,
1740- daemon.cfg.socket_dir,
1741- ),
1742- error.OutOfMemory => return err,
1743- };
1744- var dir = try std.Io.Dir.openDirAbsolute(io, daemon.cfg.socket_dir, .{});
1745- defer dir.close(io);
1746-
1747- const result = ipc.probeSession(gpa, socket_path) catch |err| {
1748- var errbuf: [4096]u8 = undefined;
1749- var ew = std.Io.File.stderr().writer(io, &errbuf);
1750- if (err == error.ConnectionRefused) {
1751- socket.cleanupStaleSocket(io, dir, daemon.session_name);
1752- ew.interface.print("cleaned up stale session {s}\n", .{daemon.session_name}) catch {};
1753- } else {
1754- ew.interface.print(
1755- "session {s} is unresponsive ({s})\ndaemon may be busy: try again\n",
1756- .{ daemon.session_name, @errorName(err) },
1757- ) catch {};
1178+ zmx.write(gpa, io, session_name, file_path, stdin_buf.items) catch |err| {
1179+ if (err == error.SessionUnresponsive) {
1180+ var errbuf: [4096]u8 = undefined;
1181+ var ew = std.Io.File.stderr().writer(io, &errbuf);
1182+ ew.interface.print("session {s} is unresponsive\ndaemon may be busy: try again\n", .{session_name}) catch {};
1183+ ew.interface.flush() catch {};
1184+ return error.SessionUnresponsive;
17581185 }
1759- ew.interface.flush() catch {};
1760- return error.SessionUnresponsive;
1761- };
1762-
1763- defer result.deinit();
1764-
1765- // Build wire payload: [u32 path len][path bytes][file content]
1766- var wire_buf = try std.ArrayList(u8).initCapacity(
1767- gpa,
1768- @sizeOf(u32) + file_path.len + stdin_buf.items.len,
1769- );
1770- defer wire_buf.deinit(gpa);
1771- const path_len: u32 = @intCast(file_path.len);
1772- try wire_buf.appendSlice(gpa, std.mem.asBytes(&path_len));
1773- try wire_buf.appendSlice(gpa, file_path);
1774- try wire_buf.appendSlice(gpa, stdin_buf.items);
1775-
1776- ipc.send(result.fd, .Write, wire_buf.items) catch |err| switch (err) {
1777- error.BrokenPipe, error.ConnectionResetByPeer => return,
1778- else => return err,
1186+ return err;
17791187 };
17801188
1781- var sb = try ipc.SocketBuffer.init(gpa);
1782- defer sb.deinit();
1783-
1784- const n = sb.read(result.fd) catch return error.ReadFailed;
1785- if (n == 0) return error.ConnectionClosed;
1786-
1787- while (sb.next()) |msg| {
1788- if (msg.header.tag == .Ack) {
1789- try w.interface.print("file created {s}\n", .{file_path});
1790- try w.interface.flush();
1791- return;
1792- }
1793- }
1794-
1795- return error.NoAckReceived;
1189+ var buf: [4096]u8 = undefined;
1190+ var w = std.Io.File.stdout().writer(io, &buf);
1191+ try w.interface.print("file created {s}\n", .{file_path});
1192+ try w.interface.flush();
17961193 }
17971194
1798-fn send(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8, tag: ipc.Tag) !void {
1195+fn send(alloc: std.mem.Allocator, io: std.Io, session_name: []const u8, text_parts: [][]const u8, tag: ipc.Tag) !void {
17991196 std.log.info("send session={s}", .{session_name});
18001197
18011198 var payload = std.ArrayList(u8).empty;
......@@ -1832,119 +1229,21 @@ fn send(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u
18321229 return printError(io, "text argument required (or pipe input via stdin)", .{});
18331230 }
18341231
1835- var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
1836- defer dir.close(io);
1837-
1838- const probe_result = ipc.probeSession(alloc, socket_path) catch |err| {
1839- std.log.err("session unresponsive: {s}", .{@errorName(err)});
1840- var errbuf: [4096]u8 = undefined;
1841- var ew = std.Io.File.stderr().writer(io, &errbuf);
1842- if (err == error.ConnectionRefused) {
1843- socket.cleanupStaleSocket(io, dir, session_name);
1844- ew.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
1845- } else {
1846- ew.interface.print(
1847- "session {s} is unresponsive ({s})\ndaemon may be busy: try again\n",
1848- .{ session_name, @errorName(err) },
1849- ) catch {};
1850- }
1851- ew.interface.flush() catch {};
1852- return error.SessionUnresponsive;
1853- };
1854- defer probe_result.deinit();
1855-
1856- ipc.send(probe_result.fd, tag, payload.items) catch |err| switch (err) {
1857- error.ConnectionResetByPeer, error.BrokenPipe => return,
1858- else => return err,
1232+ const res = switch (tag) {
1233+ .Send => zmx.send(alloc, io, session_name, payload.items),
1234+ .Output => zmx.print(alloc, io, session_name, payload.items),
1235+ else => unreachable,
18591236 };
1860-}
1861-
1862-fn run(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
1863- var cmd_to_send: ?[]const u8 = null;
1864- var allocated_cmd: ?[]u8 = null;
1865- defer if (allocated_cmd) |cmd| gpa.free(cmd);
1866-
1867- const is_daemon_proc = try daemon.ensureSession(io);
1868- if (is_daemon_proc) return;
1869-
1870- if (command_args.len > 0) {
1871- var cmd_list = std.ArrayList(u8).empty;
1872- defer cmd_list.deinit(gpa);
1873-
1874- for (command_args, 0..) |arg, i| {
1875- if (i > 0) try cmd_list.append(gpa, ' ');
1876- if (util.shellNeedsQuoting(arg)) {
1877- const quoted = try util.shellQuote(gpa, arg);
1878- defer gpa.free(quoted);
1879- try cmd_list.appendSlice(gpa, quoted);
1880- } else {
1881- try cmd_list.appendSlice(gpa, arg);
1882- }
1883- }
1884-
1885- // \r, not \n: once the shell is at the readline prompt the PTY is in
1886- // raw mode; readline's accept-line binds to CR. The first-ever run
1887- // works with \n only because it arrives during shell startup while
1888- // the line discipline is still canonical.
1889- try cmd_list.append(gpa, '\r');
1890-
1891- cmd_to_send = try cmd_list.toOwnedSlice(gpa);
1892- allocated_cmd = @constCast(cmd_to_send.?);
1893- } else {
1894- // Read from stdin when no text arguments provided.
1895- const stdin_file = std.Io.File.stdin();
1896- defer stdin_file.close(io);
1897- var stdin_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
1898- defer stdin_buf.deinit(gpa);
1899- var stdbuf: [4096]u8 = undefined;
1900- var reader = stdin_file.reader(io, &stdbuf);
1901- if (!try stdin_file.isTty(io)) {
1902- while (true) {
1903- var dest: [1024]u8 = undefined;
1904- const n = try reader.interface.readSliceShort(&dest);
1905- if (n == 0) break; // EOF
1906- try stdin_buf.appendSlice(gpa, dest[0..n]);
1907- }
1908-
1909- if (stdin_buf.items.len > 0) {
1910- // Normalize any trailing newline to CR so readline (raw mode)
1911- // accepts each line.
1912- if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') {
1913- stdin_buf.items[stdin_buf.items.len - 1] = '\r';
1914- } else {
1915- try stdin_buf.append(gpa, '\r');
1916- }
1917-
1918- cmd_to_send = try gpa.dupe(u8, stdin_buf.items);
1919- allocated_cmd = @constCast(cmd_to_send.?);
1920- }
1237+ res catch |err| {
1238+ if (err == error.SessionUnresponsive) {
1239+ var errbuf: [4096]u8 = undefined;
1240+ var ew = std.Io.File.stderr().writer(io, &errbuf);
1241+ ew.interface.print("session {s} is unresponsive\ndaemon may be busy: try again\n", .{session_name}) catch {};
1242+ ew.interface.flush() catch {};
1243+ return error.SessionUnresponsive;
19211244 }
1922- }
1923-
1924- if (cmd_to_send == null) {
1925- return printError(io, "run requires a command (or pipe input via stdin)", .{});
1926- }
1927-
1928- const client_sock = ipc.connectSession(daemon.socket_path) catch |err| {
1929- std.log.err("session not ready: {s}", .{@errorName(err)});
1930- return printError(io, "session not ready: {s}", .{@errorName(err)});
1931- };
1932- defer lib_posix.close(client_sock);
1933-
1934- const term_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
1935- ipc.send(client_sock, .Resize, std.mem.asBytes(&term_size)) catch {};
1936-
1937- var fds = try std.ArrayList(i32).initCapacity(gpa, 1);
1938- defer fds.deinit(gpa);
1939- try fds.append(gpa, client_sock);
1940-
1941- ipc.send(client_sock, .Run, cmd_to_send.?) catch |err| switch (err) {
1942- error.ConnectionResetByPeer, error.BrokenPipe => return,
1943- else => return err,
1245+ return err;
19441246 };
1945-
1946- const exit_code = try tail(gpa, fds, detached, true);
1947- lib_posix.exit(exit_code);
19481247 }
19491248
19501249 test "parseAttachArgs reads a bare session name" {
+986, -0
......@@ -0,0 +1,986 @@
1+const std = @import("std");
2+const ghostty_vt = @import("ghostty-vt");
3+const util = @import("util.zig");
4+const testing = std.testing;
5+
6+pub const HistoryFormat = util.HistoryFormat;
7+
8+/// True when a CSI carries a private marker (`?`, `>`, `=`, `<`). Keyboard
9+/// encodings never use one, but the replies a terminal sends on its own do:
10+/// kitty keyboard flags (`CSI ? flags u`), DA1 (`CSI ? 62;22 c`), DA2
11+/// (`CSI > 1;10;0 c`), DECRPM (`CSI ? 2026;2 $ y`), color scheme (`CSI ? 997;1 n`).
12+fn hasPrivateMarker(csi: ghostty_vt.Parser.Action.CSI) bool {
13+ for (csi.intermediates) |b| {
14+ if (b == '?' or b == '>' or b == '=' or b == '<') return true;
15+ }
16+ return false;
17+}
18+
19+/// Detect if the payload contains user input that should be printed to the screen or
20+/// is a key combination like up-arrow, backspace, enter, ctrl+f, etc.
21+///
22+/// Terminal-generated replies must not count: every attached client's terminal
23+/// answers the queries the app broadcasts, so a reply from a passive viewer
24+/// would otherwise make it leader and resize the session to its window.
25+pub fn isUserInput(payload: []const u8) bool {
26+ var parser = ghostty_vt.Parser.init();
27+ var i: usize = 0;
28+ while (i < payload.len) {
29+ if (payload[i] == 0x1b and i + 2 < payload.len and payload[i + 1] == '[') {
30+ if (util.parseKittyCsiU(payload[i + 2 ..])) |kitty| {
31+ if (kitty.event_type != 3) return true;
32+ i += 2 + kitty.consumed;
33+ continue;
34+ }
35+ }
36+
37+ const actions = parser.next(payload[i]);
38+ for (actions) |action_opt| {
39+ const action = action_opt orelse continue;
40+ switch (action) {
41+ .print => return true, // printable characters
42+ .csi_dispatch => |csi| {
43+ // A reply, not a key: skip it and keep scanning, since a
44+ // real keystroke can arrive in the same read.
45+ if (hasPrivateMarker(csi)) continue;
46+ // kitty keyboard: CSI ... u or CSI ... ~
47+ // legacy modified keys: CSI 27 ; ... ~
48+ // arrow/function keys with modifiers: CSI 1 ; <mod> A-D
49+ // CSI ? flags u is the terminal's reply to a kitty keyboard status
50+ // query, not a key press; skip it and keep scanning the payload
51+ const status_reply = csi.final == 'u' and csi.intermediates.len > 0 and csi.intermediates[0] == '?';
52+ if (!status_reply and (csi.final == 'u' or csi.final == '~')) return true;
53+ // modified arrow keys (e.g., Ctrl+F sends CSI 1;5C in legacy mode)
54+ if (csi.final >= 'A' and csi.final <= 'D' and csi.params.len > 1) return true;
55+ // mouse events: CSI M (basic) or CSI < (SGR extended) - EXCLUDE these
56+ // only intentional keyboard input should trigger leader switch
57+ if (csi.final == 'M' or csi.final == '<') return false;
58+ // focus events: CSI I (focus in) or CSI O (focus out) - EXCLUDE these
59+ // these are automatic terminal events, not user typing
60+ if (csi.final == 'I' or csi.final == 'O') return false;
61+ },
62+ .execute => |code| {
63+ // looking for CR, LF, tab, and backspace
64+ if (code == 0x0D or code == 0x0A or code == 0x09 or code == 0x08) return true;
65+ },
66+ else => {},
67+ }
68+ }
69+ i += 1;
70+ }
71+ return false;
72+}
73+
74+/// Emit the terminal's pwd as OSC 7.
75+///
76+/// This replaces the formatter's own `extra.pwd`, which writes
77+/// `terminal.pwd.items` verbatim. `Terminal.setPwd` appends a NUL sentinel to
78+/// that buffer, so the formatter's OSC 7 carries a stray `\x00` before the
79+/// terminator. `getPwd()` returns the same bytes without the sentinel.
80+///
81+/// The NUL is not cosmetic: a client that records what it receives (kitty
82+/// writing its session file, for one) persists the NUL and then fails to parse
83+/// its own state back. See https://github.com/neurosnap/zmx/issues/222.
84+fn writePwd(writer: *std.Io.Writer, term: *const ghostty_vt.Terminal) void {
85+ const pwd = term.getPwd() orelse return;
86+ if (pwd.len == 0) return;
87+ writer.print("\x1b]7;{s}\x1b\\", .{pwd}) catch |err| {
88+ std.log.warn("failed to format pwd err={s}", .{@errorName(err)});
89+ };
90+}
91+
92+fn writeDynamicColor(
93+ writer: *std.Io.Writer,
94+ kind: ghostty_vt.color.Dynamic,
95+ dynamic: ghostty_vt.color.DynamicRGB,
96+) void {
97+ const c = dynamic.override orelse return;
98+ writer.print("\x1b]{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", .{
99+ @intFromEnum(kind), c.r, c.g, c.b,
100+ }) catch |err| {
101+ std.log.warn("failed to format dynamic color err={s}", .{@errorName(err)});
102+ };
103+}
104+
105+fn writeColorOverrides(writer: *std.Io.Writer, term: *const ghostty_vt.Terminal) void {
106+ const colors = &term.colors;
107+ writeDynamicColor(writer, .foreground, colors.foreground);
108+ writeDynamicColor(writer, .background, colors.background);
109+ writeDynamicColor(writer, .cursor, colors.cursor);
110+}
111+
112+pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
113+ var builder: std.Io.Writer.Allocating = .init(alloc);
114+ defer builder.deinit();
115+
116+ // Synchronized output (DECSET 2026) is a transient rendering handshake
117+ // between a program and its current terminal client. Replaying it to a
118+ // newly attached client can leave that client deferring renders until its
119+ // local timeout fires, so temporarily exclude it from restored state and
120+ // restore the original mode before returning.
121+ const had_synchronized_output = term.modes.get(.synchronized_output);
122+ if (had_synchronized_output) {
123+ term.modes.set(.synchronized_output, false);
124+ }
125+ defer if (had_synchronized_output) term.modes.set(.synchronized_output, true);
126+
127+ // If state contains color override, restore it.
128+ writeColorOverrides(&builder.writer, term);
129+
130+ const pages = &term.screens.active.pages;
131+ const screen_top = pages.getTopLeft(.screen);
132+ const active_top = pages.getTopLeft(.active);
133+ const has_scrollback = !screen_top.eql(active_top);
134+
135+ // Two-phase serialization to preserve scrollback without corrupting
136+ // cursor positions. This matters for nested zmx sessions (zmx→SSH→zmx)
137+ // where the outer daemon's ghostty-vt accumulates inner session scrollback.
138+ //
139+ // Phase 1: Emit scrollback content (plain text with styles, no terminal extras).
140+ // These lines scroll past the visible area into the terminal's scrollback buffer.
141+ // Phase 2: Clear visible screen, then emit visible content with full extras.
142+ // The clear ensures visible content starts from a clean slate regardless of
143+ // how much scrollback preceded it. CUP cursor positioning is then correct.
144+ //
145+ // See: https://github.com/neurosnap/zmx/issues/31
146+
147+ // Phase 1: scrollback only (if any exists)
148+ if (has_scrollback) {
149+ if (active_top.up(1)) |sb_bottom_row| {
150+ var sb_bottom = sb_bottom_row;
151+ sb_bottom.x = @intCast(pages.cols - 1);
152+
153+ var scroll_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
154+ scroll_fmt.content = .{
155+ .selection = ghostty_vt.Selection.init(
156+ screen_top,
157+ sb_bottom,
158+ false,
159+ ),
160+ };
161+ scroll_fmt.extra = .none; // no modes, cursor, keyboard — just content
162+ scroll_fmt.format(&builder.writer) catch |err| {
163+ std.log.warn("failed to format scrollback err={s}", .{@errorName(err)});
164+ };
165+ }
166+
167+ // The formatter leaves its final history rows in the viewport. Move
168+ // every one into scrollback before clearing that viewport, including
169+ // when the entire history is shorter than the terminal height.
170+ for (0..pages.rows) |_| builder.writer.writeAll("\r\n") catch |err| {
171+ std.log.warn("failed to preserve scrollback err={s}", .{@errorName(err)});
172+ return null;
173+ };
174+
175+ // Clear visible screen after scrollback. \x1b[2J clears only the visible
176+ // rows (not the scrollback buffer). \x1b[H homes the cursor. \x1b[0m resets
177+ // SGR style so phase 1 styles don't bleed into phase 2.
178+ builder.writer.writeAll("\x1b[2J\x1b[H\x1b[0m") catch {};
179+ }
180+
181+ // Phase 2: visible screen with full extras (modes, cursor, keyboard, etc.)
182+ var vis_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
183+
184+ // Restrict content to the active viewport only
185+ const active_tl = pages.pin(.{ .active = .{ .x = 0, .y = 0 } });
186+ const active_br = pages.pin(.{
187+ .active = .{
188+ .x = @intCast(pages.cols - 1),
189+ .y = @intCast(pages.rows - 1),
190+ },
191+ });
192+
193+ if (active_tl != null and active_br != null) {
194+ vis_fmt.content = .{
195+ .selection = ghostty_vt.Selection.init(
196+ active_tl.?,
197+ active_br.?,
198+ false,
199+ ),
200+ };
201+ }
202+ // Fallback: if pins are somehow invalid, use null selection (all content)
203+
204+ vis_fmt.extra = .{
205+ .palette = false,
206+ .modes = true,
207+ .scrolling_region = true,
208+ .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
209+ .pwd = false, // emitted below without the sentinel the formatter includes
210+ .keyboard = true,
211+ .screen = .all,
212+ };
213+
214+ vis_fmt.format(&builder.writer) catch |err| {
215+ std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
216+ return null;
217+ };
218+
219+ writePwd(&builder.writer, term);
220+
221+ // The formatter has no title extra and never emits OSC 0/1/2, so the title
222+ // has to be replayed separately or an attaching client shows whatever its
223+ // terminal defaults to, usually the client process name. OSC 2 does not
224+ // move the cursor, so this is safe to append after the content.
225+ if (term.getTitle()) |title| {
226+ builder.writer.print("\x1b]2;{s}\x07", .{title}) catch |err| {
227+ std.log.warn("failed to format title err={s}", .{@errorName(err)});
228+ };
229+ }
230+
231+ const output = builder.writer.buffered();
232+ if (output.len == 0) return null;
233+
234+ return alloc.dupe(u8, output) catch |err| {
235+ std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)});
236+ return null;
237+ };
238+}
239+
240+pub fn serializeTerminal(
241+ alloc: std.mem.Allocator,
242+ term: *ghostty_vt.Terminal,
243+ format: HistoryFormat,
244+) ?[]const u8 {
245+ return serializeTerminalRange(alloc, term, format, null);
246+}
247+
248+/// Formats the active screen and at most `scrollback_rows` preceding physical
249+/// rows. Null preserves whole-history formatting. Alternate screens have no
250+/// scrollback, so their selection always stays inside the current grid.
251+pub fn serializeTerminalRange(
252+ alloc: std.mem.Allocator,
253+ term: *ghostty_vt.Terminal,
254+ format: HistoryFormat,
255+ scrollback_rows: ?u32,
256+) ?[]const u8 {
257+ var builder: std.Io.Writer.Allocating = .init(alloc);
258+ defer builder.deinit();
259+
260+ const opts: ghostty_vt.formatter.Options = switch (format) {
261+ .plain => .plain,
262+ .vt => .vt,
263+ .html => .html,
264+ };
265+ var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts);
266+ term_formatter.content = .{ .selection = null };
267+ if (scrollback_rows) |rows| {
268+ const pages = &term.screens.active.pages;
269+ const active_top = pages.getTopLeft(.active);
270+ const top = active_top.up(rows) orelse pages.getTopLeft(.screen);
271+ const bottom = pages.pin(.{ .active = .{
272+ .x = @intCast(pages.cols - 1),
273+ .y = @intCast(pages.rows - 1),
274+ } }) orelse return null;
275+ term_formatter.content = .{
276+ .selection = ghostty_vt.Selection.init(top, bottom, false),
277+ };
278+ }
279+ term_formatter.extra = switch (format) {
280+ .plain => .none,
281+ .vt => .{
282+ .palette = false,
283+ .modes = true,
284+ .scrolling_region = true,
285+ .tabstops = false,
286+ .pwd = false, // emitted below without the sentinel the formatter includes
287+ .keyboard = true,
288+ .screen = .all,
289+ },
290+ .html => .styles,
291+ };
292+
293+ term_formatter.format(&builder.writer) catch |err| {
294+ std.log.warn("failed to format terminal err={s}", .{@errorName(err)});
295+ return null;
296+ };
297+
298+ if (format == .vt) writePwd(&builder.writer, term);
299+
300+ const output = builder.writer.buffered();
301+ if (output.len == 0) return null;
302+
303+ return alloc.dupe(u8, output) catch |err| {
304+ std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)});
305+ return null;
306+ };
307+}
308+
309+test "serializeTerminalState excludes synchronized output replay" {
310+ const alloc = testing.allocator;
311+ const io = testing.io;
312+
313+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
314+ .cols = 80,
315+ .rows = 24,
316+ });
317+ defer term.deinit(alloc);
318+
319+ var stream = term.vtStream();
320+ defer stream.deinit();
321+
322+ stream.nextSlice("\x1b[?2004h"); // Bracketed paste
323+ stream.nextSlice("\x1b[?2026h"); // Synchronized output
324+ stream.nextSlice("hello");
325+
326+ try testing.expect(term.modes.get(.bracketed_paste));
327+ try testing.expect(term.modes.get(.synchronized_output));
328+
329+ const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
330+ defer alloc.free(output);
331+
332+ // The serialized output should contain bracketed paste (DECSET 2004)
333+ // but NOT synchronized output (DECSET 2026)
334+ try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2004h") != null);
335+ try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
336+}
337+
338+test "serializeTerminalState replays the title" {
339+ const alloc = testing.allocator;
340+ const io = testing.io;
341+
342+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
343+ .cols = 80,
344+ .rows = 24,
345+ });
346+ defer term.deinit(alloc);
347+
348+ var stream = term.vtStream();
349+ defer stream.deinit();
350+
351+ stream.nextSlice("\x1b]2;my title\x07");
352+ stream.nextSlice("hello");
353+
354+ const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
355+ defer alloc.free(output);
356+
357+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;my title\x07") != null);
358+}
359+
360+test "serializeTerminalState omits the title when none is set" {
361+ const alloc = testing.allocator;
362+ const io = testing.io;
363+
364+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
365+ .cols = 80,
366+ .rows = 24,
367+ });
368+ defer term.deinit(alloc);
369+
370+ var stream = term.vtStream();
371+ defer stream.deinit();
372+
373+ stream.nextSlice("hello");
374+
375+ const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
376+ defer alloc.free(output);
377+
378+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;") == null);
379+}
380+
381+test "serializeTerminalState replays the pwd without a NUL sentinel" {
382+ const alloc = testing.allocator;
383+ const io = testing.io;
384+
385+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
386+ .cols = 80,
387+ .rows = 24,
388+ });
389+ defer term.deinit(alloc);
390+
391+ var stream = term.vtStream();
392+ defer stream.deinit();
393+
394+ stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
395+ stream.nextSlice("hello");
396+
397+ const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
398+ defer alloc.free(output);
399+
400+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
401+ try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
402+}
403+
404+test "serializeTerminalState omits the pwd when none is set" {
405+ const alloc = testing.allocator;
406+ const io = testing.io;
407+
408+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
409+ .cols = 80,
410+ .rows = 24,
411+ });
412+ defer term.deinit(alloc);
413+
414+ var stream = term.vtStream();
415+ defer stream.deinit();
416+
417+ stream.nextSlice("hello");
418+
419+ const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
420+ defer alloc.free(output);
421+
422+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;") == null);
423+}
424+
425+test "serializeTerminal vt replays the pwd without a NUL sentinel" {
426+ const alloc = testing.allocator;
427+ const io = testing.io;
428+
429+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
430+ .cols = 80,
431+ .rows = 24,
432+ });
433+ defer term.deinit(alloc);
434+
435+ var stream = term.vtStream();
436+ defer stream.deinit();
437+
438+ stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
439+ stream.nextSlice("hello");
440+
441+ const output = serializeTerminal(alloc, &term, .vt) orelse return error.TestUnexpectedNull;
442+ defer alloc.free(output);
443+
444+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
445+ try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
446+}
447+
448+test "serializeTerminalRange screen only returns the active screen" {
449+ const alloc = testing.allocator;
450+ var term = try testCreateTerminal(alloc, testing.io, 80, 3, "1\r\n2\r\n3\r\n4\r\n5");
451+ defer term.deinit(alloc);
452+
453+ const output = serializeTerminalRange(alloc, &term, .plain, 0) orelse return error.TestUnexpectedNull;
454+ defer alloc.free(output);
455+ try testing.expectEqualStrings("3\n4\n5", output);
456+}
457+
458+test "serializeTerminalRange adds the requested scrollback rows" {
459+ const alloc = testing.allocator;
460+ var term = try testCreateTerminal(alloc, testing.io, 80, 3, "1\r\n2\r\n3\r\n4\r\n5");
461+ defer term.deinit(alloc);
462+
463+ const output = serializeTerminalRange(alloc, &term, .plain, 1) orelse return error.TestUnexpectedNull;
464+ defer alloc.free(output);
465+ try testing.expectEqualStrings("2\n3\n4\n5", output);
466+
467+ const all = serializeTerminalRange(alloc, &term, .plain, 100) orelse return error.TestUnexpectedNull;
468+ defer alloc.free(all);
469+ const full = serializeTerminal(alloc, &term, .plain) orelse return error.TestUnexpectedNull;
470+ defer alloc.free(full);
471+ try testing.expectEqualStrings(full, all);
472+}
473+
474+test "serializeTerminalRange alternate screen returns only its grid" {
475+ const alloc = testing.allocator;
476+ var term = try testCreateTerminal(alloc, testing.io, 80, 3, "1\r\n2\r\n3\r\n4\r\n5\x1b[?1049h\x1b[Halt");
477+ defer term.deinit(alloc);
478+
479+ const output = serializeTerminalRange(alloc, &term, .plain, 100) orelse return error.TestUnexpectedNull;
480+ defer alloc.free(output);
481+ try testing.expectEqualStrings("alt", output);
482+}
483+
484+fn testCreateTerminal(alloc: std.mem.Allocator, io: std.Io, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
485+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
486+ .cols = cols,
487+ .rows = rows,
488+ .max_scrollback_lines = 2_000,
489+ });
490+ if (vt_data.len > 0) {
491+ var stream = term.vtStream();
492+ defer stream.deinit();
493+ stream.nextSlice(vt_data);
494+ }
495+ return term;
496+}
497+
498+fn expectScreensMatch(alloc: std.mem.Allocator, expected: *ghostty_vt.Terminal, actual: *ghostty_vt.Terminal) !void {
499+ const exp_str = try expected.plainString(alloc);
500+ defer alloc.free(exp_str);
501+ const act_str = try actual.plainString(alloc);
502+ defer alloc.free(act_str);
503+ try testing.expectEqualStrings(exp_str, act_str);
504+}
505+
506+fn expectCursorAt(term: *ghostty_vt.Terminal, row: usize, col: usize) !void {
507+ const cursor = &term.screens.active.cursor;
508+ try testing.expectEqual(col, cursor.x);
509+ try testing.expectEqual(row, cursor.y);
510+}
511+
512+fn serializeRoundtrip(alloc: std.mem.Allocator, io: std.Io, source: *ghostty_vt.Terminal) !ghostty_vt.Terminal {
513+ const serialized = serializeTerminalState(alloc, source) orelse
514+ return error.SerializationFailed;
515+ defer alloc.free(serialized);
516+
517+ var dest = try ghostty_vt.Terminal.init(io, alloc, .{
518+ .cols = source.screens.active.pages.cols,
519+ .rows = source.screens.active.pages.rows,
520+ .max_scrollback_lines = 2_000,
521+ });
522+ var stream = dest.vtStream();
523+ defer stream.deinit();
524+ stream.nextSlice(serialized);
525+ return dest;
526+}
527+
528+fn expectMarkerAtRow(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, marker: []const u8, expected_row: usize) !void {
529+ const plain = try term.plainString(alloc);
530+ defer alloc.free(plain);
531+ var row: usize = 0;
532+ var iter = std.mem.splitScalar(u8, plain, '\n');
533+ while (iter.next()) |line| {
534+ if (std.mem.indexOf(u8, line, marker) != null) {
535+ try testing.expectEqual(expected_row, row);
536+ return;
537+ }
538+ row += 1;
539+ }
540+ std.debug.print("marker '{s}' not found in terminal output\n", .{marker});
541+ return error.TestExpectedEqual;
542+}
543+
544+test "serializeTerminalState roundtrip preserves cursor position" {
545+ const alloc = testing.allocator;
546+ const io = testing.io;
547+
548+ var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[2J" ++ // clear
549+ "\x1b[10;20H" // cursor at row 10, col 20 (1-indexed)
550+ );
551+ defer term.deinit(alloc);
552+
553+ try expectCursorAt(&term, 9, 19); // 0-indexed
554+
555+ var client = try serializeRoundtrip(alloc, io, &term);
556+ defer client.deinit(alloc);
557+
558+ try expectCursorAt(&client, 9, 19);
559+}
560+
561+test "serializeTerminalState roundtrip preserves CUP-positioned markers" {
562+ const alloc = testing.allocator;
563+ const io = testing.io;
564+
565+ var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[2J" ++
566+ "\x1b[2;5HMARK_A" ++
567+ "\x1b[6;15HMARK_B" ++
568+ "\x1b[10;30HMARK_C" ++
569+ "\x1b[14;50HMARK_D" ++
570+ "\x1b[16;20H");
571+ defer term.deinit(alloc);
572+
573+ var client = try serializeRoundtrip(alloc, io, &term);
574+ defer client.deinit(alloc);
575+
576+ try expectScreensMatch(alloc, &term, &client);
577+ try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
578+ try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
579+ try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
580+ try expectMarkerAtRow(alloc, &client, "MARK_D", 13);
581+ try expectCursorAt(&client, 15, 19);
582+}
583+
584+test "serializeTerminalState preserves all scrollback rows before clearing viewport" {
585+ const alloc = testing.allocator;
586+ const io = testing.io;
587+ for ([_]u16{ 1, 3, 24 }) |rows| {
588+ for ([_]usize{ 1, 2, 23, 24, 25, 80 }) |lines| {
589+ var term = try testCreateTerminal(alloc, io, 40, rows, "");
590+ defer term.deinit(alloc);
591+ {
592+ var stream = term.vtStream();
593+ defer stream.deinit();
594+ var buf: [32]u8 = undefined;
595+ for (0..lines) |index| {
596+ stream.nextSlice(try std.fmt.bufPrint(&buf, "ROW_{d}\r\n", .{index}));
597+ }
598+ stream.nextSlice("\x1b[2J\x1b[Hvisible");
599+ }
600+ var restored = try serializeRoundtrip(alloc, io, &term);
601+ defer restored.deinit(alloc);
602+ const expected = try term.screens.active.dumpStringAlloc(alloc, .{ .screen = .{ .x = 0, .y = 0 } });
603+ defer alloc.free(expected);
604+ const actual = try restored.screens.active.dumpStringAlloc(alloc, .{ .screen = .{ .x = 0, .y = 0 } });
605+ defer alloc.free(actual);
606+ try testing.expectEqualStrings(expected, actual);
607+ try expectCursorAt(&restored, term.screens.active.cursor.y, term.screens.active.cursor.x);
608+ }
609+ }
610+}
611+
612+test "serializeTerminalState allocation failure preserves synchronized output mode" {
613+ const alloc = testing.allocator;
614+ var term = try testCreateTerminal(alloc, testing.io, 40, 3, "one\r\ntwo\r\nthree\r\nfour\r\n");
615+ defer term.deinit(alloc);
616+ term.modes.set(.synchronized_output, true);
617+ var failing = testing.FailingAllocator.init(alloc, .{ .fail_index = 0 });
618+ try testing.expect(serializeTerminalState(failing.allocator(), &term) == null);
619+ try testing.expect(term.modes.get(.synchronized_output));
620+}
621+
622+test "serializeTerminalState with scrollback preserves visible content" {
623+ const alloc = testing.allocator;
624+ const io = testing.io;
625+
626+ var term = try testCreateTerminal(alloc, io, 80, 24, "");
627+ defer term.deinit(alloc);
628+
629+ var stream = term.vtStream();
630+ defer stream.deinit();
631+
632+ // Generate 80 lines of scrollback (more than 24 visible rows)
633+ var buf: [32]u8 = undefined;
634+ for (0..80) |i| {
635+ const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
636+ stream.nextSlice(line);
637+ }
638+
639+ // Clear screen and place markers at specific positions
640+ stream.nextSlice("\x1b[2J" ++
641+ "\x1b[2;5HMARK_A" ++
642+ "\x1b[6;15HMARK_B" ++
643+ "\x1b[10;30HMARK_C" ++
644+ "\x1b[16;20H");
645+
646+ // Verify source terminal has scrollback
647+ const pages = &term.screens.active.pages;
648+ const has_scrollback = !pages.getTopLeft(.screen).eql(pages.getTopLeft(.active));
649+ try testing.expect(has_scrollback);
650+
651+ // Roundtrip: serialize → feed into fresh terminal
652+ var client = try serializeRoundtrip(alloc, io, &term);
653+ defer client.deinit(alloc);
654+
655+ // Visible content must match (this is the core cursor corruption test)
656+ try expectScreensMatch(alloc, &term, &client);
657+ try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
658+ try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
659+ try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
660+ try expectCursorAt(&client, 15, 19);
661+}
662+
663+test "serializeTerminalState nested roundtrip preserves content" {
664+ // Simulates: inner zmx → serialized state → outer ghostty-vt → serialized again → client
665+ // This is the exact nested session scenario (zmx → SSH → zmx).
666+ const alloc = testing.allocator;
667+ const io = testing.io;
668+
669+ // "Inner" terminal with scrollback + markers
670+ var inner = try testCreateTerminal(alloc, io, 80, 24, "");
671+ defer inner.deinit(alloc);
672+
673+ {
674+ var inner_stream = inner.vtStream();
675+ defer inner_stream.deinit();
676+ var buf: [32]u8 = undefined;
677+ for (0..60) |i| {
678+ const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
679+ inner_stream.nextSlice(line);
680+ }
681+ inner_stream.nextSlice("\x1b[2J" ++
682+ "\x1b[3;10HINNER_A" ++
683+ "\x1b[12;25HINNER_B" ++
684+ "\x1b[20;5H");
685+ }
686+
687+ // Record inner's ground truth
688+ const inner_cursor_x = inner.screens.active.cursor.x;
689+ const inner_cursor_y = inner.screens.active.cursor.y;
690+
691+ // Serialize inner (simulates inner daemon re-attach to inner client)
692+ const inner_serialized = serializeTerminalState(alloc, &inner) orelse
693+ return error.SerializationFailed;
694+ defer alloc.free(inner_serialized);
695+
696+ // "Outer" terminal processes inner's serialized output
697+ var outer = try testCreateTerminal(alloc, io, 80, 24, "");
698+ defer outer.deinit(alloc);
699+
700+ {
701+ var outer_stream = outer.vtStream();
702+ defer outer_stream.deinit();
703+ outer_stream.nextSlice(inner_serialized);
704+ }
705+
706+ // Serialize outer (simulates outer daemon re-attach after detach)
707+ var client = try serializeRoundtrip(alloc, io, &outer);
708+ defer client.deinit(alloc);
709+
710+ // Client must see the same content as inner's visible screen
711+ try expectScreensMatch(alloc, &inner, &client);
712+ try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
713+ try expectMarkerAtRow(alloc, &client, "INNER_A", 2);
714+ try expectMarkerAtRow(alloc, &client, "INNER_B", 11);
715+}
716+
717+test "serializeTerminalState alternate screen not leaked" {
718+ const alloc = testing.allocator;
719+ const io = testing.io;
720+
721+ var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[?1049h" ++ // enter alt screen
722+ "\x1b[2J\x1b[3;10HALT_MARK" ++ // write on alt screen
723+ "\x1b[?1049l" ++ // exit alt screen
724+ "\x1b[2J\x1b[2;5HMAIN_MARK\x1b[8;20H" // write on main screen
725+ );
726+ defer term.deinit(alloc);
727+
728+ var client = try serializeRoundtrip(alloc, io, &term);
729+ defer client.deinit(alloc);
730+
731+ try expectScreensMatch(alloc, &term, &client);
732+
733+ const plain = try client.plainString(alloc);
734+ defer alloc.free(plain);
735+ try testing.expect(std.mem.indexOf(u8, plain, "ALT_MARK") == null);
736+ try testing.expect(std.mem.indexOf(u8, plain, "MAIN_MARK") != null);
737+}
738+
739+test "serializeTerminalState size mismatch roundtrip" {
740+ const alloc = testing.allocator;
741+ const io = testing.io;
742+
743+ var term = try testCreateTerminal(alloc, io, 80, 30, "\x1b[2J" ++
744+ "\x1b[3;10HSIZE_A" ++
745+ "\x1b[12;20HSIZE_B" ++
746+ "\x1b[20;40HSIZE_C" ++
747+ "\x1b[15;15H");
748+ defer term.deinit(alloc);
749+
750+ // Resize to 24 rows (simulates outer terminal being smaller)
751+ try term.resize(alloc, ghostty_vt.Terminal.Resize{ .cols = 80, .rows = 24 });
752+
753+ var client = try serializeRoundtrip(alloc, io, &term);
754+ defer client.deinit(alloc);
755+
756+ try expectScreensMatch(alloc, &term, &client);
757+ try expectCursorAt(&client, term.screens.active.cursor.y, term.screens.active.cursor.x);
758+}
759+
760+test "serializeTerminalState scrollback + size mismatch nested roundtrip" {
761+ const alloc = testing.allocator;
762+ const io = testing.io;
763+
764+ var inner = try testCreateTerminal(alloc, io, 80, 30, "");
765+ defer inner.deinit(alloc);
766+
767+ {
768+ var inner_stream = inner.vtStream();
769+ defer inner_stream.deinit();
770+ var buf: [32]u8 = undefined;
771+ for (0..80) |i| {
772+ const line = std.fmt.bufPrint(&buf, "LINE_{d}\r\n", .{i}) catch unreachable;
773+ inner_stream.nextSlice(line);
774+ }
775+ inner_stream.nextSlice("\x1b[2J" ++
776+ "\x1b[3;10HSTRESS_A" ++
777+ "\x1b[12;25HSTRESS_B" ++
778+ "\x1b[16;20H");
779+ }
780+
781+ // Resize inner to 24 rows (outer terminal is smaller)
782+ try inner.resize(alloc, ghostty_vt.Terminal.Resize{ .cols = 80, .rows = 24 });
783+
784+ const inner_cursor_x = inner.screens.active.cursor.x;
785+ const inner_cursor_y = inner.screens.active.cursor.y;
786+
787+ // Inner serialize → outer processes → outer serialize → client
788+ const inner_ser = serializeTerminalState(alloc, &inner) orelse
789+ return error.SerializationFailed;
790+ defer alloc.free(inner_ser);
791+
792+ var outer = try testCreateTerminal(alloc, io, 80, 24, "");
793+ defer outer.deinit(alloc);
794+ {
795+ var outer_stream = outer.vtStream();
796+ defer outer_stream.deinit();
797+ outer_stream.nextSlice(inner_ser);
798+ }
799+
800+ var client = try serializeRoundtrip(alloc, io, &outer);
801+ defer client.deinit(alloc);
802+
803+ try expectScreensMatch(alloc, &inner, &client);
804+ try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
805+}
806+
807+test "isUserInput: printable characters" {
808+ // Regular text should be detected as user input
809+ try testing.expect(isUserInput("hello"));
810+ try testing.expect(isUserInput("Hello World!"));
811+ try testing.expect(isUserInput("12345"));
812+ try testing.expect(isUserInput("!@#$%^&*()"));
813+}
814+
815+test "isUserInput: whitespace characters" {
816+ // Space character is printable
817+ try testing.expect(isUserInput(" "));
818+ try testing.expect(isUserInput(" "));
819+}
820+
821+test "isUserInput: line feed (LF)" {
822+ // LF triggers .execute action
823+ try testing.expect(isUserInput("\n"));
824+ try testing.expect(isUserInput("test\n"));
825+}
826+
827+test "isUserInput: carriage return (CR)" {
828+ // CR triggers .execute action
829+ try testing.expect(isUserInput("\r"));
830+ try testing.expect(isUserInput("test\r"));
831+}
832+
833+test "isUserInput: tab" {
834+ // Tab triggers .execute action
835+ try testing.expect(isUserInput("\t"));
836+ try testing.expect(isUserInput("col1\tcol2"));
837+}
838+
839+test "isUserInput: backspace" {
840+ // Backspace triggers .execute action
841+ try testing.expect(isUserInput("\x08"));
842+ try testing.expect(isUserInput("test\x08"));
843+}
844+
845+test "isUserInput: arrow keys (CSI ~)" {
846+ // Arrow keys use CSI with ~ - these have params
847+ try testing.expect(isUserInput("\x1b[3~")); // delete
848+ try testing.expect(isUserInput("\x1b[5~")); // page up
849+ try testing.expect(isUserInput("\x1b[6~")); // page down
850+}
851+
852+test "isUserInput: modified arrow keys with CSI u" {
853+ // Modified arrow keys with CSI ... u
854+ try testing.expect(isUserInput("\x1bOA")); // up with modifier
855+ try testing.expect(isUserInput("\x1bOB")); // down with modifier
856+ try testing.expect(isUserInput("\x1bOC")); // right with modifier
857+ try testing.expect(isUserInput("\x1bOD")); // left with modifier
858+}
859+
860+test "isUserInput: up arrow legacy" {
861+ // Legacy up arrow: CSI A (with params for kitty-style)
862+ try testing.expect(isUserInput("\x1b[1;1A")); // kitty-style legacy
863+}
864+
865+test "isUserInput: up arrow kitty" {
866+ // Kitty keyboard up arrow: CSI 1;1;1A (no colon format supported by parser)
867+ try testing.expect(isUserInput("\x1b[1;1;1A")); // kitty up arrow
868+}
869+
870+test "isUserInput: arrow keys with modifier params CSI A-D" {
871+ // Modified arrow keys like Ctrl+Up: CSI 1;5A
872+ try testing.expect(isUserInput("\x1b[1;5A")); // Ctrl+Up
873+ try testing.expect(isUserInput("\x1b[1;5B")); // Ctrl+Down
874+ try testing.expect(isUserInput("\x1b[1;5C")); // Ctrl+Right
875+ try testing.expect(isUserInput("\x1b[1;5D")); // Ctrl+Left
876+ try testing.expect(isUserInput("\x1b[1;3A")); // Alt+Up
877+ try testing.expect(isUserInput("\x1b[1;3B")); // Alt+Down
878+}
879+
880+test "isUserInput: function keys with modifiers CSI 27 ; ~" {
881+ // Legacy modified keys: CSI 27 ; ... ~
882+ try testing.expect(isUserInput("\x1b[15;2~")); // F4 with modifier
883+ try testing.expect(isUserInput("\x1b[17;2~")); // F5 with modifier
884+ try testing.expect(isUserInput("\x1b[18;2~")); // F6 with modifier
885+}
886+
887+test "isUserInput: enter key" {
888+ // Enter is LF (0x0A)
889+ try testing.expect(isUserInput("\x0A"));
890+}
891+
892+test "isUserInput: mixed content" {
893+ // Mix of printable and control sequences
894+ try testing.expect(isUserInput("hello\nworld"));
895+ try testing.expect(isUserInput("\x1b[3~\x1b[6~")); // multiple CSI ~ sequences
896+ try testing.expect(isUserInput("abc\x1b[3~def")); // text with CSI ~
897+}
898+
899+test "isUserInput: non-user input (escape sequences only)" {
900+ // Cursor movement without user input
901+ try testing.expect(!isUserInput("\x1b[2;1H")); // CSI H cursor home
902+ // SGR color set (no printing)
903+ try testing.expect(!isUserInput("\x1b[0m"));
904+ // Cursor position report query
905+ try testing.expect(!isUserInput("\x1b[6n"));
906+}
907+
908+test "isUserInput: empty string" {
909+ try testing.expect(!isUserInput(""));
910+}
911+
912+test "isUserInput: only whitespace controls" {
913+ // Multiple control chars should return true
914+ try testing.expect(isUserInput("\n\r\t"));
915+}
916+
917+test "isUserInput: kitty keyboard sequences" {
918+ // Kitty keyboard protocol uses CSI u
919+ try testing.expect(isUserInput("\x1b[11;2u")); // F1 with modifier
920+ try testing.expect(isUserInput("\x1b[12;2u")); // F2 with modifier
921+ try testing.expect(isUserInput("\x1b[102;1:1u")); // literal "f" press
922+ try testing.expect(isUserInput("\x1b[57444;1:1u")); // Kitty functional key press
923+ try testing.expect(!isUserInput("\x1b[102;1:3u")); // literal "f" release only
924+ try testing.expect(isUserInput("\x1b[102;1:1u\x1b[67;65;31M")); // key press with mouse noise
925+}
926+
927+test "isUserInput: mouse events (CSI M) excluded" {
928+ // Basic mouse tracking (SGR disabled): CSI M Cb Cx Cy
929+ // Mouse events should NOT trigger leader switch
930+ try testing.expect(!isUserInput("\x1b[M@ 0 0")); // button 0, pos 0,0
931+ try testing.expect(!isUserInput("\x1b[M@ 1 1")); // button 1, pos 1,1
932+}
933+
934+test "isUserInput: mouse events SGR mode CSI < excluded" {
935+ // SGR extended mouse tracking: CSI < Cb;Cx;Y M
936+ // Mouse events should NOT trigger leader switch
937+ try testing.expect(!isUserInput("\x1b[<0;1;1M")); // button release
938+ try testing.expect(!isUserInput("\x1b[<64;1;1M")); // button press
939+}
940+
941+test "isUserInput: focus events excluded" {
942+ // Focus in/out are automatic terminal events, not user typing
943+ try testing.expect(!isUserInput("\x1b[I")); // focus in
944+ try testing.expect(!isUserInput("\x1b[O")); // focus out
945+}
946+
947+test "isUserInput: terminal query replies excluded" {
948+ // Replies every attached terminal sends to queries the app broadcasts.
949+ try testing.expect(!isUserInput("\x1b[?5u")); // kitty keyboard flags
950+ try testing.expect(!isUserInput("\x1b[?0u"));
951+ try testing.expect(!isUserInput("\x1b[?62;22;52c")); // DA1
952+ try testing.expect(!isUserInput("\x1b[>1;10;0c")); // DA2
953+ try testing.expect(!isUserInput("\x1b[?2026;2$y")); // DECRPM
954+ try testing.expect(!isUserInput("\x1b[?997;1n")); // color scheme
955+ try testing.expect(!isUserInput("\x1b[12;40R")); // cursor position
956+ try testing.expect(!isUserInput("\x1b[6;32;14t")); // cell size
957+ // XTVERSION + kitty flags + DA1 in one read, as a passive Ghostty
958+ // client sent it in the wild (it made that viewer the leader).
959+ try testing.expect(!isUserInput("\x1bP>|ghostty 1.3.2-main+64f5cc175\x1b\\\x1b[?5u\x1b[?62;22;52c"));
960+}
961+
962+test "isUserInput: key after a reply in the same read still counts" {
963+ try testing.expect(isUserInput("\x1b[?5ux"));
964+ try testing.expect(isUserInput("\x1b[?62;22c\x1b[99;5u")); // reply then kitty ctrl+c
965+ try testing.expect(isUserInput("\x1b[?5u\x1b[3~")); // reply then delete
966+}
967+
968+test "isUserInput: bracketed paste included" {
969+ // Bracketed paste start/end are user-initiated paste operations
970+ try testing.expect(isUserInput("\x1b[200~")); // paste start
971+ try testing.expect(isUserInput("\x1b[201~")); // paste end
972+ // Content between start/end is also user input
973+ try testing.expect(isUserInput("\x1b[200~hello\x1b[201~"));
974+}
975+
976+test "isUserInput: kitty keyboard status reply is a terminal response" {
977+ // CSI ? flags u answers the program's CSI ? u query; no key was pressed
978+ try testing.expect(!isUserInput("\x1b[?1u"));
979+ try testing.expect(!isUserInput("\x1b[?0u"));
980+ try testing.expect(!isUserInput("\x1b[?31u"));
981+ // a real key coalesced with the reply still counts
982+ try testing.expect(isUserInput("\x1b[?1ua"));
983+ try testing.expect(isUserInput("\x1b[?1u\r"));
984+ try testing.expect(isUserInput("\x1b[?1u\x1b[97;1u"));
985+ try testing.expect(!isUserInput("\x1b[?1u\x1b[97;1:3u"));
986+}
+3, -0
......@@ -6,6 +6,9 @@ comptime {
66 _ = @import("label.zig");
77 _ = @import("signal.zig");
88 _ = @import("loop.zig");
9+ _ = @import("terminal.zig");
910 _ = @import("cfg.zig");
1011 _ = @import("daemonize.zig");
12+ _ = @import("lib.zig");
13+ _ = @import("client.zig");
1114 }
+60, -968
......@@ -1,5 +1,11 @@
11 const std = @import("std");
2-const ghostty_vt = @import("ghostty-vt");
2+const build_options = @import("build_options");
3+const has_daemon = if (@hasDecl(build_options, "has_daemon")) build_options.has_daemon else true;
4+pub const terminal = if (has_daemon) @import("terminal.zig") else struct {};
5+pub const isUserInput = if (has_daemon) terminal.isUserInput else struct {};
6+pub const serializeTerminalState = if (has_daemon) terminal.serializeTerminalState else struct {};
7+pub const serializeTerminal = if (has_daemon) terminal.serializeTerminal else struct {};
8+pub const serializeTerminalRange = if (has_daemon) terminal.serializeTerminalRange else struct {};
39 const ipc = @import("ipc.zig");
410 const socket = @import("socket.zig");
511 const cross = @import("cross.zig");
......@@ -484,42 +490,66 @@ pub fn findTaskExitMarker(output: []const u8, id_marker: [4]u8) !?u8 {
484490 }
485491
486492 /// Strip ANSI escape sequences from data, returning only printable characters
487-/// and essential whitespace (CR, LF, tab, backspace). Uses the ghostty VT
488-/// parser to correctly handle multi-byte sequences (CSI, OSC, DCS, etc.).
493+/// and essential whitespace (CR, LF, tab, backspace).
489494 /// The returned slice is owned by the caller and must be freed.
490495 pub fn stripAnsi(alloc: std.mem.Allocator, data: []const u8) ![]const u8 {
491496 var result = std.ArrayList(u8).initCapacity(alloc, data.len) catch unreachable;
492497 defer result.deinit(alloc);
493498
494- var parser = ghostty_vt.Parser.init();
495- for (data) |c| {
496- // Bytes >= 0x80 outside a sequence are UTF-8 text, not C1 controls.
497- if (c >= 0x80 and parser.state == .ground) {
498- result.append(alloc, c) catch unreachable;
499- continue;
500- }
501- const actions = parser.next(c);
502- for (actions) |action_opt| {
503- const action = action_opt orelse continue;
504- switch (action) {
505- .print => {
506- result.append(alloc, c) catch unreachable;
507- },
508- .execute => |code| {
509- // Pass through essential whitespace/control chars
510- switch (code) {
511- '\r', '\n', '\t', 0x08 => { // CR, LF, TAB, BS
512- result.append(alloc, @as(u8, @intCast(code))) catch unreachable;
513- },
514- else => {},
499+ var i: usize = 0;
500+ while (i < data.len) {
501+ const c = data[i];
502+ if (c == 0x1b) {
503+ i += 1;
504+ if (i >= data.len) break;
505+ const next = data[i];
506+ if (next == '[') { // CSI
507+ i += 1;
508+ while (i < data.len) : (i += 1) {
509+ const b = data[i];
510+ if (b >= 0x40 and b <= 0x7e) {
511+ i += 1;
512+ break;
513+ }
514+ }
515+ } else if (next == ']') { // OSC
516+ i += 1;
517+ while (i < data.len) : (i += 1) {
518+ const b = data[i];
519+ if (b == 0x07) {
520+ i += 1;
521+ break;
522+ }
523+ if (b == 0x1b and i + 1 < data.len and data[i + 1] == '\\') {
524+ i += 2;
525+ break;
526+ }
527+ }
528+ } else if (next == 'P' or next == 'X' or next == '^' or next == '_') { // DCS, SOS, PM, APC
529+ i += 1;
530+ while (i < data.len) : (i += 1) {
531+ if (data[i] == 0x07) {
532+ i += 1;
533+ break;
515534 }
516- },
517- // All other actions (CSI, OSC, DCS, etc.) are silently dropped
518- else => {},
535+ if (data[i] == 0x1b and i + 1 < data.len and data[i + 1] == '\\') {
536+ i += 2;
537+ break;
538+ }
539+ }
540+ } else {
541+ // 2-byte escape sequence (e.g. ESC c)
542+ i += 1;
519543 }
544+ } else {
545+ switch (c) {
546+ '\r', '\n', '\t', 0x08 => try result.append(alloc, c),
547+ 0x7f => {},
548+ else => if (c >= 0x20) try result.append(alloc, c),
549+ }
550+ i += 1;
520551 }
521552 }
522-
523553 return result.toOwnedSlice(alloc);
524554 }
525555
......@@ -619,14 +649,14 @@ fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool
619649 return parsed.event_type != 3;
620650 }
621651
622-const KittyCsiU = struct {
652+pub const KittyCsiU = struct {
623653 key_code: u32,
624654 modifiers: u32,
625655 event_type: u32,
626656 consumed: usize,
627657 };
628658
629-fn parseKittyCsiU(buf: []const u8) ?KittyCsiU {
659+pub fn parseKittyCsiU(buf: []const u8) ?KittyCsiU {
630660 var pos: usize = 0;
631661
632662 // 1. Parse key code.
......@@ -688,309 +718,12 @@ fn parseDecimal(buf: []const u8, pos: *usize) ?u32 {
688718 return value;
689719 }
690720
691-/// True when a CSI carries a private marker (`?`, `>`, `=`, `<`). Keyboard
692-/// encodings never use one, but the replies a terminal sends on its own do:
693-/// kitty keyboard flags (`CSI ? flags u`), DA1 (`CSI ? 62;22 c`), DA2
694-/// (`CSI > 1;10;0 c`), DECRPM (`CSI ? 2026;2 $ y`), color scheme (`CSI ? 997;1 n`).
695-fn hasPrivateMarker(csi: ghostty_vt.Parser.Action.CSI) bool {
696- for (csi.intermediates) |b| {
697- if (b == '?' or b == '>' or b == '=' or b == '<') return true;
698- }
699- return false;
700-}
701-
702-/// Detect if the payload contains user input that should be printed to the screen or
703-/// is a key combination like up-arrow, backspace, enter, ctrl+f, etc.
704-///
705-/// Terminal-generated replies must not count: every attached client's terminal
706-/// answers the queries the app broadcasts, so a reply from a passive viewer
707-/// would otherwise make it leader and resize the session to its window.
708-pub fn isUserInput(payload: []const u8) bool {
709- var parser = ghostty_vt.Parser.init();
710- var i: usize = 0;
711- while (i < payload.len) {
712- if (payload[i] == 0x1b and i + 2 < payload.len and payload[i + 1] == '[') {
713- if (parseKittyCsiU(payload[i + 2 ..])) |kitty| {
714- if (kitty.event_type != 3) return true;
715- i += 2 + kitty.consumed;
716- continue;
717- }
718- }
719-
720- const actions = parser.next(payload[i]);
721- for (actions) |action_opt| {
722- const action = action_opt orelse continue;
723- switch (action) {
724- .print => return true, // printable characters
725- .csi_dispatch => |csi| {
726- // A reply, not a key: skip it and keep scanning, since a
727- // real keystroke can arrive in the same read.
728- if (hasPrivateMarker(csi)) continue;
729- // kitty keyboard: CSI ... u or CSI ... ~
730- // legacy modified keys: CSI 27 ; ... ~
731- // arrow/function keys with modifiers: CSI 1 ; <mod> A-D
732- // CSI ? flags u is the terminal's reply to a kitty keyboard status
733- // query, not a key press; skip it and keep scanning the payload
734- const status_reply = csi.final == 'u' and csi.intermediates.len > 0 and csi.intermediates[0] == '?';
735- if (!status_reply and (csi.final == 'u' or csi.final == '~')) return true;
736- // modified arrow keys (e.g., Ctrl+F sends CSI 1;5C in legacy mode)
737- if (csi.final >= 'A' and csi.final <= 'D' and csi.params.len > 1) return true;
738- // mouse events: CSI M (basic) or CSI < (SGR extended) - EXCLUDE these
739- // only intentional keyboard input should trigger leader switch
740- if (csi.final == 'M' or csi.final == '<') return false;
741- // focus events: CSI I (focus in) or CSI O (focus out) - EXCLUDE these
742- // these are automatic terminal events, not user typing
743- if (csi.final == 'I' or csi.final == 'O') return false;
744- },
745- .execute => |code| {
746- // looking for CR, LF, tab, and backspace
747- if (code == 0x0D or code == 0x0A or code == 0x09 or code == 0x08) return true;
748- },
749- else => {},
750- }
751- }
752- i += 1;
753- }
754- return false;
755-}
756-
757-/// Emit the terminal's pwd as OSC 7.
758-///
759-/// This replaces the formatter's own `extra.pwd`, which writes
760-/// `terminal.pwd.items` verbatim. `Terminal.setPwd` appends a NUL sentinel to
761-/// that buffer, so the formatter's OSC 7 carries a stray `\x00` before the
762-/// terminator. `getPwd()` returns the same bytes without the sentinel.
763-///
764-/// The NUL is not cosmetic: a client that records what it receives (kitty
765-/// writing its session file, for one) persists the NUL and then fails to parse
766-/// its own state back. See https://github.com/neurosnap/zmx/issues/222.
767-fn writePwd(writer: *std.Io.Writer, term: *const ghostty_vt.Terminal) void {
768- const pwd = term.getPwd() orelse return;
769- if (pwd.len == 0) return;
770- writer.print("\x1b]7;{s}\x1b\\", .{pwd}) catch |err| {
771- std.log.warn("failed to format pwd err={s}", .{@errorName(err)});
772- };
773-}
774-
775-fn writeDynamicColor(
776- writer: *std.Io.Writer,
777- kind: ghostty_vt.color.Dynamic,
778- dynamic: ghostty_vt.color.DynamicRGB,
779-) void {
780- const c = dynamic.override orelse return;
781- writer.print("\x1b]{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", .{
782- @intFromEnum(kind), c.r, c.g, c.b,
783- }) catch |err| {
784- std.log.warn("failed to format dynamic color err={s}", .{@errorName(err)});
785- };
786-}
787-
788-fn writeColorOverrides(writer: *std.Io.Writer, term: *const ghostty_vt.Terminal) void {
789- const colors = &term.colors;
790- writeDynamicColor(writer, .foreground, colors.foreground);
791- writeDynamicColor(writer, .background, colors.background);
792- writeDynamicColor(writer, .cursor, colors.cursor);
793-}
794-
795-pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
796- var builder: std.Io.Writer.Allocating = .init(alloc);
797- defer builder.deinit();
798-
799- // Synchronized output (DECSET 2026) is a transient rendering handshake
800- // between a program and its current terminal client. Replaying it to a
801- // newly attached client can leave that client deferring renders until its
802- // local timeout fires, so temporarily exclude it from restored state and
803- // restore the original mode before returning.
804- const had_synchronized_output = term.modes.get(.synchronized_output);
805- if (had_synchronized_output) {
806- term.modes.set(.synchronized_output, false);
807- }
808-
809- // If state contains color override, restore it.
810- writeColorOverrides(&builder.writer, term);
811-
812- const pages = &term.screens.active.pages;
813- const screen_top = pages.getTopLeft(.screen);
814- const active_top = pages.getTopLeft(.active);
815- const has_scrollback = !screen_top.eql(active_top);
816-
817- // Two-phase serialization to preserve scrollback without corrupting
818- // cursor positions. This matters for nested zmx sessions (zmx→SSH→zmx)
819- // where the outer daemon's ghostty-vt accumulates inner session scrollback.
820- //
821- // Phase 1: Emit scrollback content (plain text with styles, no terminal extras).
822- // These lines scroll past the visible area into the terminal's scrollback buffer.
823- // Phase 2: Clear visible screen, then emit visible content with full extras.
824- // The clear ensures visible content starts from a clean slate regardless of
825- // how much scrollback preceded it. CUP cursor positioning is then correct.
826- //
827- // See: https://github.com/neurosnap/zmx/issues/31
828-
829- // Phase 1: scrollback only (if any exists)
830- if (has_scrollback) {
831- if (active_top.up(1)) |sb_bottom_row| {
832- var sb_bottom = sb_bottom_row;
833- sb_bottom.x = @intCast(pages.cols - 1);
834-
835- var scroll_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
836- scroll_fmt.content = .{
837- .selection = ghostty_vt.Selection.init(
838- screen_top,
839- sb_bottom,
840- false,
841- ),
842- };
843- scroll_fmt.extra = .none; // no modes, cursor, keyboard — just content
844- scroll_fmt.format(&builder.writer) catch |err| {
845- std.log.warn("failed to format scrollback err={s}", .{@errorName(err)});
846- };
847- }
848-
849- // Clear visible screen after scrollback. \x1b[2J clears only the visible
850- // rows (not the scrollback buffer). \x1b[H homes the cursor. \x1b[0m resets
851- // SGR style so phase 1 styles don't bleed into phase 2.
852- builder.writer.writeAll("\x1b[2J\x1b[H\x1b[0m") catch {};
853- }
854-
855- // Phase 2: visible screen with full extras (modes, cursor, keyboard, etc.)
856- var vis_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
857-
858- // Restrict content to the active viewport only
859- const active_tl = pages.pin(.{ .active = .{ .x = 0, .y = 0 } });
860- const active_br = pages.pin(.{
861- .active = .{
862- .x = @intCast(pages.cols - 1),
863- .y = @intCast(pages.rows - 1),
864- },
865- });
866-
867- if (active_tl != null and active_br != null) {
868- vis_fmt.content = .{
869- .selection = ghostty_vt.Selection.init(
870- active_tl.?,
871- active_br.?,
872- false,
873- ),
874- };
875- }
876- // Fallback: if pins are somehow invalid, use null selection (all content)
877-
878- vis_fmt.extra = .{
879- .palette = false,
880- .modes = true,
881- .scrolling_region = true,
882- .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
883- .pwd = false, // emitted below without the sentinel the formatter includes
884- .keyboard = true,
885- .screen = .all,
886- };
887-
888- vis_fmt.format(&builder.writer) catch |err| {
889- std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
890- return null;
891- };
892-
893- writePwd(&builder.writer, term);
894-
895- // The formatter has no title extra and never emits OSC 0/1/2, so the title
896- // has to be replayed separately or an attaching client shows whatever its
897- // terminal defaults to, usually the client process name. OSC 2 does not
898- // move the cursor, so this is safe to append after the content.
899- if (term.getTitle()) |title| {
900- builder.writer.print("\x1b]2;{s}\x07", .{title}) catch |err| {
901- std.log.warn("failed to format title err={s}", .{@errorName(err)});
902- };
903- }
904-
905- const output = builder.writer.buffered();
906- if (output.len == 0) return null;
907-
908- // Restore the original synchronized_output mode before returning
909- if (had_synchronized_output) {
910- term.modes.set(.synchronized_output, true);
911- }
912-
913- return alloc.dupe(u8, output) catch |err| {
914- std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)});
915- return null;
916- };
917-}
918-
919721 pub const HistoryFormat = enum(u8) {
920722 plain = 0,
921723 vt = 1,
922724 html = 2,
923725 };
924726
925-pub fn serializeTerminal(
926- alloc: std.mem.Allocator,
927- term: *ghostty_vt.Terminal,
928- format: HistoryFormat,
929-) ?[]const u8 {
930- return serializeTerminalRange(alloc, term, format, null);
931-}
932-
933-/// Formats the active screen and at most `scrollback_rows` preceding physical
934-/// rows. Null preserves whole-history formatting. Alternate screens have no
935-/// scrollback, so their selection always stays inside the current grid.
936-pub fn serializeTerminalRange(
937- alloc: std.mem.Allocator,
938- term: *ghostty_vt.Terminal,
939- format: HistoryFormat,
940- scrollback_rows: ?u32,
941-) ?[]const u8 {
942- var builder: std.Io.Writer.Allocating = .init(alloc);
943- defer builder.deinit();
944-
945- const opts: ghostty_vt.formatter.Options = switch (format) {
946- .plain => .plain,
947- .vt => .vt,
948- .html => .html,
949- };
950- var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts);
951- term_formatter.content = .{ .selection = null };
952- if (scrollback_rows) |rows| {
953- const pages = &term.screens.active.pages;
954- const active_top = pages.getTopLeft(.active);
955- const top = active_top.up(rows) orelse pages.getTopLeft(.screen);
956- const bottom = pages.pin(.{ .active = .{
957- .x = @intCast(pages.cols - 1),
958- .y = @intCast(pages.rows - 1),
959- } }) orelse return null;
960- term_formatter.content = .{
961- .selection = ghostty_vt.Selection.init(top, bottom, false),
962- };
963- }
964- term_formatter.extra = switch (format) {
965- .plain => .none,
966- .vt => .{
967- .palette = false,
968- .modes = true,
969- .scrolling_region = true,
970- .tabstops = false,
971- .pwd = false, // emitted below without the sentinel the formatter includes
972- .keyboard = true,
973- .screen = .all,
974- },
975- .html => .styles,
976- };
977-
978- term_formatter.format(&builder.writer) catch |err| {
979- std.log.warn("failed to format terminal err={s}", .{@errorName(err)});
980- return null;
981- };
982-
983- if (format == .vt) writePwd(&builder.writer, term);
984-
985- const output = builder.writer.buffered();
986- if (output.len == 0) return null;
987-
988- return alloc.dupe(u8, output) catch |err| {
989- std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)});
990- return null;
991- };
992-}
993-
994727 /// Formats a session entry for list output (only the name when `short` is
995728 /// true), adding a prefix to indicate the current session, if there is one.
996729 pub fn writeSessionLine(
......@@ -1599,635 +1332,6 @@ test "toOsc7Cwd round-trips through parseOsc7Cwd" {
15991332 }
16001333 }
16011334
1602-test "serializeTerminalState excludes synchronized output replay" {
1603- const alloc = testing.allocator;
1604- const io = testing.io;
1605-
1606- var term = try ghostty_vt.Terminal.init(io, alloc, .{
1607- .cols = 80,
1608- .rows = 24,
1609- });
1610- defer term.deinit(alloc);
1611-
1612- var stream = term.vtStream();
1613- defer stream.deinit();
1614-
1615- stream.nextSlice("\x1b[?2004h"); // Bracketed paste
1616- stream.nextSlice("\x1b[?2026h"); // Synchronized output
1617- stream.nextSlice("hello");
1618-
1619- try testing.expect(term.modes.get(.bracketed_paste));
1620- try testing.expect(term.modes.get(.synchronized_output));
1621-
1622- const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1623- defer alloc.free(output);
1624-
1625- // The serialized output should contain bracketed paste (DECSET 2004)
1626- // but NOT synchronized output (DECSET 2026)
1627- try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2004h") != null);
1628- try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
1629-}
1630-
1631-test "serializeTerminalState replays the title" {
1632- const alloc = testing.allocator;
1633- const io = testing.io;
1634-
1635- var term = try ghostty_vt.Terminal.init(io, alloc, .{
1636- .cols = 80,
1637- .rows = 24,
1638- });
1639- defer term.deinit(alloc);
1640-
1641- var stream = term.vtStream();
1642- defer stream.deinit();
1643-
1644- stream.nextSlice("\x1b]2;my title\x07");
1645- stream.nextSlice("hello");
1646-
1647- const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1648- defer alloc.free(output);
1649-
1650- try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;my title\x07") != null);
1651-}
1652-
1653-test "serializeTerminalState omits the title when none is set" {
1654- const alloc = testing.allocator;
1655- const io = testing.io;
1656-
1657- var term = try ghostty_vt.Terminal.init(io, alloc, .{
1658- .cols = 80,
1659- .rows = 24,
1660- });
1661- defer term.deinit(alloc);
1662-
1663- var stream = term.vtStream();
1664- defer stream.deinit();
1665-
1666- stream.nextSlice("hello");
1667-
1668- const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1669- defer alloc.free(output);
1670-
1671- try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;") == null);
1672-}
1673-
1674-test "serializeTerminalState replays the pwd without a NUL sentinel" {
1675- const alloc = testing.allocator;
1676- const io = testing.io;
1677-
1678- var term = try ghostty_vt.Terminal.init(io, alloc, .{
1679- .cols = 80,
1680- .rows = 24,
1681- });
1682- defer term.deinit(alloc);
1683-
1684- var stream = term.vtStream();
1685- defer stream.deinit();
1686-
1687- stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
1688- stream.nextSlice("hello");
1689-
1690- const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1691- defer alloc.free(output);
1692-
1693- try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
1694- try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
1695-}
1696-
1697-test "serializeTerminalState omits the pwd when none is set" {
1698- const alloc = testing.allocator;
1699- const io = testing.io;
1700-
1701- var term = try ghostty_vt.Terminal.init(io, alloc, .{
1702- .cols = 80,
1703- .rows = 24,
1704- });
1705- defer term.deinit(alloc);
1706-
1707- var stream = term.vtStream();
1708- defer stream.deinit();
1709-
1710- stream.nextSlice("hello");
1711-
1712- const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1713- defer alloc.free(output);
1714-
1715- try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;") == null);
1716-}
1717-
1718-test "serializeTerminal vt replays the pwd without a NUL sentinel" {
1719- const alloc = testing.allocator;
1720- const io = testing.io;
1721-
1722- var term = try ghostty_vt.Terminal.init(io, alloc, .{
1723- .cols = 80,
1724- .rows = 24,
1725- });
1726- defer term.deinit(alloc);
1727-
1728- var stream = term.vtStream();
1729- defer stream.deinit();
1730-
1731- stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
1732- stream.nextSlice("hello");
1733-
1734- const output = serializeTerminal(alloc, &term, .vt) orelse return error.TestUnexpectedNull;
1735- defer alloc.free(output);
1736-
1737- try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
1738- try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
1739-}
1740-
1741-test "serializeTerminalRange screen only returns the active screen" {
1742- const alloc = testing.allocator;
1743- var term = try testCreateTerminal(alloc, testing.io, 80, 3, "1\r\n2\r\n3\r\n4\r\n5");
1744- defer term.deinit(alloc);
1745-
1746- const output = serializeTerminalRange(alloc, &term, .plain, 0) orelse return error.TestUnexpectedNull;
1747- defer alloc.free(output);
1748- try testing.expectEqualStrings("3\n4\n5", output);
1749-}
1750-
1751-test "serializeTerminalRange adds the requested scrollback rows" {
1752- const alloc = testing.allocator;
1753- var term = try testCreateTerminal(alloc, testing.io, 80, 3, "1\r\n2\r\n3\r\n4\r\n5");
1754- defer term.deinit(alloc);
1755-
1756- const output = serializeTerminalRange(alloc, &term, .plain, 1) orelse return error.TestUnexpectedNull;
1757- defer alloc.free(output);
1758- try testing.expectEqualStrings("2\n3\n4\n5", output);
1759-
1760- const all = serializeTerminalRange(alloc, &term, .plain, 100) orelse return error.TestUnexpectedNull;
1761- defer alloc.free(all);
1762- const full = serializeTerminal(alloc, &term, .plain) orelse return error.TestUnexpectedNull;
1763- defer alloc.free(full);
1764- try testing.expectEqualStrings(full, all);
1765-}
1766-
1767-test "serializeTerminalRange alternate screen returns only its grid" {
1768- const alloc = testing.allocator;
1769- var term = try testCreateTerminal(alloc, testing.io, 80, 3, "1\r\n2\r\n3\r\n4\r\n5\x1b[?1049h\x1b[Halt");
1770- defer term.deinit(alloc);
1771-
1772- const output = serializeTerminalRange(alloc, &term, .plain, 100) orelse return error.TestUnexpectedNull;
1773- defer alloc.free(output);
1774- try testing.expectEqualStrings("alt", output);
1775-}
1776-
1777-fn testCreateTerminal(alloc: std.mem.Allocator, io: std.Io, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
1778- var term = try ghostty_vt.Terminal.init(io, alloc, .{
1779- .cols = cols,
1780- .rows = rows,
1781- .max_scrollback_lines = 2_000,
1782- });
1783- if (vt_data.len > 0) {
1784- var stream = term.vtStream();
1785- defer stream.deinit();
1786- stream.nextSlice(vt_data);
1787- }
1788- return term;
1789-}
1790-
1791-fn expectScreensMatch(alloc: std.mem.Allocator, expected: *ghostty_vt.Terminal, actual: *ghostty_vt.Terminal) !void {
1792- const exp_str = try expected.plainString(alloc);
1793- defer alloc.free(exp_str);
1794- const act_str = try actual.plainString(alloc);
1795- defer alloc.free(act_str);
1796- try testing.expectEqualStrings(exp_str, act_str);
1797-}
1798-
1799-fn expectCursorAt(term: *ghostty_vt.Terminal, row: usize, col: usize) !void {
1800- const cursor = &term.screens.active.cursor;
1801- try testing.expectEqual(col, cursor.x);
1802- try testing.expectEqual(row, cursor.y);
1803-}
1804-
1805-fn serializeRoundtrip(alloc: std.mem.Allocator, io: std.Io, source: *ghostty_vt.Terminal) !ghostty_vt.Terminal {
1806- const serialized = serializeTerminalState(alloc, source) orelse
1807- return error.SerializationFailed;
1808- defer alloc.free(serialized);
1809-
1810- var dest = try ghostty_vt.Terminal.init(io, alloc, .{
1811- .cols = source.screens.active.pages.cols,
1812- .rows = source.screens.active.pages.rows,
1813- .max_scrollback_lines = 2_000,
1814- });
1815- var stream = dest.vtStream();
1816- defer stream.deinit();
1817- stream.nextSlice(serialized);
1818- return dest;
1819-}
1820-
1821-fn expectMarkerAtRow(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, marker: []const u8, expected_row: usize) !void {
1822- const plain = try term.plainString(alloc);
1823- defer alloc.free(plain);
1824- var row: usize = 0;
1825- var iter = std.mem.splitScalar(u8, plain, '\n');
1826- while (iter.next()) |line| {
1827- if (std.mem.indexOf(u8, line, marker) != null) {
1828- try testing.expectEqual(expected_row, row);
1829- return;
1830- }
1831- row += 1;
1832- }
1833- std.debug.print("marker '{s}' not found in terminal output\n", .{marker});
1834- return error.TestExpectedEqual;
1835-}
1836-
1837-test "serializeTerminalState roundtrip preserves cursor position" {
1838- const alloc = testing.allocator;
1839- const io = testing.io;
1840-
1841- var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[2J" ++ // clear
1842- "\x1b[10;20H" // cursor at row 10, col 20 (1-indexed)
1843- );
1844- defer term.deinit(alloc);
1845-
1846- try expectCursorAt(&term, 9, 19); // 0-indexed
1847-
1848- var client = try serializeRoundtrip(alloc, io, &term);
1849- defer client.deinit(alloc);
1850-
1851- try expectCursorAt(&client, 9, 19);
1852-}
1853-
1854-test "serializeTerminalState roundtrip preserves CUP-positioned markers" {
1855- const alloc = testing.allocator;
1856- const io = testing.io;
1857-
1858- var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[2J" ++
1859- "\x1b[2;5HMARK_A" ++
1860- "\x1b[6;15HMARK_B" ++
1861- "\x1b[10;30HMARK_C" ++
1862- "\x1b[14;50HMARK_D" ++
1863- "\x1b[16;20H");
1864- defer term.deinit(alloc);
1865-
1866- var client = try serializeRoundtrip(alloc, io, &term);
1867- defer client.deinit(alloc);
1868-
1869- try expectScreensMatch(alloc, &term, &client);
1870- try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
1871- try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
1872- try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
1873- try expectMarkerAtRow(alloc, &client, "MARK_D", 13);
1874- try expectCursorAt(&client, 15, 19);
1875-}
1876-
1877-test "serializeTerminalState with scrollback preserves visible content" {
1878- const alloc = testing.allocator;
1879- const io = testing.io;
1880-
1881- var term = try testCreateTerminal(alloc, io, 80, 24, "");
1882- defer term.deinit(alloc);
1883-
1884- var stream = term.vtStream();
1885- defer stream.deinit();
1886-
1887- // Generate 80 lines of scrollback (more than 24 visible rows)
1888- var buf: [32]u8 = undefined;
1889- for (0..80) |i| {
1890- const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
1891- stream.nextSlice(line);
1892- }
1893-
1894- // Clear screen and place markers at specific positions
1895- stream.nextSlice("\x1b[2J" ++
1896- "\x1b[2;5HMARK_A" ++
1897- "\x1b[6;15HMARK_B" ++
1898- "\x1b[10;30HMARK_C" ++
1899- "\x1b[16;20H");
1900-
1901- // Verify source terminal has scrollback
1902- const pages = &term.screens.active.pages;
1903- const has_scrollback = !pages.getTopLeft(.screen).eql(pages.getTopLeft(.active));
1904- try testing.expect(has_scrollback);
1905-
1906- // Roundtrip: serialize → feed into fresh terminal
1907- var client = try serializeRoundtrip(alloc, io, &term);
1908- defer client.deinit(alloc);
1909-
1910- // Visible content must match (this is the core cursor corruption test)
1911- try expectScreensMatch(alloc, &term, &client);
1912- try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
1913- try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
1914- try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
1915- try expectCursorAt(&client, 15, 19);
1916-}
1917-
1918-test "serializeTerminalState nested roundtrip preserves content" {
1919- // Simulates: inner zmx → serialized state → outer ghostty-vt → serialized again → client
1920- // This is the exact nested session scenario (zmx → SSH → zmx).
1921- const alloc = testing.allocator;
1922- const io = testing.io;
1923-
1924- // "Inner" terminal with scrollback + markers
1925- var inner = try testCreateTerminal(alloc, io, 80, 24, "");
1926- defer inner.deinit(alloc);
1927-
1928- {
1929- var inner_stream = inner.vtStream();
1930- defer inner_stream.deinit();
1931- var buf: [32]u8 = undefined;
1932- for (0..60) |i| {
1933- const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
1934- inner_stream.nextSlice(line);
1935- }
1936- inner_stream.nextSlice("\x1b[2J" ++
1937- "\x1b[3;10HINNER_A" ++
1938- "\x1b[12;25HINNER_B" ++
1939- "\x1b[20;5H");
1940- }
1941-
1942- // Record inner's ground truth
1943- const inner_cursor_x = inner.screens.active.cursor.x;
1944- const inner_cursor_y = inner.screens.active.cursor.y;
1945-
1946- // Serialize inner (simulates inner daemon re-attach to inner client)
1947- const inner_serialized = serializeTerminalState(alloc, &inner) orelse
1948- return error.SerializationFailed;
1949- defer alloc.free(inner_serialized);
1950-
1951- // "Outer" terminal processes inner's serialized output
1952- var outer = try testCreateTerminal(alloc, io, 80, 24, "");
1953- defer outer.deinit(alloc);
1954-
1955- {
1956- var outer_stream = outer.vtStream();
1957- defer outer_stream.deinit();
1958- outer_stream.nextSlice(inner_serialized);
1959- }
1960-
1961- // Serialize outer (simulates outer daemon re-attach after detach)
1962- var client = try serializeRoundtrip(alloc, io, &outer);
1963- defer client.deinit(alloc);
1964-
1965- // Client must see the same content as inner's visible screen
1966- try expectScreensMatch(alloc, &inner, &client);
1967- try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
1968- try expectMarkerAtRow(alloc, &client, "INNER_A", 2);
1969- try expectMarkerAtRow(alloc, &client, "INNER_B", 11);
1970-}
1971-
1972-test "serializeTerminalState alternate screen not leaked" {
1973- const alloc = testing.allocator;
1974- const io = testing.io;
1975-
1976- var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[?1049h" ++ // enter alt screen
1977- "\x1b[2J\x1b[3;10HALT_MARK" ++ // write on alt screen
1978- "\x1b[?1049l" ++ // exit alt screen
1979- "\x1b[2J\x1b[2;5HMAIN_MARK\x1b[8;20H" // write on main screen
1980- );
1981- defer term.deinit(alloc);
1982-
1983- var client = try serializeRoundtrip(alloc, io, &term);
1984- defer client.deinit(alloc);
1985-
1986- try expectScreensMatch(alloc, &term, &client);
1987-
1988- const plain = try client.plainString(alloc);
1989- defer alloc.free(plain);
1990- try testing.expect(std.mem.indexOf(u8, plain, "ALT_MARK") == null);
1991- try testing.expect(std.mem.indexOf(u8, plain, "MAIN_MARK") != null);
1992-}
1993-
1994-test "serializeTerminalState size mismatch roundtrip" {
1995- const alloc = testing.allocator;
1996- const io = testing.io;
1997-
1998- var term = try testCreateTerminal(alloc, io, 80, 30, "\x1b[2J" ++
1999- "\x1b[3;10HSIZE_A" ++
2000- "\x1b[12;20HSIZE_B" ++
2001- "\x1b[20;40HSIZE_C" ++
2002- "\x1b[15;15H");
2003- defer term.deinit(alloc);
2004-
2005- // Resize to 24 rows (simulates outer terminal being smaller)
2006- try term.resize(alloc, ghostty_vt.Terminal.Resize{ .cols = 80, .rows = 24 });
2007-
2008- var client = try serializeRoundtrip(alloc, io, &term);
2009- defer client.deinit(alloc);
2010-
2011- try expectScreensMatch(alloc, &term, &client);
2012- try expectCursorAt(&client, term.screens.active.cursor.y, term.screens.active.cursor.x);
2013-}
2014-
2015-test "serializeTerminalState scrollback + size mismatch nested roundtrip" {
2016- const alloc = testing.allocator;
2017- const io = testing.io;
2018-
2019- var inner = try testCreateTerminal(alloc, io, 80, 30, "");
2020- defer inner.deinit(alloc);
2021-
2022- {
2023- var inner_stream = inner.vtStream();
2024- defer inner_stream.deinit();
2025- var buf: [32]u8 = undefined;
2026- for (0..80) |i| {
2027- const line = std.fmt.bufPrint(&buf, "LINE_{d}\r\n", .{i}) catch unreachable;
2028- inner_stream.nextSlice(line);
2029- }
2030- inner_stream.nextSlice("\x1b[2J" ++
2031- "\x1b[3;10HSTRESS_A" ++
2032- "\x1b[12;25HSTRESS_B" ++
2033- "\x1b[16;20H");
2034- }
2035-
2036- // Resize inner to 24 rows (outer terminal is smaller)
2037- try inner.resize(alloc, ghostty_vt.Terminal.Resize{ .cols = 80, .rows = 24 });
2038-
2039- const inner_cursor_x = inner.screens.active.cursor.x;
2040- const inner_cursor_y = inner.screens.active.cursor.y;
2041-
2042- // Inner serialize → outer processes → outer serialize → client
2043- const inner_ser = serializeTerminalState(alloc, &inner) orelse
2044- return error.SerializationFailed;
2045- defer alloc.free(inner_ser);
2046-
2047- var outer = try testCreateTerminal(alloc, io, 80, 24, "");
2048- defer outer.deinit(alloc);
2049- {
2050- var outer_stream = outer.vtStream();
2051- defer outer_stream.deinit();
2052- outer_stream.nextSlice(inner_ser);
2053- }
2054-
2055- var client = try serializeRoundtrip(alloc, io, &outer);
2056- defer client.deinit(alloc);
2057-
2058- try expectScreensMatch(alloc, &inner, &client);
2059- try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
2060-}
2061-
2062-test "isUserInput: printable characters" {
2063- // Regular text should be detected as user input
2064- try testing.expect(isUserInput("hello"));
2065- try testing.expect(isUserInput("Hello World!"));
2066- try testing.expect(isUserInput("12345"));
2067- try testing.expect(isUserInput("!@#$%^&*()"));
2068-}
2069-
2070-test "isUserInput: whitespace characters" {
2071- // Space character is printable
2072- try testing.expect(isUserInput(" "));
2073- try testing.expect(isUserInput(" "));
2074-}
2075-
2076-test "isUserInput: line feed (LF)" {
2077- // LF triggers .execute action
2078- try testing.expect(isUserInput("\n"));
2079- try testing.expect(isUserInput("test\n"));
2080-}
2081-
2082-test "isUserInput: carriage return (CR)" {
2083- // CR triggers .execute action
2084- try testing.expect(isUserInput("\r"));
2085- try testing.expect(isUserInput("test\r"));
2086-}
2087-
2088-test "isUserInput: tab" {
2089- // Tab triggers .execute action
2090- try testing.expect(isUserInput("\t"));
2091- try testing.expect(isUserInput("col1\tcol2"));
2092-}
2093-
2094-test "isUserInput: backspace" {
2095- // Backspace triggers .execute action
2096- try testing.expect(isUserInput("\x08"));
2097- try testing.expect(isUserInput("test\x08"));
2098-}
2099-
2100-test "isUserInput: arrow keys (CSI ~)" {
2101- // Arrow keys use CSI with ~ - these have params
2102- try testing.expect(isUserInput("\x1b[3~")); // delete
2103- try testing.expect(isUserInput("\x1b[5~")); // page up
2104- try testing.expect(isUserInput("\x1b[6~")); // page down
2105-}
2106-
2107-test "isUserInput: modified arrow keys with CSI u" {
2108- // Modified arrow keys with CSI ... u
2109- try testing.expect(isUserInput("\x1bOA")); // up with modifier
2110- try testing.expect(isUserInput("\x1bOB")); // down with modifier
2111- try testing.expect(isUserInput("\x1bOC")); // right with modifier
2112- try testing.expect(isUserInput("\x1bOD")); // left with modifier
2113-}
2114-
2115-test "isUserInput: up arrow legacy" {
2116- // Legacy up arrow: CSI A (with params for kitty-style)
2117- try testing.expect(isUserInput("\x1b[1;1A")); // kitty-style legacy
2118-}
2119-
2120-test "isUserInput: up arrow kitty" {
2121- // Kitty keyboard up arrow: CSI 1;1;1A (no colon format supported by parser)
2122- try testing.expect(isUserInput("\x1b[1;1;1A")); // kitty up arrow
2123-}
2124-
2125-test "isUserInput: arrow keys with modifier params CSI A-D" {
2126- // Modified arrow keys like Ctrl+Up: CSI 1;5A
2127- try testing.expect(isUserInput("\x1b[1;5A")); // Ctrl+Up
2128- try testing.expect(isUserInput("\x1b[1;5B")); // Ctrl+Down
2129- try testing.expect(isUserInput("\x1b[1;5C")); // Ctrl+Right
2130- try testing.expect(isUserInput("\x1b[1;5D")); // Ctrl+Left
2131- try testing.expect(isUserInput("\x1b[1;3A")); // Alt+Up
2132- try testing.expect(isUserInput("\x1b[1;3B")); // Alt+Down
2133-}
2134-
2135-test "isUserInput: function keys with modifiers CSI 27 ; ~" {
2136- // Legacy modified keys: CSI 27 ; ... ~
2137- try testing.expect(isUserInput("\x1b[15;2~")); // F4 with modifier
2138- try testing.expect(isUserInput("\x1b[17;2~")); // F5 with modifier
2139- try testing.expect(isUserInput("\x1b[18;2~")); // F6 with modifier
2140-}
2141-
2142-test "isUserInput: enter key" {
2143- // Enter is LF (0x0A)
2144- try testing.expect(isUserInput("\x0A"));
2145-}
2146-
2147-test "isUserInput: mixed content" {
2148- // Mix of printable and control sequences
2149- try testing.expect(isUserInput("hello\nworld"));
2150- try testing.expect(isUserInput("\x1b[3~\x1b[6~")); // multiple CSI ~ sequences
2151- try testing.expect(isUserInput("abc\x1b[3~def")); // text with CSI ~
2152-}
2153-
2154-test "isUserInput: non-user input (escape sequences only)" {
2155- // Cursor movement without user input
2156- try testing.expect(!isUserInput("\x1b[2;1H")); // CSI H cursor home
2157- // SGR color set (no printing)
2158- try testing.expect(!isUserInput("\x1b[0m"));
2159- // Cursor position report query
2160- try testing.expect(!isUserInput("\x1b[6n"));
2161-}
2162-
2163-test "isUserInput: empty string" {
2164- try testing.expect(!isUserInput(""));
2165-}
2166-
2167-test "isUserInput: only whitespace controls" {
2168- // Multiple control chars should return true
2169- try testing.expect(isUserInput("\n\r\t"));
2170-}
2171-
2172-test "isUserInput: kitty keyboard sequences" {
2173- // Kitty keyboard protocol uses CSI u
2174- try testing.expect(isUserInput("\x1b[11;2u")); // F1 with modifier
2175- try testing.expect(isUserInput("\x1b[12;2u")); // F2 with modifier
2176- try testing.expect(isUserInput("\x1b[102;1:1u")); // literal "f" press
2177- try testing.expect(isUserInput("\x1b[57444;1:1u")); // Kitty functional key press
2178- try testing.expect(!isUserInput("\x1b[102;1:3u")); // literal "f" release only
2179- try testing.expect(isUserInput("\x1b[102;1:1u\x1b[67;65;31M")); // key press with mouse noise
2180-}
2181-
2182-test "isUserInput: mouse events (CSI M) excluded" {
2183- // Basic mouse tracking (SGR disabled): CSI M Cb Cx Cy
2184- // Mouse events should NOT trigger leader switch
2185- try testing.expect(!isUserInput("\x1b[M@ 0 0")); // button 0, pos 0,0
2186- try testing.expect(!isUserInput("\x1b[M@ 1 1")); // button 1, pos 1,1
2187-}
2188-
2189-test "isUserInput: mouse events SGR mode CSI < excluded" {
2190- // SGR extended mouse tracking: CSI < Cb;Cx;Y M
2191- // Mouse events should NOT trigger leader switch
2192- try testing.expect(!isUserInput("\x1b[<0;1;1M")); // button release
2193- try testing.expect(!isUserInput("\x1b[<64;1;1M")); // button press
2194-}
2195-
2196-test "isUserInput: focus events excluded" {
2197- // Focus in/out are automatic terminal events, not user typing
2198- try testing.expect(!isUserInput("\x1b[I")); // focus in
2199- try testing.expect(!isUserInput("\x1b[O")); // focus out
2200-}
2201-
2202-test "isUserInput: terminal query replies excluded" {
2203- // Replies every attached terminal sends to queries the app broadcasts.
2204- try testing.expect(!isUserInput("\x1b[?5u")); // kitty keyboard flags
2205- try testing.expect(!isUserInput("\x1b[?0u"));
2206- try testing.expect(!isUserInput("\x1b[?62;22;52c")); // DA1
2207- try testing.expect(!isUserInput("\x1b[>1;10;0c")); // DA2
2208- try testing.expect(!isUserInput("\x1b[?2026;2$y")); // DECRPM
2209- try testing.expect(!isUserInput("\x1b[?997;1n")); // color scheme
2210- try testing.expect(!isUserInput("\x1b[12;40R")); // cursor position
2211- try testing.expect(!isUserInput("\x1b[6;32;14t")); // cell size
2212- // XTVERSION + kitty flags + DA1 in one read, as a passive Ghostty
2213- // client sent it in the wild (it made that viewer the leader).
2214- try testing.expect(!isUserInput("\x1bP>|ghostty 1.3.2-main+64f5cc175\x1b\\\x1b[?5u\x1b[?62;22;52c"));
2215-}
2216-
2217-test "isUserInput: key after a reply in the same read still counts" {
2218- try testing.expect(isUserInput("\x1b[?5ux"));
2219- try testing.expect(isUserInput("\x1b[?62;22c\x1b[99;5u")); // reply then kitty ctrl+c
2220- try testing.expect(isUserInput("\x1b[?5u\x1b[3~")); // reply then delete
2221-}
2222-
2223-test "isUserInput: bracketed paste included" {
2224- // Bracketed paste start/end are user-initiated paste operations
2225- try testing.expect(isUserInput("\x1b[200~")); // paste start
2226- try testing.expect(isUserInput("\x1b[201~")); // paste end
2227- // Content between start/end is also user input
2228- try testing.expect(isUserInput("\x1b[200~hello\x1b[201~"));
2229-}
2230-
22311335 test "stripAnsi: plain text passes through" {
22321336 const alloc = testing.allocator;
22331337 const result = try stripAnsi(alloc, "hello world\n");
......@@ -2313,15 +1417,3 @@ test "stripAnsi: UTF-8 bytes do not start escape sequences" {
23131417 defer alloc.free(result);
23141418 try testing.expectEqualStrings("Ýmir\nline two\n", result);
23151419 }
2316-
2317-test "isUserInput: kitty keyboard status reply is a terminal response" {
2318- // CSI ? flags u answers the program's CSI ? u query; no key was pressed
2319- try testing.expect(!isUserInput("\x1b[?1u"));
2320- try testing.expect(!isUserInput("\x1b[?0u"));
2321- try testing.expect(!isUserInput("\x1b[?31u"));
2322- // a real key coalesced with the reply still counts
2323- try testing.expect(isUserInput("\x1b[?1ua"));
2324- try testing.expect(isUserInput("\x1b[?1u\r"));
2325- try testing.expect(isUserInput("\x1b[?1u\x1b[97;1u"));
2326- try testing.expect(!isUserInput("\x1b[?1u\x1b[97;1:3u"));
2327-}