a73x

908ba6a1

fix: the picker's claim, its auto-close, and the help that still taught `:`

a73x   2026-08-28 19:53

Commit message
fix: the picker's claim, its auto-close, and the help that still taught `:`

`mux --help` sent the user to `Ctrl-\ :`, a chord this branch swallows, and
named the picker nowhere. The eight comments that still called `:` a live
binding say what they mean now — a kept comment has to name what fails when
it is wrong, and these named nothing.

A focus CLAIM taken while the popup is up was consumed and lost:
`claimTerminal` writes the session's modes through the same paint sink the
picker refuses, and `claim_pending` had already been swapped false. The tile
ended up focused holding no terminal — no mouse modes, no side channels, the
`[forgot …]` notice eaten — until the focus moved away and back. It re-arms
now, the pass goes on (`takePass` has already cleared `resize_pending`), and
the close rings the focused pump so the retry is a keystroke away.

The auto-close cleared `picking` but not `prompting`, so a tile arriving
under a half-typed spelling took the editor off the screen and left it
eating every key, its Enter reaching the main switch's dead `.add_tile` arm.
The auto-open/close is now `PickerAuto` — a seam with tests — and it needs
two flags, not one: the Esc that closes the WALL's own popup leaves the wall
still empty, and one flag reopened the box over the one line it asked for.

Also: `tilePaintBegin` reads `picker_open` under `paint_mu` rather than
before it, so a pump that lost the lock race cannot paint over the box (and
`picker_stamp` cannot make that damage stick); a forget the file refused is
narrated as a refusal, not as `[forgot …]`; a birth with no hosts at all
says so; and the decisions entry is back under its budget.

docs/decisions.md
Old New
@@ -7324,29 +7324,17 @@ went with the shell's code.
7324 7324
7325 ## 2026-08-28 — a popup, because a wall of sessions is a bad place for a host 7325 ## 2026-08-28 — a popup, because a wall of sessions is a bad place for a host
7326 7326
7327 **A stripe was a host pretending to be a tile, and every reflex was wrong on 7327 A stripe was a host pretending to be a tile and every reflex was wrong on it:
7328 it.** `x` did nothing (a stripe has no session to end), the cursor stayed in 7328 `x` did nothing, the cursor stayed in the previous tile, and a daemon's last
7329 the previous tile (no pump paints a stripe), and ending a daemon's last 7329 session ending turned that host into one. The wall shows live sessions only
7330 session turned that host into one. So the wall shows live sessions and 7330 and the hosts moved into a popup, `Ctrl-\ s`.
7331 nothing else, and the hosts — all of them, with their state — moved into a 7331
7332 popup: `Ctrl-\ s`. A row you cannot type into is a row no reflex misfires on. 7332 A MODE (`interact.PrefixFilter.picking`, the `:` prompt's shape), so every
7333 7333 byte is the popup's and the key table is testable without a terminal. `s` and
7334 **A mode, not a chord.** The picker is `interact.PrefixFilter.picking`, the 7334 `a` do not end the read as one-shot chords do — the bytes behind them were
7335 shape the `:` prompt already had, so every byte is the popup's and the key 7335 typed at the mode they opened. `x` and Enter close it: each re-shapes the
7336 table is unit-testable without a terminal. `s` and `a` deliberately do NOT 7336 wall, and the user is owed the wall that made.
7337 end the read the way one-shot chords do: the bytes behind them were typed at 7337
7338 the mode the same keystroke opened, and `\x1c s a --sock ...` in one read 7338 Tiles pause under it (`Shared.picker_open`, read under `paint_mu`); replicas
7339 has to reach the line editor. 7339 stay hot, the close bumps `repaint_gen`, and the frame is hashed so the
7340 7340 per-host-per-second repaint cannot leave a terminal that never goes quiet.
7341 **Tiles pause while it is open.** Every tile paint asks `Shared.picker_open`
7342 first; the replicas stay hot and the close bumps `repaint_gen`, so every
7343 rect comes back. Painting under the box was the alternative, and it is not
7344 one: a tile that repaints its rect erases the rows the user is choosing
7345 from, and the popup is not any tile's to redraw. The frame is hashed and an
7346 identical one is not rewritten — the pollers repaint this box once a second
7347 per host, and a terminal that never goes quiet also breaks every `settle` in
7348 the suite.
7349
7350 **`x` in the picker closes it.** Forgetting a host takes its tiles off the
7351 wall, and the user is owed the wall that made rather than a popup over it.
7352 A birth closes for the same reason. Only `a` and the moves keep it up.
src/cli/mux_main.zig
Old New
@@ -56,8 +56,12 @@ const usage =
56 \\ `Ctrl-\ c` and `Ctrl-\ |/-` create a session on the focused tile's 56 \\ `Ctrl-\ c` and `Ctrl-\ |/-` create a session on the focused tile's
57 \\ daemon, `Ctrl-\ x` ends the focused session (twice when others are 57 \\ daemon, `Ctrl-\ x` ends the focused session (twice when others are
58 \\ attached), `Ctrl-\ f` fullscreen, `Ctrl-\ r` resize mode, 58 \\ attached), `Ctrl-\ f` fullscreen, `Ctrl-\ r` resize mode,
59 \\ `Ctrl-\ w` zooms out, `Ctrl-\ :` adds a DAEMON by spelling 59 \\ `Ctrl-\ w` zooms out, `Ctrl-\ d` leaves.
60 \\ (Enter adds, Esc cancels), `Ctrl-\ d` leaves. 60 \\
61 \\ `Ctrl-\ s` opens the host picker: every DAEMON on the wall with what
62 \\ its last poll said. j/k or the arrows move, 1-9 pick a row, Enter or
63 \\ c starts a session on the selected machine, x forgets it (the
64 \\ sessions keep running), a adds one by spelling, Esc closes.
61 \\ 65 \\
62 ; 66 ;
63 67
@@ -435,8 +439,8 @@ fn attachLocal(
435 // session shell is exactly the mistake this catches. It sits on the 439 // session shell is exactly the mistake this catches. It sits on the
436 // USER's attach only — the Ctrl-\ chords grow their tiles from inside 440 // USER's attach only — the Ctrl-\ chords grow their tiles from inside
437 // the wall and never come back through here, so focusing from session 0 441 // the wall and never come back through here, so focusing from session 0
438 // to session 1 keeps working. `Ctrl-\ :` is the one chord that takes a 442 // to session 1 keeps working. The picker's `a` is the one chord that
439 // spelling, and it runs `wallview.showsSelf` itself. 443 // takes a spelling, and it runs `wallview.showsSelf` itself.
440 if (insideThisSession( 444 if (insideThisSession(
441 std.posix.getenv(proto.sock_env), 445 std.posix.getenv(proto.sock_env),
442 std.posix.getenv(proto.session_env), 446 std.posix.getenv(proto.session_env),
src/interact.zig
Old New
@@ -2427,6 +2427,35 @@ test "interact: prose before the prefix is forwarded, the prompt's Enter still e
2427 try std.testing.expectEqualStrings("x", out.action.add_tile); 2427 try std.testing.expectEqualStrings("x", out.action.add_tile);
2428 } 2428 }
2429 2429
2430 fn refuseSink(_: ?*anyopaque) bool {
2431 return false;
2432 }
2433
2434 test "claimTerminal: a sink that refuses leaves the claim unheld, so the caller must retry" {
2435 const alloc = std.testing.allocator;
2436 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
2437 defer std.posix.close(p[0]);
2438 defer std.posix.close(p[1]);
2439 var core = try Core.initSized(alloc, -1, p[1], .{ .cols = 80, .rows = 24 });
2440 defer core.deinit();
2441 core.is_tty = true;
2442 // The wall's host picker refuses every tile paint while its popup owns
2443 // the screen, and a claim writes the session's modes THROUGH that same
2444 // sink. False and `.none` together are what tell the pump to re-arm:
2445 // a claim read as "already held" leaves that tile focused owning no
2446 // terminal — no mouse modes, no side channels — until the focus moves
2447 // away and back.
2448 core.sink = .{ .begin = refuseSink };
2449 try std.testing.expect(!core.claimTerminal());
2450 try std.testing.expectEqual(Claim.none, core.claim);
2451 var buf: [64]u8 = undefined;
2452 try std.testing.expectEqual(@as(usize, 0), std.posix.read(p[0], &buf) catch 0);
2453 // ...and the same call lands once the sink lets it through.
2454 core.sink = .{};
2455 try std.testing.expect(core.claimTerminal());
2456 try std.testing.expectEqual(Claim.session, core.claim);
2457 }
2458
2430 test "interact: Ctrl-\\ s opens the host picker" { 2459 test "interact: Ctrl-\\ s opens the host picker" {
2431 var f: PrefixFilter = .{}; 2460 var f: PrefixFilter = .{};
2432 var buf = "\x1cs".*; 2461 var buf = "\x1cs".*;
@@ -2514,8 +2543,11 @@ test "interact: Esc in the editor returns to the picker, not to the session" {
2514 try std.testing.expect(f.picking); 2543 try std.testing.expect(f.picking);
2515 } 2544 }
2516 2545
2517 test "interact: Esc and s both close the picker" { 2546 test "interact: Esc, s and Ctrl-C all close the picker" {
2518 for ([_][]const u8{ "\x1b", "s" }) |key| { 2547 // Ctrl-C is the reflex cancel and closes the popup like Esc, for the
2548 // reason it cancels the editor: a user reaching for it wants OUT, and
2549 // a mode that eats every byte must not eat the way out of itself.
2550 for ([_][]const u8{ "\x1b", "s", "\x03" }) |key| {
2519 var f: PrefixFilter = .{}; 2551 var f: PrefixFilter = .{};
2520 var open = "\x1cs".*; 2552 var open = "\x1cs".*;
2521 _ = f.feed(&open); 2553 _ = f.feed(&open);
src/wallview.zig
Old New
@@ -776,8 +776,16 @@ fn copySelection(
776 fn tilePaintBegin(ctx: ?*anyopaque) bool { 776 fn tilePaintBegin(ctx: ?*anyopaque) bool {
777 const t: *Tile = @ptrCast(@alignCast(ctx.?)); 777 const t: *Tile = @ptrCast(@alignCast(ctx.?));
778 if (t.gone.load(.acquire)) return false; 778 if (t.gone.load(.acquire)) return false;
779 if (t.shared.picker_open.load(.acquire)) return false;
780 t.shared.paint_mu.lock(); 779 t.shared.paint_mu.lock();
780 // Read UNDER the lock, not before it: a pump that tested the flag and
781 // then lost the race for `paint_mu` would paint its rect on top of the
782 // box the keyboard had just drawn — and `picker_stamp` suppresses the
783 // identical repaint that would have repaired it, so the damage sticks
784 // until a key changes the frame.
785 if (t.shared.picker_open.load(.acquire)) {
786 t.shared.paint_mu.unlock();
787 return false;
788 }
781 return true; 789 return true;
782 } 790 }
783 791
@@ -1351,25 +1359,40 @@ fn pumpTile(t: *Tile) void {
1351 const rect: proto.Size = .{ .cols = snap_view_cols, .rows = snap_view_rows }; 1359 const rect: proto.Size = .{ .cols = snap_view_cols, .rows = snap_view_rows };
1352 if (core.size.cols != rect.cols or core.size.rows != rect.rows) 1360 if (core.size.cols != rect.cols or core.size.rows != rect.rows)
1353 core.adoptSize(rect); 1361 core.adoptSize(rect);
1354 _ = core.claimTerminal(); 1362 // The claim writes the session's modes THROUGH the paint sink,
1355 // A sentence the keyboard left for whoever owns the terminal 1363 // so the popup refuses it like any other paint. Re-armed rather
1356 // next — a refused `Ctrl-\ c`, so far. Painted here because a 1364 // than dropped: a swallowed claim leaves this pump focused
1357 // banner belongs to a Core and this is the Core that has just 1365 // holding no terminal — no mouse modes, no side channels — and
1358 // taken the screen; painted AFTER the repaint below would be 1366 // eats the notice below with it, until the user moves the focus
1359 // wrong, so it is taken now and shown once the grid is up. 1367 // away and back. The picker's close rings this pump, so the
1360 var notice_buf: [96]u8 = undefined; 1368 // retry is a keystroke away rather than a poll.
1361 const notice = takeNotice(t.shared, &notice_buf); 1369 //
1362 // The replica has been hot the whole time, so a claim paints 1370 // The rest of the PASS still runs: `takePass` has already
1363 // from it NOW rather than waiting for the daemon's answering 1371 // cleared `resize_pending`, so skipping out here would drop a
1364 // snapshot. That is the headline: moving the focus costs a 1372 // relayout this tile owes the daemon.
1365 // local repaint, never a wire frame. 1373 if (!core.claimTerminal() and t.shared.picker_open.load(.acquire)) {
1366 // ...but only when there IS one. A tile focused before its 1374 t.claim_pending.store(true, .release);
1367 // first snapshot — the entry tile, on every `mux` — would 1375 } else {
1368 // otherwise paint a blank grid over the terminal before the 1376 // A sentence the keyboard left for whoever owns the terminal
1369 // session has said anything, which is a screen the plain client 1377 // next — a refused `Ctrl-\ c`, so far. Painted here because
1370 // never drew and bytes a capture never held. 1378 // a banner belongs to a Core and this is the Core that has
1371 if (core.rep.session_epoch != 0) core.repaint() catch {}; 1379 // just taken the screen; painted AFTER the repaint below
1372 if (notice.len > 0) core.banner(notice); 1380 // would be wrong, so it is taken now and shown once the grid
1381 // is up.
1382 var notice_buf: [96]u8 = undefined;
1383 const notice = takeNotice(t.shared, &notice_buf);
1384 // The replica has been hot the whole time, so a claim paints
1385 // from it NOW rather than waiting for the daemon's answering
1386 // snapshot. That is the headline: moving the focus costs a
1387 // local repaint, never a wire frame.
1388 // ...but only when there IS one. A tile focused before its
1389 // first snapshot — the entry tile, on every `mux` — would
1390 // otherwise paint a blank grid over the terminal before the
1391 // session has said anything, which is a screen the plain
1392 // client never drew and bytes a capture never held.
1393 if (core.rep.session_epoch != 0) core.repaint() catch {};
1394 if (notice.len > 0) core.banner(notice);
1395 }
1373 } 1396 }
1374 // FOCUS RELEASE. The keyboard moved the focus off this tile and 1397 // FOCUS RELEASE. The keyboard moved the focus off this tile and
1375 // wrote the session's release itself, under `paint_mu`, before 1398 // wrote the session's release itself, under `paint_mu`, before
@@ -2393,8 +2416,8 @@ fn badHost(shared: *Shared, e: anyerror) void {
2393 setNotice(shared, text); 2416 setNotice(shared, text);
2394 } 2417 }
2395 2418
2396 /// `Ctrl-\ :`'s answer: the spelling names a DAEMON. The new host's index 2419 /// The picker's `a`, answered: the spelling names a DAEMON. The new host's
2397 /// for the caller to poll; null when nothing was added, notice already set. 2420 /// index for the caller to poll; null when nothing was added, notice set.
2398 fn addHost( 2421 fn addHost(
2399 alloc: std.mem.Allocator, 2422 alloc: std.mem.Allocator,
2400 shared: *Shared, 2423 shared: *Shared,
@@ -2866,9 +2889,9 @@ const Host = struct {
2866 /// News for the keyboard: a poll finished, well or badly. 2889 /// News for the keyboard: a poll finished, well or badly.
2867 list_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), 2890 list_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
2868 /// Whether a list of this host's has reached the WALL. Here rather than 2891 /// Whether a list of this host's has reached the WALL. Here rather than
2869 /// in an array beside the table, because `Ctrl-\ :` grows the table and 2892 /// in an array beside the table, because the picker's `a` grows the
2870 /// an array sized when the wall opened is one index out of bounds per 2893 /// table and an array sized when the wall opened is one index out of
2871 /// added host. Keyboard-thread only, so no lock: a poller flag read 2894 /// bounds per added host. Keyboard-thread only, so no lock: a poller flag read
2872 /// between its own two stores would restore over a wall still missing 2895 /// between its own two stores would restore over a wall still missing
2873 /// that host's sessions. 2896 /// that host's sessions.
2874 applied: bool = false, 2897 applied: bool = false,
@@ -2963,6 +2986,51 @@ fn pickerRow(out: []u8, n: usize, spelling: []const u8, state: []const u8, selec
2963 return out[0..w]; 2986 return out[0..w];
2964 } 2987 }
2965 2988
2989 /// Whether the popup on screen is one the WALL opened rather than the user,
2990 /// and what the next pass through the keyboard loop owes it.
2991 const PickerAuto = struct {
2992 /// The popup currently on screen is the WALL's, not the user's.
2993 on: bool = false,
2994 /// This spell of emptiness has already had its one auto-open. Distinct
2995 /// from `on`, and that is the whole of it: the Esc that closes the
2996 /// wall's popup leaves the wall STILL EMPTY, and a single flag would
2997 /// reopen it over the one-line text the Esc asked for.
2998 spent: bool = false,
2999
3000 const Step = enum { open, close, leave };
3001
3002 /// What an empty (or newly un-empty) wall owes the popup.
3003 fn step(self: *PickerAuto, is_tty: bool, empty: bool, picking: bool, prompting: bool) Step {
3004 if (empty) {
3005 if (is_tty and !picking and !self.spent) {
3006 self.spent = true;
3007 self.on = true;
3008 return .open;
3009 }
3010 return .leave;
3011 }
3012 // The wall has tiles again, so the next emptiness earns its own open.
3013 self.spent = false;
3014 // A tile arriving takes the screen back from a popup nobody asked
3015 // for — but never out from under a spelling in progress, because
3016 // `prompting` layers over `picking` and clearing the lower one
3017 // alone leaves an editor eating every key with nothing on screen.
3018 if (self.on and !prompting) {
3019 self.on = false;
3020 return .close;
3021 }
3022 return .leave;
3023 }
3024
3025 /// The user closed it by hand.
3026 fn taken(self: *PickerAuto) void {
3027 // The next tile to arrive must not force-close a popup they then
3028 // open themselves. `spent` is untouched: an Esc on an empty wall
3029 // asked for the one-line text, not for the popup again.
3030 self.on = false;
3031 }
3032 };
3033
2966 /// Whether this action is the picker's. The popup answers every one of 3034 /// Whether this action is the picker's. The popup answers every one of
2967 /// them, so the arms below it in `run` name them only to stay exhaustive. 3035 /// them, so the arms below it in `run` name them only to stay exhaustive.
2968 fn isPickAction(a: interact.PrefixFilter.Action) bool { 3036 fn isPickAction(a: interact.PrefixFilter.Action) bool {
@@ -2992,7 +3060,13 @@ fn pickBirth(
2992 ) ?usize { 3060 ) ?usize {
2993 // The tile creates on attach exactly as a chord-born one does: no side 3061 // The tile creates on attach exactly as a chord-born one does: no side
2994 // connection, and no second road onto the wall to keep in step. 3062 // connection, and no second road onto the wall to keep in step.
2995 if (sel >= host_table.len) return null; 3063 if (sel >= host_table.len) {
3064 // The one key the footer advertises, on a wall with nothing to
3065 // birth on: an Enter that closes the popup and does nothing reads
3066 // as a broken key rather than as an empty hosts file.
3067 setNotice(shared, "[no hosts to start a session on - a adds one]");
3068 return null;
3069 }
2996 const h = &host_table[sel]; 3070 const h = &host_table[sel];
2997 var list_buf: [proto.sessions_text_max]u8 = undefined; 3071 var list_buf: [proto.sessions_text_max]u8 = undefined;
2998 h.list_mu.lock(); 3072 h.list_mu.lock();
@@ -3057,8 +3131,18 @@ fn pickForget(
3057 if (h.forgotten.load(.acquire)) return; 3131 if (h.forgotten.load(.acquire)) return;
3058 // The file first, for `addHost`'s reason: the wall the user is looking 3132 // The file first, for `addHost`'s reason: the wall the user is looking
3059 // at and the wall they get back next time are the same wall. 3133 // at and the wall they get back next time are the same wall.
3134 //
3135 // What the file said is what the notice says. A `false` is a line that
3136 // was not there to remove — a hand-edited file, or a host this wall took
3137 // off argv — and the tiles go either way, so a flat `[forgot ...]` would
3138 // leave the wall and the file disagreeing with nobody told.
3139 var gone_from_file = true;
3140 var why: ?anyerror = null;
3060 if (path) |p| { 3141 if (path) |p| {
3061 _ = hosts.forget(alloc, p, h.spec.spelling) catch {}; 3142 gone_from_file = hosts.forget(alloc, p, h.spec.spelling) catch |e| blk: {
3143 why = e;
3144 break :blk false;
3145 };
3062 } 3146 }
3063 h.forgotten.store(true, .release); 3147 h.forgotten.store(true, .release);
3064 for (0..live) |i| { 3148 for (0..live) |i| {
@@ -3066,7 +3150,13 @@ fn pickForget(
3066 vanishTile(tiles[0..live], present[0..live], shared, i, null); 3150 vanishTile(tiles[0..live], present[0..live], shared, i, null);
3067 } 3151 }
3068 var buf: [96]u8 = undefined; 3152 var buf: [96]u8 = undefined;
3069 setNotice(shared, std.fmt.bufPrint(&buf, "[forgot {s}]", .{h.spec.spelling}) catch "[forgot the host]"); 3153 const said = if (why) |e|
3154 std.fmt.bufPrint(&buf, "[hosts file not updated: {s}]", .{hosts.reason(e)}) catch "[hosts file not updated]"
3155 else if (!gone_from_file)
3156 std.fmt.bufPrint(&buf, "[{s} was not on the wall]", .{h.spec.spelling}) catch "[that host was not on the wall]"
3157 else
3158 std.fmt.bufPrint(&buf, "[forgot {s}]", .{h.spec.spelling}) catch "[forgot the host]";
3159 setNotice(shared, said);
3070 } 3160 }
3071 3161
3072 /// The popup. Painted by the KEYBOARD thread, which is the only one that 3162 /// The popup. Painted by the KEYBOARD thread, which is the only one that
@@ -3076,8 +3166,10 @@ fn pickForget(
3076 /// `line` is the spelling editor's text when `a` has it open. 3166 /// `line` is the spelling editor's text when `a` has it open.
3077 fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u8) void { 3167 fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u8) void {
3078 if (!shared.is_tty) return; 3168 if (!shared.is_tty) return;
3079 // Before the lock, so a pump already inside `tilePaintBegin` blocks 3169 // The flag is set before the lock and READ under it (`tilePaintBegin`),
3080 // this paint rather than landing on top of the box it drew. 3170 // which is what orders the two: a pump either takes `paint_mu` first and
3171 // finishes its rect before this paint starts, or takes it after and sees
3172 // the flag already set.
3081 shared.picker_open.store(true, .release); 3173 shared.picker_open.store(true, .release);
3082 shared.paint_mu.lock(); 3174 shared.paint_mu.lock();
3083 defer shared.paint_mu.unlock(); 3175 defer shared.paint_mu.unlock();
@@ -3391,8 +3483,8 @@ fn recordHost(
3391 spelling: []const u8, 3483 spelling: []const u8,
3392 path: []const u8, 3484 path: []const u8,
3393 ) ?anyerror { 3485 ) ?anyerror {
3394 // Both doors — `mux HOST` and `Ctrl-\ :` — write the line on the user's 3486 // Both doors — `mux HOST` and the picker's `a` — write the line on the
3395 // word: a daemon that never answers is a host the file still remembers, 3487 // user's word: a daemon that never answers is a host the file still remembers,
3396 // rather than a line missing from it. 3488 // rather than a line missing from it.
3397 // 3489 //
3398 // The failure comes BACK rather than being printed, because where it may 3490 // The failure comes BACK rather than being printed, because where it may
@@ -3695,7 +3787,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3695 // 3787 //
3696 // Allocated at CAPACITY for the tiles' reason: a poller thread holds 3788 // Allocated at CAPACITY for the tiles' reason: a poller thread holds
3697 // its `*Host` for the wall's whole life, so the array may never move 3789 // its `*Host` for the wall's whole life, so the array may never move
3698 // when `Ctrl-\ :` adds a host to it. 3790 // when the picker's `a` adds a host to it.
3699 const host_table = try alloc.alloc(Host, max_tiles); 3791 const host_table = try alloc.alloc(Host, max_tiles);
3700 var hosts_live: usize = @min(host_specs.len, max_tiles); 3792 var hosts_live: usize = @min(host_specs.len, max_tiles);
3701 for (host_table[0..hosts_live], host_specs[0..hosts_live], 0..) |*h, spec, i| { 3793 for (host_table[0..hosts_live], host_specs[0..hosts_live], 0..) |*h, spec, i| {
@@ -3714,8 +3806,8 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3714 const th = std.Thread.spawn(.{}, pollHost, .{h}) catch continue; 3806 const th = std.Thread.spawn(.{}, pollHost, .{h}) catch continue;
3715 th.detach(); 3807 th.detach();
3716 } 3808 }
3717 // Where `Ctrl-\ :` writes the host it adds. Resolved once, up here: a 3809 // Where the picker's `a` writes the host it adds. Resolved once, up
3718 // prompt is not the place to find out there is no state directory, and 3810 // here: a popup is not the place to find out there is no state dir, and
3719 // a null path leaves the host on this wall and out of the file rather 3811 // a null path leaves the host on this wall and out of the file rather
3720 // than refusing it. 3812 // than refusing it.
3721 const hosts_path = hosts.statePath(alloc) catch null; 3813 const hosts_path = hosts.statePath(alloc) catch null;
@@ -3735,7 +3827,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3735 // Whether the picker on screen is one the WALL opened, not the user: 3827 // Whether the picker on screen is one the WALL opened, not the user:
3736 // an empty wall opens it once, and an Esc there has to be able to 3828 // an empty wall opens it once, and an Esc there has to be able to
3737 // leave the empty line showing rather than be re-opened over. 3829 // leave the empty line showing rather than be re-opened over.
3738 var picker_auto = false; 3830 var picker_auto: PickerAuto = .{};
3739 // Whether the popup was already on the screen when this key arrived, 3831 // Whether the popup was already on the screen when this key arrived,
3740 // so an OPEN can be told from a keystroke inside one. 3832 // so an OPEN can be told from a keystroke inside one.
3741 var picker_shown = false; 3833 var picker_shown = false;
@@ -3760,8 +3852,8 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3760 const restore_due: i64 = std.time.milliTimestamp() + 2000; 3852 const restore_due: i64 = std.time.milliTimestamp() + 2000;
3761 var restore_tried = false; 3853 var restore_tried = false;
3762 var restore_ok = false; 3854 var restore_ok = false;
3763 // Only the hosts the wall OPENED with are waited for. One added by 3855 // Only the hosts the wall OPENED with are waited for. One added in the
3764 // `Ctrl-\ :` arrives long after this, on a wall the user is already 3856 // picker arrives long after this, on a wall the user is already
3765 // looking at, and holding the restore for it would relay their panes 3857 // looking at, and holding the restore for it would relay their panes
3766 // under their hands. 3858 // under their hands.
3767 const opening_hosts = hosts_live; 3859 const opening_hosts = hosts_live;
@@ -3908,31 +4000,30 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3908 // list of machines. Once, so the Esc that closes it leaves the 4000 // list of machines. Once, so the Esc that closes it leaves the
3909 // one-line empty-wall text standing. 4001 // one-line empty-wall text standing.
3910 var picker_opened = false; 4002 var picker_opened = false;
3911 if (presentCount(present[0..live]) == 0) { 4003 // `restore_tried` is the gate, not emptiness alone: it is the moment
3912 // Not before the opening hosts have answered (`restore_tried` 4004 // every opening host has answered (or 2s), and a wall still dialling
3913 // is that same moment, or 2s): a wall still dialling is not 4005 // is not known to be empty. A popup flashed over a wall that is
3914 // known to be empty, and a popup flashed over a wall that is 4006 // about to have tiles is one the user never asked for.
3915 // about to have tiles is one the user never asked for. 4007 switch (picker_auto.step(
3916 if (shared.is_tty and restore_tried and !input.prefix.picking and !picker_auto) { 4008 shared.is_tty and restore_tried,
3917 picker_auto = true; 4009 presentCount(present[0..live]) == 0,
4010 input.prefix.picking,
4011 input.prefix.prompting,
4012 )) {
4013 .open => {
3918 picker_opened = true; 4014 picker_opened = true;
3919 picker_shown = true; 4015 picker_shown = true;
3920 input.prefix.picking = true; 4016 input.prefix.picking = true;
3921 picker_sel = pickerNearest(host_table[0..hosts_live], picker_sel); 4017 picker_sel = pickerNearest(host_table[0..hosts_live], picker_sel);
3922 } 4018 },
3923 } else if (picker_auto) { 4019 .close => {
3924 // A tile arrived under a popup NOBODY asked for: the wall has 4020 input.prefix.picking = false;
3925 // something to show again, so it gets the screen back. One the 4021 picker_shown = false;
3926 // user opened stays open — they are choosing, and a session 4022 shared.picker_open.store(false, .release);
3927 // being born on another host is not an answer to that. 4023 shared.picker_stamp = 0;
3928 picker_auto = false; 4024 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
3929 input.prefix.picking = false; 4025 },
3930 picker_shown = false; 4026 .leave => {},
3931 shared.picker_open.store(false, .release);
3932 // The screen under the box is about to be redrawn, so the next
3933 // open owes a paint however identical its rows are.
3934 shared.picker_stamp = 0;
3935 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
3936 } 4027 }
3937 // On news, not on the tick: the rows have to follow the pollers 4028 // On news, not on the tick: the rows have to follow the pollers
3938 // while the popup is open, and a repaint every 100ms would rewrite 4029 // while the popup is open, and a repaint every 100ms would rewrite
@@ -4033,10 +4124,15 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
4033 // open owes a paint however identical its rows are. 4124 // open owes a paint however identical its rows are.
4034 shared.picker_stamp = 0; 4125 shared.picker_stamp = 0;
4035 picker_shown = false; 4126 picker_shown = false;
4127 picker_auto.taken();
4036 if (birth_at) |at| 4128 if (birth_at) |at|
4037 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, true, at) 4129 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, true, at)
4038 else 4130 else
4039 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel); 4131 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
4132 // The focused pump may be holding a claim the popup refused;
4133 // it re-arms and retries on its next pass, and this is what
4134 // makes that pass happen now rather than within a poll.
4135 if (shared.sel < live and present[shared.sel]) _ = ringLive(&tiles[shared.sel]);
4040 } 4136 }
4041 continue; 4137 continue;
4042 } 4138 }
@@ -4545,7 +4641,7 @@ test "applyReadyLists: a host added after the wall opened gets its sessions, and
4545 var present = [_]bool{false} ** 8; 4641 var present = [_]bool{false} ** 8;
4546 var live: usize = 0; 4642 var live: usize = 0;
4547 defer endPumps(tiles[0..live]); 4643 defer endPumps(tiles[0..live]);
4548 // TWO hosts open the wall and a THIRD arrives from `Ctrl-\ :`. A fixture 4644 // TWO hosts open the wall and a THIRD arrives from the picker. A fixture
4549 // whose table never grows cannot see a per-host flag sized at the count 4645 // whose table never grows cannot see a per-host flag sized at the count
4550 // the wall opened with — which is the whole of this. 4646 // the wall opened with — which is the whole of this.
4551 var table = [_]Host{ 4647 var table = [_]Host{
@@ -4567,6 +4663,221 @@ test "applyReadyLists: a host added after the wall opened gets its sessions, and
4567 try std.testing.expectEqualStrings("late", tiles[0].r.session); 4663 try std.testing.expectEqualStrings("late", tiles[0].r.session);
4568 } 4664 }
4569 4665
4666 // A picker painted into a pipe, drained. Non-blocking on both ends so a
4667 // frame that outgrew the pipe FAILS here rather than parking the suite.
4668 fn pickerFrame(shared: *Shared, r: std.posix.fd_t, host_table: []Host, sel: usize, out: []u8) []const u8 {
4669 paintPicker(shared, host_table, sel, null);
4670 const n = std.posix.read(r, out) catch 0;
4671 return out[0..n];
4672 }
4673
4674 test "paintPicker: the box is centred on the terminal, not pinned to the origin" {
4675 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
4676 defer std.posix.close(pipe[0]);
4677 defer std.posix.close(pipe[1]);
4678 // Off-origin is the BASELINE here: a fixture on a terminal the box
4679 // happens to fill is blind to every centring arithmetic mistake.
4680 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 100, .rows = 40 }, .is_tty = true };
4681 var table = [_]Host{
4682 testHost(&shared, "a", "/tmp/a.sock"),
4683 testHost(&shared, "b", "/tmp/b.sock"),
4684 testHost(&shared, "c", "/tmp/c.sock"),
4685 };
4686 for (&table) |*h| {
4687 setList(h, "");
4688 h.applied = true;
4689 }
4690 var buf: [8192]u8 = undefined;
4691 const frame = pickerFrame(&shared, pipe[0], &table, 0, &buf);
4692 // Five rows (header + three hosts + footer) on forty: top = (40-5)/2.
4693 // The box is the terminal's width here, so left is 0 and the columns
4694 // are 1-based CUP.
4695 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[18;1H") != null);
4696 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[19;1H") != null);
4697 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[22;1H") != null);
4698 // ...and nothing above or below the box.
4699 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[17;1H") == null);
4700 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[23;1H") == null);
4701 // The cursor goes with the popup: a caret blinking in a tile says the
4702 // keys are going there.
4703 try std.testing.expect(std.mem.startsWith(u8, frame, "\x1b[?25l"));
4704 }
4705
4706 test "paintPicker: a box wider than its cap is centred in the columns" {
4707 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
4708 defer std.posix.close(pipe[0]);
4709 defer std.posix.close(pipe[1]);
4710 // Wider than `picker_row_max`, so the box is capped and the leftover
4711 // columns are split: (200 - 128) / 2.
4712 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 200, .rows = 10 }, .is_tty = true };
4713 var table = [_]Host{testHost(&shared, "a", "/tmp/a.sock")};
4714 setList(&table[0], "");
4715 table[0].applied = true;
4716 var buf: [8192]u8 = undefined;
4717 const frame = pickerFrame(&shared, pipe[0], &table, 0, &buf);
4718 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[4;37H") != null);
4719 }
4720
4721 test "paintPicker: a terminal too small for a box still says which popup has the keys" {
4722 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
4723 defer std.posix.close(pipe[0]);
4724 defer std.posix.close(pipe[1]);
4725 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 20, .rows = 3 }, .is_tty = true };
4726 var table = [_]Host{
4727 testHost(&shared, "a", "/tmp/a.sock"),
4728 testHost(&shared, "b", "/tmp/b.sock"),
4729 };
4730 for (&table) |*h| {
4731 setList(h, "");
4732 h.applied = true;
4733 }
4734 var buf: [8192]u8 = undefined;
4735 const frame = pickerFrame(&shared, pipe[0], &table, 0, &buf);
4736 try std.testing.expect(std.mem.indexOf(u8, frame, "hosts") != null);
4737 // Under the minimum the header is the WHOLE popup: no rows, no legend.
4738 // A box that does not fit is worse than a line saying which one it is.
4739 try std.testing.expect(std.mem.indexOf(u8, frame, "Enter/c new session") == null);
4740 try std.testing.expect(std.mem.indexOf(u8, frame, " 1 a") == null);
4741 }
4742
4743 test "paintPicker: the window scrolls so the selected row is always on screen" {
4744 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
4745 defer std.posix.close(pipe[0]);
4746 defer std.posix.close(pipe[1]);
4747 // Six rows for the box: header, footer, and four hosts of the eight.
4748 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 60, .rows = 6 }, .is_tty = true };
4749 const names = [_][]const u8{ "h0", "h1", "h2", "h3", "h4", "h5", "h6", "h7" };
4750 var table: [8]Host = undefined;
4751 for (&table, names) |*h, name| {
4752 h.* = testHost(&shared, name, "/tmp/x.sock");
4753 setList(h, "");
4754 h.applied = true;
4755 }
4756 var buf: [8192]u8 = undefined;
4757 const top = pickerFrame(&shared, pipe[0], &table, 0, &buf);
4758 try std.testing.expect(std.mem.indexOf(u8, top, " 1> h0") != null);
4759 try std.testing.expect(std.mem.indexOf(u8, top, "h7") == null);
4760 // The last row: the window has to follow the selection, or `j` walks
4761 // off the bottom of a box that never moves.
4762 var buf2: [8192]u8 = undefined;
4763 const bottom = pickerFrame(&shared, pipe[0], &table, 7, &buf2);
4764 try std.testing.expect(std.mem.indexOf(u8, bottom, " 8> h7") != null);
4765 try std.testing.expect(std.mem.indexOf(u8, bottom, "h0") == null);
4766 }
4767
4768 test "paintPicker: an unchanged frame is not written again" {
4769 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
4770 defer std.posix.close(pipe[0]);
4771 defer std.posix.close(pipe[1]);
4772 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 60, .rows = 20 }, .is_tty = true };
4773 var table = [_]Host{
4774 testHost(&shared, "a", "/tmp/a.sock"),
4775 testHost(&shared, "b", "/tmp/b.sock"),
4776 };
4777 for (&table) |*h| {
4778 setList(h, "");
4779 h.applied = true;
4780 }
4781 var buf: [8192]u8 = undefined;
4782 try std.testing.expect(pickerFrame(&shared, pipe[0], &table, 0, &buf).len > 0);
4783 // The pollers repaint this box once a second PER HOST. Rewriting an
4784 // identical screen at that rate is a terminal that never goes quiet,
4785 // which is also every `settle` in the e2e suite.
4786 try std.testing.expectEqual(@as(usize, 0), pickerFrame(&shared, pipe[0], &table, 0, &buf).len);
4787 // A moved selection is a different frame, and is written.
4788 try std.testing.expect(pickerFrame(&shared, pipe[0], &table, 1, &buf).len > 0);
4789 // ...and a cleared stamp is what a relayout leaves behind, so the box
4790 // goes back onto a screen that was wiped under it.
4791 shared.picker_stamp = 0;
4792 try std.testing.expect(pickerFrame(&shared, pipe[0], &table, 1, &buf).len > 0);
4793 }
4794
4795 test "PickerAuto: an empty wall opens the picker once, and a tile takes it back" {
4796 var a: PickerAuto = .{};
4797 // Empty and nothing open: the wall opens it itself, because a blank
4798 // screen is no place to act from.
4799 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
4800 // ...ONCE. The Esc that closed it has to leave the one-line text
4801 // standing rather than being reopened over.
4802 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
4803 // A tile arrived: the wall has something to show and gets the screen.
4804 try std.testing.expectEqual(PickerAuto.Step.close, a.step(true, false, true, false));
4805 // ...and only once; the popup is already gone.
4806 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, false, false));
4807 }
4808
4809 test "PickerAuto: a spelling in progress keeps the popup a tile would have closed" {
4810 var a: PickerAuto = .{};
4811 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
4812 // `prompting` layers OVER `picking`: closing on `picking` alone would
4813 // take the editor off the screen and leave it eating every key, and its
4814 // Enter would reach the main switch's dead `.add_tile` arm — no line,
4815 // no notice, and no host added.
4816 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, true, true));
4817 // The moment the line is submitted or cancelled, the close is owed again.
4818 try std.testing.expectEqual(PickerAuto.Step.close, a.step(true, false, true, false));
4819 }
4820
4821 test "PickerAuto: a picker the user opened is not closed by an arriving tile" {
4822 var a: PickerAuto = .{};
4823 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
4824 // Esc, and the user opens it again by hand. Without `taken` the flag
4825 // outlives the close and the next birth force-closes a popup they are
4826 // choosing from.
4827 a.taken();
4828 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, true, false));
4829 }
4830
4831 test "PickerAuto: the Esc that closes the wall's own popup is not undone by the wall" {
4832 var a: PickerAuto = .{};
4833 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
4834 // The close runs `taken` on EVERY exit, the wall's popup included, and
4835 // the wall is still empty afterwards. One flag for both facts reopened
4836 // the box over the one-line text the Esc had just asked for — and on
4837 // that wall `Ctrl-\ d` never reached the keyboard again.
4838 a.taken();
4839 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
4840 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
4841 // A tile arrives and leaves again: THAT emptiness earns its own open.
4842 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, false, false));
4843 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
4844 }
4845
4846 test "PickerAuto: a wall still dialling, and a wall with no terminal, open nothing" {
4847 var a: PickerAuto = .{};
4848 // The caller folds `restore_tried` into this argument: a wall that has
4849 // not heard from its hosts yet is not KNOWN to be empty, and a popup
4850 // flashed over tiles that are about to arrive is one nobody asked for.
4851 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(false, true, false, false));
4852 // A piped mux has no screen to put a popup on.
4853 var b: PickerAuto = .{};
4854 try std.testing.expectEqual(PickerAuto.Step.leave, b.step(false, true, false, false));
4855 }
4856
4857 test "tilePaintBegin: no tile paints while the picker owns the screen" {
4858 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4859 var t = Tile{
4860 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
4861 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4862 .shared = &shared,
4863 .idx = 0,
4864 .wake_r = -1,
4865 .wake_w = -1,
4866 };
4867 // The gate the whole popup rests on, and the gate that makes a focus
4868 // CLAIM fail under it (`Core.claimTerminal` writes through the same
4869 // sink) — which is why the pump re-arms `claim_pending` instead of
4870 // treating a false return as "already claimed".
4871 try std.testing.expect(tilePaintBegin(&t));
4872 tilePaintEnd(&t);
4873 shared.picker_open.store(true, .release);
4874 try std.testing.expect(!tilePaintBegin(&t));
4875 // Refused WITHOUT holding the lock: a begin that returned false and
4876 // kept `paint_mu` would wedge the keyboard on its next paint.
4877 try std.testing.expect(shared.paint_mu.tryLock());
4878 shared.paint_mu.unlock();
4879 }
4880
4570 test "pickerRows: every host says what its poller last answered" { 4881 test "pickerRows: every host says what its poller last answered" {
4571 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; 4882 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4572 // Four hosts, one per state the column can report. A fixture of one 4883 // Four hosts, one per state the column can report. A fixture of one
test/e2e_10_agent.sh
Old New
@@ -101,7 +101,12 @@ send exit\n
101 # On a TERMINAL the last tile's exit leaves an EMPTY WALL — `x` ends a 101 # On a TERMINAL the last tile's exit leaves an EMPTY WALL — `x` ends a
102 # session, never mux — and the picker opens over it. Esc closes the popup 102 # session, never mux — and the picker opens over it. Esc closes the popup
103 # and `d` is what leaves. A piped mux still exits with the shell's code. 103 # and `d` is what leaves. A piped mux still exits with the shell's code.
104 settle 700 15000 104 #
105 # `expect`, not `settle`: the popup is the CLAIM, so waiting for its legend
106 # is what makes this leg fail if the exit ever ends mux again instead. The
107 # empty-wall scenario asserts a picker on a wall that never had a tile;
108 # only this one sees the TRANSITION.
109 expect Enter/c new session 15000
105 send \x1b 110 send \x1b
106 settle 300 15000 111 settle 300 15000
107 send \x1cd 112 send \x1cd
@@ -116,6 +121,15 @@ set -e
116 echo "e2e FAIL: agent: the client's key never answered ssh-add -l in the" 121 echo "e2e FAIL: agent: the client's key never answered ssh-add -l in the"
117 echo " session (wanted $FP48):" 122 echo " session (wanted $FP48):"
118 cat "$OUT.agt.log"; exit 1; } 123 cat "$OUT.agt.log"; exit 1; }
124 # The transition, on the GRID: the shell exited, mux stayed up, and what it
125 # put on the screen was the empty wall's one line under the host picker.
126 # The stream would show the box either way — only the render says the user
127 # was still looking at it when the Esc arrived.
128 "$RENDER" --cols 100 --rows 30 < "$OUT.agt" > "$OUT.agtgrid" || {
129 echo "e2e FAIL: agent: render oracle failed"; cat "$OUT.agtgrid"; exit 1; }
130 grep -q "the wall is empty" "$OUT.agtgrid" || {
131 echo "e2e FAIL: agent: the sole session's exit did not leave an empty wall:"
132 cat "$OUT.agtgrid"; exit 1; }
119 ok "agent forwarding: ssh-add -l in the session lists the client's key" 133 ok "agent forwarding: ssh-add -l in the session lists the client's key"
120 134
121 # --- ...and `-A` with nothing behind it is refused before the attach ------ 135 # --- ...and `-A` with nothing behind it is refused before the attach ------