2197eacd
refactor: client names no tui module — the detach byte is keymap's
a73x 2026-08-28 20:28
Commit message
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 |