a73x

9175a4e5

feat: a pane can wait — pending tiles exist and never dial

a73x   2026-08-30 16:01

Commit message
feat: a pane can wait — pending tiles exist and never dial

A pending tile is a rect and a name with no pump: `endedTile` does not
read it as dead (else a resumed wall exits 1 on its first pass), and
`spawnPump` refuses it at the one arming point, so nothing seeded from
a file can ever re-create a session. `State.waiting` is its word.

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

src/tui/wall_test_wall.zig
Old New
@@ -1181,3 +1181,52 @@ test "tileBanner: a prompt wider than its pane shows the tail inside the pane" {
1181 const to = std.mem.indexOf(u8, got, "\x1b[0m").?; 1181 const to = std.mem.indexOf(u8, got, "\x1b[0m").?;
1182 try std.testing.expectEqualStrings(text[text.len - 10 ..], got[from..to]); 1182 try std.testing.expectEqualStrings(text[text.len - 10 ..], got[from..to]);
1183 } 1183 }
1184
1185 test "endedTile: a pending pane is not an ended one - mux does not exit on a pane no pump ran for" {
1186 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
1187 var tiles = fixture.diffFixture(&shared);
1188 const present = [_]bool{ true, true, true };
1189 // Plural on purpose: two pending panes, and the focus sits on one of
1190 // them - the resumed wall's exact first pass.
1191 for (&tiles) |*t| t.alive.store(false, .release);
1192 tiles[0].pending = true;
1193 tiles[1].pending = true;
1194 tiles[2].end.store(@intFromEnum(EndReason.exited), .release);
1195 shared.sel = 0;
1196 // The real exit among the pending panes is still found; neither
1197 // pending pane is.
1198 try std.testing.expectEqual(@as(?usize, 2), wv.endedTile(&tiles, &present, &shared));
1199 try std.testing.expectEqual(@as(?usize, null), wv.endedTile(&tiles, &present, &shared));
1200 try std.testing.expect(!tiles[0].end_seen and !tiles[1].end_seen);
1201 // The other arm proves the guard is load-bearing: the same wall with
1202 // `pending` off reads the focused pane as ended with reason `.none`.
1203 tiles[0].pending = false;
1204 try std.testing.expectEqual(@as(?usize, 0), wv.endedTile(&tiles, &present, &shared));
1205 }
1206
1207 test "spawnPump: a remembered shape never dials - a pending tile gets no thread" {
1208 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
1209 var tiles = fixture.diffFixture(&shared);
1210 shared.running.store(false, .release);
1211 tiles[1].pending = true;
1212 tiles[1].alive.store(false, .release);
1213 tiles[1].pump_done.store(true, .release);
1214 tiles[1].state = .waiting;
1215 wv.spawnPump(&tiles[1]);
1216 // A pump's first acts are `t.core = &core` and a `.connecting` label;
1217 // a refused spawn leaves the seed's own settings alone. Bounded wait,
1218 // so a pump that DID spawn has time to betray itself.
1219 std.Thread.sleep(20 * std.time.ns_per_ms);
1220 try std.testing.expect(!tiles[1].alive.load(.acquire));
1221 try std.testing.expect(tiles[1].pump_done.load(.acquire));
1222 try std.testing.expectEqual(State.waiting, tiles[1].state);
1223 try std.testing.expectEqual(@as(?*interact.Core, null), tiles[1].core);
1224 }
1225
1226 test "State.waiting has a word of its own, and truncation drops the label before it" {
1227 var buf: [256]u8 = undefined;
1228 const long = "x" ** 400;
1229 const bar = wv.labelText(&buf, 30, "2> ", long, State.waiting.word());
1230 if (!std.mem.endsWith(u8, bar, " [waiting]")) return error.WaitingBarLostTheStateWord;
1231 if (bar.len > 30) return error.WaitingBarOverranTheTerminal;
1232 }
src/tui/wallview.zig
Old New
@@ -59,6 +59,9 @@ fn headless(out_fd: std.posix.fd_t) bool {
59 } 59 }
60 60
61 pub const State = enum { 61 pub const State = enum {
62 /// A pending pane: nothing has dialed and nothing will until the
63 /// host's own list names its session. `.connecting` would be a lie.
64 waiting,
62 connecting, 65 connecting,
63 up, 66 up,
64 reconnecting, 67 reconnecting,
@@ -71,6 +74,7 @@ pub const State = enum {
71 74
72 pub fn word(s: State) []const u8 { 75 pub fn word(s: State) []const u8 {
73 return switch (s) { 76 return switch (s) {
77 .waiting => "waiting",
74 .connecting => "connecting", 78 .connecting => "connecting",
75 .up => "up", 79 .up => "up",
76 .reconnecting => "reconnecting", 80 .reconnecting => "reconnecting",
@@ -294,6 +298,9 @@ pub const Tile = struct {
294 /// One list already failed to name this tile's session while its pump 298 /// One list already failed to name this tile's session while its pump
295 /// was still alive. Keyboard-thread only, like the diff that sets it. 299 /// was still alive. Keyboard-thread only, like the diff that sets it.
296 missed_once: bool = false, 300 missed_once: bool = false,
301 /// A pane seeded from the layout sidecar: a rect and a name, no pump.
302 /// Keyboard-thread only; `bindTile` is the one clear.
303 pending: bool = false,
297 /// This tile's interaction core, set by its OWN pump thread once the 304 /// This tile's interaction core, set by its OWN pump thread once the
298 /// core exists and dereferenced only inside that pump's paint-end hook. 305 /// core exists and dereferenced only inside that pump's paint-end hook.
299 /// No lifetime beyond the pump's scope: the core is freed before the 306 /// No lifetime beyond the pump's scope: the core is freed before the
@@ -995,6 +1002,9 @@ fn initTile(t: *Tile, r: Resolved, s: layout.Rect, shared: *Shared, idx: usize,
995 } 1002 }
996 1003
997 pub fn spawnPump(t: *Tile) void { 1004 pub fn spawnPump(t: *Tile) void {
1005 // The one arming point is also the one refusal: a remembered shape
1006 // never dials, so the sidecar cannot re-create a session.
1007 if (t.pending) return;
998 const th = std.Thread.spawn(.{}, wall_pump.pumpTile, .{t}) catch { 1008 const th = std.Thread.spawn(.{}, wall_pump.pumpTile, .{t}) catch {
999 // A tile with no thread is a tile nothing will ever paint — the 1009 // A tile with no thread is a tile nothing will ever paint — the
1000 // same hole `pumpTile`'s exit closes, reached without the pump 1010 // same hole `pumpTile`'s exit closes, reached without the pump
@@ -1383,11 +1393,13 @@ pub fn endAction(
1383 /// keyboard to act (vanish); the rest narrated on their own bars and need 1393 /// keyboard to act (vanish); the rest narrated on their own bars and need
1384 /// nothing, so they are marked seen here. The loop calls this every pass, 1394 /// nothing, so they are marked seen here. The loop calls this every pass,
1385 /// so a batch of exits drains across passes. 1395 /// so a batch of exits drains across passes.
1386 fn endedTile(tiles: []Tile, present: []const bool, shared: *Shared) ?usize { 1396 pub fn endedTile(tiles: []Tile, present: []const bool, shared: *Shared) ?usize {
1387 const z = shared.sel; 1397 const z = shared.sel;
1388 var hit: ?usize = null; 1398 var hit: ?usize = null;
1389 for (tiles, present, 0..) |*t, p, i| { 1399 for (tiles, present, 0..) |*t, p, i| {
1390 if (!p or t.end_seen or t.alive.load(.acquire)) continue; 1400 // A pending pane is `!alive` because no pump ran, not because one
1401 // ended: reading it as dead would exit mux on a resumed wall.
1402 if (!p or t.end_seen or t.pending or t.alive.load(.acquire)) continue;
1391 const reason: EndReason = @enumFromInt(t.end.load(.acquire)); 1403 const reason: EndReason = @enumFromInt(t.end.load(.acquire));
1392 // A tile that only narrates (lost / refused / taken) needs no 1404 // A tile that only narrates (lost / refused / taken) needs no
1393 // keyboard action: its pump painted its bar before it died. 1405 // keyboard action: its pump painted its bar before it died.