908ba6a1
fix: the picker's claim, its auto-close, and the help that still taught `:`
a73x 2026-08-28 19:53
Commit message
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, ¬ice_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, ¬ice_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 ------ |