9175a4e5
feat: a pane can wait — pending tiles exist and never dial
a73x 2026-08-30 16:01
Commit message
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. |