a73x

2197eacd

refactor: client names no tui module — the detach byte is keymap's

a73x   2026-08-28 20:28

Commit message
refactor: client names no tui module — the detach byte is keymap's

The whole client -> interact edge was one u8. The dialler watches for
Ctrl-\ with no session to command and no terminal to drive, so the byte
belongs beside the other bytes a key turns into, not beside the filter
that consumes it in a live session.

folder_exemptions is now empty and kept so. With nothing left to hide
behind, rule 3 refuses a client -> tui import outright, and `client`'s
import closure — protocol, replica, engine, keymap, quic_client, quic,
handoff, xdg, wall — names no termios and no tty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

CLAUDE.md
Old New
@@ -55,8 +55,9 @@ engine and a client can link those folders and paint its own way:
55 server or cli module; server names no client and no terminal; tui imports 55 server or cli module; server names no client and no terminal; tui imports
56 client and never the reverse; and no line outside a `test` block under 56 client and never the reverse; and no line outside a `test` block under
57 `src/engine/` or `src/client/` spells `termios` or an escape byte without a 57 `src/engine/` or `src/client/` spells `termios` or an escape byte without a
58 `// folder rule 4 exemption:` line saying why. The known debts are signed in 58 `// folder rule 4 exemption:` line saying why. `folder_exemptions` is empty and
59 `folder_exemptions` and those markers. 59 kept so; the three remaining debts are the markers in `engine.zig`,
60 `protocol.zig` and `keymap.zig`, each of which produces VT bytes by contract.
60 61
61 Layers are enforced in the same module table (grep `.layer =` for the graph). 62 Layers are enforced in the same module table (grep `.layer =` for the graph).
62 63
build.zig
Old New
@@ -249,7 +249,7 @@ const mod_table = [_]ModSpec{
249 // must be drivable by anything holding a transport — the CLI client 249 // must be drivable by anything holding a transport — the CLI client
250 // today, a wall tile from the wall's phase-3 convergence on — and it 250 // today, a wall tile from the wall's phase-3 convergence on — and it
251 // names no transport type for exactly that reason (see its header). 251 // names no transport type for exactly that reason (see its header).
252 .{ .name = "interact", .path = "src/tui/interact.zig", .layer = 2, .imports = &.{ "engine", "protocol", "replica", "predict", "client_core", "paint", "select" } }, 252 .{ .name = "interact", .path = "src/tui/interact.zig", .layer = 2, .imports = &.{ "engine", "protocol", "replica", "predict", "client_core", "keymap", "paint", "select" } },
253 // quic and quic_server both: the listener it owns, and the vocabulary 253 // quic and quic_server both: the listener it owns, and the vocabulary
254 // it names directly (the key it loads, the idle default it falls back 254 // it names directly (the key it loads, the idle default it falls back
255 // to). xdg is for endpoint_req's lazy bind — the default key path, 255 // to). xdg is for endpoint_req's lazy bind — the default key path,
@@ -273,7 +273,7 @@ const mod_table = [_]ModSpec{
273 // attach records its own tile (the wall is attach history), and the 273 // attach records its own tile (the wall is attach history), and the
274 // chord switches that re-dial from inside client.attach have to record 274 // chord switches that re-dial from inside client.attach have to record
275 // theirs too, so the writer cannot live up in mux_main. 275 // theirs too, so the writer cannot live up in mux_main.
276 .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "interact", "quic_client", "quic", "handoff", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 276 .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "keymap", "quic_client", "quic", "handoff", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
277 // The daemon entrypoint loads the key and constructs the listener, so 277 // The daemon entrypoint loads the key and constructs the listener, so
278 // it needs quic/quic_server directly rather than through the server. 278 // it needs quic/quic_server directly rather than through the server.
279 // `muxd endpoint` prints the announce line handoff spells; sockpath is 279 // `muxd endpoint` prints the announce line handoff spells; sockpath is
@@ -386,19 +386,9 @@ const src_dirs = [_][]const u8{ "src", "src/engine", "src/server", "src/client",
386 /// A folder edge the rules forbid and this repo still has, signed with its 386 /// A folder edge the rules forbid and this repo still has, signed with its
387 /// reason. Not a waiver mechanism to reach for: an entry here is a stated 387 /// reason. Not a waiver mechanism to reach for: an entry here is a stated
388 /// debt, and the rule's whole value is that removing one is a visible diff. 388 /// debt, and the rule's whole value is that removing one is a visible diff.
389 /// Empty, and kept so — an edge nobody could sign is one nobody adds quietly.
389 const FolderExemption = struct { from: []const u8, to: []const u8, why: []const u8 }; 390 const FolderExemption = struct { from: []const u8, to: []const u8, why: []const u8 };
390 const folder_exemptions = [_]FolderExemption{ 391 const folder_exemptions = [_]FolderExemption{};
391 .{
392 .from = "client",
393 .to = "interact",
394 // The attach loop drives the terminal-facing machinery directly, so
395 // today `client` cannot be linked without a tty — exactly the thing
396 // the rule exists to make visible. Splitting interact into the
397 // transport-driving half and the tty-owning half is the fix, and it
398 // is bigger than a folder move.
399 .why = "client.attach drives interact directly; linking client still pulls a tty in",
400 },
401 };
402 392
403 fn folderExempt(from: []const u8, to: []const u8) bool { 393 fn folderExempt(from: []const u8, to: []const u8) bool {
404 for (folder_exemptions) |e| { 394 for (folder_exemptions) |e| {
src/client/client.zig
Old New
@@ -14,10 +14,10 @@
14 //! asks for by name. Everything at the terminal is interact.zig's. 14 //! asks for by name. Everything at the terminal is interact.zig's.
15 const std = @import("std"); 15 const std = @import("std");
16 const Replica = @import("replica").Replica; 16 const Replica = @import("replica").Replica;
17 // For `interact.detach_key` only — the abort byte both opening waits watch 17 // For `keymap.detach_key` only — the abort byte both opening waits watch for.
18 // for. Everything ELSE this module used to reach into interact for went with 18 // The dial has no session to command and no terminal to drive, which is why
19 // the session loop. 19 // the byte comes from keymap rather than from the filter that consumes it.
20 const interact = @import("interact"); 20 const keymap = @import("keymap");
21 const proto = @import("protocol"); 21 const proto = @import("protocol");
22 const TmpDir = @import("testtmp").TmpDir; 22 const TmpDir = @import("testtmp").TmpDir;
23 const quic_client = @import("quic_client"); 23 const quic_client = @import("quic_client");
@@ -751,7 +751,7 @@ fn waitReady(
751 const n = std.posix.read(abort_fd, &buf) catch 0; 751 const n = std.posix.read(abort_fd, &buf) catch 0;
752 if (n == 0) watch_stdin = false; 752 if (n == 0) watch_stdin = false;
753 if (n > 0) { 753 if (n > 0) {
754 if (std.mem.indexOfScalar(u8, buf[0..n], interact.detach_key) != null) return error.UserAbort; 754 if (std.mem.indexOfScalar(u8, buf[0..n], keymap.detach_key) != null) return error.UserAbort;
755 // Not the abort key. Whether these bytes are kept or dropped 755 // Not the abort key. Whether these bytes are kept or dropped
756 // is the caller's policy, not this function's: on a first 756 // is the caller's policy, not this function's: on a first
757 // attach they are the user's first keystrokes and are owed to 757 // attach they are the user's first keystrokes and are owed to
@@ -806,7 +806,7 @@ fn readAnnounceAbortable(
806 const got = std.posix.read(abort_fd, &in) catch 0; 806 const got = std.posix.read(abort_fd, &in) catch 0;
807 if (got == 0) watch_stdin = false; 807 if (got == 0) watch_stdin = false;
808 if (got > 0) { 808 if (got > 0) {
809 if (std.mem.indexOfScalar(u8, in[0..got], interact.detach_key) != null) 809 if (std.mem.indexOfScalar(u8, in[0..got], keymap.detach_key) != null)
810 return error.UserAbort; 810 return error.UserAbort;
811 // `openHandoff` always passes `carry` null: on a first 811 // `openHandoff` always passes `carry` null: on a first
812 // attach this fd is -1 and never read — the keystrokes 812 // attach this fd is -1 and never read — the keystrokes
src/client/keymap.zig
Old New
@@ -14,6 +14,12 @@
14 14
15 const std = @import("std"); 15 const std = @import("std");
16 16
17 // Ctrl-\. In a live session it is the command prefix (interact.PrefixFilter);
18 // while dialling or reconnecting there is no session to command, so a bare
19 // press still means "give up", which is why the byte is named here and not
20 // beside the filter — the dialler must know it without linking a terminal.
21 pub const detach_key: u8 = 0x1c;
22
17 pub const Mods = packed struct { 23 pub const Mods = packed struct {
18 shift: bool = false, 24 shift: bool = false,
19 alt: bool = false, 25 alt: bool = false,
src/tui/interact.zig
Old New
@@ -29,6 +29,9 @@ const client_core = @import("client_core");
29 // `paint`, which a container-level `paint` would collide with. 29 // `paint`, which a container-level `paint` would collide with.
30 const paint_mod = @import("paint"); 30 const paint_mod = @import("paint");
31 const select = @import("select"); 31 const select = @import("select");
32 // The command prefix byte. Owned by keymap because the dialler in client.zig
33 // watches for it with no session and no terminal in the picture.
34 const detach_key = @import("keymap").detach_key;
32 35
33 /// What `select` answers, in the shape `paint` asks for. 36 /// What `select` answers, in the shape `paint` asks for.
34 /// 37 ///
@@ -64,11 +67,6 @@ pub const Highlight = struct {
64 } 67 }
65 }; 68 };
66 69
67 // Ctrl-\. In a live session it is the command prefix (see PrefixFilter);
68 // while dialling or reconnecting there is no session to command, so a bare
69 // press still means "give up".
70 pub const detach_key: u8 = 0x1c;
71
72 /// The attached client's keybinding layer: Ctrl-\ selects a command rather 70 /// The attached client's keybinding layer: Ctrl-\ selects a command rather
73 /// than acting on its own. `d` or a second Ctrl-\ detach, `c` creates a new 71 /// than acting on its own. `d` or a second Ctrl-\ detach, `c` creates a new
74 /// session, `n` and `p` step to the next and previous one, `l` skips to the 72 /// session, `n` and `p` step to the next and previous one, `l` skips to the