a73x

056e4f6a

fix: a tile with nothing left to say leaves the wall

a73x   2026-08-24 11:32

Commit message
fix: a tile with nothing left to say leaves the wall

A chord-born refusal (Ctrl-\ c on a full daemon) and a cleanly exited
session both leave no stripe behind: the tile vanishes and the wall
re-cuts onto the survivor. This restores the fall_back arm deleted in
ade23fe (the refusal's sentence goes to stderr, a notice to the next
claim) and extends the same vanishing to an exited tile, which was a
dead bar for a shell that is simply over.

The narrate exceptions stay: a LOST link keeps its rect and its bar (a
tear can heal), and a hydrated refusal — no born_from — narrates
[refused] exactly as the dead-tile leg asserts. endedTile now returns
unfocused exited tiles too, one per pass, so a batch of exits drains;
it was hoisted out of the doorbell block because two pumps dying
together share one ring and drainBell clears it.

The stdin-closed rule is unchanged: a piped client whose session ends
with no keyboard left ends mux with the code, so the exit-semantics
leg's last-tile-exit half keeps its exit-code assertion. The e2e
exitsem leg drops its [exited]/[up] bar greps (a vanished tile leaves
no bar, and a one-tile wall draws none) and keeps the survivor's
marker repaint; the new-session witness and the dead-tile leg are
untouched.

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

docs/superpowers/specs/2026-08-23-retire-zoom-design.md
Old New
@@ -104,9 +104,9 @@ None. The wire is unchanged; the xversion gate passes as is.
104 104
105 ## Errors 105 ## Errors
106 106
107 - Tile ends (exit / refused / lost): label bar says so, rect stays, focus 107 - Tile ends: an exit leaves and the wall re-cuts; refused or lost
108 moves to the next present tile. Last tile ending ends the run with its 108 says so on the label bar, rect stays. Last tile ending still ends the
109 code, as `.finish` does now. 109 run with its code, as `.finish` does now.
110 - Relayout refuses (`TooSmall`): notice in the corner, no tile added — as 110 - Relayout refuses (`TooSmall`): notice in the corner, no tile added — as
111 today's `[no room on the wall]`. 111 today's `[no room on the wall]`.
112 - `Ctrl-\ c` refused by the daemon: tile leaves with no trace, focus goes 112 - `Ctrl-\ c` refused by the daemon: tile leaves with no trace, focus goes
src/wallview.zig
Old New
@@ -1741,6 +1741,9 @@ const EndAction = union(enum) {
1741 refocus: usize, 1741 refocus: usize,
1742 /// Nothing left to look at. mux ends here. 1742 /// Nothing left to look at. mux ends here.
1743 finish: struct { code: u8, msg: ?[]const u8 }, 1743 finish: struct { code: u8, msg: ?[]const u8 },
1744 /// A tile with nothing left to say leaves no trace; `msg` is the
1745 /// refusal's sentence, an exit says nothing.
1746 vanish: struct { back: usize, msg: ?[]const u8 },
1744 }; 1747 };
1745 1748
1746 /// Closed `stdin` ends mux: a wall nobody types at is nowhere to leave a user. 1749 /// Closed `stdin` ends mux: a wall nobody types at is nowhere to leave a user.
@@ -1753,11 +1756,22 @@ fn endAction(
1753 ) EndAction { 1756 ) EndAction {
1754 const t = &tiles[ended]; 1757 const t = &tiles[ended];
1755 const reason: EndReason = @enumFromInt(t.end.load(.acquire)); 1758 const reason: EndReason = @enumFromInt(t.end.load(.acquire));
1759 // A chord-born refusal leaves no trace: the session it reached for never
1760 // existed, so the tile goes back where the chord was typed, with the
1761 // sentence a script reads on stderr and a survivor the screen shows.
1756 if (reason == .refused) { 1762 if (reason == .refused) {
1757 if (t.born_from) |back| { 1763 if (t.born_from) |back| {
1758 if (back < live and present[back]) return .{ .refocus = back }; 1764 if (back < live and present[back])
1765 return .{ .vanish = .{ .back = back, .msg = "mux: cannot create a new session (daemon full?)" } };
1759 } 1766 }
1760 } 1767 }
1768 // A clean exit is noise once it is over: the tile leaves and the wall
1769 // re-cuts. Only when somebody is left to steer it — a piped client
1770 // whose stdin has gone has no keyboard, so its ending is mux's own.
1771 if (reason == .exited and stdin_open and presentCount(present[0..live]) > 1)
1772 return .{ .vanish = .{ .back = stepPresent(present[0..live], ended, true) orelse ended, .msg = null } };
1773 // A lost link may heal, and a hydrated refusal narrates: both keep
1774 // their rect and their bar, and the focus steps on.
1761 if (stdin_open and presentCount(present[0..live]) > 1) 1775 if (stdin_open and presentCount(present[0..live]) > 1)
1762 return .{ .refocus = stepPresent(present[0..live], ended, true) orelse ended }; 1776 return .{ .refocus = stepPresent(present[0..live], ended, true) orelse ended };
1763 return .{ 1777 return .{
@@ -1785,16 +1799,27 @@ fn endAction(
1785 }; 1799 };
1786 } 1800 }
1787 1801
1788 /// Newly dead tiles, marked as seen. Returns the FOCUSED one if it is among 1802 /// Newly dead tiles, drained one per pass. Returns the first newly-dead
1789 /// them, because that is the only end the keyboard has to act on — the rest 1803 /// tile that is focused or whose reason is `.exited` — both need the
1790 /// have already narrated themselves on their own bars. 1804 /// keyboard to act (vanish); the rest narrated on their own bars and need
1805 /// nothing, so they are marked seen here. The loop calls this every pass,
1806 /// so a batch of exits drains across passes.
1791 fn endedTile(tiles: []Tile, present: []const bool, shared: *Shared) ?usize { 1807 fn endedTile(tiles: []Tile, present: []const bool, shared: *Shared) ?usize {
1792 const z = shared.sel; 1808 const z = shared.sel;
1793 var hit: ?usize = null; 1809 var hit: ?usize = null;
1794 for (tiles, present, 0..) |*t, p, i| { 1810 for (tiles, present, 0..) |*t, p, i| {
1795 if (!p or t.end_seen or t.alive.load(.acquire)) continue; 1811 if (!p or t.end_seen or t.alive.load(.acquire)) continue;
1796 t.end_seen = true; 1812 const reason: EndReason = @enumFromInt(t.end.load(.acquire));
1797 if (i == z) hit = i; 1813 // A tile that only narrates (lost / refused / taken) needs no
1814 // keyboard action: its pump painted its bar before it died.
1815 if (i != z and reason != .exited) {
1816 t.end_seen = true;
1817 continue;
1818 }
1819 if (hit == null) {
1820 t.end_seen = true;
1821 hit = i;
1822 }
1798 } 1823 }
1799 return hit; 1824 return hit;
1800 } 1825 }
@@ -2186,18 +2211,34 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2186 .stay => {}, 2211 .stay => {},
2187 } 2212 }
2188 } 2213 }
2189 if (endedTile(tiles[0..live], present[0..live], &shared)) |ended| { 2214 }
2190 switch (endAction(tiles, present, live, ended, stdin_open)) { 2215 // Ends are read every pass, not only on the bell: two pumps dying
2191 .refocus => |to| { 2216 // together share one ring, and `drainBell` clears it, so a residual
2192 last_focus = ended; 2217 // end would otherwise wait for a ring that may not come.
2193 setFocus(tiles[0..live], &shared, to); 2218 if (endedTile(tiles[0..live], present[0..live], &shared)) |ended| {
2194 }, 2219 switch (endAction(tiles, present, live, ended, stdin_open)) {
2195 .finish => |how| { 2220 .refocus => |to| {
2196 exit_code = how.code; 2221 last_focus = ended;
2197 exit_msg = how.msg; 2222 setFocus(tiles[0..live], &shared, to);
2198 break :keys; 2223 },
2199 }, 2224 .vanish => |v| {
2200 } 2225 present[ended] = false;
2226 tiles[ended].gone.store(true, .release);
2227 if (v.msg) |msg| {
2228 std.debug.print("{s}\n", .{msg});
2229 setNotice(&shared, "[cannot create a new session (daemon full?)]");
2230 }
2231 // An unfocused tile ending must not move the user's
2232 // focus: it goes back where it was, not where the dead
2233 // tile pointed.
2234 const target = if (ended == shared.sel) v.back else shared.sel;
2235 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, true, target);
2236 },
2237 .finish => |how| {
2238 exit_code = how.code;
2239 exit_msg = how.msg;
2240 break :keys;
2241 },
2201 } 2242 }
2202 } 2243 }
2203 if (fds[0].revents == 0) continue; 2244 if (fds[0].revents == 0) continue;
@@ -2533,11 +2574,11 @@ test "endAction: the only tile's ending is mux's, and so is any ending nobody ca
2533 endAction(&tiles, &one, 3, 0, true), 2574 endAction(&tiles, &one, 3, 0, true),
2534 ); 2575 );
2535 2576
2536 // Two tiles: there is somewhere to go and a bar that says what became 2577 // Two tiles: the exit vanishes — a dead stripe for a cleanly exited
2537 // of this one. Focus moves to the next present tile. 2578 // shell is noise — and the wall re-cuts onto the survivor.
2538 endBench(&tiles, &shared, 0, .exited, 7, null); 2579 endBench(&tiles, &shared, 0, .exited, 7, null);
2539 try std.testing.expectEqual( 2580 try std.testing.expectEqual(
2540 EndAction{ .refocus = 1 }, 2581 EndAction{ .vanish = .{ .back = 1, .msg = null } },
2541 endAction(&tiles, &two, 3, 0, true), 2582 endAction(&tiles, &two, 3, 0, true),
2542 ); 2583 );
2543 2584
@@ -2568,18 +2609,19 @@ test "endAction: a refusal a chord earned goes back where the chord was typed" {
2568 const two = [_]bool{ true, true, false }; 2609 const two = [_]bool{ true, true, false };
2569 2610
2570 // The plain client's `.refused` recovery: the daemon would not create 2611 // The plain client's `.refused` recovery: the daemon would not create
2571 // the session, and the one the chord was typed in is still there. 2612 // the session, and the chord-born tile leaves with no trace — the
2613 // sentence is the record a script reads, the survivor the screen.
2572 endBench(&tiles, &shared, 1, .refused, 1, 0); 2614 endBench(&tiles, &shared, 1, .refused, 1, 0);
2573 try std.testing.expectEqual( 2615 try std.testing.expectEqual(
2574 EndAction{ .refocus = 0 }, 2616 EndAction{ .vanish = .{ .back = 0, .msg = "mux: cannot create a new session (daemon full?)" } },
2575 endAction(&tiles, &two, 3, 1, true), 2617 endAction(&tiles, &two, 3, 1, true),
2576 ); 2618 );
2577 2619
2578 // It outranks the closed stdin, because refocusing lands on a live 2620 // It outranks the closed stdin, because vanishing lands on a live
2579 // SESSION rather than on nothing: whatever ends that one ends the run. 2621 // SESSION rather than on nothing: whatever ends that one ends the run.
2580 endBench(&tiles, &shared, 1, .refused, 1, 0); 2622 endBench(&tiles, &shared, 1, .refused, 1, 0);
2581 try std.testing.expectEqual( 2623 try std.testing.expectEqual(
2582 EndAction{ .refocus = 0 }, 2624 EndAction{ .vanish = .{ .back = 0, .msg = "mux: cannot create a new session (daemon full?)" } },
2583 endAction(&tiles, &two, 3, 1, false), 2625 endAction(&tiles, &two, 3, 1, false),
2584 ); 2626 );
2585 2627
@@ -2603,6 +2645,37 @@ test "endAction: a refusal a chord earned goes back where the chord was typed" {
2603 ); 2645 );
2604 } 2646 }
2605 2647
2648 test "endAction: an exited session's tile vanishes; a lost one narrates" {
2649 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
2650 var tiles: [3]Tile = undefined;
2651 const two = [_]bool{ true, true, false };
2652
2653 // A clean exit is noise once it is over: the tile leaves, the wall
2654 // re-cuts, and nothing is said.
2655 endBench(&tiles, &shared, 0, .exited, 7, null);
2656 try std.testing.expectEqual(
2657 EndAction{ .vanish = .{ .back = 1, .msg = null } },
2658 endAction(&tiles, &two, 3, 0, true),
2659 );
2660
2661 // A lost link can heal: the tile keeps its rect and its bar, and the
2662 // focus steps to the next present tile rather than the tile leaving.
2663 endBench(&tiles, &shared, 0, .lost, 1, null);
2664 try std.testing.expectEqual(
2665 EndAction{ .refocus = 1 },
2666 endAction(&tiles, &two, 3, 0, true),
2667 );
2668
2669 // The last present tile exiting is still mux's own ending, with the
2670 // shell's code — `mux` in a script is the thing that ran the shell.
2671 endBench(&tiles, &shared, 0, .exited, 3, null);
2672 const one = [_]bool{ true, false, false };
2673 try std.testing.expectEqual(
2674 EndAction{ .finish = .{ .code = 3, .msg = null } },
2675 endAction(&tiles, &one, 3, 0, true),
2676 );
2677 }
2678
2606 test "every tile's attach carries its rect, not 0x0" { 2679 test "every tile's attach carries its rect, not 0x0" {
2607 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 2680 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
2608 // A one-tile wall: no label bar, the tile claims every row. 2681 // A one-tile wall: no label bar, the tile claims every row.
test/e2e.sh
Old New
@@ -6412,10 +6412,10 @@ ok "Ctrl-\\ n grows the wall for a sibling with no tile, and is free for one tha
6412 # closed stdin as a wall nobody is left to steer. Zero is not seven and 6412 # closed stdin as a wall nobody is left to steer. Zero is not seven and
6413 # the leg says so; 124 is the other shape the same bug can take. 6413 # the leg says so; 124 is the other shape the same bug can take.
6414 # * two tiles. `\x1cc` adds one and focuses it, the shell in it exits, and 6414 # * two tiles. `\x1cc` adds one and focuses it, the shell in it exits, and
6415 # mux is still running, on the wall, with the dead tile narrating 6415 # the tile vanishes — a dead stripe for a cleanly exited shell is noise
6416 # `[exited]` beside the live one. Then `\x1cd` leaves — exit 0, not 7 6416 # — leaving the survivor the whole screen. Then `\x1cd` leaves — exit
6417 # and not the shell's anything, because leaving is the user's act and 6417 # 0, not 7 and not the shell's anything, because leaving is the user's
6418 # not the session's. 6418 # act and not the session's.
6419 # * two tiles and a PIPED client whose stdin has already gone. The case 6419 # * two tiles and a PIPED client whose stdin has already gone. The case
6420 # review reproduced as a hang, and the reason the rule's second half is 6420 # review reproduced as a hang, and the reason the rule's second half is
6421 # stated in `endAction` rather than at the read that notices EOF. 6421 # stated in `endAction` rather than at the read that notices EOF.
@@ -6454,16 +6454,11 @@ set -e
6454 echo "e2e FAIL: exit semantics: the multi-tile leg exited $RC (did the focus" 6454 echo "e2e FAIL: exit semantics: the multi-tile leg exited $RC (did the focus"
6455 echo " move to the neighbour, and did \x1cd leave it?):" 6455 echo " move to the neighbour, and did \x1cd leave it?):"
6456 cat "$OUT.xepc"; exit 1; } 6456 cat "$OUT.xepc"; exit 1; }
6457 # The focus landed on a WALL — a bar per tile — and the dead one says what 6457 # The exited tile vanished — a dead stripe for a cleanly exited shell is
6458 # became of it. `[exited]` is the word phase 1 gave a pump whose session 6458 # noise — so there is no `[exited]` bar to read, and the survivor took the
6459 # ended, and it can only be on this terminal if the keyboard painted for a 6459 # whole screen: a one-tile wall draws no label bar. The marker it repainted
6460 # tile with no pump left. 6460 # is what `expect xehome-pin` above caught; the grid check below is the
6461 grep -q -- "--sock $SOCK47#1 \[exited\]" "$OUT.xecap" || { 6461 # same fact from the daemon's side.
6462 echo "e2e FAIL: exit semantics: the dead tile never narrated its exit:"
6463 cat "$OUT.xepc"; exit 1; }
6464 grep -q -- "--sock $SOCK47#0 \[up\]" "$OUT.xecap" || {
6465 echo "e2e FAIL: exit semantics: the surviving tile never got its stripe back:"
6466 cat "$OUT.xepc"; exit 1; }
6467 # The session that did NOT end is still there: a shell exiting in the 6462 # The session that did NOT end is still there: a shell exiting in the
6468 # focused tile ends that session and nothing else. 6463 # focused tile ends that session and nothing else.
6469 wait_grid "$SOCK47" "xehome-pin" "exit semantics: session 0 outlived its neighbour" 6464 wait_grid "$SOCK47" "xehome-pin" "exit semantics: session 0 outlived its neighbour"