49907e99
docs: plan — the wall is the layout, ten tasks
a73x 2026-09-03 05:20
Commit message
docs/superpowers/plans/2026-09-02-wall-is-the-layout.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,1679 @@ | |||
| 1 | # The Wall Is the Layout — Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** Tiles come from the layout file and from nothing else; the once-a-second poll grades panes and never adds one; sessions join a wall through the picker; `x` removes a pane without ending its session. | ||
| 6 | |||
| 7 | **Architecture:** The layout sidecar (`$XDG_STATE_HOME/mux/layout`, leaves spelled `HOST#SESSION`) becomes authored intent, saved on every change and loaded strictly. `wall_host.planHostDiff` loses its `births` output; `wall_layout.seedLayout` loses its healing against live lists; the picker gains a session level backed by a side-connection `end_req`; the hub reads and writes the same file. Each task leaves `make check` green and the e2e groups it touches green. | ||
| 8 | |||
| 9 | **Tech Stack:** Zig 0.15.2 (vendored at `deps/zig/zig`), POSIX shell e2e under `test/`, ptyclient fixture for real-terminal legs. | ||
| 10 | |||
| 11 | **Spec:** `docs/superpowers/specs/2026-09-02-wall-is-the-layout-design.md` | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - Build only with `deps/zig/zig` (`make build test e2e check`); the system zig does not build this tree. | ||
| 16 | - `make check` before every commit; it runs fmt, unit tests, shell syntax, and the comment-reference gate (`zig build check`). A cited symbol in a comment must resolve. | ||
| 17 | - A unit test must never write to fd 1: it wedges `zig build test` silently. Diagnostics go to `std.debug.print`. | ||
| 18 | - Every e2e leg runs under an isolated `XDG_STATE_HOME` and `XDG_RUNTIME_DIR` (e2e_lib does this); every hand rig exports both. | ||
| 19 | - Commit subjects are `type: what changed`, type in `feat fix refactor test docs build chore`, no scope. | ||
| 20 | - Fixtures are plural by default: two daemons, three sessions each, unless the leg is about N=1. | ||
| 21 | - `test/e2e.sh` pins the scenario count (`OK_COUNT`) and the convergence count; every added or removed `ok "..."` line moves the first pin, and `E2E_ONLY=<group>` runs never check it. | ||
| 22 | - Comment rule: say *why*, plainly. No project-history codenames. | ||
| 23 | - The wire stays backward compatible: an old client against a new daemon and a new client against an old daemon must both keep working for everything that worked before. | ||
| 24 | |||
| 25 | --- | ||
| 26 | |||
| 27 | ## File map | ||
| 28 | |||
| 29 | | File | Responsibility after this plan | | ||
| 30 | |---|---| | ||
| 31 | | `src/engine/protocol.zig` | `# holds NAME N` lines in `sessions_reply`: `appendSessionsHolds`, `parseSessionsHolds`, the grown `sessions_reply_max` | | ||
| 32 | | `src/server/server.zig` | the `.sessions_req` arm appends one holds line per session | | ||
| 33 | | `src/client/client.zig` | `endSession`: `end_req` over a side connection, mirroring `birthSession` | | ||
| 34 | | `src/client/layout.zig` | unchanged parser and `Tree`; used by both the wall and the hub | | ||
| 35 | | `src/tui/wall_layout.zig` | `seedLayout` takes every leaf verbatim, refuses a bad file with its line; `persist` is the one save path, gated on `Shared.layout_path` | | ||
| 36 | | `src/tui/wall_host.zig` | `planHostDiff` grades (bind, gone, vanish) and births nothing; `applyHostList` places no tiles | | ||
| 37 | | `src/tui/wallview.zig` | `Shared.layout_path`; `removePane`; the keyboard loop persists after every change; `keeps_wall` gone | | ||
| 38 | | `src/tui/interact.zig` | the picker filter has two levels and the new actions | | ||
| 39 | | `src/tui/wall_picker.zig` | session rows, `pickAdd`, `pickEnd` | | ||
| 40 | | `src/client/webhub.zig` | `Hub.init` takes the layout's leaves; `applyList` grades; `spawn` writes the layout | | ||
| 41 | | `src/cli/webhub_main.zig` | reads the layout file and hands its leaves to the hub | | ||
| 42 | | `test/e2e_09_hosts.sh`, `test/e2e_12_panes.sh`, `test/e2e_13_birth.sh`, `test/e2e_06_web.sh`, `test/e2e_07_wallcli.sh` | rewritten legs; `test/e2e.sh` pin | | ||
| 43 | | `README.md`, `CLAUDE.md`, `docs/decisions.md` | the new model stated | | ||
| 44 | |||
| 45 | --- | ||
| 46 | |||
| 47 | ### Task 1: `# holds NAME N` lines in `sessions_reply` | ||
| 48 | |||
| 49 | **Files:** | ||
| 50 | - Modify: `src/engine/protocol.zig` (beside `appendSessionsMeta` / `parseSessionsMeta`) | ||
| 51 | - Modify: `src/server/server.zig` (the `.sessions_req` arm of the observer frame switch, the one that calls `self.sessions.text`) | ||
| 52 | - Test: `src/engine/protocol.zig` (inline tests), `src/server/server_test_session.zig` | ||
| 53 | |||
| 54 | **Interfaces:** | ||
| 55 | - Produces: `proto.sessions_holds_prefix: []const u8 = "# holds "`, `proto.appendSessionsHolds(buf: []u8, len: usize, name: []const u8, holds: u8) usize`, `proto.parseSessionsHolds(payload: []const u8, name: []const u8) ?u8`, and a larger `proto.sessions_reply_max`. | ||
| 56 | - Consumed by: Task 6 (the picker's session rows). | ||
| 57 | |||
| 58 | - [ ] **Step 1: Write the failing protocol tests** | ||
| 59 | |||
| 60 | Append to `src/engine/protocol.zig`, next to the `parseSessionsMeta` tests: | ||
| 61 | |||
| 62 | ```zig | ||
| 63 | test "sessions holds: a holds line rides beside the names, old readers skip it, and a name reads its own count" { | ||
| 64 | var buf: [sessions_reply_max]u8 = undefined; | ||
| 65 | @memcpy(buf[0..4], "0\nwk"); | ||
| 66 | var len: usize = 4; | ||
| 67 | len = appendSessionsHolds(&buf, len, "0", 1); | ||
| 68 | len = appendSessionsHolds(&buf, len, "wk", 0); | ||
| 69 | const payload = buf[0..len]; | ||
| 70 | try std.testing.expectEqualStrings("0\nwk\n# holds 0 1\n# holds wk 0", payload); | ||
| 71 | |||
| 72 | // The iterator every old client walks yields the names and nothing else. | ||
| 73 | var it = sessionsIter(payload); | ||
| 74 | try std.testing.expectEqualStrings("0", it.next().?); | ||
| 75 | try std.testing.expectEqualStrings("wk", it.next().?); | ||
| 76 | try std.testing.expect(it.next() == null); | ||
| 77 | |||
| 78 | try std.testing.expectEqual(@as(?u8, 1), parseSessionsHolds(payload, "0")); | ||
| 79 | try std.testing.expectEqual(@as(?u8, 0), parseSessionsHolds(payload, "wk")); | ||
| 80 | // A name the daemon did not count, and an old daemon's payload with no | ||
| 81 | // holds lines at all, both read as unknown rather than zero. | ||
| 82 | try std.testing.expect(parseSessionsHolds(payload, "w") == null); | ||
| 83 | try std.testing.expect(parseSessionsHolds("0\nwk", "0") == null); | ||
| 84 | // The meta line and the holds lines coexist in either order. | ||
| 85 | const with_meta = appendSessionsMeta(&buf, len, "0.0.1-18", false); | ||
| 86 | try std.testing.expectEqual(@as(?u8, 1), parseSessionsHolds(buf[0..with_meta], "0")); | ||
| 87 | try std.testing.expectEqualStrings("0.0.1-18", parseSessionsMeta(buf[0..with_meta]).?.version); | ||
| 88 | } | ||
| 89 | |||
| 90 | test "sessions holds: sessions_reply_max holds every session's name, holds line and the meta line" { | ||
| 91 | // The daemon writes names, then one holds line per session, then meta, | ||
| 92 | // into ONE buffer of this size; the bound must cover the worst case. | ||
| 93 | const worst_line = sessions_holds_prefix.len + session_name_max + 1 + 3; | ||
| 94 | try std.testing.expect(sessions_reply_max >= sessions_text_max + sessions_max * (worst_line + 1) + sessions_meta_max); | ||
| 95 | } | ||
| 96 | ``` | ||
| 97 | |||
| 98 | - [ ] **Step 2: Run the tests to see them fail** | ||
| 99 | |||
| 100 | Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -5` | ||
| 101 | Expected: compile errors naming `appendSessionsHolds`, `parseSessionsHolds`, `sessions_holds_prefix`. | ||
| 102 | |||
| 103 | - [ ] **Step 3: Implement the codec** | ||
| 104 | |||
| 105 | In `src/engine/protocol.zig`, replace the `sessions_reply_max` line and add after `sessions_meta_max`: | ||
| 106 | |||
| 107 | ```zig | ||
| 108 | /// One `# holds NAME N` line per session, appended by a daemon that can | ||
| 109 | /// count: how many clients hold that session. The picker's session list | ||
| 110 | /// shows it, and the end key's first press is judged against it. Spelled | ||
| 111 | /// as a `#` line so `sessionsIter`, which yields only valid session names, | ||
| 112 | /// skips it on a client that predates it — exactly as it skips the meta | ||
| 113 | /// line — and a daemon that predates it sends none, which | ||
| 114 | /// `parseSessionsHolds` reads as unknown. | ||
| 115 | pub const sessions_holds_prefix = "# holds "; | ||
| 116 | /// `# holds NAME N\n` at its widest: N is a u8, so at most three digits. | ||
| 117 | pub const sessions_holds_line_max = sessions_holds_prefix.len + session_name_max + 1 + 3 + 1; | ||
| 118 | pub const sessions_reply_max = sessions_text_max + sessions_max * sessions_holds_line_max + sessions_meta_max; | ||
| 119 | |||
| 120 | pub fn appendSessionsHolds(buf: []u8, len: usize, name: []const u8, holds: u8) usize { | ||
| 121 | var w = len; | ||
| 122 | if (w != 0) { | ||
| 123 | if (w >= buf.len) return len; | ||
| 124 | buf[w] = '\n'; | ||
| 125 | w += 1; | ||
| 126 | } | ||
| 127 | const line = std.fmt.bufPrint(buf[w..], "{s}{s} {d}", .{ sessions_holds_prefix, name, holds }) catch return len; | ||
| 128 | return w + line.len; | ||
| 129 | } | ||
| 130 | |||
| 131 | pub fn parseSessionsHolds(payload: []const u8, name: []const u8) ?u8 { | ||
| 132 | var lines = std.mem.splitScalar(u8, payload, '\n'); | ||
| 133 | while (lines.next()) |line| { | ||
| 134 | if (!std.mem.startsWith(u8, line, sessions_holds_prefix)) continue; | ||
| 135 | const rest = line[sessions_holds_prefix.len..]; | ||
| 136 | const sp = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse continue; | ||
| 137 | if (!std.mem.eql(u8, rest[0..sp], name)) continue; | ||
| 138 | return std.fmt.parseInt(u8, rest[sp + 1 ..], 10) catch continue; | ||
| 139 | } | ||
| 140 | return null; | ||
| 141 | } | ||
| 142 | ``` | ||
| 143 | |||
| 144 | `sessions_max` is the existing constant `sessions_text_max` is derived from; keep the names as they are in the file. | ||
| 145 | |||
| 146 | - [ ] **Step 4: Run the protocol tests** | ||
| 147 | |||
| 148 | Run: `deps/zig/zig build test 2>&1 | grep -a "sessions holds\|error:" | head -5` | ||
| 149 | Expected: no errors; the two tests are not named on failure lines. | ||
| 150 | |||
| 151 | - [ ] **Step 5: Write the failing daemon test** | ||
| 152 | |||
| 153 | Append to `src/server/server_test_session.zig` (use the file's existing `TestDaemon`/harness import — read its first test to copy the setup shape; the assertion below is the whole point): | ||
| 154 | |||
| 155 | ```zig | ||
| 156 | test "sessions_req: a daemon that states its version also states how many clients hold each session" { | ||
| 157 | // Two sessions, one of them held by a client: the holds lines say 1 | ||
| 158 | // and 0, and an old client's iterator still yields exactly the names. | ||
| 159 | var d = try h.TestDaemon.start(std.testing.allocator, .{ .version = "0.0.1-test" }); | ||
| 160 | defer d.stop(); | ||
| 161 | try d.createSession("wk"); | ||
| 162 | var held = try d.attachClient("0"); | ||
| 163 | defer held.close(); | ||
| 164 | |||
| 165 | var out: [proto.sessions_reply_max]u8 = undefined; | ||
| 166 | const reply = try d.ask(.sessions_req, "", .sessions_reply, &out); | ||
| 167 | try std.testing.expectEqual(@as(?u8, 1), proto.parseSessionsHolds(reply, "0")); | ||
| 168 | try std.testing.expectEqual(@as(?u8, 0), proto.parseSessionsHolds(reply, "wk")); | ||
| 169 | var names = proto.sessionsIter(reply); | ||
| 170 | try std.testing.expectEqualStrings("0", names.next().?); | ||
| 171 | try std.testing.expectEqualStrings("wk", names.next().?); | ||
| 172 | try std.testing.expect(names.next() == null); | ||
| 173 | try std.testing.expectEqualStrings("0.0.1-test", proto.parseSessionsMeta(reply).?.version); | ||
| 174 | } | ||
| 175 | ``` | ||
| 176 | |||
| 177 | If the harness spells `start`, `createSession`, `attachClient`, or `ask` differently, use the harness's own names (grep `pub fn` in `src/server/server_test_harness.zig`); do not add a second harness. | ||
| 178 | |||
| 179 | - [ ] **Step 6: Run it to see it fail** | ||
| 180 | |||
| 181 | Run: `deps/zig/zig build test 2>&1 | grep -a "states how many\|error:" | head -5` | ||
| 182 | Expected: FAIL on the first `expectEqual` (`parseSessionsHolds` returns null: the daemon sends no holds lines yet). | ||
| 183 | |||
| 184 | - [ ] **Step 7: Append the holds lines in the daemon** | ||
| 185 | |||
| 186 | In `src/server/server.zig`, the `.sessions_req` arm currently reads: | ||
| 187 | |||
| 188 | ```zig | ||
| 189 | var buf: [proto.sessions_reply_max]u8 = undefined; | ||
| 190 | const names = self.sessions.text(buf[0..proto.sessions_text_max]); | ||
| 191 | const len = if (self.version.len != 0) | ||
| 192 | proto.appendSessionsMeta(&buf, names.len, self.version, selfImageStale()) | ||
| 193 | else | ||
| 194 | names.len; | ||
| 195 | self.replyTo(p, .sessions_reply, buf[0..len]); | ||
| 196 | ``` | ||
| 197 | |||
| 198 | Replace with: | ||
| 199 | |||
| 200 | ```zig | ||
| 201 | var buf: [proto.sessions_reply_max]u8 = undefined; | ||
| 202 | const names = self.sessions.text(buf[0..proto.sessions_text_max]); | ||
| 203 | // Holds lines and the meta line ride the same gate: a daemon with no | ||
| 204 | // version to state appends nothing, so a bare fixture's payload stays | ||
| 205 | // byte-identical to the old wire and the exact-equality test keeps | ||
| 206 | // pinning it. | ||
| 207 | var len = names.len; | ||
| 208 | if (self.version.len != 0) { | ||
| 209 | for (self.sessions.table, 0..) |slot, si| { | ||
| 210 | const s = slot orelse continue; | ||
| 211 | const holds: u8 = @intCast(@min(self.clientsInSession(si), std.math.maxInt(u8))); | ||
| 212 | len = proto.appendSessionsHolds(&buf, len, s.name(), holds); | ||
| 213 | } | ||
| 214 | len = proto.appendSessionsMeta(&buf, len, self.version, selfImageStale()); | ||
| 215 | } | ||
| 216 | self.replyTo(p, .sessions_reply, buf[0..len]); | ||
| 217 | ``` | ||
| 218 | |||
| 219 | `clientsInSession` exists (`fn clientsInSession(self: *const Server, si: usize) usize`). `appendSessionsMeta` takes `names_len` as the current length; passing `len` is the same contract. | ||
| 220 | |||
| 221 | - [ ] **Step 8: Run the whole gate** | ||
| 222 | |||
| 223 | Run: `make check 2>&1 | tail -3; echo rc=$?` | ||
| 224 | Expected: `rc=0`. If the sibling exact-equality test on a version-less daemon fails, the gate above is wrong; both appends must sit under `self.version.len != 0`. | ||
| 225 | |||
| 226 | - [ ] **Step 9: Commit** | ||
| 227 | |||
| 228 | ```bash | ||
| 229 | git add src/engine/protocol.zig src/server/server.zig src/server/server_test_session.zig | ||
| 230 | git commit -m "feat: sessions_reply says how many clients hold each session" | ||
| 231 | ``` | ||
| 232 | |||
| 233 | --- | ||
| 234 | |||
| 235 | ### Task 2: The layout loads verbatim and refuses a bad file with its line | ||
| 236 | |||
| 237 | **Files:** | ||
| 238 | - Modify: `src/tui/wall_layout.zig` (`seedSidecar`, `seedLayout`, `seedAttempt`, `SeedPlan`) | ||
| 239 | - Modify: `src/tui/wallview.zig` (the `seed_plan` block in `run`; the `dropped` notice) | ||
| 240 | - Test: `src/tui/wall_test_layout.zig` | ||
| 241 | |||
| 242 | **Interfaces:** | ||
| 243 | - Consumes: `layout.parse(alloc, bytes) ?ParsedLayout` (spellings + tree + focus), `Host.spec.spelling`, `wv.max_tiles`. | ||
| 244 | - Produces: `wall_layout.seedLayout(alloc, table, shared, bytes, entry_spelling) SeedResult`, where | ||
| 245 | |||
| 246 | ```zig | ||
| 247 | pub const SeedResult = union(enum) { | ||
| 248 | plan: SeedPlan, | ||
| 249 | /// The file is not a wall: the first offending line, borrowed from | ||
| 250 | /// `bytes`, for the caller to print. The wall then starts as if the | ||
| 251 | /// file were missing. | ||
| 252 | refused: []const u8, | ||
| 253 | /// No file, or a file with no leaves this wall can seat at this size. | ||
| 254 | none, | ||
| 255 | }; | ||
| 256 | ``` | ||
| 257 | |||
| 258 | `SeedPlan` keeps its fields (`panes: []?SeedPane`, `focus: ?usize`, `dropped: usize`); `dropped` now counts only leaves that name the session this `mux` runs inside (the self-loop refusal) and leaves cut to fit the terminal. | ||
| 259 | |||
| 260 | - [ ] **Step 1: Write the failing tests** | ||
| 261 | |||
| 262 | Append to `src/tui/wall_test_layout.zig` (it already imports `wall_layout`, `wv`, `Shared`, `Host` and the harness `fixture`; reuse `fixture.testHost` for hosts): | ||
| 263 | |||
| 264 | ```zig | ||
| 265 | test "seedLayout: every leaf of a good file is a pane, in the file's tree, and nothing else is consulted" { | ||
| 266 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true }; | ||
| 267 | defer shared.tree.deinit(); | ||
| 268 | var table = [_]Host{ | ||
| 269 | fixture.testHost(&shared, "--sock /a", "/a"), | ||
| 270 | fixture.testHost(&shared, "box", "/b"), | ||
| 271 | }; | ||
| 272 | // No poll answer on either host: the file alone decides. | ||
| 273 | const file = | ||
| 274 | \\mux-layout 1 | ||
| 275 | \\beside 0 | ||
| 276 | \\ leaf 1 --sock /a#0 | ||
| 277 | \\ leaf 1 box#work | ||
| 278 | \\ leaf 1 --sock /a#2 | ||
| 279 | \\focus 1 | ||
| 280 | \\ | ||
| 281 | ; | ||
| 282 | var res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null); | ||
| 283 | defer if (res == .plan) res.plan.deinit(std.testing.allocator); | ||
| 284 | try std.testing.expect(res == .plan); | ||
| 285 | try std.testing.expectEqual(@as(usize, 3), res.plan.panes.len); | ||
| 286 | try std.testing.expectEqualStrings("0", res.plan.panes[0].?.session); | ||
| 287 | try std.testing.expectEqual(@as(usize, 0), res.plan.panes[0].?.host); | ||
| 288 | try std.testing.expectEqualStrings("work", res.plan.panes[1].?.session); | ||
| 289 | try std.testing.expectEqual(@as(usize, 1), res.plan.panes[1].?.host); | ||
| 290 | try std.testing.expectEqual(@as(?usize, 1), res.plan.focus); | ||
| 291 | try std.testing.expectEqual(@as(usize, 0), res.plan.dropped); | ||
| 292 | try std.testing.expectEqual(@as(usize, 3), shared.tree.count()); | ||
| 293 | } | ||
| 294 | |||
| 295 | test "seedLayout: a leaf whose host is not in the hosts file refuses the whole file and names the line" { | ||
| 296 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true }; | ||
| 297 | defer shared.tree.deinit(); | ||
| 298 | var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")}; | ||
| 299 | const file = | ||
| 300 | \\mux-layout 1 | ||
| 301 | \\beside 0 | ||
| 302 | \\ leaf 1 --sock /a#0 | ||
| 303 | \\ leaf 1 nowhere#0 | ||
| 304 | \\ | ||
| 305 | ; | ||
| 306 | const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null); | ||
| 307 | try std.testing.expect(res == .refused); | ||
| 308 | try std.testing.expectEqualStrings("nowhere#0", res.refused); | ||
| 309 | // Nothing was seated: the caller starts as if the file were missing. | ||
| 310 | try std.testing.expect(shared.tree.root == null); | ||
| 311 | } | ||
| 312 | |||
| 313 | test "seedLayout: a leaf with no session, a bad name, or a repeat refuses; garbage refuses with its first line" { | ||
| 314 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true }; | ||
| 315 | defer shared.tree.deinit(); | ||
| 316 | var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")}; | ||
| 317 | const no_session = "mux-layout 1\nleaf 0 --sock /a\n"; | ||
| 318 | const bad_name = "mux-layout 1\nleaf 0 --sock /a#no space\n"; | ||
| 319 | const repeat = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#0\n"; | ||
| 320 | const garbage = "not a layout\n"; | ||
| 321 | for ([_][]const u8{ no_session, bad_name, repeat, garbage }) |file| { | ||
| 322 | const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null); | ||
| 323 | try std.testing.expect(res == .refused); | ||
| 324 | try std.testing.expect(res.refused.len > 0); | ||
| 325 | } | ||
| 326 | try std.testing.expectEqualStrings("--sock /a#0", wall_layout.seedLayout(std.testing.allocator, &table, &shared, repeat, null).refused); | ||
| 327 | try std.testing.expectEqualStrings("not a layout", wall_layout.seedLayout(std.testing.allocator, &table, &shared, garbage, null).refused); | ||
| 328 | } | ||
| 329 | |||
| 330 | test "seedLayout: the entry spelling takes leaf 0 when the file has it, and is inserted beside the focus when it does not" { | ||
| 331 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true }; | ||
| 332 | defer shared.tree.deinit(); | ||
| 333 | var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")}; | ||
| 334 | const file = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#1\nfocus 1\n"; | ||
| 335 | var has = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#1"); | ||
| 336 | defer has.plan.deinit(std.testing.allocator); | ||
| 337 | try std.testing.expect(has == .plan); | ||
| 338 | // The entry is pane 0 by contract; the other leaf follows. | ||
| 339 | try std.testing.expect(has.plan.panes[0] == null); // the entry tile is the caller's | ||
| 340 | try std.testing.expectEqualStrings("0", has.plan.panes[1].?.session); | ||
| 341 | try std.testing.expectEqual(@as(usize, 2), shared.tree.count()); | ||
| 342 | |||
| 343 | shared.tree.deinit(); | ||
| 344 | shared.tree = layout.Tree.init(std.testing.allocator); | ||
| 345 | var not = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#9"); | ||
| 346 | defer not.plan.deinit(std.testing.allocator); | ||
| 347 | try std.testing.expect(not == .plan); | ||
| 348 | try std.testing.expectEqual(@as(usize, 3), shared.tree.count()); | ||
| 349 | } | ||
| 350 | ``` | ||
| 351 | |||
| 352 | `layout` here is `@import("client").layout`, already imported by the test file's siblings; add the import if the file lacks it. | ||
| 353 | |||
| 354 | - [ ] **Step 2: Run to see them fail** | ||
| 355 | |||
| 356 | Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -5` | ||
| 357 | Expected: compile errors on `SeedResult`/`.refused`. | ||
| 358 | |||
| 359 | - [ ] **Step 3: Rewrite `seedLayout` and `seedAttempt`** | ||
| 360 | |||
| 361 | Replace `seedLayout` in `src/tui/wall_layout.zig` with: | ||
| 362 | |||
| 363 | ```zig | ||
| 364 | pub const SeedResult = union(enum) { | ||
| 365 | plan: SeedPlan, | ||
| 366 | refused: []const u8, | ||
| 367 | none, | ||
| 368 | }; | ||
| 369 | |||
| 370 | /// The layout is authored: every leaf is a pane, in the saved tree. The | ||
| 371 | /// only things that keep a leaf off the wall are the session this `mux` | ||
| 372 | /// runs inside (a wall may not attach to itself) and a terminal too small | ||
| 373 | /// for the whole tree, which trims from the end. Anything else wrong with | ||
| 374 | /// the file — a host the hosts file does not list, a leaf with no session | ||
| 375 | /// or a bad name, a repeated leaf, more leaves than the wall seats, or | ||
| 376 | /// text that is not a layout — refuses the FILE, with the first bad line, | ||
| 377 | /// because silently seating part of a wall is how a user loses one. | ||
| 378 | pub fn seedLayout( | ||
| 379 | alloc: std.mem.Allocator, | ||
| 380 | table: []const Host, | ||
| 381 | shared: *Shared, | ||
| 382 | bytes: []const u8, | ||
| 383 | entry_spelling: ?[]const u8, | ||
| 384 | ) SeedResult { | ||
| 385 | var probe = layout.parse(alloc, bytes) orelse return .{ .refused = firstLine(bytes) }; | ||
| 386 | defer probe.deinit(alloc); | ||
| 387 | var keeps = std.ArrayListUnmanaged(SeedKeep){}; | ||
| 388 | defer keeps.deinit(alloc); | ||
| 389 | var entry_at: ?usize = null; | ||
| 390 | var dropped: usize = 0; | ||
| 391 | for (probe.spellings.items, 0..) |sp, i| { | ||
| 392 | if (entry_spelling) |es| { | ||
| 393 | if (entry_at == null and std.mem.eql(u8, sp, es)) { | ||
| 394 | entry_at = i; | ||
| 395 | continue; | ||
| 396 | } | ||
| 397 | } | ||
| 398 | const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse return .{ .refused = sp }; | ||
| 399 | const sess = sp[cut + 1 ..]; | ||
| 400 | if (!proto.validSessionName(sess)) return .{ .refused = sp }; | ||
| 401 | const hi = for (table, 0..) |*h, j| { | ||
| 402 | if (std.mem.eql(u8, h.spec.spelling, sp[0..cut])) break j; | ||
| 403 | } else return .{ .refused = sp }; | ||
| 404 | if (table[hi].self_name) |self| { | ||
| 405 | if (std.mem.eql(u8, self, sess)) { | ||
| 406 | dropped += 1; | ||
| 407 | continue; | ||
| 408 | } | ||
| 409 | } | ||
| 410 | for (keeps.items) |k| { | ||
| 411 | if (std.mem.eql(u8, probe.spellings.items[k.saved], sp)) return .{ .refused = sp }; | ||
| 412 | } | ||
| 413 | keeps.append(alloc, .{ .saved = i, .host = hi }) catch return .none; | ||
| 414 | } | ||
| 415 | const base: usize = if (entry_spelling != null) 1 else 0; | ||
| 416 | if (base + keeps.items.len > wv.max_tiles) return .{ .refused = probe.spellings.items[keeps.items[wv.max_tiles - base].saved] }; | ||
| 417 | if (keeps.items.len == 0 and entry_at == null) return .none; | ||
| 418 | var n = keeps.items.len; | ||
| 419 | while (true) : (n -= 1) { | ||
| 420 | if (seedAttempt(alloc, shared, bytes, entry_at, keeps.items[0..n], base)) |plan| { | ||
| 421 | var out = plan; | ||
| 422 | out.dropped = dropped + (keeps.items.len - n); | ||
| 423 | return .{ .plan = out }; | ||
| 424 | } | ||
| 425 | if (n == 0) return .none; | ||
| 426 | } | ||
| 427 | } | ||
| 428 | |||
| 429 | fn firstLine(bytes: []const u8) []const u8 { | ||
| 430 | const nl = std.mem.indexOfScalar(u8, bytes, '\n') orelse bytes.len; | ||
| 431 | return bytes[0..nl]; | ||
| 432 | } | ||
| 433 | ``` | ||
| 434 | |||
| 435 | `seedAttempt` is unchanged except that it is now reached only with leaves that passed every check; leave its body as is. Note `probe` is now deferred rather than deinit'd by hand at each exit: remove the hand `probe.deinit(alloc)` calls that the old body had. | ||
| 436 | |||
| 437 | `seedSidecar` changes its return to match and prints the refusal: | ||
| 438 | |||
| 439 | ```zig | ||
| 440 | pub fn seedSidecar(alloc: std.mem.Allocator, table: []const Host, shared: *Shared, entry_spelling: ?[]const u8) ?SeedPlan { | ||
| 441 | if (!shared.is_tty) return null; | ||
| 442 | const path = hosts.layoutPath(alloc) catch return null; | ||
| 443 | defer alloc.free(path); | ||
| 444 | const bytes = loadLayout(alloc, path) orelse return null; | ||
| 445 | defer alloc.free(bytes); | ||
| 446 | return switch (seedLayout(alloc, table, shared, bytes, entry_spelling)) { | ||
| 447 | .plan => |p| p, | ||
| 448 | .refused => |line| blk: { | ||
| 449 | // Said once, on stderr, before the alternate screen: the wall | ||
| 450 | // then starts as if the file were missing, and the line is the | ||
| 451 | // thing to fix or delete. | ||
| 452 | std.debug.print("mux: layout ignored ({s}): {s}\n", .{ path, line }); | ||
| 453 | break :blk null; | ||
| 454 | }, | ||
| 455 | .none => null, | ||
| 456 | }; | ||
| 457 | } | ||
| 458 | ``` | ||
| 459 | |||
| 460 | - [ ] **Step 4: Run the tests** | ||
| 461 | |||
| 462 | Run: `deps/zig/zig build test 2>&1 | grep -a "seedLayout\|error:" | head -8` | ||
| 463 | Expected: the four new tests pass; the OLD `seedLayout` tests in `wall_test_layout.zig` that assert healing against live lists (leaves dropped because a poll did not list them) now fail. | ||
| 464 | |||
| 465 | - [ ] **Step 5: Retire the healing tests** | ||
| 466 | |||
| 467 | In `src/tui/wall_test_layout.zig`, delete every test whose name says a leaf is dropped, healed, or kept according to a host's LIST (grep `seedLayout` in the file; each such test feeds `fixture.setList` before seeding). Keep tests about the tree, `doResize`, `relayout`, and the entry insertion. For each deleted test, check the new tests above cover the file-shape it exercised (self-loop drop stays: keep the test that pins `self_name` dropping a leaf, updating its expectation to `.plan` with `dropped == 1`). | ||
| 468 | |||
| 469 | - [ ] **Step 6: Run the gate** | ||
| 470 | |||
| 471 | Run: `make check 2>&1 | tail -3; echo rc=$?` | ||
| 472 | Expected: `rc=0`. | ||
| 473 | |||
| 474 | - [ ] **Step 7: Commit** | ||
| 475 | |||
| 476 | ```bash | ||
| 477 | git add src/tui/wall_layout.zig src/tui/wall_test_layout.zig src/tui/wallview.zig | ||
| 478 | git commit -m "feat: the layout file seats every leaf it names, and a bad file is refused with its line" | ||
| 479 | ``` | ||
| 480 | |||
| 481 | --- | ||
| 482 | |||
| 483 | ### Task 3: The poll grades panes and births nothing | ||
| 484 | |||
| 485 | **Files:** | ||
| 486 | - Modify: `src/tui/wall_host.zig` (`planHostDiff`, `applyHostList`, the `BirthNames` type, the `//!` header) | ||
| 487 | - Modify: `src/tui/wall_test_host.zig` | ||
| 488 | |||
| 489 | **Interfaces:** | ||
| 490 | - Produces: `wall_host.planHostDiff(tiles, present, live, host, list, self_name, binds, vanish, gones) void` — the `births` parameter is gone. `BirthNames` is deleted. | ||
| 491 | - Consumed by: `applyHostList` only. | ||
| 492 | |||
| 493 | - [ ] **Step 1: Write the failing test** | ||
| 494 | |||
| 495 | Append to `src/tui/wall_test_host.zig`: | ||
| 496 | |||
| 497 | ```zig | ||
| 498 | test "planHostDiff: a session the daemon has and the wall does not is nobody's business: no birth, no tile" { | ||
| 499 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | ||
| 500 | var tiles = fixture.diffFixture(&shared); | ||
| 501 | var present = [_]bool{ true, true, true }; | ||
| 502 | var binds = TileIdxs{}; | ||
| 503 | var vanish = TileIdxs{}; | ||
| 504 | var gones = TileIdxs{}; | ||
| 505 | // Host 0 answers with its two panes' sessions and three the wall never | ||
| 506 | // asked for. Twice, so the grace has been spent and a vanish would show. | ||
| 507 | wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nx\ny\nz\n", null, &binds, &vanish, &gones); | ||
| 508 | wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nx\ny\nz\n", null, &binds, &vanish, &gones); | ||
| 509 | try std.testing.expectEqual(@as(usize, 0), vanish.len); | ||
| 510 | try std.testing.expectEqual(@as(usize, 0), gones.len); | ||
| 511 | try std.testing.expectEqual(@as(usize, 3), wv.presentCount(&present)); | ||
| 512 | } | ||
| 513 | ``` | ||
| 514 | |||
| 515 | `fixture.diffFixture` seats tiles `a` and `b` on host 0 and `a` on host 1 (read its body in `wall_test_harness.zig` to confirm the names; adjust the list above to the fixture's real names). | ||
| 516 | |||
| 517 | - [ ] **Step 2: Run to see it fail** | ||
| 518 | |||
| 519 | Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3` | ||
| 520 | Expected: compile error — `planHostDiff` still takes ten arguments. | ||
| 521 | |||
| 522 | - [ ] **Step 3: Drop births from the planner and the applier** | ||
| 523 | |||
| 524 | In `src/tui/wall_host.zig`: | ||
| 525 | |||
| 526 | 1. Delete the `BirthNames` type (the `Fixed(...)` instantiation for names) if nothing else uses it (grep). | ||
| 527 | 2. `planHostDiff`: remove the `births: *BirthNames` parameter and, in the first loop, the `if (!found) births.append(name);` line. The first loop's only remaining job is `binds`: a pending pane whose session the list names. | ||
| 528 | 3. `applyHostList`: delete the `var births = BirthNames{};`, the `while (placed < births.len)` loop, and the `unplaced` notice block. Keep binds, gones, vanish, drift, and `dressSilent`. | ||
| 529 | 4. Rewrite the `//!` header's last sentence: "A host contributes nothing but the GRADE of the panes the layout already gave it — `applyHostList` binds a pending pane whose session the list names, marks `gone` one it does not, and vanishes a live pane whose shell has ended. The layout is the only source of tiles." | ||
| 530 | |||
| 531 | - [ ] **Step 4: Update the existing planner tests** | ||
| 532 | |||
| 533 | In `src/tui/wall_test_host.zig`, every `planHostDiff` call loses its `&births` argument. The test "names the daemon has and the wall does not are births…" becomes an assertion that nothing was born (delete its `births` expectations; keep the vanish half). Any test named for births of unlisted names is replaced by the Step 1 test. | ||
| 534 | |||
| 535 | - [ ] **Step 5: Run the gate** | ||
| 536 | |||
| 537 | Run: `make check 2>&1 | tail -3; echo rc=$?` | ||
| 538 | Expected: `rc=0`. | ||
| 539 | |||
| 540 | - [ ] **Step 6: Run the two wall groups** | ||
| 541 | |||
| 542 | Run: `E2E_ONLY=09_hosts make e2e 2>&1 | grep -a "FAIL\|e2e OK (" | head -3` | ||
| 543 | Expected: FAIL at "every live session of every listed daemon is a tile…". That leg pins the old model and is rewritten in Task 9; note the failure and continue. Do NOT edit the leg here. | ||
| 544 | |||
| 545 | - [ ] **Step 7: Commit** | ||
| 546 | |||
| 547 | ```bash | ||
| 548 | git add src/tui/wall_host.zig src/tui/wall_test_host.zig | ||
| 549 | git commit -m "feat: the poll grades panes and adds none; the layout is the only source of tiles" | ||
| 550 | ``` | ||
| 551 | |||
| 552 | --- | ||
| 553 | |||
| 554 | ### Task 4: One save path, and every change goes through it | ||
| 555 | |||
| 556 | **Files:** | ||
| 557 | - Modify: `src/tui/wallview.zig` (`Shared` gains `layout_path`; `run` sets it; the keyboard loop calls `persist` after births, splits, resizes, vanishes, detach) | ||
| 558 | - Modify: `src/tui/wall_layout.zig` (`saveSidecar` becomes `persist`, gated on `layout_path`) | ||
| 559 | - Modify: `src/tui/wall_picker.zig` (`pickBirth`, `pickForget` persist) | ||
| 560 | - Test: `src/tui/wall_test_layout.zig` | ||
| 561 | |||
| 562 | **Interfaces:** | ||
| 563 | - Produces: `Shared.layout_path: ?[]const u8 = null`; `wall_layout.persist(w: Wall) void` (replaces `saveSidecar`; same body, gated on `w.shared.layout_path`). | ||
| 564 | - Consumed by: Tasks 5, 6, 8. | ||
| 565 | |||
| 566 | - [ ] **Step 1: Write the failing test** | ||
| 567 | |||
| 568 | Append to `src/tui/wall_test_layout.zig`: | ||
| 569 | |||
| 570 | ```zig | ||
| 571 | test "persist: a birth and a vanish each write the layout, and no layout_path writes nothing" { | ||
| 572 | const testtmp = @import("testtmp"); | ||
| 573 | var tmp = try testtmp.TmpDir.make(); | ||
| 574 | defer tmp.cleanup(); | ||
| 575 | var path_buf: [64]u8 = undefined; | ||
| 576 | const path = try std.fmt.bufPrint(&path_buf, "{s}/layout", .{tmp.path()}); | ||
| 577 | |||
| 578 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = false }; | ||
| 579 | defer shared.tree.deinit(); | ||
| 580 | var tiles: [wv.max_tiles]Tile = undefined; | ||
| 581 | var present = [_]bool{false} ** wv.max_tiles; | ||
| 582 | var live: usize = 0; | ||
| 583 | var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")}; | ||
| 584 | const w = fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table); | ||
| 585 | |||
| 586 | // Not yet a wall that persists: nothing is written. | ||
| 587 | wall_layout.persist(w); | ||
| 588 | try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(path, .{})); | ||
| 589 | |||
| 590 | shared.layout_path = path; | ||
| 591 | const at = wv.birthTile(w, .{ | ||
| 592 | .r = .{ .target = hosts_table[0].spec.target, .label = "", .session = "0" }, | ||
| 593 | .from = 0, | ||
| 594 | .place = .beside_focus, | ||
| 595 | .creates = false, | ||
| 596 | .born_from = null, | ||
| 597 | .host = 0, | ||
| 598 | .borrowed = true, | ||
| 599 | }).?; | ||
| 600 | wall_layout.persist(w); | ||
| 601 | const first = try std.fs.cwd().readFileAlloc(std.testing.allocator, path, 4096); | ||
| 602 | defer std.testing.allocator.free(first); | ||
| 603 | try std.testing.expect(std.mem.indexOf(u8, first, "leaf 0 --sock /a#0") != null); | ||
| 604 | |||
| 605 | wv.vanishTile(w.liveTiles(), w.livePresent(), &shared, at, null); | ||
| 606 | wall_layout.persist(w); | ||
| 607 | const second = try std.fs.cwd().readFileAlloc(std.testing.allocator, path, 4096); | ||
| 608 | defer std.testing.allocator.free(second); | ||
| 609 | try std.testing.expect(std.mem.indexOf(u8, second, "#0") == null); | ||
| 610 | fixture.endPumps(&tiles); | ||
| 611 | } | ||
| 612 | ``` | ||
| 613 | |||
| 614 | `birthTile` is `pub`; `Birth` is a private struct literal, which is fine for an anonymous literal. If `birthTile` refuses because `tiles` is uninitialized memory, seat the tile through `fixture.claimBench` the way `wall_test_wall.zig` does, then call `persist`. | ||
| 615 | |||
| 616 | - [ ] **Step 2: Run to see it fail** | ||
| 617 | |||
| 618 | Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3` | ||
| 619 | Expected: `no member named 'layout_path'`, `persist` undefined. | ||
| 620 | |||
| 621 | - [ ] **Step 3: Implement** | ||
| 622 | |||
| 623 | In `src/tui/wallview.zig`, in `Shared` beside `is_tty`: | ||
| 624 | |||
| 625 | ```zig | ||
| 626 | /// Where this wall is written, or null for a wall that persists nothing | ||
| 627 | /// (a piped `mux`, a test). Set once by `run` on a terminal. The ONE | ||
| 628 | /// gate every save reads, so a test points it at a file and proves what | ||
| 629 | /// an operation wrote rather than that a save was called. | ||
| 630 | layout_path: ?[]const u8 = null, | ||
| 631 | ``` | ||
| 632 | |||
| 633 | In `run`, right after `is_tty` is known and before the host table is built: | ||
| 634 | |||
| 635 | ```zig | ||
| 636 | if (is_tty) shared.layout_path = hosts.layoutPath(alloc) catch null; | ||
| 637 | defer if (shared.layout_path) |p| alloc.free(p); | ||
| 638 | ``` | ||
| 639 | |||
| 640 | In `src/tui/wall_layout.zig`, rename `saveSidecar` to `persist` and make its body: | ||
| 641 | |||
| 642 | ```zig | ||
| 643 | /// The one save path. Every change to the pane set or the tree comes | ||
| 644 | /// through here — a birth, a removal, a split, a resize, a detach — so two | ||
| 645 | /// terminals on one device see each other's adds on their next start, and | ||
| 646 | /// a wall that crashes loses nothing it committed. | ||
| 647 | pub fn persist(w: Wall) void { | ||
| 648 | const path = w.shared.layout_path orelse return; | ||
| 649 | saveLayoutTo(w.alloc, path, w.liveTiles(), w.livePresent(), w.shared); | ||
| 650 | } | ||
| 651 | ``` | ||
| 652 | |||
| 653 | Also drop the `if (!shared.is_tty) return null;` line from `seedSidecar` in favour of `const path = shared.layout_path orelse return null;` so load and save agree on one gate. | ||
| 654 | |||
| 655 | Call sites of `persist` in `src/tui/wallview.zig`'s keyboard loop (find each by its action tag): | ||
| 656 | - after a successful `birthTile` in the `.new_session, .split_right, .split_below` arm; | ||
| 657 | - in the `.detach` arm, where `saveSidecar` was; | ||
| 658 | - after `doResize` returns true in the `.resize` arm; | ||
| 659 | - after every `vanishTile` that follows an `endAction` `.vanish` (the pump-ended path) and after `closePicker` when `birth_at` is non-null; | ||
| 660 | - after the entry tile is seated at startup (the `addFirst(0)` and the seed plan's insertion), once, before the keys loop. | ||
| 661 | |||
| 662 | In `src/tui/wall_picker.zig`: `pickBirth` calls `wall_layout.persist(w)` after `spawnPump`; `pickForget` calls it after its vanish loop. | ||
| 663 | |||
| 664 | - [ ] **Step 4: Run the gate** | ||
| 665 | |||
| 666 | Run: `make check 2>&1 | tail -3; echo rc=$?` | ||
| 667 | Expected: `rc=0`. The comment gate needs `saveSidecar` gone from every comment; grep and reword to `persist`. | ||
| 668 | |||
| 669 | - [ ] **Step 5: Commit** | ||
| 670 | |||
| 671 | ```bash | ||
| 672 | git add src/tui/wallview.zig src/tui/wall_layout.zig src/tui/wall_picker.zig src/tui/wall_test_layout.zig | ||
| 673 | git commit -m "feat: every change to the wall writes the layout through one save path" | ||
| 674 | ``` | ||
| 675 | |||
| 676 | --- | ||
| 677 | |||
| 678 | ### Task 5: `x` removes the pane; the session lives on | ||
| 679 | |||
| 680 | **Files:** | ||
| 681 | - Modify: `src/tui/wallview.zig` (`removePane`; the `.end_session` arm) | ||
| 682 | - Test: `src/tui/wall_test_wall.zig` | ||
| 683 | |||
| 684 | **Interfaces:** | ||
| 685 | - Produces: `wv.removePane(w: Wall, z: usize) void`. | ||
| 686 | - `endKey`, `intentForEnd`, `onEndReply`, `EndKey` stay for Task 6 (the picker's end path reuses `end_arm_ms`). | ||
| 687 | |||
| 688 | - [ ] **Step 1: Write the failing test** | ||
| 689 | |||
| 690 | Append to `src/tui/wall_test_wall.zig`, modelled on the file's existing `claimBench`/`endBench` tests: | ||
| 691 | |||
| 692 | ```zig | ||
| 693 | test "removePane: the pane leaves the wall, its pump is told to detach, and nothing is asked to end" { | ||
| 694 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = false }; | ||
| 695 | defer shared.tree.deinit(); | ||
| 696 | var tiles = [_]Tile{ fixture.claimBench(&shared, 0), fixture.claimBench(&shared, 1) }; | ||
| 697 | var present = [_]bool{ true, true }; | ||
| 698 | try shared.tree.addFirst(0); | ||
| 699 | try shared.tree.splitRight(0, 1); | ||
| 700 | const w = fixture.wallAll(std.testing.allocator, &tiles, &present, &shared); | ||
| 701 | shared.sel = 1; | ||
| 702 | |||
| 703 | wv.removePane(w, 1); | ||
| 704 | try std.testing.expect(!present[1]); | ||
| 705 | try std.testing.expect(present[0]); | ||
| 706 | try std.testing.expect(tiles[1].detach_req.load(.acquire)); | ||
| 707 | // The daemon was NOT asked to end anything: the ask mailbox is empty. | ||
| 708 | try std.testing.expectEqual(@as(u8, 0), tiles[1].ask.load(.acquire)); | ||
| 709 | try std.testing.expectEqual(@as(usize, 0), shared.sel); | ||
| 710 | var buf: [128]u8 = undefined; | ||
| 711 | try std.testing.expectEqualStrings("[pane removed - the session is still on its daemon]", wv.takeNotice(&shared, &buf)); | ||
| 712 | fixture.endPumps(&tiles); | ||
| 713 | } | ||
| 714 | ``` | ||
| 715 | |||
| 716 | - [ ] **Step 2: Run to see it fail** | ||
| 717 | |||
| 718 | Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3` | ||
| 719 | Expected: `removePane` undefined. | ||
| 720 | |||
| 721 | - [ ] **Step 3: Implement** | ||
| 722 | |||
| 723 | In `src/tui/wallview.zig`, below `vanishTile`: | ||
| 724 | |||
| 725 | ```zig | ||
| 726 | /// `Ctrl-\ x`: this pane leaves THIS wall. The session is the daemon's and | ||
| 727 | /// keeps running for whoever else holds it; ending one is the picker's | ||
| 728 | /// job, beside the count of who else is there. The pump is told to say | ||
| 729 | /// goodbye (`detach_req`) so the daemon frees the slot now rather than at | ||
| 730 | /// a timeout, then the tile is vanished and the layout written without it. | ||
| 731 | pub fn removePane(w: Wall, z: usize) void { | ||
| 732 | if (z >= w.live.* or !w.present[z]) return; | ||
| 733 | const t = &w.tiles[z]; | ||
| 734 | t.detach_req.store(true, .release); | ||
| 735 | ring(t); | ||
| 736 | vanishTile(w.liveTiles(), w.livePresent(), w.shared, z, null); | ||
| 737 | setNotice(w.shared, "[pane removed - the session is still on its daemon]"); | ||
| 738 | wall_layout.relayout(w, w.shared.sel); | ||
| 739 | wall_layout.persist(w); | ||
| 740 | } | ||
| 741 | ``` | ||
| 742 | |||
| 743 | Read `vanishTile` and the pump's `detach_req` handling (`wall_pump.zig`, the block that writes `.detach` and sets `detach_ack`) to confirm a pump parked in `dial` also exits on `removed`; the `.drop` arm of the old handler already relied on that for never-up tiles. | ||
| 744 | |||
| 745 | Replace the `.end_session` arm of the keyboard loop with: | ||
| 746 | |||
| 747 | ```zig | ||
| 748 | .end_session => if (z < w.live.* and present[z]) { | ||
| 749 | removePane(w, z); | ||
| 750 | if (!shared.is_tty and presentCount(present[0..live]) == 0) { | ||
| 751 | exit_code = 0; | ||
| 752 | exit_msg = "mux: aborted before attaching"; | ||
| 753 | break :keys; | ||
| 754 | } | ||
| 755 | }, | ||
| 756 | ``` | ||
| 757 | |||
| 758 | Keep the `endKey`/`intentForEnd`/`onEndReply` functions and `end_arm_ms`; Task 6 moves their caller. | ||
| 759 | |||
| 760 | - [ ] **Step 4: Run the gate** | ||
| 761 | |||
| 762 | Run: `make check 2>&1 | tail -3; echo rc=$?` | ||
| 763 | Expected: `rc=0`. Tests in `wall_test_wall.zig` that drove `endKey` through the keyboard path may now be unreachable; keep the pure `endKey` tests (they still pin the two-step timing Task 6 reuses). | ||
| 764 | |||
| 765 | - [ ] **Step 5: Commit** | ||
| 766 | |||
| 767 | ```bash | ||
| 768 | git add src/tui/wallview.zig src/tui/wall_test_wall.zig | ||
| 769 | git commit -m "feat: x takes the pane off this wall and ends nothing" | ||
| 770 | ``` | ||
| 771 | |||
| 772 | --- | ||
| 773 | |||
| 774 | ### Task 6: The picker's session level: add, birth, end | ||
| 775 | |||
| 776 | **Files:** | ||
| 777 | - Modify: `src/tui/interact.zig` (`PrefixFilter`: `pick_level`, actions `pick_enter`, `pick_back`, `pick_end`) | ||
| 778 | - Modify: `src/client/client.zig` (`endSession`) | ||
| 779 | - Modify: `src/tui/wall_picker.zig` (`sessionRows`, `pickAdd`, `pickEnd`, `paintPicker` at the session level) | ||
| 780 | - Modify: `src/tui/wallview.zig` (the picker arm of the keyboard loop: `picker_row`, level state) | ||
| 781 | - Test: `src/tui/interact.zig` (inline), `src/tui/wall_test_picker.zig`, `src/client/client.zig` (inline, against a real daemon via the existing `TestDaemon` shape used by `birthSession`'s test) | ||
| 782 | |||
| 783 | **Interfaces:** | ||
| 784 | - `interact.PrefixFilter` gains `pick_level: enum { hosts, sessions } = .hosts`. Actions added: `pick_enter` (Enter at either level), `pick_back` (Esc at the session level), `pick_end` (`x` at the session level). `pick_forget` is `x` at the host level only. `pick_birth` is `c` at either level. The filter sets `picking = false` on `pick_enter` at the session level, on `pick_birth`, on `pick_forget`, and on close; it stays open on `pick_enter` at the host level, on `pick_back`, and on `pick_end`. | ||
| 785 | - `client.endSession(alloc: std.mem.Allocator, target: Target, name: []const u8, force: bool) !EndOutcome` with | ||
| 786 | |||
| 787 | ```zig | ||
| 788 | pub const EndOutcome = struct { | ||
| 789 | accepted: bool, | ||
| 790 | others: u8, | ||
| 791 | reason_buf: [proto.end_reply_max_len]u8 = undefined, | ||
| 792 | reason_len: usize = 0, | ||
| 793 | pub fn reason(self: *const EndOutcome) []const u8 { | ||
| 794 | return self.reason_buf[0..self.reason_len]; | ||
| 795 | } | ||
| 796 | }; | ||
| 797 | ``` | ||
| 798 | |||
| 799 | - `wall_picker.sessionRows(body: *PickerBody, h: *Host, w: Wall, host: usize, sel_row: usize, cols: u16) void` | ||
| 800 | - `wall_picker.pickAdd(w: Wall, host: usize, row: usize) ?usize` — the tile index added or zoomed to; null with a notice. | ||
| 801 | - `wall_picker.pickEnd(w: Wall, host: usize, row: usize, now: i64) void` — sends `end_req`, arms the 3 s force window per (host, name) in `PickerEnd` state on `Shared`, sets the notice. | ||
| 802 | |||
| 803 | - [ ] **Step 1: Write the failing filter tests** | ||
| 804 | |||
| 805 | Append to `src/tui/interact.zig`'s tests, beside the existing `pick_open` tests (copy their `PrefixFilter{}` construction and the byte sequence they use to open the picker — `Ctrl-\ s`): | ||
| 806 | |||
| 807 | ```zig | ||
| 808 | test "picker levels: Enter on a host opens its sessions, Esc backs out a level, Enter on a session closes with pick_enter" { | ||
| 809 | var f = PrefixFilter{}; | ||
| 810 | var open = [_]u8{ detach_key, 's' }; | ||
| 811 | try std.testing.expectEqual(PrefixFilter.Action.pick_open, f.feed(&open).action); | ||
| 812 | try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level); | ||
| 813 | |||
| 814 | var enter = [_]u8{'\r'}; | ||
| 815 | try std.testing.expectEqual(PrefixFilter.Action.pick_enter, f.feed(&enter).action); | ||
| 816 | try std.testing.expect(f.picking); | ||
| 817 | try std.testing.expectEqual(PrefixFilter.PickLevel.sessions, f.pick_level); | ||
| 818 | |||
| 819 | var esc = [_]u8{0x1b}; | ||
| 820 | try std.testing.expectEqual(PrefixFilter.Action.pick_back, f.feed(&esc).action); | ||
| 821 | try std.testing.expect(f.picking); | ||
| 822 | try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level); | ||
| 823 | |||
| 824 | _ = f.feed(&enter); | ||
| 825 | try std.testing.expectEqual(PrefixFilter.Action.pick_enter, f.feed(&enter).action); | ||
| 826 | try std.testing.expect(!f.picking); | ||
| 827 | try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level); | ||
| 828 | } | ||
| 829 | |||
| 830 | test "picker levels: x forgets at the host level and ends at the session level; c births at either; every byte stays the popup's" { | ||
| 831 | var f = PrefixFilter{}; | ||
| 832 | var open = [_]u8{ detach_key, 's' }; | ||
| 833 | _ = f.feed(&open); | ||
| 834 | var x = [_]u8{'x'}; | ||
| 835 | try std.testing.expectEqual(PrefixFilter.Action.pick_forget, f.feed(&x).action); | ||
| 836 | try std.testing.expect(!f.picking); | ||
| 837 | |||
| 838 | _ = f.feed(&open); | ||
| 839 | var enter = [_]u8{'\r'}; | ||
| 840 | _ = f.feed(&enter); | ||
| 841 | const ended = f.feed(&x); | ||
| 842 | try std.testing.expectEqual(PrefixFilter.Action.pick_end, ended.action); | ||
| 843 | try std.testing.expectEqual(@as(usize, 0), ended.forward.len); | ||
| 844 | try std.testing.expect(f.picking); // the popup stays up to show the count or the end | ||
| 845 | |||
| 846 | var c = [_]u8{'c'}; | ||
| 847 | try std.testing.expectEqual(PrefixFilter.Action.pick_birth, f.feed(&c).action); | ||
| 848 | try std.testing.expect(!f.picking); | ||
| 849 | try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level); | ||
| 850 | } | ||
| 851 | ``` | ||
| 852 | |||
| 853 | - [ ] **Step 2: Run to see them fail** | ||
| 854 | |||
| 855 | Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3` | ||
| 856 | Expected: `pick_level`, `PickLevel`, `pick_enter` undefined. | ||
| 857 | |||
| 858 | - [ ] **Step 3: Implement the filter** | ||
| 859 | |||
| 860 | In `src/tui/interact.zig`, `PrefixFilter`: | ||
| 861 | |||
| 862 | ```zig | ||
| 863 | pub const PickLevel = enum { hosts, sessions }; | ||
| 864 | /// Which list the popup shows. The filter owns it because Enter and Esc | ||
| 865 | /// mean different things at each level, and a key must resolve to ONE | ||
| 866 | /// action without the wall's help. | ||
| 867 | pick_level: PickLevel = .hosts, | ||
| 868 | ``` | ||
| 869 | |||
| 870 | Add to `Action`: `pick_enter`, `pick_back`, `pick_end`. | ||
| 871 | |||
| 872 | In the `if (self.picking)` switch: | ||
| 873 | |||
| 874 | ```zig | ||
| 875 | 0x1b => { | ||
| 876 | const tail = buf[i + 1 ..]; | ||
| 877 | if (arrowMove(tail)) |d| | ||
| 878 | return .{ .forward = buf[0..kept], .action = .{ .pick_move = d } }; | ||
| 879 | if (tail.len > 0 and (tail[0] == '[' or tail[0] == 'O')) | ||
| 880 | return .{ .forward = buf[0..kept], .action = .none }; | ||
| 881 | if (self.pick_level == .sessions) { | ||
| 882 | self.pick_level = .hosts; | ||
| 883 | return .{ .forward = buf[0..kept], .action = .pick_back }; | ||
| 884 | } | ||
| 885 | self.picking = false; | ||
| 886 | return .{ .forward = buf[0..kept], .action = .pick_close }; | ||
| 887 | }, | ||
| 888 | '\r', '\n' => { | ||
| 889 | if (self.pick_level == .hosts) { | ||
| 890 | self.pick_level = .sessions; | ||
| 891 | return .{ .forward = buf[0..kept], .action = .pick_enter }; | ||
| 892 | } | ||
| 893 | self.picking = false; | ||
| 894 | self.pick_level = .hosts; | ||
| 895 | return .{ .forward = buf[0..kept], .action = .pick_enter }; | ||
| 896 | }, | ||
| 897 | 'c' => { | ||
| 898 | self.picking = false; | ||
| 899 | self.pick_level = .hosts; | ||
| 900 | return .{ .forward = buf[0..kept], .action = .pick_birth }; | ||
| 901 | }, | ||
| 902 | 'x' => { | ||
| 903 | if (self.pick_level == .sessions) | ||
| 904 | return .{ .forward = buf[0..kept], .action = .pick_end }; | ||
| 905 | self.picking = false; | ||
| 906 | return .{ .forward = buf[0..kept], .action = .pick_forget }; | ||
| 907 | }, | ||
| 908 | 's', 0x03 => { | ||
| 909 | self.picking = false; | ||
| 910 | self.pick_level = .hosts; | ||
| 911 | return .{ .forward = buf[0..kept], .action = .pick_close }; | ||
| 912 | }, | ||
| 913 | ``` | ||
| 914 | |||
| 915 | The old `'\r', '\n', 'c' => pick_birth` arm is replaced by the two arms above. Add `.pick_enter, .pick_back, .pick_end` to `wall_picker.isPickAction`. | ||
| 916 | |||
| 917 | - [ ] **Step 4: Run the filter tests** | ||
| 918 | |||
| 919 | Run: `deps/zig/zig build test 2>&1 | grep -a "picker levels\|error:" | head -5` | ||
| 920 | Expected: pass. Existing tests that expected Enter to be `pick_birth` now see `pick_enter`; update those expectations (Enter births nothing any more; `c` does). | ||
| 921 | |||
| 922 | - [ ] **Step 5: Write the failing `endSession` test** | ||
| 923 | |||
| 924 | In `src/client/client.zig`, beside `birthSession`'s test (grep `birthSession` in the file's tests for the daemon-fixture shape; it starts a real daemon on a `TmpDir` socket): | ||
| 925 | |||
| 926 | ```zig | ||
| 927 | test "endSession: a held session refuses with the count, force ends it, and an unknown name is refused with the daemon's reason" { | ||
| 928 | // (fixture: a real daemon on a tmp socket, one session "0" with one | ||
| 929 | // client attached — same setup as birthSession's test above) | ||
| 930 | var first = try endSession(std.testing.allocator, .{ .sock = sock_path }, "0", false); | ||
| 931 | try std.testing.expect(!first.accepted); | ||
| 932 | try std.testing.expectEqual(@as(u8, 1), first.others); | ||
| 933 | var forced = try endSession(std.testing.allocator, .{ .sock = sock_path }, "0", true); | ||
| 934 | try std.testing.expect(forced.accepted); | ||
| 935 | var missing = try endSession(std.testing.allocator, .{ .sock = sock_path }, "nope", false); | ||
| 936 | try std.testing.expect(!missing.accepted); | ||
| 937 | try std.testing.expectEqualStrings(proto.end_reason.no_session, missing.reason()); | ||
| 938 | } | ||
| 939 | ``` | ||
| 940 | |||
| 941 | - [ ] **Step 6: Implement `endSession`** | ||
| 942 | |||
| 943 | In `src/client/client.zig`, after `birthSession`: | ||
| 944 | |||
| 945 | ```zig | ||
| 946 | pub const EndOutcome = struct { | ||
| 947 | accepted: bool, | ||
| 948 | others: u8, | ||
| 949 | reason_buf: [proto.end_reply_max_len]u8 = undefined, | ||
| 950 | reason_len: usize = 0, | ||
| 951 | pub fn reason(self: *const EndOutcome) []const u8 { | ||
| 952 | return self.reason_buf[0..self.reason_len]; | ||
| 953 | } | ||
| 954 | }; | ||
| 955 | |||
| 956 | /// `end_req` on a side connection of its own: the picker ends a session | ||
| 957 | /// that may have no pane on this wall, so there is no pump to ask | ||
| 958 | /// through. The daemon owns the two-step; this only carries `force`. | ||
| 959 | pub fn endSession(alloc: std.mem.Allocator, target: Target, name: []const u8, force: bool) !EndOutcome { | ||
| 960 | var tr = try Transport.open(alloc, target, null, -1, null); | ||
| 961 | defer tr.close(); | ||
| 962 | var buf: [proto.end_req_max_len]u8 = undefined; | ||
| 963 | const req = proto.encodeEndReq(&buf, force, proto.wireName(name)); | ||
| 964 | const deadline = std.time.milliTimestamp() + birth_budget_ms; | ||
| 965 | const f = roundTrip(&tr, alloc, .end_req, req, &.{ .end_reply, .exit_status }, deadline) catch |e| return switch (e) { | ||
| 966 | error.Timeout => error.Timeout, | ||
| 967 | else => error.Transport, | ||
| 968 | }; | ||
| 969 | defer f.deinit(alloc); | ||
| 970 | if (f.type != .end_reply) return error.Refused; | ||
| 971 | const r = try proto.decodeEndReply(f.payload); | ||
| 972 | var out: EndOutcome = .{ .accepted = r.accepted, .others = r.others }; | ||
| 973 | const n = @min(r.reason.len, out.reason_buf.len); | ||
| 974 | @memcpy(out.reason_buf[0..n], r.reason[0..n]); | ||
| 975 | out.reason_len = n; | ||
| 976 | return out; | ||
| 977 | } | ||
| 978 | ``` | ||
| 979 | |||
| 980 | `proto.decodeEndReply` exists beside `encodeEndReply` (grep to confirm the name; if it is `parseEndReply`, use that). | ||
| 981 | |||
| 982 | - [ ] **Step 7: Run the client test** | ||
| 983 | |||
| 984 | Run: `deps/zig/zig build test 2>&1 | grep -a "endSession\|error:" | head -5` | ||
| 985 | Expected: pass. | ||
| 986 | |||
| 987 | - [ ] **Step 8: Write the failing picker tests** | ||
| 988 | |||
| 989 | Append to `src/tui/wall_test_picker.zig` (it has `fixture.testHost`, `fixture.setList`, and `pickerFrame` for painted rows): | ||
| 990 | |||
| 991 | ```zig | ||
| 992 | test "sessionRows: a host's sessions, marked when already on this wall, with the holder count when the daemon says" { | ||
| 993 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | ||
| 994 | defer shared.tree.deinit(); | ||
| 995 | var hosts_table = [_]Host{fixture.testHost(&shared, "box", "/b")}; | ||
| 996 | fixture.setList(&hosts_table[0], "0\nwork\n# holds 0 2\n# holds work 0\n# mux 0.0.1-18"); | ||
| 997 | var tiles = [_]Tile{fixture.claimBench(&shared, 0)}; | ||
| 998 | tiles[0].host = 0; | ||
| 999 | tiles[0].r.session = "work"; | ||
| 1000 | var present = [_]bool{true}; | ||
| 1001 | const w = fixture.wallAll(std.testing.allocator, &tiles, &present, &shared); | ||
| 1002 | _ = w.hosts; // wallAll has no hosts; the rows take the table directly | ||
| 1003 | |||
| 1004 | var body = wall_picker.PickerBody{}; | ||
| 1005 | wall_picker.sessionRows(&body, &hosts_table[0], fixture.wallOf(std.testing.allocator, &tiles, &present, w.live, &shared, &hosts_table), 0, 1, 80); | ||
| 1006 | try std.testing.expectEqual(@as(usize, 2), body.n); | ||
| 1007 | try std.testing.expect(std.mem.indexOf(u8, body.row(0), "0") != null); | ||
| 1008 | try std.testing.expect(std.mem.indexOf(u8, body.row(0), "2 clients") != null); | ||
| 1009 | try std.testing.expect(std.mem.indexOf(u8, body.row(1), "work") != null); | ||
| 1010 | try std.testing.expect(std.mem.indexOf(u8, body.row(1), "on this wall") != null); | ||
| 1011 | fixture.endPumps(&tiles); | ||
| 1012 | } | ||
| 1013 | |||
| 1014 | test "sessionRows: an old daemon's list shows no count rather than zero" { | ||
| 1015 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | ||
| 1016 | defer shared.tree.deinit(); | ||
| 1017 | var hosts_table = [_]Host{fixture.testHost(&shared, "box", "/b")}; | ||
| 1018 | fixture.setList(&hosts_table[0], "0\n"); | ||
| 1019 | var tiles: [1]Tile = undefined; | ||
| 1020 | var present = [_]bool{false}; | ||
| 1021 | var live: usize = 0; | ||
| 1022 | var body = wall_picker.PickerBody{}; | ||
| 1023 | wall_picker.sessionRows(&body, &hosts_table[0], fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table), 0, 0, 80); | ||
| 1024 | try std.testing.expectEqual(@as(usize, 1), body.n); | ||
| 1025 | try std.testing.expect(std.mem.indexOf(u8, body.row(0), "client") == null); | ||
| 1026 | } | ||
| 1027 | |||
| 1028 | test "pickAdd: a listed session becomes a pane once; a second add zooms to it" { | ||
| 1029 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = false }; | ||
| 1030 | defer shared.tree.deinit(); | ||
| 1031 | var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")}; | ||
| 1032 | fixture.setList(&hosts_table[0], "0\nwork\n"); | ||
| 1033 | var tiles: [wv.max_tiles]Tile = undefined; | ||
| 1034 | var present = [_]bool{false} ** wv.max_tiles; | ||
| 1035 | var live: usize = 0; | ||
| 1036 | const w = fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table); | ||
| 1037 | |||
| 1038 | const first = wall_picker.pickAdd(w, 0, 1).?; | ||
| 1039 | try std.testing.expectEqualStrings("work", tiles[first].r.session); | ||
| 1040 | try std.testing.expect(!tiles[first].creates); | ||
| 1041 | try std.testing.expectEqual(@as(usize, 1), wv.presentCount(w.livePresent())); | ||
| 1042 | const again = wall_picker.pickAdd(w, 0, 1).?; | ||
| 1043 | try std.testing.expectEqual(first, again); | ||
| 1044 | try std.testing.expectEqual(@as(usize, 1), wv.presentCount(w.livePresent())); | ||
| 1045 | try std.testing.expectEqual(first, shared.sel); | ||
| 1046 | fixture.endPumps(&tiles); | ||
| 1047 | } | ||
| 1048 | ``` | ||
| 1049 | |||
| 1050 | - [ ] **Step 9: Implement the picker's session level** | ||
| 1051 | |||
| 1052 | In `src/tui/wall_picker.zig`: | ||
| 1053 | |||
| 1054 | ```zig | ||
| 1055 | /// The second level: one row per session the host's last answer named. | ||
| 1056 | /// "on this wall" when the layout already has it, and the holder count | ||
| 1057 | /// when the daemon is new enough to say (`proto.parseSessionsHolds`); an | ||
| 1058 | /// old daemon's rows carry no count rather than a zero that would read as | ||
| 1059 | /// "safe to end". | ||
| 1060 | pub fn sessionRows(body: *PickerBody, h: *Host, w: Wall, host: usize, sel_row: usize, cols: u16) void { | ||
| 1061 | body.n = 0; | ||
| 1062 | var list_buf: [proto.sessions_reply_max]u8 = undefined; | ||
| 1063 | const list = h.poll.snapshot(&list_buf); | ||
| 1064 | var it = proto.sessionsIter(list); | ||
| 1065 | var row: usize = 0; | ||
| 1066 | while (it.next()) |name| : (row += 1) { | ||
| 1067 | if (body.n >= wv.max_tiles) break; | ||
| 1068 | var state_buf: [48]u8 = undefined; | ||
| 1069 | var state: []const u8 = ""; | ||
| 1070 | const on_wall = paneOf(w, host, name) != null; | ||
| 1071 | if (proto.parseSessionsHolds(list, name)) |n| { | ||
| 1072 | state = std.fmt.bufPrint(&state_buf, "{s}{d} client{s}", .{ | ||
| 1073 | if (on_wall) "on this wall, " else "", | ||
| 1074 | n, | ||
| 1075 | if (n == 1) "" else "s", | ||
| 1076 | }) catch ""; | ||
| 1077 | } else if (on_wall) state = "on this wall"; | ||
| 1078 | body.host[body.n] = host; | ||
| 1079 | body.lens[body.n] = pickerRow(&body.text[body.n], body.n + 1, false, name, state, row == sel_row, cols).len; | ||
| 1080 | body.n += 1; | ||
| 1081 | } | ||
| 1082 | } | ||
| 1083 | |||
| 1084 | fn paneOf(w: Wall, host: usize, name: []const u8) ?usize { | ||
| 1085 | for (w.liveTiles(), w.livePresent(), 0..) |*t, p, i| { | ||
| 1086 | if (p and wall_host.ownedBy(t, host) and std.mem.eql(u8, proto.resolveName(t.r.session), name)) return i; | ||
| 1087 | } | ||
| 1088 | return null; | ||
| 1089 | } | ||
| 1090 | |||
| 1091 | fn sessionAt(h: *Host, row: usize, out: *[proto.session_name_max]u8) ?[]const u8 { | ||
| 1092 | var list_buf: [proto.sessions_reply_max]u8 = undefined; | ||
| 1093 | const list = h.poll.snapshot(&list_buf); | ||
| 1094 | var it = proto.sessionsIter(list); | ||
| 1095 | var i: usize = 0; | ||
| 1096 | while (it.next()) |name| : (i += 1) { | ||
| 1097 | if (i == row) { | ||
| 1098 | @memcpy(out[0..name.len], name); | ||
| 1099 | return out[0..name.len]; | ||
| 1100 | } | ||
| 1101 | } | ||
| 1102 | return null; | ||
| 1103 | } | ||
| 1104 | |||
| 1105 | /// Enter on a session row: a pane for it, joined (never created), zoomed | ||
| 1106 | /// to. A session already on the wall is only zoomed to. The layout is | ||
| 1107 | /// written, because this is one of the three places a pane comes from. | ||
| 1108 | pub fn pickAdd(w: Wall, host: usize, row: usize) ?usize { | ||
| 1109 | if (host >= w.hosts.len) return null; | ||
| 1110 | const h = &w.hosts[host]; | ||
| 1111 | var name_buf: [proto.session_name_max]u8 = undefined; | ||
| 1112 | const name = sessionAt(h, row, &name_buf) orelse { | ||
| 1113 | wv.setNotice(w.shared, "[no session on that row]"); | ||
| 1114 | return null; | ||
| 1115 | }; | ||
| 1116 | if (paneOf(w, host, name)) |at| { | ||
| 1117 | wv.setFocus(w.liveTiles(), w.shared, at); | ||
| 1118 | return at; | ||
| 1119 | } | ||
| 1120 | var target = h.spec.target; | ||
| 1121 | if (target == .hand) target.hand.asked = true; | ||
| 1122 | const anchor = wall_layout.anchorTile(w.livePresent(), w.shared.sel); | ||
| 1123 | const has_anchor = wv.presentCount(w.livePresent()) > 0; | ||
| 1124 | const at = wv.birthTile(w, .{ | ||
| 1125 | .r = .{ .target = target, .label = "", .session = name, .agent = false }, | ||
| 1126 | .from = anchor, | ||
| 1127 | .place = .beside_focus, | ||
| 1128 | .creates = false, | ||
| 1129 | .born_from = if (has_anchor) anchor else null, | ||
| 1130 | .host = host, | ||
| 1131 | .borrowed = true, | ||
| 1132 | }) orelse { | ||
| 1133 | wv.setNotice(w.shared, "[no room on the wall for another pane]"); | ||
| 1134 | return null; | ||
| 1135 | }; | ||
| 1136 | wv.spawnPump(&w.tiles[at]); | ||
| 1137 | wv.setFocus(w.liveTiles(), w.shared, at); | ||
| 1138 | wall_layout.persist(w); | ||
| 1139 | return at; | ||
| 1140 | } | ||
| 1141 | |||
| 1142 | /// `x` on a session row: the daemon's two-step, from a side connection. | ||
| 1143 | /// The first press on a session others hold is refused with the count and | ||
| 1144 | /// arms 3 s; a second press inside that window forces. Armed per host and | ||
| 1145 | /// name on `Shared`, so a press on a different row is a first press. | ||
| 1146 | pub fn pickEnd(w: Wall, host: usize, row: usize, now: i64) void { | ||
| 1147 | if (host >= w.hosts.len) return; | ||
| 1148 | const h = &w.hosts[host]; | ||
| 1149 | var name_buf: [proto.session_name_max]u8 = undefined; | ||
| 1150 | const name = sessionAt(h, row, &name_buf) orelse return; | ||
| 1151 | const armed = w.shared.pick_end.armedFor(host, name, now); | ||
| 1152 | const out = client.endSession(w.alloc, h.spec.target, name, armed) catch |e| { | ||
| 1153 | var buf: [96]u8 = undefined; | ||
| 1154 | wv.setNotice(w.shared, std.fmt.bufPrint(&buf, "[could not ask {s} to end {s}: {s}]", .{ h.spec.spelling, name, @errorName(e) }) catch "[could not ask the daemon]"); | ||
| 1155 | return; | ||
| 1156 | }; | ||
| 1157 | var buf: [128]u8 = undefined; | ||
| 1158 | if (out.accepted) { | ||
| 1159 | w.shared.pick_end.clear(); | ||
| 1160 | wv.setNotice(w.shared, std.fmt.bufPrint(&buf, "[ending {s} on {s}]", .{ name, h.spec.spelling }) catch "[ending the session]"); | ||
| 1161 | } else { | ||
| 1162 | w.shared.pick_end.arm(host, name, now + wv.end_arm_ms); | ||
| 1163 | wv.setNotice(w.shared, std.fmt.bufPrint(&buf, "[{s}: {d} other client{s} attached - x again within 3s to end anyway]", .{ | ||
| 1164 | name, out.others, if (out.others == 1) "" else "s", | ||
| 1165 | }) catch "[others attached - x again to end anyway]"); | ||
| 1166 | } | ||
| 1167 | h.poll.poke.store(true, .release); | ||
| 1168 | } | ||
| 1169 | ``` | ||
| 1170 | |||
| 1171 | Add to `Shared` in `wallview.zig`: | ||
| 1172 | |||
| 1173 | ```zig | ||
| 1174 | /// The picker's end two-step, per host and name: a second `x` on the SAME | ||
| 1175 | /// row inside the window forces, any other row is a first press. | ||
| 1176 | pick_end: PickEnd = .{}, | ||
| 1177 | |||
| 1178 | pub const PickEnd = struct { | ||
| 1179 | host: usize = 0, | ||
| 1180 | name: client.SessionName = .{}, | ||
| 1181 | until: i64 = 0, | ||
| 1182 | pub fn armedFor(self: *const PickEnd, host: usize, name: []const u8, now: i64) bool { | ||
| 1183 | return now < self.until and self.host == host and std.mem.eql(u8, self.name.slice(), name); | ||
| 1184 | } | ||
| 1185 | pub fn arm(self: *PickEnd, host: usize, name: []const u8, until: i64) void { | ||
| 1186 | self.host = host; | ||
| 1187 | self.name = client.SessionName.of(name); | ||
| 1188 | self.until = until; | ||
| 1189 | } | ||
| 1190 | pub fn clear(self: *PickEnd) void { | ||
| 1191 | self.until = 0; | ||
| 1192 | } | ||
| 1193 | }; | ||
| 1194 | ``` | ||
| 1195 | |||
| 1196 | `end_arm_ms` must be `pub` in `wallview.zig`. `client.SessionName.of` exists (the hub uses it). | ||
| 1197 | |||
| 1198 | `paintPicker` gains the level: when `prefix.pick_level == .sessions`, the body comes from `sessionRows` for `host_table[picker_sel]` and the title line names the host; otherwise as today. `pickerStep`/`pickerAt` at the session level step over `body.n` rows rather than hosts; add `picker_row: usize` beside `picker_sel` in the keyboard loop and clamp it to the row count on each repaint. | ||
| 1199 | |||
| 1200 | In `wallview.zig`'s picker arm of the keyboard loop: | ||
| 1201 | |||
| 1202 | ```zig | ||
| 1203 | .pick_enter => if (input.prefix.pick_level == .sessions) { | ||
| 1204 | // Enter at the host level just opened the list; nothing to do but paint. | ||
| 1205 | picker_row = 0; | ||
| 1206 | } else { | ||
| 1207 | // Enter at the session level closed the popup with a choice. | ||
| 1208 | birth_at = wall_picker.pickAdd(w, picker_sel, picker_row); | ||
| 1209 | }, | ||
| 1210 | .pick_back => picker_row = 0, | ||
| 1211 | .pick_end => wall_picker.pickEnd(w, picker_sel, picker_row, std.time.milliTimestamp()), | ||
| 1212 | .pick_move => |d| if (input.prefix.pick_level == .sessions) { | ||
| 1213 | picker_row = wall_picker.rowStep(picker_row, d, wall_picker.sessionCount(&w.hosts[picker_sel])); | ||
| 1214 | } else picker_sel = wall_picker.pickerStep(w.hosts, picker_sel, d), | ||
| 1215 | .pick_select => |row| if (input.prefix.pick_level == .sessions) { | ||
| 1216 | picker_row = @min(row - 1, wall_picker.sessionCount(&w.hosts[picker_sel]) -| 1); | ||
| 1217 | } else if (wall_picker.pickerAt(w.hosts, row - 1)) |hi| picker_sel = hi, | ||
| 1218 | ``` | ||
| 1219 | |||
| 1220 | with in `wall_picker.zig`: | ||
| 1221 | |||
| 1222 | ```zig | ||
| 1223 | pub fn sessionCount(h: *Host) usize { | ||
| 1224 | var list_buf: [proto.sessions_reply_max]u8 = undefined; | ||
| 1225 | var it = proto.sessionsIter(h.poll.snapshot(&list_buf)); | ||
| 1226 | var n: usize = 0; | ||
| 1227 | while (it.next()) |_| n += 1; | ||
| 1228 | return n; | ||
| 1229 | } | ||
| 1230 | |||
| 1231 | pub fn rowStep(row: usize, d: i8, n: usize) usize { | ||
| 1232 | if (n == 0) return 0; | ||
| 1233 | if (d < 0) return if (row == 0) n - 1 else row - 1; | ||
| 1234 | return if (row + 1 >= n) 0 else row + 1; | ||
| 1235 | } | ||
| 1236 | ``` | ||
| 1237 | |||
| 1238 | Note the order problem: the filter flips `pick_level` BEFORE the wall sees the action, so on `.pick_enter` the wall reads the NEW level: `.sessions` means the list just opened, `.hosts` means a session was chosen. The two arms above are written for that. | ||
| 1239 | |||
| 1240 | - [ ] **Step 10: Run the gate** | ||
| 1241 | |||
| 1242 | Run: `make check 2>&1 | tail -3; echo rc=$?` | ||
| 1243 | Expected: `rc=0`. | ||
| 1244 | |||
| 1245 | - [ ] **Step 11: Commit** | ||
| 1246 | |||
| 1247 | ```bash | ||
| 1248 | git add src/tui/interact.zig src/client/client.zig src/tui/wall_picker.zig src/tui/wallview.zig src/tui/wall_test_picker.zig | ||
| 1249 | git commit -m "feat: the picker lists a host's sessions; Enter adds one, c births, x ends with the daemon's two-step" | ||
| 1250 | ``` | ||
| 1251 | |||
| 1252 | --- | ||
| 1253 | |||
| 1254 | ### Task 7: The hub serves and writes the layout | ||
| 1255 | |||
| 1256 | **Files:** | ||
| 1257 | - Modify: `src/client/webhub.zig` (`Hub.init`, `applyList`, `spawn`, `json`) | ||
| 1258 | - Modify: `src/cli/webhub_main.zig` (reads the layout, hands leaves to `Hub.init`) | ||
| 1259 | - Test: `src/client/webhub.zig` (inline tests) | ||
| 1260 | |||
| 1261 | **Interfaces:** | ||
| 1262 | - `webhub.Leaf = struct { host: usize, session: []const u8 }` | ||
| 1263 | - `Hub.init(alloc, specs: []const client.HostSpec, leaves: []const Leaf) !Hub` — one tile per leaf, ids in leaf order. | ||
| 1264 | - `Hub.applyList(host_idx, list, reachable)` grades: a tile whose session the list lacks gets `state = .gone` after the one-list grace; one the list names again leaves `gone`; nothing is born, nothing vanishes. | ||
| 1265 | - `Hub.spawn(id) !client.SessionName` births as today AND appends the new leaf to the layout file beside the tile `id` names, then adds the tile. | ||
| 1266 | - `webhub.readLeaves(alloc, path, specs) ![]Leaf` — parse the layout with `layout.parse`, map each spelling `HOST#SESSION` to a spec index; a leaf naming an unlisted host or a bad spelling refuses the file (`error.BadLayout`) and `mux web` prints the line and serves an empty wall. | ||
| 1267 | - `webhub.appendLeaf(alloc, path, specs, beside: Leaf, new: Leaf) !void` — read-modify-write: `layout.parse`, find the leaf id of `beside`, `Tree.insert`, `serialize` with the spellings, `hosts.saveBytes`. A missing file becomes a one-leaf tree. | ||
| 1268 | |||
| 1269 | - [ ] **Step 1: Write the failing hub tests** | ||
| 1270 | |||
| 1271 | Replace the test "hub: a listed name births a tile once; ids are birth order and never reused" in `src/client/webhub.zig` with: | ||
| 1272 | |||
| 1273 | ```zig | ||
| 1274 | test "hub: tiles are the layout's leaves in order; a list names born elsewhere add nothing; a missing session reads gone and comes back" { | ||
| 1275 | const alloc = std.testing.allocator; | ||
| 1276 | const specs = [_]client.HostSpec{ | ||
| 1277 | .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } }, | ||
| 1278 | .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } }, | ||
| 1279 | }; | ||
| 1280 | const leaves = [_]Leaf{ .{ .host = 1, .session = "0" }, .{ .host = 0, .session = "0" }, .{ .host = 0, .session = "b" } }; | ||
| 1281 | var hub = try Hub.init(alloc, &specs, &leaves); | ||
| 1282 | defer hub.deinit(); | ||
| 1283 | try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len); | ||
| 1284 | try std.testing.expectEqual(@as(u32, 0), hub.tiles.items[0].id); | ||
| 1285 | try std.testing.expectEqualStrings("box", specs[hub.tiles.items[0].host].spelling); | ||
| 1286 | |||
| 1287 | hub.applyList(0, "0\nb\nstranger\n", true); | ||
| 1288 | try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len); | ||
| 1289 | |||
| 1290 | hub.applyList(0, "0\n", true); | ||
| 1291 | hub.applyList(0, "0\n", true); | ||
| 1292 | try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len); | ||
| 1293 | try std.testing.expectEqual(TileState.gone, hub.tiles.items[2].state); | ||
| 1294 | hub.applyList(0, "0\nb\n", true); | ||
| 1295 | try std.testing.expect(hub.tiles.items[2].state != .gone); | ||
| 1296 | |||
| 1297 | const json = try hub.json(alloc); | ||
| 1298 | defer alloc.free(json); | ||
| 1299 | try std.testing.expectEqualStrings( | ||
| 1300 | \\[{"id":0,"label":"box","session":"0","state":"connecting"},{"id":1,"label":"--sock /tmp/a","session":"0","state":"connecting"},{"id":2,"label":"--sock /tmp/a","session":"b","state":"connecting"}] | ||
| 1301 | , json); | ||
| 1302 | } | ||
| 1303 | |||
| 1304 | test "readLeaves and appendLeaf: the layout round-trips through the hub, and a bad file is refused with its line" { | ||
| 1305 | const alloc = std.testing.allocator; | ||
| 1306 | const testtmp = @import("testtmp"); | ||
| 1307 | var tmp = try testtmp.TmpDir.make(); | ||
| 1308 | defer tmp.cleanup(); | ||
| 1309 | var pb: [64]u8 = undefined; | ||
| 1310 | const path = try std.fmt.bufPrint(&pb, "{s}/layout", .{tmp.path()}); | ||
| 1311 | const specs = [_]client.HostSpec{ | ||
| 1312 | .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } }, | ||
| 1313 | }; | ||
| 1314 | // No file yet: the first append makes a one-leaf tree. | ||
| 1315 | try appendLeaf(alloc, path, &specs, null, .{ .host = 0, .session = "0" }); | ||
| 1316 | try appendLeaf(alloc, path, &specs, .{ .host = 0, .session = "0" }, .{ .host = 0, .session = "b" }); | ||
| 1317 | const leaves = try readLeaves(alloc, path, &specs); | ||
| 1318 | defer alloc.free(leaves); | ||
| 1319 | try std.testing.expectEqual(@as(usize, 2), leaves.len); | ||
| 1320 | try std.testing.expectEqualStrings("0", leaves[0].session); | ||
| 1321 | try std.testing.expectEqualStrings("b", leaves[1].session); | ||
| 1322 | |||
| 1323 | try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "mux-layout 1\nleaf 0 nowhere#0\n" }); | ||
| 1324 | try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs)); | ||
| 1325 | } | ||
| 1326 | ``` | ||
| 1327 | |||
| 1328 | If `TileState` has no `gone` variant, add one (the browser's `controlMessage` table gains a `gone` string: "session ended on its daemon"). | ||
| 1329 | |||
| 1330 | - [ ] **Step 2: Run to see them fail** | ||
| 1331 | |||
| 1332 | Run: `deps/zig/zig build test 2>&1 | grep -a "error:" | head -3` | ||
| 1333 | Expected: `Leaf`, `readLeaves`, `appendLeaf` undefined. | ||
| 1334 | |||
| 1335 | - [ ] **Step 3: Implement** | ||
| 1336 | |||
| 1337 | In `src/client/webhub.zig`: | ||
| 1338 | |||
| 1339 | ```zig | ||
| 1340 | pub const Leaf = struct { host: usize, session: []const u8 }; | ||
| 1341 | |||
| 1342 | /// The layout's leaves in tree order, each mapped to a spec index. The | ||
| 1343 | /// same strictness as the terminal wall: a leaf the hosts file cannot | ||
| 1344 | /// place, or one with no session, refuses the FILE, because a hub that | ||
| 1345 | /// silently served part of a wall would be a wall the user cannot see is | ||
| 1346 | /// short. | ||
| 1347 | pub fn readLeaves(alloc: std.mem.Allocator, path: []const u8, specs: []const client.HostSpec) ![]Leaf { | ||
| 1348 | const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |e| switch (e) { | ||
| 1349 | error.FileNotFound => return alloc.alloc(Leaf, 0), | ||
| 1350 | else => return e, | ||
| 1351 | }; | ||
| 1352 | defer alloc.free(bytes); | ||
| 1353 | var parsed = layout.parse(alloc, bytes) orelse return error.BadLayout; | ||
| 1354 | defer parsed.deinit(alloc); | ||
| 1355 | var out = std.ArrayListUnmanaged(Leaf){}; | ||
| 1356 | errdefer { | ||
| 1357 | for (out.items) |l| alloc.free(l.session); | ||
| 1358 | out.deinit(alloc); | ||
| 1359 | } | ||
| 1360 | for (parsed.spellings.items) |sp| { | ||
| 1361 | const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse return error.BadLayout; | ||
| 1362 | const sess = sp[cut + 1 ..]; | ||
| 1363 | if (!proto.validSessionName(sess)) return error.BadLayout; | ||
| 1364 | const hi = for (specs, 0..) |s, i| { | ||
| 1365 | if (std.mem.eql(u8, s.spelling, sp[0..cut])) break i; | ||
| 1366 | } else return error.BadLayout; | ||
| 1367 | try out.append(alloc, .{ .host = hi, .session = try alloc.dupe(u8, sess) }); | ||
| 1368 | } | ||
| 1369 | return out.toOwnedSlice(alloc); | ||
| 1370 | } | ||
| 1371 | |||
| 1372 | /// Read-modify-write over the atomic rename `hosts.saveBytes` does. Two | ||
| 1373 | /// writers in the same instant lose one update; the hosts file accepts | ||
| 1374 | /// the same, and the terminal wall re-reads on its next start. | ||
| 1375 | pub fn appendLeaf(alloc: std.mem.Allocator, path: []const u8, specs: []const client.HostSpec, beside: ?Leaf, new: Leaf) !void { | ||
| 1376 | var parsed: layout.ParsedLayout = blk: { | ||
| 1377 | const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |e| switch (e) { | ||
| 1378 | error.FileNotFound => break :blk .{ .tree = layout.Tree.init(alloc) }, | ||
| 1379 | else => return e, | ||
| 1380 | }; | ||
| 1381 | defer alloc.free(bytes); | ||
| 1382 | break :blk layout.parse(alloc, bytes) orelse return error.BadLayout; | ||
| 1383 | }; | ||
| 1384 | defer parsed.deinit(alloc); | ||
| 1385 | const new_sp = try std.fmt.allocPrint(alloc, "{s}#{s}", .{ specs[new.host].spelling, new.session }); | ||
| 1386 | errdefer alloc.free(new_sp); | ||
| 1387 | const new_id: u8 = @intCast(parsed.spellings.items.len); | ||
| 1388 | if (new_id >= layout.max_leaves) return error.WallFull; | ||
| 1389 | var anchor: ?u8 = null; | ||
| 1390 | if (beside) |b| { | ||
| 1391 | for (parsed.spellings.items, 0..) |sp, i| { | ||
| 1392 | const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse continue; | ||
| 1393 | if (std.mem.eql(u8, sp[0..cut], specs[b.host].spelling) and std.mem.eql(u8, sp[cut + 1 ..], b.session)) { | ||
| 1394 | anchor = @intCast(i); | ||
| 1395 | break; | ||
| 1396 | } | ||
| 1397 | } | ||
| 1398 | } | ||
| 1399 | if (parsed.tree.root == null) try parsed.tree.addFirst(new_id) else try parsed.tree.insert(anchor orelse 0, new_id); | ||
| 1400 | try parsed.spellings.append(alloc, new_sp); | ||
| 1401 | var buf = std.ArrayListUnmanaged(u8){}; | ||
| 1402 | defer buf.deinit(alloc); | ||
| 1403 | try parsed.tree.serialize(parsed.spellings.items, parsed.focus, buf.writer(alloc)); | ||
| 1404 | try hosts.saveBytes(path, buf.items); | ||
| 1405 | } | ||
| 1406 | ``` | ||
| 1407 | |||
| 1408 | `layout.max_leaves` may be spelled differently (the wall uses `wv.max_tiles`); use the constant `Tree` itself bounds ids by (grep `u8` ids in `layout.zig`; if there is none, use `wv.max_tiles`'s value, 32, stated once in `layout.zig` as `pub const max_leaves`). `ParsedLayout.focus` and `.spellings` are the fields the wall's seed reads; `webhub` imports `layout` as `client.layout` and `hosts` as `client.hosts` (check the file's existing imports). | ||
| 1409 | |||
| 1410 | `Hub.init(alloc, specs, leaves)`: after the host setup, for each leaf call the existing `self.birth(leaf.host, leaf.session)` (which dupes the name and assigns the next id) — ids are leaf order, matching the wall's tree order. | ||
| 1411 | |||
| 1412 | `applyList`: delete the births loop; in the walk, replace `self.vanish(i)` with `t.state = .gone` (keep the one-list grace), and when `sessionsHas` is true and `t.state == .gone`, set `t.state = .connecting` so the tile's pump redials. Read `pumpTile` to confirm a hub pump parked on a refused attach redials when the state is flipped; if it needs a doorbell, use the mechanism the hub already has for `.reconnecting`. | ||
| 1413 | |||
| 1414 | `spawn(id)`: after `client.birthSession` succeeds, call `appendLeaf(self.alloc, self.layout_path, self.specs, .{ .host = hi, .session = self.tiles.items[idx].session }, .{ .host = hi, .session = name.slice() })` and then `self.birth(hi, name.slice())` under the mutex so the tile exists before the poll answers. `Hub` gains `layout_path: []const u8` and `specs: []const client.HostSpec` fields set by `init`. | ||
| 1415 | |||
| 1416 | `json`: add `"state":"<tag>"` per tile from `@tagName(t.state)`. | ||
| 1417 | |||
| 1418 | In `src/cli/webhub_main.zig`, after `hosts.load`: `const layout_path = try hosts.layoutPath(arena);` then | ||
| 1419 | |||
| 1420 | ```zig | ||
| 1421 | const leaves = webhub.readLeaves(arena, layout_path, specs) catch |e| switch (e) { | ||
| 1422 | error.BadLayout => blk: { | ||
| 1423 | std.debug.print("mux web: layout ignored ({s}): not a wall this hosts file can place\n", .{layout_path}); | ||
| 1424 | break :blk &.{}; | ||
| 1425 | }, | ||
| 1426 | else => return e, | ||
| 1427 | }; | ||
| 1428 | var hub = try webhub.Hub.init(arena, specs, leaves); | ||
| 1429 | ``` | ||
| 1430 | |||
| 1431 | and pass `layout_path` into the hub (`hub.layout_path = layout_path;` or an `init` parameter). | ||
| 1432 | |||
| 1433 | - [ ] **Step 4: Run the gate** | ||
| 1434 | |||
| 1435 | Run: `make check 2>&1 | tail -3; echo rc=$?` | ||
| 1436 | Expected: `rc=0`. The `web/mux.js` page reads `/tiles`; the extra `state` field is additive and the page ignores unknown fields (confirm by grepping `mux.js` for how it reads the array; it indexes by name). | ||
| 1437 | |||
| 1438 | - [ ] **Step 5: Commit** | ||
| 1439 | |||
| 1440 | ```bash | ||
| 1441 | git add src/client/webhub.zig src/cli/webhub_main.zig | ||
| 1442 | git commit -m "feat: the hub serves the layout's panes and writes a birth back into it" | ||
| 1443 | ``` | ||
| 1444 | |||
| 1445 | --- | ||
| 1446 | |||
| 1447 | ### Task 8: New machine, the entry pane, and the end of `keeps_wall` | ||
| 1448 | |||
| 1449 | **Files:** | ||
| 1450 | - Modify: `src/tui/wallview.zig` (`run`'s startup; `Tile.keeps_wall`, `Birth.keeps_wall`, the `endAction` branch; `Entry`) | ||
| 1451 | - Modify: `src/tui/wall_picker.zig` (`pickBirth` no longer sets `keeps_wall`; it persists) | ||
| 1452 | - Modify: `src/tui/wall_test_wall.zig` (the `keeps_wall` tests) | ||
| 1453 | |||
| 1454 | **Interfaces:** none new. `keeps_wall` is deleted everywhere. | ||
| 1455 | |||
| 1456 | - [ ] **Step 1: Startup writes the wall it seated** | ||
| 1457 | |||
| 1458 | In `run`, after the entry tile is seated and any seed plan applied (the block ending in `shared.last_flat = init_flat;`), add: | ||
| 1459 | |||
| 1460 | ```zig | ||
| 1461 | // The wall as seated is the wall as written: a first `mux` on a machine | ||
| 1462 | // leaves a one-leaf layout behind, and `mux HOST` leaves its new pane in | ||
| 1463 | // the file. Before the keys loop, so a wall that dies at once still | ||
| 1464 | // persisted what it showed. | ||
| 1465 | wall_layout.persist(w); | ||
| 1466 | ``` | ||
| 1467 | |||
| 1468 | placing it where `w` exists (the `Wall` value is built before the keys loop; put the call right after that construction). | ||
| 1469 | |||
| 1470 | - [ ] **Step 2: Delete `keeps_wall`** | ||
| 1471 | |||
| 1472 | Remove the field from `Tile` and from `Birth`, the assignment in `birthTileOrRefuse`, the `.keeps_wall = true` in `pickBirth`, and the `if (t.keeps_wall and is_tty and stdin_open)` branch in `endAction`. The branch it guarded returned `.refocus`/kept the wall for a refused picker birth; on a terminal, `endAction`'s `.exited`/`refused` paths already leave the wall standing when other panes are present, and an empty wall opens the picker. Run the `wall_test_wall.zig` tests that set `keeps_wall = true` (four sites): rewrite each to assert the SAME outcome without the flag — a refused picker birth on a terminal with `stdin_open` vanishes the tile with its message and the wall stands. If one of the four cannot pass without the flag, that is the case `keeps_wall` was really for; keep the flag and record why in its doc comment, and note it in the commit. | ||
| 1473 | |||
| 1474 | - [ ] **Step 3: Run the gate** | ||
| 1475 | |||
| 1476 | Run: `make check 2>&1 | tail -3; echo rc=$?` | ||
| 1477 | Expected: `rc=0`. | ||
| 1478 | |||
| 1479 | - [ ] **Step 4: Hand-run the new-machine path** | ||
| 1480 | |||
| 1481 | ```bash | ||
| 1482 | export XDG_STATE_HOME=/tmp/nm-$$/s XDG_RUNTIME_DIR=/tmp/nm-$$/r XDG_CONFIG_HOME=/tmp/nm-$$/c XDG_CACHE_HOME=/tmp/nm-$$/k HOME=/tmp/nm-$$/h | ||
| 1483 | mkdir -p $XDG_STATE_HOME $XDG_RUNTIME_DIR $XDG_CONFIG_HOME $XDG_CACHE_HOME $HOME | ||
| 1484 | deps/zig/zig build | ||
| 1485 | ./zig-out/bin/ptyclient --cols 100 --rows 30 --out /tmp/nm-$$/cap -- ./zig-out/bin/mux <<'EOF' | ||
| 1486 | expect \$ 10000 | ||
| 1487 | send \x1cd | ||
| 1488 | EOF | ||
| 1489 | cat $XDG_STATE_HOME/mux/hosts; cat $XDG_STATE_HOME/mux/layout | ||
| 1490 | ./zig-out/bin/mux d stop | ||
| 1491 | ``` | ||
| 1492 | |||
| 1493 | Expected: the hosts file holds one `--sock` line; the layout holds `leaf 0 --sock <path>#0` and nothing else. | ||
| 1494 | |||
| 1495 | - [ ] **Step 5: Commit** | ||
| 1496 | |||
| 1497 | ```bash | ||
| 1498 | git add src/tui/wallview.zig src/tui/wall_picker.zig src/tui/wall_test_wall.zig | ||
| 1499 | git commit -m "feat: a first mux writes its one-pane wall, and a refused birth needs no flag to leave the wall standing" | ||
| 1500 | ``` | ||
| 1501 | |||
| 1502 | --- | ||
| 1503 | |||
| 1504 | ### Task 9: The e2e suite says the new model | ||
| 1505 | |||
| 1506 | **Files:** | ||
| 1507 | - Modify: `test/e2e_09_hosts.sh`, `test/e2e_12_panes.sh`, `test/e2e_13_birth.sh`, `test/e2e_06_web.sh`, `test/e2e_07_wallcli.sh`, `test/e2e.sh` | ||
| 1508 | |||
| 1509 | Every leg below runs under e2e_lib's isolated state; a leg that needs its own layout writes `$XDG_STATE_HOME/mux/layout` for the wall it starts, using the format `mux-layout 1` / `leaf 0 HOST#SESSION` (single leaf) or `beside 0` with indented `leaf 1 ...` children. Legs use `ptyclient` for anything about what the wall SHOWS; `pipe_mux` legs cannot see tiles. | ||
| 1510 | |||
| 1511 | - [ ] **Step 1: Rewrite the legs that pin the old model** | ||
| 1512 | |||
| 1513 | By scenario name (the `ok "..."` line), each becomes: | ||
| 1514 | |||
| 1515 | `e2e_09_hosts.sh` | ||
| 1516 | - "every live session of every listed daemon is a tile, n walks them, and a birth writes nothing down" → **"a wall shows its layout's panes and no more; a session born elsewhere never appears"**: two daemons, three sessions each (`fill_sessions`), a layout naming two panes on daemon A and one on B; a ptyclient wall; assert the three labels are on screen and the other three names are NOT (`expect` on each present, and a `settle` then `grep -c` on the capture for each absent name = 0); then `mux a` births a fourth session on A; `settle 2500`; the new name is still absent; `Ctrl-\ n` walks exactly the three. | ||
| 1517 | - "x refuses while others are attached, then ends; the other client sees the exit" → moves to the picker: **"the picker's x refuses while others hold the session, ends on the second press, and the other client sees the exit"**: wall A holds session `0` as a pane; a second `pipe_mux` client holds it too; on the wall `Ctrl-\ s`, Enter on the host, `x` on row `0`: the notice names `1 other client`; `x` again within 3 s; the pipe client's capture gets `exit_status`; `mux d stats` says sessions dropped by one. | ||
| 1518 | - "x on a wall ends the focused tile's session and no other, and that tile leaves the next list" → **"x on a wall removes the focused pane and ends nothing: the session keeps its other client, and the layout loses the leaf"**: two panes; a pipe client on the focused pane's session; `Ctrl-\ x`; assert the pane is gone from the screen, `mux d stats` still lists the session with `clients=1`, the pipe client is still attached (send a line, await it), and the layout file no longer names the leaf. | ||
| 1519 | - "mux records the daemon on the wall, never a session, and starts a listed local one that is gone" → keep, and add: the layout after `mux --sock S` names `S#0` exactly once. | ||
| 1520 | - "the first mux on a machine starts the local daemon and writes it down" → add the layout assertion: one leaf, `--sock <default>#0`. | ||
| 1521 | - "a daemon that goes down keeps its panes wearing unreachable, and comes back re-creating nothing" → keep as is (the model is unchanged for unreachable). | ||
| 1522 | - "the picker births on the host a digit names, forgets a host without ending it, adds one back…" → Enter is now `c` for the birth: replace `send \r` with `send c` where the leg births; add after it: Enter on the host opens the session list (`expect` the new session's row), Esc backs out. | ||
| 1523 | - "an empty wall opens the picker, Esc leaves the one line, and Ctrl-\\ d leaves at once" → keep; it now also covers "a hosts file with lines and no layout". | ||
| 1524 | |||
| 1525 | `e2e_12_panes.sh` | ||
| 1526 | - "the layout heals on live drift: …" → **"a poll changes nothing on the wall: a newcomer stays off it, a survivor keeps its pane, the ended one's pane leaves with its shell"**: same rig, but the newcomer born by `mux a` is asserted ABSENT after `settle 2500`, and the ended session's pane leaves (shell `exit`), and the layout file matches the screen. | ||
| 1527 | - "a corrupted sidecar degrades silently to the default layout" → **"a corrupted layout is reported with its line and the wall starts as if it were missing"**: write garbage; run a wall with `mux --sock S` (an entry); stderr carries `mux: layout ignored (`; the wall shows the entry pane alone; after detach the layout file is the one-leaf wall. | ||
| 1528 | - "a resized layout survives a detach/reattach round trip via the sidecar" → keep; the sidecar IS the wall now, nothing else changes. | ||
| 1529 | - "a rebooted daemon's panes wear gone and the cut never moves; Enter revives, x dismisses" → keep; `x` on a gone pane removes it (already the behaviour). | ||
| 1530 | |||
| 1531 | `e2e_13_birth.sh` | ||
| 1532 | - "the picker's a adds a host by spelling: its sessions become tiles…" → **"…its sessions are listed in the picker, not put on the wall; Enter on one adds it"**: after `a` adds the host, assert no tile appeared (`grep -c` of its session names on the capture = 0 after `settle`), then Enter on the host, Enter on the first row, and the pane appears. | ||
| 1533 | - "a new tile takes the lowest free digit, and the daemon takes the name back" → `c` in the picker instead of Enter; otherwise unchanged. | ||
| 1534 | - "a birth the daemon refuses paints [refused] and leaves the wall standing" → `c` instead of Enter. | ||
| 1535 | |||
| 1536 | `e2e_06_web.sh` | ||
| 1537 | - "the hub's wall is the hosts file; tiles are those daemons' live sessions" → **"the hub's wall is the layout: /tiles lists its leaves in order, a session born elsewhere is not listed, and + writes the new pane into the file"**: write a layout of two leaves across two daemons; start `mux web`; `curl /tiles` equals the two leaves in tree order (parse with the leg's existing JSON grep); `mux a` births a session on daemon A; `sleep 2.5`; `/tiles` unchanged; `POST /tiles/0`; `/tiles` has three; the layout file names the new session. | ||
| 1538 | |||
| 1539 | `e2e_07_wallcli.sh` | ||
| 1540 | - "mux --sock: every session the daemon has, and a live delta, on one terminal" → **"mux --sock: the entry pane and the layout's other panes, and a live delta, on one terminal"**: seed a layout with two leaves on the daemon (the entry `#0` and `#1`), leave a third session off it, assert the two and not the third. | ||
| 1541 | |||
| 1542 | - [ ] **Step 2: Add the legs the spec names that no rewrite above covers** | ||
| 1543 | |||
| 1544 | In `e2e_09_hosts.sh`, after the picker leg: | ||
| 1545 | |||
| 1546 | ```sh | ||
| 1547 | # ---- two devices, one daemon: each wall is its own layout ---------------- | ||
| 1548 | # | ||
| 1549 | # The whole point of the change: a second state dir against the SAME two | ||
| 1550 | # daemons starts with nothing but what it adds, and the first wall never | ||
| 1551 | # learns of it. Two XDG_STATE_HOMEs stand in for two machines. | ||
| 1552 | DEV2="${TMPDIR:-/tmp}/mux-e2e-dev2-$$" | ||
| 1553 | defer_rm "$DEV2" | ||
| 1554 | mkdir -p "$DEV2/mux" | ||
| 1555 | printf -- '--sock %s\n--sock %s\n' "$SOCKA" "$SOCKB" > "$DEV2/mux/hosts" | ||
| 1556 | # Device 1: a two-pane wall on A#0 and B#0, already running from the leg | ||
| 1557 | # above under $HSTATE (its layout has exactly those leaves). | ||
| 1558 | # Device 2: no layout. `mux` opens the picker on an empty wall; Enter on | ||
| 1559 | # host B, Enter on its second row adds B#1 and nothing else. | ||
| 1560 | XDG_STATE_HOME="$DEV2" XDG_RUNTIME_DIR="$HRUN" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \ | ||
| 1561 | --out "$OUT.dev2.cap" --err "$OUT.dev2.cap.err" -- "$MUX" > "$OUT.dev2.pc" 2>&1 <<EOF | ||
| 1562 | expect no sessions on the wall 10000 | ||
| 1563 | send \x1cs | ||
| 1564 | expect sessions 10000 | ||
| 1565 | send 2 | ||
| 1566 | send \r | ||
| 1567 | settle 400 10000 | ||
| 1568 | send j | ||
| 1569 | send \r | ||
| 1570 | expect ${SOCKB##*/}#1 15000 | ||
| 1571 | settle 1500 10000 | ||
| 1572 | send \x1cd | ||
| 1573 | EOF | ||
| 1574 | grep -q "leaf 0 --sock $SOCKB#1" "$DEV2/mux/layout" || { | ||
| 1575 | echo "e2e FAIL: device 2's layout is not the one pane it added:"; cat "$DEV2/mux/layout"; exit 1; } | ||
| 1576 | grep -c "#0" "$OUT.dev2.cap" | grep -qx 0 || { | ||
| 1577 | echo "e2e FAIL: device 2 saw a pane it never added"; exit 1; } | ||
| 1578 | # Device 1 is unchanged: its layout still has two leaves and no B#1. | ||
| 1579 | [ "$(grep -c '^ leaf' "$HSTATE/mux/layout")" -eq 2 ] || { | ||
| 1580 | echo "e2e FAIL: device 1's layout changed under device 2's add:"; cat "$HSTATE/mux/layout"; exit 1; } | ||
| 1581 | ok "two walls on the same daemons are two layouts; neither learns of the other's panes" | ||
| 1582 | ``` | ||
| 1583 | |||
| 1584 | Adapt `$SOCKA`, `$SOCKB`, `$HSTATE`, `$HRUN` to the names the group's earlier legs use (grep the file's `start_daemon` lines); the `expect` strings must match what the picker paints (`hostState` prints `N sessions`; the empty-wall hint is `emptyWallHint`'s text — read it and pin the real words). | ||
| 1585 | |||
| 1586 | - [ ] **Step 3: Update the pins** | ||
| 1587 | |||
| 1588 | Count: the rewrites keep their `ok` lines one-for-one; Step 2 adds one. `test/e2e.sh`: `109` → `110` on both lines. The convergence count is unchanged unless a rewritten leg drops an `assert_converged`; if the full run prints a different number, read which leg changed it before touching the pin. | ||
| 1589 | |||
| 1590 | - [ ] **Step 4: Run each group alone, then the whole suite** | ||
| 1591 | |||
| 1592 | ```bash | ||
| 1593 | for g in 09_hosts 12_panes 13_birth 06_web 07_wallcli; do E2E_ONLY=$g make e2e 2>&1 | grep -a "FAIL\|e2e OK (" | head -3; done | ||
| 1594 | make e2e 2>&1 | tail -3 | ||
| 1595 | ``` | ||
| 1596 | |||
| 1597 | Expected: every group `e2e OK (N scenarios in G; the pin is the whole suite's)`; the full run `e2e OK (110 scenarios, 38 convergence points)`. | ||
| 1598 | |||
| 1599 | - [ ] **Step 5: Mutation-check the two new oracles** | ||
| 1600 | |||
| 1601 | Reintroduce a birth in `applyHostList` (the smallest mutant: call `wv.birthTile` for the first unlisted name) and run `E2E_ONLY=09_hosts`; the "shows its layout's panes and no more" leg must FAIL. Restore by copying the saved original file back (never an inverse sed). Then drop the `wall_layout.persist(w)` call from `removePane` and run the same group; the "x removes the focused pane" leg must FAIL on the layout assertion. Restore, re-run green. | ||
| 1602 | |||
| 1603 | - [ ] **Step 6: Commit** | ||
| 1604 | |||
| 1605 | ```bash | ||
| 1606 | git add test/ | ||
| 1607 | git commit -m "test: the e2e suite pins the wall as the layout" | ||
| 1608 | ``` | ||
| 1609 | |||
| 1610 | --- | ||
| 1611 | |||
| 1612 | ### Task 10: Docs say the new model | ||
| 1613 | |||
| 1614 | **Files:** | ||
| 1615 | - Modify: `README.md` (the "wall of hosts" paragraphs quoted in the spec's Problem section; the `x` key; the picker keys) | ||
| 1616 | - Modify: `CLAUDE.md` (replace the invariant bullets "The wall file lists DAEMONS; tiles are their live sessions", "Hosts live in the picker, not on the wall", "Ctrl-\ x ends a session; the daemon owns the two-step", and "The layout sidecar is derived convenience, not authored intent") | ||
| 1617 | - Modify: `docs/decisions.md` (a dated section) | ||
| 1618 | |||
| 1619 | - [ ] **Step 1: README** | ||
| 1620 | |||
| 1621 | Replace the paragraph beginning "`mux` on a machine that has never run it records your own daemon" and the one before it (which says tiles are whatever the daemons have live) with: | ||
| 1622 | |||
| 1623 | > The wall is your layout. `$XDG_STATE_HOME/mux/layout` names the panes you have opened, each `HOST#SESSION`, in the tree you arranged them in; every `mux` on this machine opens exactly that. Sessions live on daemons and a daemon may have more of them than your wall shows: a session born by `mux a`, a browser, or another machine is on no wall until you add it. `Ctrl-\ s` lists your daemons, Enter on one lists its sessions with who else holds each, Enter on a session adds it as a pane, `c` starts a new one there, `x` ends one (asking first when someone else holds it), Esc backs out. On the wall, `Ctrl-\ x` takes the focused pane off this wall and ends nothing. | ||
| 1624 | > | ||
| 1625 | > `mux` on a machine that has never run it records your own daemon and opens one pane on its session `0` — a first run is still just a shell. `mux HOST` opens zoomed on HOST's session `0`, adding HOST to your daemons and the pane to your wall if they were not there. | ||
| 1626 | |||
| 1627 | Update the key table's `x` row and the picker rows to match. | ||
| 1628 | |||
| 1629 | - [ ] **Step 2: CLAUDE.md** | ||
| 1630 | |||
| 1631 | Replace the four bullets named above with: | ||
| 1632 | |||
| 1633 | ``` | ||
| 1634 | - **The layout is the wall; the poll grades it and adds nothing.** | ||
| 1635 | `$XDG_STATE_HOME/mux/layout` is authored intent: a pane tree whose | ||
| 1636 | leaves are `HOST#SESSION`, `HOST` a hosts-file line verbatim. Tiles come | ||
| 1637 | from it and from three doors only — the file on start, the picker, and a | ||
| 1638 | chord split — never from a daemon's `sessions_reply`; `mux HOST`'s entry | ||
| 1639 | pane goes through the same seat-then-`persist` path. The once-a-second | ||
| 1640 | poll binds a pending pane whose session the list names, marks `gone` one | ||
| 1641 | it does not, and vanishes a live pane whose shell ended. `persist` is | ||
| 1642 | the ONE save path, gated on `Shared.layout_path`, and every change to | ||
| 1643 | the pane set or tree calls it. A file that fails `seedLayout` — a host | ||
| 1644 | the hosts file lacks, a leaf without a session, a repeat, garbage — is | ||
| 1645 | reported with its line and treated as missing; a missing file is a wall | ||
| 1646 | of one local pane (session `0`) when there is an entry, and an empty | ||
| 1647 | wall that opens the picker when there is not. The hub reads and writes | ||
| 1648 | the same file (`webhub.readLeaves`, `webhub.appendLeaf`). | ||
| 1649 | - **Hosts and sessions live in the picker.** `Ctrl-\ s` is a MODE of | ||
| 1650 | `interact.PrefixFilter` with two levels (`pick_level`): hosts, then a | ||
| 1651 | host's sessions with `# holds` counts. Enter descends or adds, `c` | ||
| 1652 | births at `client.nextFreeName`, `x` forgets a host or ends a session | ||
| 1653 | (`client.endSession` on a side connection; the daemon's two-step, armed | ||
| 1654 | 3 s per host and name in `Shared.pick_end`), `a` edits a spelling, Esc | ||
| 1655 | backs out. Tiles do not paint while it is open. | ||
| 1656 | - **`Ctrl-\ x` removes a pane and ends nothing.** `removePane` tells the | ||
| 1657 | pump to detach, vanishes the tile, and persists. Ending is the picker's. | ||
| 1658 | ``` | ||
| 1659 | |||
| 1660 | Keep the daemon-side facts from the old `x` bullet (bounded end, `end_req`/`end_reply` opcodes, `mux d upgrade` refused while ending) in the "picker" bullet or the daemon section; they did not change. | ||
| 1661 | |||
| 1662 | - [ ] **Step 3: decisions.md** | ||
| 1663 | |||
| 1664 | Append a section `## 2026-09-02 — the wall is the layout` stating: the user's report and expectation (quoted in the spec), the three approaches and why the flip won, the strictness reversal, the `# holds` line and why a `#` line, `x`'s new meaning and where ending went, what the hub does, the pin count, and anything Task 8's `keeps_wall` step learned. | ||
| 1665 | |||
| 1666 | - [ ] **Step 4: Gate and commit** | ||
| 1667 | |||
| 1668 | Run: `make check 2>&1 | tail -3; echo rc=$?` — the comment gate reads `CLAUDE.md`'s cited symbols; every backticked name above must exist. | ||
| 1669 | |||
| 1670 | ```bash | ||
| 1671 | git add README.md CLAUDE.md docs/decisions.md | ||
| 1672 | git commit -m "docs: the wall is the layout" | ||
| 1673 | ``` | ||
| 1674 | |||
| 1675 | --- | ||
| 1676 | |||
| 1677 | ## Delivery | ||
| 1678 | |||
| 1679 | After Task 10: `make ci` (detached, `setsid nohup make ci > /tmp/ci.log 2>&1 &`, then watch the log; ~50 min), then `make install`, then a hands-on demo on a real terminal with the user's own two daemons before anything is called done. The demo script is part of the deliverable: run it yourself first. | ||