a73x

285ceedf

feat: the seed — the sidecar's cut before any host has answered

a73x   2026-08-30 16:31

Commit message
feat: the seed — the sidecar's cut before any host has answered

`seedLayout` turns sidecar bytes into a SeedPlan: the parsed tree
installed with leaves remapped to pending panes, orientation and
weights intact. The file stays a match key, never an address — a leaf
is dropped unless its host part equals a wall host's spelling byte for
byte — and a cut too big for this terminal drops panes until it fits,
because the boot's first flatten has no TooSmall degrade of its own.

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

src/tui/wall_layout.zig
Old New
@@ -9,6 +9,7 @@ const interact = @import("interact.zig");
9 const layout = @import("client").layout; 9 const layout = @import("client").layout;
10 const wall_host = @import("wall_host.zig"); 10 const wall_host = @import("wall_host.zig");
11 const wv = @import("wallview.zig"); 11 const wv = @import("wallview.zig");
12 const Host = wall_host.Host;
12 const Resolved = wall_host.Resolved; 13 const Resolved = wall_host.Resolved;
13 const Shared = wv.Shared; 14 const Shared = wv.Shared;
14 const Tile = wv.Tile; 15 const Tile = wv.Tile;
@@ -333,6 +334,195 @@ pub fn saveSidecar(w: Wall) void {
333 saveLayoutTo(w.alloc, path, w.liveTiles(), w.livePresent(), w.shared); 334 saveLayoutTo(w.alloc, path, w.liveTiles(), w.livePresent(), w.shared);
334 } 335 }
335 336
337 pub const SeedPane = struct { host: usize, session: []u8, label: []u8 };
338
339 const SeedKeep = struct { saved: usize, host: usize };
340
341 /// What a seeded wall is made of, index = tile index. A null pane is the
342 /// ENTRY tile's own slot: real from birth, never pending.
343 pub const SeedPlan = struct {
344 panes: []?SeedPane,
345 focus: ?usize,
346 dropped: usize,
347
348 pub fn deinit(self: *SeedPlan, alloc: std.mem.Allocator) void {
349 for (self.panes) |mp| {
350 if (mp) |pane| {
351 alloc.free(pane.session);
352 alloc.free(pane.label);
353 }
354 }
355 alloc.free(self.panes);
356 }
357 };
358
359 /// The sidecar's cut before any host answers; every failure degrades
360 /// silently, leaf by leaf or file whole, to the default cut.
361 pub fn seedLayout(
362 alloc: std.mem.Allocator,
363 table: []const Host,
364 shared: *Shared,
365 bytes: []const u8,
366 entry_spelling: ?[]const u8,
367 ) ?SeedPlan {
368 // Pass 1: read the spellings once and give each leaf its fate.
369 var probe = layout.parse(alloc, bytes) orelse return null;
370 var keeps = std.ArrayListUnmanaged(SeedKeep){};
371 defer keeps.deinit(alloc);
372 var entry_at: ?usize = null;
373 var dropped: usize = 0;
374 for (probe.spellings.items, 0..) |sp, i| {
375 if (entry_spelling) |es| {
376 if (entry_at == null and std.mem.eql(u8, sp, es)) {
377 entry_at = i;
378 continue;
379 }
380 }
381 const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse {
382 dropped += 1;
383 continue;
384 };
385 const sess = sp[cut + 1 ..];
386 if (!proto.validSessionName(sess)) {
387 dropped += 1;
388 continue;
389 }
390 // The host part must be ON the wall, byte for byte: the file is a
391 // match key, never an address, so nothing here can be resurrected.
392 const hi = for (table, 0..) |*h, j| {
393 if (std.mem.eql(u8, h.spec.spelling, sp[0..cut])) break j;
394 } else {
395 dropped += 1;
396 continue;
397 };
398 // The shell's own session would wait forever: never birthed
399 // (`showsSelf`) and named by every list, so never collapsed.
400 if (table[hi].self_name) |self| {
401 if (std.mem.eql(u8, self, sess)) {
402 dropped += 1;
403 continue;
404 }
405 }
406 // A host's list names a session once; a second pane on the same
407 // (host, session) could never bind.
408 const dup = blk: {
409 if (entry_spelling) |es| {
410 if (std.mem.eql(u8, sp, es)) break :blk true;
411 }
412 for (keeps.items) |k| {
413 if (std.mem.eql(u8, probe.spellings.items[k.saved], sp)) break :blk true;
414 }
415 break :blk false;
416 };
417 if (dup) {
418 dropped += 1;
419 continue;
420 }
421 keeps.append(alloc, .{ .saved = i, .host = hi }) catch {
422 probe.deinit(alloc);
423 return null;
424 };
425 }
426 const base: usize = if (entry_spelling != null) 1 else 0;
427 while (base + keeps.items.len > wv.max_tiles) {
428 _ = keeps.pop();
429 dropped += 1;
430 }
431 probe.deinit(alloc);
432 if (keeps.items.len == 0) return null;
433 // A tree cut on a big screen must not refuse the boot on a laptop:
434 // drop the last pane and retry until the terminal can hold what is
435 // left, or nothing is left and today's default cut runs.
436 var n = keeps.items.len;
437 while (n >= 1) : (n -= 1) {
438 if (seedAttempt(alloc, shared, bytes, entry_at, keeps.items[0..n], base)) |plan| {
439 var out = plan;
440 out.dropped = dropped + (keeps.items.len - n);
441 return out;
442 }
443 }
444 return null;
445 }
446
447 /// One sizing attempt over the first `keeps` panes; the tree is
448 /// installed only if the terminal can cut it.
449 fn seedAttempt(
450 alloc: std.mem.Allocator,
451 shared: *Shared,
452 bytes: []const u8,
453 entry_at: ?usize,
454 keeps: []const SeedKeep,
455 base: usize,
456 ) ?SeedPlan {
457 var parsed = layout.parse(alloc, bytes) orelse return null;
458 const map = alloc.alloc(?u8, parsed.spellings.items.len) catch {
459 parsed.deinit(alloc);
460 return null;
461 };
462 defer alloc.free(map);
463 @memset(map, null);
464 if (entry_at) |e| map[e] = 0;
465 for (keeps, 0..) |k, i| map[k.saved] = @intCast(base + i);
466 var focus: ?usize = null;
467 if (parsed.focus) |k| {
468 if (k < map.len) {
469 if (map[k]) |f| focus = f;
470 }
471 }
472 const total = base + keeps.len;
473 var plan: SeedPlan = .{
474 .panes = alloc.alloc(?SeedPane, total) catch {
475 parsed.deinit(alloc);
476 return null;
477 },
478 .focus = focus,
479 .dropped = 0,
480 };
481 @memset(plan.panes, null);
482 // Duped: a wall tile owns its two copies, and the birth that reuses a
483 // collapsed pane's digit frees them with the wall's allocator.
484 const ok = blk: {
485 for (keeps, 0..) |k, i| {
486 const sp = parsed.spellings.items[k.saved];
487 const cut = std.mem.lastIndexOfScalar(u8, sp, '#').?;
488 const sess = alloc.dupe(u8, sp[cut + 1 ..]) catch break :blk false;
489 const label = alloc.dupe(u8, sp) catch {
490 alloc.free(sess);
491 break :blk false;
492 };
493 plan.panes[base + i] = .{ .host = k.host, .session = sess, .label = label };
494 }
495 break :blk true;
496 };
497 if (!ok) {
498 plan.deinit(alloc);
499 parsed.deinit(alloc);
500 return null;
501 }
502 parsed.tree.remapLeaves(map);
503 if (base == 1 and entry_at == null) {
504 const anchor: u8 = if (focus) |f| @intCast(f) else @intCast(base);
505 parsed.tree.insert(anchor, 0) catch {
506 plan.deinit(alloc);
507 parsed.deinit(alloc);
508 return null;
509 };
510 }
511 if (parsed.tree.flatten(alloc, shared.size.rows, shared.size.cols, wallFloors(total), null)) |flat| {
512 var f = flat;
513 f.deinit(alloc);
514 } else |_| {
515 plan.deinit(alloc);
516 parsed.deinit(alloc);
517 return null;
518 }
519 shared.tree.deinit();
520 shared.tree = parsed.tree;
521 for (parsed.spellings.items) |sp| alloc.free(sp);
522 parsed.spellings.deinit(alloc);
523 return plan;
524 }
525
336 /// The saved layout over the tiles the hosts turned out to have; null 526 /// The saved layout over the tiles the hosts turned out to have; null
337 /// when nothing matched. 527 /// when nothing matched.
338 pub fn restoreSidecar(w: Wall, focus_out: *?usize) ?void { 528 pub fn restoreSidecar(w: Wall, focus_out: *?usize) ?void {
src/tui/wall_test_layout.zig
Old New
@@ -937,3 +937,237 @@ test "layoutPathFrom: the sidecar sits beside the hosts file" {
937 defer alloc.free(q); 937 defer alloc.free(q);
938 try std.testing.expectEqualStrings("/home/u/.local/state/mux/layout", q); 938 try std.testing.expectEqualStrings("/home/u/.local/state/mux/layout", q);
939 } 939 }
940
941 // ---- the seed: the sidecar's cut before any host has answered ----
942
943 /// Two hosts and a three-leaf sidecar — beside(a, stacked(b, c)) with
944 /// UNEQUAL weights — so orientation, weight and host aliasing are all
945 /// dimensions every seed test exercises by default.
946 const seed_bytes = "mux-layout 1\nbeside 0\n leaf 70 --sock /tmp/h0.sock#a\n stacked 29\n leaf 20 --sock /tmp/h0.sock#b\n leaf 8 --sock /tmp/h1.sock#a\nfocus 1\n";
947
948 fn seedShared(alloc: std.mem.Allocator, shared: *Shared) void {
949 shared.* = Shared{ .out_fd = -1, .size = .{ .cols = 100, .rows = 30 }, .is_tty = false };
950 shared.tree = layout.Tree.init(alloc);
951 shared.flat_alloc = alloc;
952 }
953
954 test "seed: the saved cut is the tree before any host has answered - orientation and weights both" {
955 const alloc = std.testing.allocator;
956 var shared: Shared = undefined;
957 seedShared(alloc, &shared);
958 defer shared.tree.deinit();
959 var table = [_]wall_host.Host{
960 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
961 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
962 };
963 var plan = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null) orelse
964 return error.SeedRefusedAValidSidecar;
965 defer plan.deinit(alloc);
966 try std.testing.expectEqual(@as(usize, 3), plan.panes.len);
967 try std.testing.expectEqual(@as(usize, 0), plan.dropped);
968 try std.testing.expectEqual(@as(usize, 0), plan.panes[0].?.host);
969 try std.testing.expectEqualStrings("a", plan.panes[0].?.session);
970 try std.testing.expectEqual(@as(usize, 1), plan.panes[2].?.host);
971 var f = try shared.tree.flatten(alloc, 30, 100, wall_layout.wallFloors(3), null);
972 defer f.deinit(alloc);
973 // The saved orientation: pane 0 beside the stack of 1 over 2. The old
974 // heal lost both to the default cut; the rects are the pin.
975 const r0 = f.rectOf(0).?;
976 const r1 = f.rectOf(1).?;
977 const r2 = f.rectOf(2).?;
978 try std.testing.expect(r1.left > 0 and r1.top == 0);
979 try std.testing.expect(r2.left == r1.left and r2.top > 0);
980 // The saved weights: 70/29 is not an even cut.
981 try std.testing.expect(r0.cols > 2 * r1.cols);
982 }
983
984 test "seed: a leaf whose host is not on the wall is dropped and the rest keep their cut" {
985 const alloc = std.testing.allocator;
986 var shared: Shared = undefined;
987 seedShared(alloc, &shared);
988 defer shared.tree.deinit();
989 var table = [_]wall_host.Host{
990 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
991 };
992 var plan = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null) orelse
993 return error.SeedRefusedAValidSidecar;
994 defer plan.deinit(alloc);
995 try std.testing.expectEqual(@as(usize, 2), plan.panes.len);
996 try std.testing.expectEqual(@as(usize, 1), plan.dropped);
997 try std.testing.expectEqualStrings("--sock /tmp/h0.sock#a", plan.panes[0].?.label);
998 try std.testing.expectEqualStrings("--sock /tmp/h0.sock#b", plan.panes[1].?.label);
999 try std.testing.expectEqual(@as(usize, 2), shared.tree.count());
1000 }
1001
1002 test "seed: a leaf naming no session, or a name the wire grammar refuses, is dropped" {
1003 const alloc = std.testing.allocator;
1004 var shared: Shared = undefined;
1005 seedShared(alloc, &shared);
1006 defer shared.tree.deinit();
1007 var table = [_]wall_host.Host{
1008 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
1009 };
1010 const bytes = "mux-layout 1\nstacked 0\n leaf 10 --sock /tmp/h0.sock#a\n leaf 10 no-hash-at-all\n leaf 10 --sock /tmp/h0.sock#ha sh\n";
1011 var plan = wall_layout.seedLayout(alloc, &table, &shared, bytes, null) orelse
1012 return error.SeedRefusedAValidSidecar;
1013 defer plan.deinit(alloc);
1014 try std.testing.expectEqual(@as(usize, 1), plan.panes.len);
1015 try std.testing.expectEqual(@as(usize, 2), plan.dropped);
1016 }
1017
1018 test "seed: two leaves naming one host's one session become one pane" {
1019 const alloc = std.testing.allocator;
1020 var shared: Shared = undefined;
1021 seedShared(alloc, &shared);
1022 defer shared.tree.deinit();
1023 var table = [_]wall_host.Host{
1024 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
1025 };
1026 const bytes = "mux-layout 1\nstacked 0\n leaf 10 --sock /tmp/h0.sock#a\n leaf 10 --sock /tmp/h0.sock#a\n";
1027 var plan = wall_layout.seedLayout(alloc, &table, &shared, bytes, null) orelse
1028 return error.SeedRefusedAValidSidecar;
1029 defer plan.deinit(alloc);
1030 try std.testing.expectEqual(@as(usize, 1), plan.panes.len);
1031 try std.testing.expectEqual(@as(usize, 1), plan.dropped);
1032 }
1033
1034 test "seed: the session this shell is standing in is never seeded" {
1035 const alloc = std.testing.allocator;
1036 var shared: Shared = undefined;
1037 seedShared(alloc, &shared);
1038 defer shared.tree.deinit();
1039 var table = [_]wall_host.Host{
1040 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
1041 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
1042 };
1043 table[0].self_name = "a";
1044 var plan = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null) orelse
1045 return error.SeedRefusedAValidSidecar;
1046 defer plan.deinit(alloc);
1047 try std.testing.expectEqual(@as(usize, 2), plan.panes.len);
1048 try std.testing.expectEqual(@as(usize, 1), plan.dropped);
1049 // Host 1's aliasing "a" is another host's session and stays.
1050 try std.testing.expectEqualStrings("--sock /tmp/h0.sock#b", plan.panes[0].?.label);
1051 try std.testing.expectEqualStrings("--sock /tmp/h1.sock#a", plan.panes[1].?.label);
1052 }
1053
1054 test "seed: garbage, an empty file and a wall with no matching host all leave the tree exactly as it was" {
1055 const alloc = std.testing.allocator;
1056 var shared: Shared = undefined;
1057 seedShared(alloc, &shared);
1058 defer shared.tree.deinit();
1059 try shared.tree.addFirst(7);
1060 var table = [_]wall_host.Host{
1061 fixture.testHost(&shared, "--sock /tmp/other.sock", "/tmp/other.sock"),
1062 };
1063 const cases = [_][]const u8{ "not a layout at all", "", seed_bytes };
1064 for (cases) |bytes| {
1065 try std.testing.expectEqual(
1066 @as(?wall_layout.SeedPlan, null),
1067 wall_layout.seedLayout(alloc, &table, &shared, bytes, null),
1068 );
1069 try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
1070 }
1071 }
1072
1073 test "seed: more leaves than the terminal can cut seeds what fits and counts the rest" {
1074 const alloc = std.testing.allocator;
1075 var shared: Shared = undefined;
1076 seedShared(alloc, &shared);
1077 // Nine stacked panes at 3 rows each need 27; give them 13 rows, which
1078 // holds four. The boot must not refuse - relayout's TooSmall degrade
1079 // never runs on the first flatten, so the seed carries its own.
1080 shared.size = .{ .cols = 100, .rows = 13 };
1081 defer shared.tree.deinit();
1082 var table = [_]wall_host.Host{
1083 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
1084 };
1085 var buf = std.ArrayListUnmanaged(u8){};
1086 defer buf.deinit(alloc);
1087 try buf.appendSlice(alloc, "mux-layout 1\nstacked 0\n");
1088 for (0..9) |i| try buf.writer(alloc).print(" leaf 10 --sock /tmp/h0.sock#s{d}\n", .{i});
1089 var plan = wall_layout.seedLayout(alloc, &table, &shared, buf.items, null) orelse
1090 return error.SeedRefusedAFittableSidecar;
1091 defer plan.deinit(alloc);
1092 try std.testing.expectEqual(@as(usize, 4), plan.panes.len);
1093 try std.testing.expectEqual(@as(usize, 5), plan.dropped);
1094 try std.testing.expectEqual(@as(usize, 4), shared.tree.count());
1095 }
1096
1097 test "seed: more leaves than max_tiles seeds max_tiles" {
1098 const alloc = std.testing.allocator;
1099 var shared: Shared = undefined;
1100 seedShared(alloc, &shared);
1101 // Tall enough that every kept pane fits: the cap under test is the
1102 // wall's, not the terminal's.
1103 shared.size = .{ .cols = 100, .rows = 500 };
1104 defer shared.tree.deinit();
1105 var table = [_]wall_host.Host{
1106 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
1107 };
1108 var buf = std.ArrayListUnmanaged(u8){};
1109 defer buf.deinit(alloc);
1110 try buf.appendSlice(alloc, "mux-layout 1\nstacked 0\n");
1111 for (0..40) |i| try buf.writer(alloc).print(" leaf 10 --sock /tmp/h0.sock#s{d}\n", .{i});
1112 var plan = wall_layout.seedLayout(alloc, &table, &shared, buf.items, null) orelse
1113 return error.SeedRefusedAFittableSidecar;
1114 defer plan.deinit(alloc);
1115 try std.testing.expectEqual(wv.max_tiles, plan.panes.len);
1116 try std.testing.expectEqual(@as(usize, 8), plan.dropped);
1117 }
1118
1119 test "seed: the saved focus comes back as the pane that carries it, and is null when that leaf was dropped" {
1120 const alloc = std.testing.allocator;
1121 var shared: Shared = undefined;
1122 seedShared(alloc, &shared);
1123 defer shared.tree.deinit();
1124 var table = [_]wall_host.Host{
1125 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
1126 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
1127 };
1128 var plan = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null) orelse
1129 return error.SeedRefusedAValidSidecar;
1130 try std.testing.expectEqual(@as(?usize, 1), plan.focus);
1131 plan.deinit(alloc);
1132 // The focused leaf (saved index 1, host 0's "b") dropped with its host
1133 // gone from the wall: nothing carries the focus record.
1134 shared.tree.deinit();
1135 shared.tree = layout.Tree.init(alloc);
1136 var one = [_]wall_host.Host{
1137 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
1138 };
1139 var plan2 = wall_layout.seedLayout(alloc, &one, &shared, seed_bytes, null) orelse
1140 return error.SeedRefusedAValidSidecar;
1141 defer plan2.deinit(alloc);
1142 try std.testing.expectEqual(@as(?usize, null), plan2.focus);
1143 }
1144
1145 test "seed: the entry tile's own pane is not pending; a sidecar that does not know it inserts it" {
1146 const alloc = std.testing.allocator;
1147 var shared: Shared = undefined;
1148 seedShared(alloc, &shared);
1149 defer shared.tree.deinit();
1150 var table = [_]wall_host.Host{
1151 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
1152 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
1153 };
1154 // Matched: the entry stands where its saved pane was, id 0, not
1155 // pending, and no second pane claims its (host, session).
1156 var plan = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, "--sock /tmp/h0.sock#a") orelse
1157 return error.SeedRefusedAValidSidecar;
1158 try std.testing.expectEqual(@as(usize, 3), plan.panes.len);
1159 try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), plan.panes[0]);
1160 try std.testing.expect(plan.panes[1] != null and plan.panes[2] != null);
1161 try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
1162 plan.deinit(alloc);
1163 // Unmatched: the sidecar predates this entry; its pane is inserted
1164 // beside the saved focus, and every saved leaf is still a pane.
1165 shared.tree.deinit();
1166 shared.tree = layout.Tree.init(alloc);
1167 var plan2 = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, "--sock /tmp/h9.sock#z") orelse
1168 return error.SeedRefusedAValidSidecar;
1169 defer plan2.deinit(alloc);
1170 try std.testing.expectEqual(@as(usize, 4), plan2.panes.len);
1171 try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), plan2.panes[0]);
1172 try std.testing.expectEqual(@as(usize, 4), shared.tree.count());
1173 }