a73x

7984d06e

feat: mux client with replica grid, renderer, resize, Ctrl-\ detach

a73x   2026-08-08 14:08

Commit message
feat: mux client with replica grid, renderer, resize, Ctrl-\ detach

build.zig
Old New
@@ -41,6 +41,23 @@ pub fn build(b: *std.Build) void {
41 server_mod.addImport("pty", pty_mod); 41 server_mod.addImport("pty", pty_mod);
42 server_mod.addImport("protocol", protocol_mod); 42 server_mod.addImport("protocol", protocol_mod);
43 43
44 const client_mod = b.createModule(.{
45 .root_source_file = b.path("src/client.zig"),
46 .target = target,
47 .optimize = optimize,
48 .link_libc = true,
49 });
50 client_mod.addImport("engine", engine_mod);
51 client_mod.addImport("protocol", protocol_mod);
52
53 const mux_mod = b.createModule(.{
54 .root_source_file = b.path("src/mux_main.zig"),
55 .target = target,
56 .optimize = optimize,
57 .link_libc = true,
58 });
59 mux_mod.addImport("client", client_mod);
60
44 const exe_mod = b.createModule(.{ 61 const exe_mod = b.createModule(.{
45 .root_source_file = b.path("src/main.zig"), 62 .root_source_file = b.path("src/main.zig"),
46 .target = target, 63 .target = target,
@@ -57,8 +74,13 @@ pub fn build(b: *std.Build) void {
57 exe.use_lld = true; 74 exe.use_lld = true;
58 b.installArtifact(exe); 75 b.installArtifact(exe);
59 76
77 const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod });
78 mux_exe.use_llvm = true;
79 mux_exe.use_lld = true;
80 b.installArtifact(mux_exe);
81
60 const test_step = b.step("test", "Run unit tests"); 82 const test_step = b.step("test", "Run unit tests");
61 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod }) |mod| { 83 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod }) |mod| {
62 const t = b.addTest(.{ .root_module = mod }); 84 const t = b.addTest(.{ .root_module = mod });
63 t.use_llvm = true; 85 t.use_llvm = true;
64 t.use_lld = true; 86 t.use_lld = true;
@@ -67,6 +89,7 @@ pub fn build(b: *std.Build) void {
67 89
68 const e2e = b.addSystemCommand(&.{"test/e2e.sh"}); 90 const e2e = b.addSystemCommand(&.{"test/e2e.sh"});
69 e2e.addArtifactArg(exe); 91 e2e.addArtifactArg(exe);
92 e2e.addArtifactArg(mux_exe);
70 const e2e_step = b.step("e2e", "Run end-to-end test"); 93 const e2e_step = b.step("e2e", "Run end-to-end test");
71 e2e_step.dependOn(&e2e.step); 94 e2e_step.dependOn(&e2e.step);
72 } 95 }
src/client.zig
Old New
@@ -0,0 +1,160 @@
1 //! mux client: connects, attaches, maintains a replica engine rebuilt
2 //! from snapshots, repaints the local terminal, forwards keystrokes.
3 //! Detach chord: Ctrl-\ (0x1c). No keybinding layer in this prototype.
4 const std = @import("std");
5 const Engine = @import("engine").Engine;
6 const proto = @import("protocol");
7
8 var winch_flag = std.atomic.Value(bool).init(false);
9
10 fn onWinch(_: c_int) callconv(.c) void {
11 winch_flag.store(true, .release);
12 }
13
14 pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
15 const stream = std.net.connectUnixSocket(sock_path) catch {
16 std.debug.print("mux: cannot connect to {s} (is muxd running?)\n", .{sock_path});
17 return 1;
18 };
19 defer stream.close();
20 const sock = stream.handle;
21
22 const stdin_fd = std.posix.STDIN_FILENO;
23 const stdout_fd = std.posix.STDOUT_FILENO;
24 const is_tty = std.posix.isatty(stdin_fd);
25
26 var size = ttySize(stdout_fd) orelse proto.Size{ .cols = 80, .rows = 24 };
27
28 var replica = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows });
29 defer replica.deinit();
30
31 // Raw mode + alternate screen when we own a terminal.
32 var orig_termios: ?std.posix.termios = null;
33 if (is_tty) {
34 const orig = try std.posix.tcgetattr(stdin_fd);
35 orig_termios = orig;
36 var raw = orig;
37 raw.lflag.ICANON = false;
38 raw.lflag.ECHO = false;
39 raw.lflag.ISIG = false;
40 raw.iflag.IXON = false;
41 raw.iflag.ICRNL = false;
42 try std.posix.tcsetattr(stdin_fd, .FLUSH, raw);
43 try proto.writeAllFd(stdout_fd, "\x1b[?1049h\x1b[?25l");
44
45 var sa: std.posix.Sigaction = .{
46 .handler = .{ .handler = onWinch },
47 .mask = std.posix.sigemptyset(),
48 .flags = 0,
49 };
50 std.posix.sigaction(std.posix.SIG.WINCH, &sa, null);
51 }
52 defer if (orig_termios) |t| {
53 proto.writeAllFd(stdout_fd, "\x1b[?25h\x1b[?1049l") catch {};
54 std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {};
55 };
56
57 try proto.writeFrame(sock, .attach, &proto.encodeSize(size.cols, size.rows));
58
59 var stdin_open = true;
60 var buf: [16 * 1024]u8 = undefined;
61 while (true) {
62 if (winch_flag.swap(false, .acq_rel)) {
63 if (ttySize(stdout_fd)) |new_size| {
64 if (new_size.cols != size.cols or new_size.rows != size.rows) {
65 size = new_size;
66 try replica.resize(size.cols, size.rows);
67 try proto.writeFrame(sock, .resize, &proto.encodeSize(size.cols, size.rows));
68 }
69 }
70 }
71
72 var fds = [_]std.posix.pollfd{
73 .{ .fd = sock, .events = std.posix.POLL.IN, .revents = 0 },
74 .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 },
75 };
76 _ = try std.posix.poll(&fds, 100);
77
78 if (fds[0].revents != 0) {
79 const frame = (try proto.readFrame(alloc, sock)) orelse return 1;
80 defer frame.deinit(alloc);
81 switch (frame.type) {
82 .snapshot => {
83 replica.reset();
84 replica.feed(frame.payload);
85 try render(alloc, replica, stdout_fd);
86 },
87 .exit_status => {
88 return if (frame.payload.len >= 1) frame.payload[0] else 0;
89 },
90 else => {},
91 }
92 }
93
94 if (stdin_open and fds[1].revents != 0) {
95 const n = std.posix.read(stdin_fd, &buf) catch 0;
96 if (n == 0) {
97 stdin_open = false;
98 } else {
99 if (std.mem.indexOfScalar(u8, buf[0..n], 0x1c) != null) {
100 // Ctrl-\: detach and leave the session running.
101 proto.writeFrame(sock, .detach, "") catch {};
102 return 0;
103 }
104 try proto.writeFrame(sock, .input, buf[0..n]);
105 }
106 }
107 }
108 }
109
110 fn ttySize(fd: std.posix.fd_t) ?proto.Size {
111 if (!std.posix.isatty(fd)) return null;
112 var ws: std.posix.winsize = undefined;
113 if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null;
114 return .{ .cols = ws.col, .rows = ws.row };
115 }
116
117 /// Repaint the whole replica: styled dump + cursor, bracketed by
118 /// synchronized output so capable terminals apply it atomically.
119 fn render(alloc: std.mem.Allocator, replica: *Engine, out_fd: std.posix.fd_t) !void {
120 var paint: std.ArrayList(u8) = .empty;
121 defer paint.deinit(alloc);
122
123 try paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J");
124 const styled = try replica.dumpVt(alloc);
125 defer alloc.free(styled);
126 try paint.appendSlice(alloc, styled);
127
128 const cur = replica.cursorPos();
129 var cup_buf: [32]u8 = undefined;
130 const cup = try std.fmt.bufPrint(&cup_buf, "\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 });
131 try paint.appendSlice(alloc, cup);
132
133 try paint.appendSlice(alloc, "\x1b[?25h\x1b[?2026l");
134 try proto.writeAllFd(out_fd, paint.items);
135 }
136
137 test "render paints replica content with cursor restore" {
138 const alloc = std.testing.allocator;
139 var replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
140 defer replica.deinit();
141 replica.feed("painted\x1b[3;7H");
142
143 const pipe = try std.posix.pipe();
144 defer std.posix.close(pipe[0]);
145 try render(alloc, replica, pipe[1]);
146 std.posix.close(pipe[1]);
147
148 var out: std.ArrayList(u8) = .empty;
149 defer out.deinit(alloc);
150 var chunk: [4096]u8 = undefined;
151 while (true) {
152 const n = try std.posix.read(pipe[0], &chunk);
153 if (n == 0) break;
154 try out.appendSlice(alloc, chunk[0..n]);
155 }
156
157 try std.testing.expect(std.mem.indexOf(u8, out.items, "painted") != null);
158 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[3;7H") != null); // cursor
159 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?2026h") != null); // sync
160 }
src/engine.zig
Old New
@@ -65,11 +65,14 @@ pub const Engine = struct {
65 return self.term.plainString(alloc); 65 return self.term.plainString(alloc);
66 } 66 }
67 67
68 /// Visible screen with SGR/style sequences preserved. Caller frees. 68 /// Visible screen with SGR/style sequences preserved — content only,
69 /// no palette/mode side effects, so it is safe to paint onto a host
70 /// terminal (the client renderer) without clobbering its theme.
69 pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 { 71 pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 {
70 var aw: std.Io.Writer.Allocating = .init(alloc); 72 var aw: std.Io.Writer.Allocating = .init(alloc);
71 defer aw.deinit(); 73 defer aw.deinit();
72 const f = vt.formatter.TerminalFormatter.init(&self.term, .vt); 74 var f = vt.formatter.TerminalFormatter.init(&self.term, .vt);
75 f.extra = .none;
73 try f.format(&aw.writer); 76 try f.format(&aw.writer);
74 return try aw.toOwnedSlice(); 77 return try aw.toOwnedSlice();
75 } 78 }
src/mux_main.zig
Old New
@@ -0,0 +1,36 @@
1 //! mux — client binary. `mux [--sock PATH]` attaches to the running muxd.
2 const std = @import("std");
3 const client = @import("client");
4
5 const usage = "usage: mux [--sock PATH]\n";
6
7 pub fn main() !u8 {
8 var gpa: std.heap.DebugAllocator(.{}) = .init;
9 defer _ = gpa.deinit();
10 const alloc = gpa.allocator();
11
12 const args = try std.process.argsAlloc(alloc);
13 defer std.process.argsFree(alloc, args);
14
15 var sock_arg: ?[]const u8 = null;
16 var i: usize = 1;
17 while (i < args.len) : (i += 1) {
18 if (std.mem.eql(u8, args[i], "--sock") and i + 1 < args.len) {
19 i += 1;
20 sock_arg = args[i];
21 } else {
22 std.debug.print("{s}", .{usage});
23 return 2;
24 }
25 }
26
27 const sock_path = if (sock_arg) |s|
28 try alloc.dupe(u8, s)
29 else if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir|
30 try std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir})
31 else
32 try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()});
33 defer alloc.free(sock_path);
34
35 return client.attach(alloc, sock_path);
36 }