a73x

2cad1d1c

feat: a host's list wakes the pane the sidecar promised it

a73x   2026-08-30 16:18

Commit message
feat: a host's list wakes the pane the sidecar promised it

`planHostDiff` answers a pending pane's name with a bind, not a birth:
`bindTile` wakes it in place — rect untouched, `creates` false, the
claim armed only under the saved focus — so a wall whose sessions all
came back re-cuts nothing. A pending pane the list does not name goes
on the first list; it has no exit code to lose, so no grace.

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

src/tui/wall_host.zig
Old New
@@ -187,6 +187,7 @@ pub fn planHostDiff(
187 list: []const u8, 187 list: []const u8,
188 self_name: ?[]const u8, 188 self_name: ?[]const u8,
189 births: *BirthNames, 189 births: *BirthNames,
190 binds: *TileIdxs,
190 vanish: *TileIdxs, 191 vanish: *TileIdxs,
191 ) void { 192 ) void {
192 var it = std.mem.splitScalar(u8, list, '\n'); 193 var it = std.mem.splitScalar(u8, list, '\n');
@@ -199,8 +200,11 @@ pub fn planHostDiff(
199 if (!proto.validSessionName(name)) continue; 200 if (!proto.validSessionName(name)) continue;
200 if (self_name) |self| if (std.mem.eql(u8, self, name)) continue; 201 if (self_name) |self| if (std.mem.eql(u8, self, name)) continue;
201 var found = false; 202 var found = false;
202 for (tiles[0..live], present[0..live]) |*t, p| { 203 for (tiles[0..live], present[0..live], 0..) |*t, p, i| {
203 if (p and ownedBy(t, host) and std.mem.eql(u8, proto.resolveName(t.r.session), name)) { 204 if (p and ownedBy(t, host) and std.mem.eql(u8, proto.resolveName(t.r.session), name)) {
205 // The sidecar's guess, confirmed by the host's own list:
206 // wake the pending pane rather than birthing a twin.
207 if (t.pending) binds.append(i);
204 found = true; 208 found = true;
205 break; 209 break;
206 } 210 }
@@ -328,8 +332,12 @@ pub fn applyHostList(w: Wall, hi: usize) void {
328 // would tear a wall down over one dropped packet. 332 // would tear a wall down over one dropped packet.
329 if (reachable) { 333 if (reachable) {
330 var births = BirthNames{}; 334 var births = BirthNames{};
335 var binds = TileIdxs{};
331 var vanish = TileIdxs{}; 336 var vanish = TileIdxs{};
332 planHostDiff(w.liveTiles(), w.livePresent(), w.live.*, hi, list, h.self_name, &births, &vanish); 337 planHostDiff(w.liveTiles(), w.livePresent(), w.live.*, hi, list, h.self_name, &births, &binds, &vanish);
338 // Binds first, and they do not count as change: a pane whose rect
339 // was cut at seed time wakes in place, and re-cuts nothing.
340 for (binds.items[0..binds.len]) |bi| wv.bindTile(w, bi);
333 for (vanish.items[0..vanish.len]) |v| { 341 for (vanish.items[0..vanish.len]) |v| {
334 wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, v, null); 342 wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, v, null);
335 changed = true; 343 changed = true;
src/tui/wall_test_host.zig
Old New
@@ -6,6 +6,8 @@ const hosts = @import("client").hosts;
6 const TmpDir = @import("testtmp").TmpDir; 6 const TmpDir = @import("testtmp").TmpDir;
7 const fixture = @import("wall_test_harness.zig"); 7 const fixture = @import("wall_test_harness.zig");
8 const wall_host = @import("wall_host.zig"); 8 const wall_host = @import("wall_host.zig");
9 const wall_layout = @import("wall_layout.zig");
10 const wall_pump = @import("wall_pump.zig");
9 const wv = @import("wallview.zig"); 11 const wv = @import("wallview.zig");
10 const AddHost = wall_host.AddHost; 12 const AddHost = wall_host.AddHost;
11 const BirthNames = wall_host.BirthNames; 13 const BirthNames = wall_host.BirthNames;
@@ -21,28 +23,29 @@ test "planHostDiff: names the daemon has and the wall does not are births, tiles
21 var present = [_]bool{ true, true, true }; 23 var present = [_]bool{ true, true, true };
22 24
23 var births = BirthNames{}; 25 var births = BirthNames{};
26 var binds = TileIdxs{};
24 var vanish = TileIdxs{}; 27 var vanish = TileIdxs{};
25 // The birth is immediate; the vanish waits for a second list to agree 28 // The birth is immediate; the vanish waits for a second list to agree
26 // (see the grace test below), so this list is asked twice. 29 // (see the grace test below), so this list is asked twice.
27 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &vanish); 30 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &binds, &vanish);
28 try std.testing.expectEqual(@as(usize, 1), births.len); 31 try std.testing.expectEqual(@as(usize, 1), births.len);
29 try std.testing.expectEqualStrings("c", births.get(0)); 32 try std.testing.expectEqualStrings("c", births.get(0));
30 births.len = 0; 33 births.len = 0;
31 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &vanish); 34 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &binds, &vanish);
32 try std.testing.expectEqual(@as(usize, 1), vanish.len); 35 try std.testing.expectEqual(@as(usize, 1), vanish.len);
33 try std.testing.expectEqual(@as(usize, 1), vanish.get(0)); 36 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
34 37
35 // Host 1's "a" survives host 0's list saying nothing about it. 38 // Host 1's "a" survives host 0's list saying nothing about it.
36 births.len = 0; 39 births.len = 0;
37 vanish.len = 0; 40 vanish.len = 0;
38 wall_host.planHostDiff(&tiles, &present, 3, 1, "a\n", null, &births, &vanish); 41 wall_host.planHostDiff(&tiles, &present, 3, 1, "a\n", null, &births, &binds, &vanish);
39 try std.testing.expectEqual(@as(usize, 0), births.len + vanish.len); 42 try std.testing.expectEqual(@as(usize, 0), births.len + vanish.len);
40 43
41 // An empty list vanishes everything the host had, once every tile has 44 // An empty list vanishes everything the host had, once every tile has
42 // spent the grace. 45 // spent the grace.
43 wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &vanish); 46 wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &binds, &vanish);
44 vanish.len = 0; 47 vanish.len = 0;
45 wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &vanish); 48 wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &binds, &vanish);
46 try std.testing.expectEqual(@as(usize, 2), vanish.len); 49 try std.testing.expectEqual(@as(usize, 2), vanish.len);
47 try std.testing.expectEqual(@as(usize, 0), births.len); 50 try std.testing.expectEqual(@as(usize, 0), births.len);
48 } 51 }
@@ -53,24 +56,25 @@ test "planHostDiff: a live pump's tile is not vanished by ONE list that lacks it
53 var present = [_]bool{ true, true, true }; 56 var present = [_]bool{ true, true, true };
54 57
55 var births = BirthNames{}; 58 var births = BirthNames{};
59 var binds = TileIdxs{};
56 var vanish = TileIdxs{}; 60 var vanish = TileIdxs{};
57 // A poll can overtake a session's exit: the daemon has already dropped 61 // A poll can overtake a session's exit: the daemon has already dropped
58 // the name while the pump has not yet read its `exit_status`. Vanishing 62 // the name while the pump has not yet read its `exit_status`. Vanishing
59 // on that list loses the exit code — on a wall of one, `endedTile` skips 63 // on that list loses the exit code — on a wall of one, `endedTile` skips
60 // a tile that is no longer present and mux never leaves. 64 // a tile that is no longer present and mux never leaves.
61 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 65 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &binds, &vanish);
62 try std.testing.expectEqual(@as(usize, 0), vanish.len); 66 try std.testing.expectEqual(@as(usize, 0), vanish.len);
63 try std.testing.expect(tiles[1].missed_once); 67 try std.testing.expect(tiles[1].missed_once);
64 68
65 // A list that names it again forgives it: a tile does not accumulate 69 // A list that names it again forgives it: a tile does not accumulate
66 // misses across the seconds it is legitimately live. 70 // misses across the seconds it is legitimately live.
67 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\n", null, &births, &vanish); 71 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\n", null, &births, &binds, &vanish);
68 try std.testing.expectEqual(@as(usize, 0), vanish.len); 72 try std.testing.expectEqual(@as(usize, 0), vanish.len);
69 try std.testing.expect(!tiles[1].missed_once); 73 try std.testing.expect(!tiles[1].missed_once);
70 74
71 // Two consecutive lists without it, and it goes. 75 // Two consecutive lists without it, and it goes.
72 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 76 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &binds, &vanish);
73 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 77 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &binds, &vanish);
74 try std.testing.expectEqual(@as(usize, 1), vanish.len); 78 try std.testing.expectEqual(@as(usize, 1), vanish.len);
75 try std.testing.expectEqual(@as(usize, 1), vanish.get(0)); 79 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
76 80
@@ -79,7 +83,7 @@ test "planHostDiff: a live pump's tile is not vanished by ONE list that lacks it
79 tiles[0].alive.store(false, .release); 83 tiles[0].alive.store(false, .release);
80 vanish.len = 0; 84 vanish.len = 0;
81 const only0 = [_]bool{ true, false, false }; 85 const only0 = [_]bool{ true, false, false };
82 wall_host.planHostDiff(&tiles, &only0, 3, 0, "", null, &births, &vanish); 86 wall_host.planHostDiff(&tiles, &only0, 3, 0, "", null, &births, &binds, &vanish);
83 try std.testing.expectEqual(@as(usize, 1), vanish.len); 87 try std.testing.expectEqual(@as(usize, 1), vanish.len);
84 try std.testing.expectEqual(@as(usize, 0), vanish.get(0)); 88 try std.testing.expectEqual(@as(usize, 0), vanish.get(0));
85 } 89 }
@@ -90,8 +94,9 @@ test "planHostDiff: a vanished tile is not present, so the next list does not va
90 var present = [_]bool{ true, false, true }; 94 var present = [_]bool{ true, false, true };
91 95
92 var births = BirthNames{}; 96 var births = BirthNames{};
97 var binds = TileIdxs{};
93 var vanish = TileIdxs{}; 98 var vanish = TileIdxs{};
94 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 99 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &binds, &vanish);
95 try std.testing.expectEqual(@as(usize, 0), vanish.len); 100 try std.testing.expectEqual(@as(usize, 0), vanish.len);
96 // ...and the name is not reborn either: the tile is gone, but the 101 // ...and the name is not reborn either: the tile is gone, but the
97 // daemon no longer lists it, so there is nothing to bring back. 102 // daemon no longer lists it, so there is nothing to bring back.
@@ -104,10 +109,11 @@ test "planHostDiff: the session this shell is inside is never born as a tile" {
104 var present = [_]bool{ true, true, true }; 109 var present = [_]bool{ true, true, true };
105 110
106 var births = BirthNames{}; 111 var births = BirthNames{};
112 var binds = TileIdxs{};
107 var vanish = TileIdxs{}; 113 var vanish = TileIdxs{};
108 // The daemon has "a", "b" and "self"; "self" is the shell mux runs in, 114 // The daemon has "a", "b" and "self"; "self" is the shell mux runs in,
109 // so a tile of it would paint into the grid it is reading. 115 // so a tile of it would paint into the grid it is reading.
110 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nself\n", "self", &births, &vanish); 116 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nself\n", "self", &births, &binds, &vanish);
111 try std.testing.expectEqual(@as(usize, 0), births.len); 117 try std.testing.expectEqual(@as(usize, 0), births.len);
112 try std.testing.expectEqual(@as(usize, 0), vanish.len); 118 try std.testing.expectEqual(@as(usize, 0), vanish.len);
113 } 119 }
@@ -118,6 +124,7 @@ test "planHostDiff: a name the wire grammar refuses never becomes a tile, howeve
118 var present = [_]bool{ true, true, true }; 124 var present = [_]bool{ true, true, true };
119 125
120 var births = BirthNames{}; 126 var births = BirthNames{};
127 var binds = TileIdxs{};
121 var vanish = TileIdxs{}; 128 var vanish = TileIdxs{};
122 // A whole reply may be `sessions_text_max`, so ONE "name" in it can be 129 // A whole reply may be `sessions_text_max`, so ONE "name" in it can be
123 // 1056 bytes; `encodeAttachNamed` memcpys the birth's name into a 130 // 1056 bytes; `encodeAttachNamed` memcpys the birth's name into a
@@ -125,7 +132,7 @@ test "planHostDiff: a name the wire grammar refuses never becomes a tile, howeve
125 // where they become a tile, not asserted about at the wire. 132 // where they become a tile, not asserted about at the wire.
126 const over_long = "x" ** (proto.session_name_max + 1); 133 const over_long = "x" ** (proto.session_name_max + 1);
127 const list = "a\n" ++ over_long ++ "\nhas space\n\x1b[2J\nc\n"; 134 const list = "a\n" ++ over_long ++ "\nhas space\n\x1b[2J\nc\n";
128 wall_host.planHostDiff(&tiles, &present, 3, 0, list, null, &births, &vanish); 135 wall_host.planHostDiff(&tiles, &present, 3, 0, list, null, &births, &binds, &vanish);
129 try std.testing.expectEqual(@as(usize, 1), births.len); 136 try std.testing.expectEqual(@as(usize, 1), births.len);
130 try std.testing.expectEqualStrings("c", births.get(0)); 137 try std.testing.expectEqualStrings("c", births.get(0));
131 } 138 }
@@ -429,16 +436,17 @@ test "planHostDiff: a creating tile whose attach has not landed yet is not vanis
429 var present = [_]bool{ true, true, true }; 436 var present = [_]bool{ true, true, true };
430 437
431 var births = BirthNames{}; 438 var births = BirthNames{};
439 var binds = TileIdxs{};
432 var vanish = TileIdxs{}; 440 var vanish = TileIdxs{};
433 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 441 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &binds, &vanish);
434 try std.testing.expectEqual(@as(usize, 0), vanish.len); 442 try std.testing.expectEqual(@as(usize, 0), vanish.len);
435 443
436 // ...and once its session exists, the same list does drop it — after 444 // ...and once its session exists, the same list does drop it — after
437 // the one list of grace a live pump gets: a session the daemon HAD and 445 // the one list of grace a live pump gets: a session the daemon HAD and
438 // no longer lists is gone. 446 // no longer lists is gone.
439 tiles[1].ever_up.store(true, .release); 447 tiles[1].ever_up.store(true, .release);
440 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 448 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &binds, &vanish);
441 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 449 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &binds, &vanish);
442 try std.testing.expectEqual(@as(usize, 1), vanish.len); 450 try std.testing.expectEqual(@as(usize, 1), vanish.len);
443 try std.testing.expectEqual(@as(usize, 1), vanish.get(0)); 451 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
444 } 452 }
@@ -624,3 +632,203 @@ fn hasWord(argv: []const []const u8, want: []const u8) bool {
624 for (argv) |w| if (std.mem.eql(u8, w, want)) return true; 632 for (argv) |w| if (std.mem.eql(u8, w, want)) return true;
625 return false; 633 return false;
626 } 634 }
635
636 /// Three pending panes off one sidecar: "a" and "b" on host 0 and an
637 /// aliasing "a" on host 1, so a bind keyed on name alone is caught.
638 /// Dupes the names: every wall tile owns its copies, and the birth that
639 /// reuses a collapsed pane's digit frees them with the wall's allocator.
640 fn seedBench(alloc: std.mem.Allocator, tiles: []Tile, present: []bool, live: *usize, shared: *Shared) !void {
641 try shared.tree.addFirst(0);
642 try shared.tree.insert(0, 1);
643 try shared.tree.insert(1, 2);
644 const rs = [_]wall_host.Resolved{
645 .{ .target = .{ .sock = "/tmp/h0.sock" }, .label = "--sock /tmp/h0.sock#a", .session = "a" },
646 .{ .target = .{ .sock = "/tmp/h0.sock" }, .label = "--sock /tmp/h0.sock#b", .session = "b" },
647 .{ .target = .{ .sock = "/tmp/h1.sock" }, .label = "--sock /tmp/h1.sock#a", .session = "a" },
648 };
649 const hostof = [_]usize{ 0, 0, 1 };
650 for (rs, hostof, 0..) |r, h, i| {
651 const owned: wall_host.Resolved = .{
652 .target = r.target,
653 .label = try alloc.dupe(u8, r.label),
654 .session = try alloc.dupe(u8, r.session),
655 };
656 try wv.seedTile(&tiles[i], owned, .{ .top = 0, .left = 0, .rows = 0, .cols = 0 }, shared, i, h);
657 present[i] = true;
658 }
659 live.* = 3;
660 }
661
662 test "planHostDiff: a pending pane the list names is a bind, not a birth - and the same name on another host is still a birth" {
663 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
664 defer arena.deinit();
665 const alloc = arena.allocator();
666 var shared: Shared = undefined;
667 fixture.stoppedWall(alloc, &shared);
668 defer shared.tree.deinit();
669 var tiles: [4]Tile = undefined;
670 var present = [_]bool{false} ** 4;
671 var live: usize = 0;
672 try seedBench(alloc, &tiles, &present, &live, &shared);
673 defer fixture.endPumps(tiles[0..live]);
674
675 var births = BirthNames{};
676 var binds = TileIdxs{};
677 var vanish = TileIdxs{};
678 // Host 0's whole list is pending panes: two binds, no birth, no twin.
679 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\n", null, &births, &binds, &vanish);
680 try std.testing.expectEqual(@as(usize, 0), births.len);
681 try std.testing.expectEqual(@as(usize, 0), vanish.len);
682 try std.testing.expectEqual(@as(usize, 2), binds.len);
683 try std.testing.expectEqual(@as(usize, 0), binds.get(0));
684 try std.testing.expectEqual(@as(usize, 1), binds.get(1));
685 // Host 1's "a" is its own pane, not host 0's; its "x" is a stranger
686 // the sidecar never named, so it is still a birth.
687 binds.len = 0;
688 wall_host.planHostDiff(&tiles, &present, 3, 1, "a\nx\n", null, &births, &binds, &vanish);
689 try std.testing.expectEqual(@as(usize, 1), binds.len);
690 try std.testing.expectEqual(@as(usize, 2), binds.get(0));
691 try std.testing.expectEqual(@as(usize, 1), births.len);
692 try std.testing.expectEqualStrings("x", births.get(0));
693 }
694
695 test "planHostDiff: a pending pane the list does not name goes on the first list - it has no exit code to lose" {
696 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
697 defer arena.deinit();
698 const alloc = arena.allocator();
699 var shared: Shared = undefined;
700 fixture.stoppedWall(alloc, &shared);
701 defer shared.tree.deinit();
702 var tiles: [4]Tile = undefined;
703 var present = [_]bool{false} ** 4;
704 var live: usize = 0;
705 try seedBench(alloc, &tiles, &present, &live, &shared);
706 defer fixture.endPumps(tiles[0..live]);
707
708 var births = BirthNames{};
709 var binds = TileIdxs{};
710 var vanish = TileIdxs{};
711 // "b" is not on the host's own list: no grace, unlike a live pump's
712 // tile - the contrast the grace test above pins.
713 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &binds, &vanish);
714 try std.testing.expectEqual(@as(usize, 1), binds.len);
715 try std.testing.expectEqual(@as(usize, 1), vanish.len);
716 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
717 }
718
719 test "applyHostList: binding a saved pane moves no rect and re-cuts nothing" {
720 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
721 defer arena.deinit();
722 const alloc = arena.allocator();
723 var shared: Shared = undefined;
724 fixture.stoppedWall(alloc, &shared);
725 defer shared.tree.deinit();
726 var tiles: [4]Tile = undefined;
727 var present = [_]bool{false} ** 4;
728 var live: usize = 0;
729 try seedBench(alloc, &tiles, &present, &live, &shared);
730 defer fixture.endPumps(tiles[0..live]);
731 var table = [_]Host{
732 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
733 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
734 };
735 const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table);
736 // The seed's own cut, then quiet: what the list must not disturb.
737 wall_layout.relayout(w, 0);
738 for (tiles[0..3]) |*t| t.resize_pending = false;
739 const gen = shared.repaint_gen.load(.acquire);
740 var rects: [3]@TypeOf(tiles[0].rect) = undefined;
741 for (tiles[0..3], 0..) |t, i| rects[i] = t.rect;
742
743 fixture.setList(&table[0], "a\nb\n");
744 wall_host.applyHostList(w, 0);
745
746 try std.testing.expectEqual(gen, shared.repaint_gen.load(.acquire));
747 for (tiles[0..3], 0..) |t, i| {
748 try std.testing.expectEqual(rects[i], t.rect);
749 try std.testing.expect(!t.resize_pending);
750 }
751 // Bound panes joined - the daemon already has these sessions, and a
752 // sized attach on a live one would resize somebody.
753 try std.testing.expect(!tiles[0].pending and !tiles[1].pending);
754 try std.testing.expect(!tiles[0].creates and !tiles[1].creates);
755 // Host 1's aliasing "a" is still waiting for ITS host.
756 try std.testing.expect(tiles[2].pending);
757 }
758
759 test "bindTile: the pane under the saved focus claims the terminal when it binds, its neighbour does not" {
760 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
761 defer arena.deinit();
762 const alloc = arena.allocator();
763 var shared: Shared = undefined;
764 fixture.stoppedWall(alloc, &shared);
765 defer shared.tree.deinit();
766 var tiles: [4]Tile = undefined;
767 var present = [_]bool{false} ** 4;
768 var live: usize = 0;
769 try seedBench(alloc, &tiles, &present, &live, &shared);
770 defer fixture.endPumps(tiles[0..live]);
771 var table = [_]Host{
772 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
773 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
774 };
775 const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table);
776 // The saved focus record put the user on pane 1 before any host spoke.
777 shared.sel = 1;
778 fixture.setList(&table[0], "a\nb\n");
779 wall_host.applyHostList(w, 0);
780 try std.testing.expect(tiles[1].claim_pending.load(.acquire));
781 try std.testing.expect(!tiles[0].claim_pending.load(.acquire));
782 }
783
784 test "applyHostList: the list that collapses a gone pane is the list that births the newcomer - one re-cut" {
785 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
786 defer arena.deinit();
787 const alloc = arena.allocator();
788 var shared: Shared = undefined;
789 fixture.stoppedWall(alloc, &shared);
790 defer shared.tree.deinit();
791 var tiles: [8]Tile = undefined;
792 var present = [_]bool{false} ** 8;
793 var live: usize = 0;
794 try seedBench(alloc, &tiles, &present, &live, &shared);
795 defer fixture.endPumps(tiles[0..live]);
796 var table = [_]Host{
797 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
798 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
799 };
800 const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table);
801 wall_layout.relayout(w, 0);
802 const gen = shared.repaint_gen.load(.acquire);
803
804 // One list: "a" binds, "b" is gone, "c" is new. One relayout serves
805 // the collapse and the birth both.
806 fixture.setList(&table[0], "a\nc\n");
807 wall_host.applyHostList(w, 0);
808
809 try std.testing.expectEqual(gen + 1, shared.repaint_gen.load(.acquire));
810 try std.testing.expect(!tiles[0].pending);
811 // The newcomer takes the collapsed pane's freed digit - reuse, not
812 // renumbering, same as any departed tile's.
813 try std.testing.expect(present[1] and !tiles[1].pending);
814 try std.testing.expectEqual(@as(usize, 3), live);
815 try std.testing.expectEqualStrings("c", tiles[1].r.session);
816 }
817
818 test "sendKeys: the mailbox of a pending pane holds what was typed for the pump the bind will start" {
819 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
820 defer arena.deinit();
821 const alloc = arena.allocator();
822 var shared: Shared = undefined;
823 fixture.stoppedWall(alloc, &shared);
824 defer shared.tree.deinit();
825 var tiles: [4]Tile = undefined;
826 var present = [_]bool{false} ** 4;
827 var live: usize = 0;
828 try seedBench(alloc, &tiles, &present, &live, &shared);
829 defer fixture.endPumps(tiles[0..live]);
830
831 wv.sendKeys(&tiles[1], "typed-early");
832 var buf: [64]u8 = undefined;
833 try std.testing.expectEqualStrings("typed-early", wall_pump.takeKeys(&tiles[1], &buf));
834 }
src/tui/wallview.zig
Old New
@@ -1001,6 +1001,19 @@ fn initTile(t: *Tile, r: Resolved, s: layout.Rect, shared: *Shared, idx: usize,
1001 }; 1001 };
1002 } 1002 }
1003 1003
1004 /// The one maker of a pending tile: a slot whose rect the seed already
1005 /// cut, wearing the sidecar's names, that nothing will dial. `pump_done`
1006 /// is true because there is no thread to wait for; `bindTile` undoes it.
1007 pub fn seedTile(t: *Tile, r: Resolved, s: layout.Rect, shared: *Shared, idx: usize, host: usize) !void {
1008 try initTile(t, r, s, shared, idx, .fresh);
1009 t.pending = true;
1010 t.host = host;
1011 t.state = .waiting;
1012 t.creates = false;
1013 t.alive.store(false, .release);
1014 t.pump_done.store(true, .release);
1015 }
1016
1004 pub fn spawnPump(t: *Tile) void { 1017 pub fn spawnPump(t: *Tile) void {
1005 // The one arming point is also the one refusal: a remembered shape 1018 // The one arming point is also the one refusal: a remembered shape
1006 // never dials, so the sidecar cannot re-create a session. 1019 // never dials, so the sidecar cannot re-create a session.
@@ -1079,6 +1092,26 @@ const Birth = struct {
1079 borrowed: bool = false, 1092 borrowed: bool = false,
1080 }; 1093 };
1081 1094
1095 /// A pending pane the host's own list confirmed: wake it in place. The
1096 /// rect was cut at seed time, so binding moves nothing and re-cuts
1097 /// nothing — which is the whole point of seeding.
1098 pub fn bindTile(w: Wall, i: usize) void {
1099 const t = &w.tiles[i];
1100 w.shared.paint_mu.lock();
1101 t.pending = false;
1102 t.state = .connecting;
1103 w.shared.paint_mu.unlock();
1104 t.end_seen = false;
1105 // The saved focus may be sitting on this pane; the claim is what makes
1106 // it reach the terminal, since no `setFocus` moves onto it.
1107 if (i == w.shared.sel) t.claim_pending.store(true, .release);
1108 // False must land before the thread exists: the pump only ever stores
1109 // true, and `freeSlot` reads it as "no thread still holds this slot".
1110 t.pump_done.store(false, .release);
1111 t.alive.store(true, .release);
1112 spawnPump(t);
1113 }
1114
1082 /// Every road onto a running wall — chord, fold, prompt — one body. 1115 /// Every road onto a running wall — chord, fold, prompt — one body.
1083 pub fn birthTile(w: Wall, b: Birth) ?usize { 1116 pub fn birthTile(w: Wall, b: Birth) ?usize {
1084 return birthTileOrRefuse(w, b) catch null; 1117 return birthTileOrRefuse(w, b) catch null;