a73x

src/tui/wall_picker.zig

Ref:   Size: 43.1 KiB   History

//! `Ctrl-\ s`: the host picker's popup. A MODE of the wall's prefix filter, so
//! every byte typed here is the popup's and none reaches a session. Rows are
//! the hosts file's daemons in file order with the poller's last answer beside
//! each; Enter opens that host's SESSIONS, `c` births, `x` forgets, `a` is the
//! spelling editor. The session level is the second list: Enter adds one as a
//! pane, `c` still births, `x` ends it through the daemon's two-step, and Esc
//! goes back to the hosts rather than closing the box.
const std = @import("std");
const proto = @import("term").protocol;
const client = @import("client");
const hosts = @import("client").hosts;
const handoff = @import("client").handoff;
const interact = @import("interact.zig");
const askpass = @import("client").askpass;
const wall_host = @import("wall_host.zig");
const wall_layout = @import("wall_layout.zig");
const wall_pump = @import("wall_pump.zig");
const wv = @import("wallview.zig");
const Host = wall_host.Host;
const Shared = wv.Shared;
const Wall = wv.Wall;

/// The widest row the picker draws. A spelling past it is cut, never
/// wrapped: a host list that reflows is one a digit cannot address.
pub const picker_row_max: usize = 128;

/// Under either, the popup is one row: a box that does not fit is worse
/// than a header saying which one it is.
const picker_min_cols: u16 = 24;
const picker_min_rows: u16 = 4;

/// Room for the clears a shrunken box owes the rows it no longer covers:
/// one cursor address and one ECH per row of the box that was there, whose
/// height is capped the same way this one's is, plus the SGR reset the run
/// opens with.
const picker_clear_max: usize = (wv.max_tiles + 2) * 24 + 4;

/// One whole popup, written in one go: every row plus its cursor address
/// and its two SGRs, at the table's cap of rows, and the clears in front of
/// it — a resize can put the old box and the new one on disjoint rows, so
/// the two heights are budgeted separately rather than shared.
const picker_frame_max: usize = (wv.max_tiles + 2) * (picker_row_max + 32) + 8 + picker_clear_max;

/// The picker's rendered rows. Rendered once per paint and read twice —
/// by the writer and by the tests — so the box on the screen is the box
/// the claims are made about.
pub const PickerBody = struct {
    text: [wv.max_tiles][picker_row_max]u8 = undefined,
    lens: [wv.max_tiles]usize = [_]usize{0} ** wv.max_tiles,
    /// Which host each row is, so a digit answers in host indices.
    host: [wv.max_tiles]usize = [_]usize{0} ** wv.max_tiles,
    n: usize = 0,

    pub fn row(self: *const PickerBody, i: usize) []const u8 {
        return self.text[i][0..self.lens[i]];
    }
};

/// What the state column says: the POLLER's last answer, never the wall's
/// tiles. A host with no tiles is exactly the one the user is about to
/// birth on, and it has to say whether the daemon is answering at all.
pub fn hostState(buf: []u8, h: *Host) []const u8 {
    // `applied` is set by the first list to reach the wall, well or badly,
    // so a host that has never reported is `connecting` — not `no
    // sessions`, which would send the user to birth on a dead machine.
    if (!h.applied) return "connecting";
    if (!h.poll.reachable.load(.acquire)) {
        // ssh's own sentence when there is one — `No route to host`,
        // `Permission denied (publickey)` — because `unreachable` alone
        // tells the user nothing they can act on. Cut to `buf`, which is
        // the row's, so a long line shortens rather than overflowing.
        var said_buf: [handoff.reason_max]u8 = undefined;
        const said = h.poll.reasonSnapshot(&said_buf);
        const head = "unreachable: ";
        // Written by hand rather than by `bufPrint`, whose failure leaves
        // `buf` UNWRITTEN: a caller with a narrow buffer would then paint
        // whatever the stack held. Truncating is the whole contract here.
        if (said.len == 0 or buf.len <= head.len) return "unreachable";
        const n = @min(said.len, buf.len - head.len);
        @memcpy(buf[0..head.len], head);
        @memcpy(buf[head.len..][0..n], said[0..n]);
        return buf[0 .. head.len + n];
    }
    var n: usize = 0;
    h.poll.list_mu.lock();
    // The same walk `planHostDiff` grades panes through, so the count a row
    // advertises is the number of sessions Enter can open on that host.
    var it = proto.sessionsIter(h.poll.list[0..h.poll.list_len]);
    while (it.next()) |_| n += 1;
    h.poll.list_mu.unlock();
    var count_buf: [16]u8 = undefined;
    const count: []const u8 = switch (n) {
        0 => "no sessions",
        1 => "1 session",
        else => std.fmt.bufPrint(&count_buf, "{d} sessions", .{n}) catch "sessions",
    };
    if (h.drift_len == 0) {
        // Verbatim only if it fits: `count` may live in this frame's
        // buffer, and the caller keeps a slice of ITS buffer, not ours.
        if (count.len > buf.len) return "sessions";
        @memcpy(buf[0..count.len], count);
        return buf[0..count.len];
    }
    // The drift word beside the count, hand-truncated like the reason
    // above and for the same reason: shortening is the contract, and
    // `bufPrint`'s failure would leave `buf` holding the stack's garbage.
    var w: usize = 0;
    for ([_][]const u8{ count, ", ", h.drift[0..h.drift_len] }) |part| {
        const room = @min(part.len, buf.len - w);
        @memcpy(buf[w..][0..room], part[0..room]);
        w += room;
        if (w == buf.len) break;
    }
    return buf[0..w];
}

/// The row's fixed left column: ` N` or ` NN`, plus the marker.
fn rowHeadLen(wide: bool) usize {
    return 1 + @as(usize, if (wide) 2 else 1) + 2;
}

/// One row: ` N> SPELLING    STATE `, padded to `cols` so the box has an
/// edge. The spelling is cut from the LEFT, because the head of a socket
/// path is what every daemon on one machine has in common.
pub fn pickerRow(out: []u8, n: usize, wide: bool, spelling: []const u8, state: []const u8, selected: bool, cols: u16) []const u8 {
    // The same two-wide marker a label bar wears, so the eye reads the
    // popup and the wall the same way and rows do not shift as it moves.
    // `wide` is the number's half of that: an unpadded 10 puts its spelling
    // a column right of row 9's.
    const marker: []const u8 = if (selected) "> " else "  ";
    var head_buf: [16]u8 = undefined;
    const head = (if (wide)
        std.fmt.bufPrint(&head_buf, " {d: >2}{s}", .{ n, marker })
    else
        std.fmt.bufPrint(&head_buf, " {d}{s}", .{ n, marker })) catch return out[0..0];
    // The two spellings of one width, tied together: `pickerRows` budgets
    // the state against `rowHeadLen`, and a head that grew here without it
    // would hand the state columns this row does not have.
    std.debug.assert(head.len == rowHeadLen(wide));
    const room = @min(@as(usize, cols), out.len);
    const field = room -| head.len -| state.len -| 2;
    const cut = if (spelling.len > field) spelling[spelling.len - field ..] else spelling;
    var w: usize = 0;
    for ([_][]const u8{ head, cut }) |part| {
        const take = @min(part.len, room - w);
        @memcpy(out[w..][0..take], part[0..take]);
        w += take;
    }
    while (w < room -| state.len -| 1 and w < room) : (w += 1) out[w] = ' ';
    for ([_][]const u8{ state, " " }) |part| {
        const take = @min(part.len, room - w);
        @memcpy(out[w..][0..take], part[0..take]);
        w += take;
    }
    return out[0..w];
}

/// Whether the popup on screen is one the WALL opened rather than the user,
/// and what the next pass through the keyboard loop owes it.
pub const PickerAuto = struct {
    /// The popup currently on screen is the WALL's, not the user's.
    on: bool = false,
    /// This spell of emptiness has already had its one auto-open. Distinct
    /// from `on`, and that is the whole of it: the Esc that closes the
    /// wall's popup leaves the wall STILL EMPTY, and a single flag would
    /// reopen it over the one-line text the Esc asked for.
    spent: bool = false,

    pub const Step = enum { open, close, leave };

    /// What an empty (or newly un-empty) wall owes the popup.
    pub fn step(self: *PickerAuto, is_tty: bool, empty: bool, picking: bool, prompting: bool) Step {
        if (empty) {
            if (is_tty and !picking and !self.spent) {
                self.spent = true;
                self.on = true;
                return .open;
            }
            // A popup already up on an empty wall has had this emptiness's
            // one open, whoever opened it. Left unspent, the Esc that closes
            // a picker the USER opened is answered by the wall opening it
            // straight back, and the one-line text costs two Escs.
            if (is_tty and picking) self.spent = true;
            return .leave;
        }
        // The wall has tiles again, so the next emptiness earns its own open.
        self.spent = false;
        // A tile arriving takes the screen back from a popup nobody asked
        // for — but never out from under a spelling in progress, because
        // `prompting` layers over `picking` and clearing the lower one
        // alone leaves an editor eating every key with nothing on screen.
        if (self.on and !prompting) {
            self.on = false;
            return .close;
        }
        return .leave;
    }

    /// The user closed it by hand.
    pub fn taken(self: *PickerAuto) void {
        // The next tile to arrive must not force-close a popup they then
        // open themselves. `spent` is untouched: an Esc on an empty wall
        // asked for the one-line text, not for the popup again.
        self.on = false;
    }
};

/// How many rows of ssh's question the box will show. Past it the question
/// is cut: a prompt that filled the terminal would leave nowhere to type.
pub const ask_rows_max: usize = 4;

/// The prompt box's rendered rows: the question, wrapped, and the answer
/// line under it. Rendered once and read twice — by the writer and by the
/// tests — so the box on the screen is the box the claims are about.
pub const AskBody = struct {
    text: [ask_rows_max + 1][picker_row_max]u8 = undefined,
    lens: [ask_rows_max + 1]usize = [_]usize{0} ** (ask_rows_max + 1),
    n: usize = 0,

    pub fn row(self: *const AskBody, i: usize) []const u8 {
        return self.text[i][0..self.lens[i]];
    }
};

/// Pure, so the whole box — the wrap, the cut, the stars — is assertable
/// with no terminal anywhere near it.
pub fn askRows(body: *AskBody, prompt: []const u8, answer: []const u8, kind: askpass.Kind, cols: u16) void {
    const w = @min(@as(usize, cols), picker_row_max);
    // One space of margin each side, the picker's own inset.
    const room = w -| 2;
    body.n = 0;
    var rest = prompt;
    while (body.n < ask_rows_max and rest.len > 0 and room > 0) {
        const take = @min(rest.len, room);
        body.text[body.n][0] = ' ';
        @memcpy(body.text[body.n][1..][0..take], rest[0..take]);
        body.lens[body.n] = take + 1;
        body.n += 1;
        rest = rest[take..];
    }
    // A notice is not a question — "Confirm user presence for key ..." —
    // and ssh takes no answer for it: the box goes away when the touch
    // lands and the helper is killed. An input line under it would invite
    // an answer nothing would ever read.
    if (kind == .notice) return;
    // The cursor is drawn, not placed: the terminal's own caret is hidden
    // for as long as a popup owns the screen, and a box with no visible
    // insertion point reads as one that is not listening.
    var line: [picker_row_max]u8 = undefined;
    var n: usize = 0;
    for ("> ") |c| {
        line[n] = c;
        n += 1;
    }
    // Stars per BYTE, and the count is deliberate: it is the only feedback
    // a typist gets that the box took the key, and ssh is not going to
    // echo it back.
    const shown = @min(answer.len, w -| 4);
    const mask = kind == .secret;
    for (answer[answer.len - shown ..]) |c| {
        line[n] = if (mask) '*' else c;
        n += 1;
    }
    line[n] = '_';
    n += 1;
    const at = @min(body.n, ask_rows_max);
    @memcpy(body.text[at][0..n], line[0..n]);
    body.lens[at] = n;
    body.n = at + 1;
}

/// ssh's question, over everything: the picker included, because a prompt
/// ARRIVES and the box that owns the keyboard is the one that came last.
pub fn paintAsk(shared: *Shared, prompt: []const u8, prefix: *const interact.PrefixFilter) void {
    if (!shared.is_tty) return;
    // Set before the lock and read under it, `paintPicker`'s ordering.
    shared.ask_open.store(true, .release);
    shared.paint_mu.lock();
    defer shared.paint_mu.unlock();
    const cols = shared.size.cols;
    const rows = shared.size.rows;
    const w: u16 = @min(cols, @as(u16, picker_row_max));
    const left: u16 = (cols -| w) / 2;
    var body: AskBody = .{};
    askRows(&body, prompt, prefix.askLine(), prefix.kind, w);
    const cramped = cols < picker_min_cols or rows < picker_min_rows;
    const want: u16 = @intCast(@min(@as(usize, rows), body.n + 1));
    const height: u16 = if (cramped) 1 else want;
    const top: u16 = (rows -| height) / 2;
    var out: [picker_frame_max]u8 = undefined;
    var fbs = std.io.fixedBufferStream(&out);
    const wr = fbs.writer();
    wr.writeAll("\x1b[?25l") catch return;
    // No host on the header: this client cannot say which tile's ssh is
    // asking without reading a pump's own transport, and ssh's prompts
    // name the box themselves (`user@host's password:`).
    pickerLine(wr, top, left, w, " ssh");
    if (!cramped) {
        var r: u16 = 0;
        while (r < body.n and top + 1 + r < rows) : (r += 1)
            pickerLine(wr, top + 1 + r, left, w, body.row(r));
    }
    // The picker's screen is gone under this box, so its next paint owes a
    // frame however unchanged its rows are — and must not ECH over rows
    // this box now holds, which is why the geometry goes with the hash.
    shared.picker_frame = .{};
    proto.writeAllFd(shared.out_fd, fbs.getWritten()) catch {};
}

/// Whether this action is the prompt box's.
pub fn isAskAction(a: interact.PrefixFilter.Action) bool {
    return switch (a) {
        .ask_answer, .ask_decline => true,
        else => false,
    };
}

/// Whether this action is the picker's. The popup answers every one of
/// them, so the arms below it in `run` name them only to stay exhaustive.
pub fn isPickAction(a: interact.PrefixFilter.Action) bool {
    return switch (a) {
        .pick_open,
        .pick_move,
        .pick_select,
        .pick_birth,
        .pick_forget,
        .pick_add_open,
        .pick_close,
        .pick_enter,
        .pick_back,
        .pick_end,
        => true,
        else => false,
    };
}

/// How many sessions a host's last answer named — the session level's row
/// count, and what a row step and a digit are clamped against. Takes the
/// table and an index rather than a host: `picker_sel` can name a slot the
/// table does not have (an empty hosts file leaves it at 0), and a level
/// that indexed that would fault on a keystroke.
pub fn sessionCount(host_table: []Host, host: usize) usize {
    if (host >= host_table.len) return 0;
    var list_buf: [proto.sessions_reply_max]u8 = undefined;
    var it = proto.sessionsIter(host_table[host].poll.snapshot(&list_buf));
    var n: usize = 0;
    while (it.next()) |_| n += 1;
    return n;
}

/// The session row `d` away. WRAPPED, unlike `pickerStep`: a session list is
/// short and every row on it is a session that already exists, so rolling
/// round costs nothing — the key that acts is Enter, not the move.
pub fn rowStep(row: usize, d: i8, n: usize) usize {
    if (n == 0) return 0;
    if (d < 0) return if (row == 0) n - 1 else row - 1;
    return if (row + 1 >= n) 0 else row + 1;
}

/// The tile showing `name` on `host`, or null. Per HOST, because two daemons
/// may each have a session called `work` and a lookup by name alone would
/// mark the wrong one.
fn paneOf(w: Wall, host: usize, name: []const u8) ?usize {
    for (w.liveTiles(), w.livePresent(), 0..) |*t, p, i| {
        if (p and wall_host.ownedBy(t, host) and
            std.mem.eql(u8, proto.resolveName(t.r.session), name)) return i;
    }
    return null;
}

/// The name on a session row of a list already in hand, copied out: the
/// list is a snapshot on some caller's stack and every caller outlives it.
fn nameAt(list: []const u8, row: usize, out: *[proto.session_name_max]u8) ?[]const u8 {
    var it = proto.sessionsIter(list);
    var i: usize = 0;
    while (it.next()) |name| : (i += 1) {
        if (i != row) continue;
        // `sessionsIter` yields only valid names, which are bounded by
        // `session_name_max` — the guard is here so a peer that got past
        // the iterator cannot smash this buffer.
        if (name.len > out.len) return null;
        @memcpy(out[0..name.len], name);
        return out[0..name.len];
    }
    return null;
}

fn sessionAt(h: *Host, row: usize, out: *[proto.session_name_max]u8) ?[]const u8 {
    var list_buf: [proto.sessions_reply_max]u8 = undefined;
    return nameAt(h.poll.snapshot(&list_buf), row, out);
}

/// The second level: one row per session the host's last answer named.
/// "on this wall" when the layout already has it, and the holder count when
/// the daemon is new enough to say (`proto.parseSessionsHolds`); an old
/// daemon's rows carry no count rather than a zero that would read as "safe
/// to end". The count is EVERY holder, this client included — what the end
/// key gets is the DAEMON's verdict on who else is there, not this number.
pub fn sessionRows(body: *PickerBody, w: Wall, host: usize, sel_row: usize, cols: u16) void {
    body.n = 0;
    if (host >= w.hosts.len) return;
    const h = &w.hosts[host];
    var list_buf: [proto.sessions_reply_max]u8 = undefined;
    const list = h.poll.snapshot(&list_buf);
    // Counted before anything is spelled, `pickerRows`' reason: the padding
    // is the LIST's, and a row cannot know from its own number whether a 10
    // is coming.
    var count: usize = 0;
    var counter = proto.sessionsIter(list);
    while (counter.next()) |_| count += 1;
    const wide = count >= 10;
    var it = proto.sessionsIter(list);
    var row: usize = 0;
    while (it.next()) |name| : (row += 1) {
        if (body.n >= wv.max_tiles) break;
        var state_buf: [48]u8 = undefined;
        var state: []const u8 = "";
        const on_wall = paneOf(w, host, name) != null;
        if (proto.parseSessionsHolds(list, name)) |n| {
            state = std.fmt.bufPrint(&state_buf, "{s}{d} client{s}", .{
                if (on_wall) "on this wall, " else "",
                n,
                if (n == 1) "" else "s",
            }) catch "";
        } else if (on_wall) state = "on this wall";
        body.host[body.n] = host;
        body.lens[body.n] = pickerRow(&body.text[body.n], body.n + 1, wide, name, state, row == sel_row, cols).len;
        body.n += 1;
    }
}

/// Enter on a session row: a pane for it, joined (never created), zoomed to.
/// A session already on the wall is only zoomed to. The layout is written,
/// because this is one of the three places a pane comes from.
pub fn pickAdd(w: Wall, host: usize, row: usize) ?usize {
    // A forgotten host's poller has exited, so a pane born on one could
    // never be confirmed or vanished — `pickBirth`'s reason, and the same
    // here because a joined pane is graded by that poller too.
    if (host >= w.hosts.len or w.hosts[host].forgotten.load(.acquire)) {
        wv.setNotice(w.shared, "[no host to add a session from - a adds one]");
        return null;
    }
    const h = &w.hosts[host];
    var name_buf: [proto.session_name_max]u8 = undefined;
    const name = sessionAt(h, row, &name_buf) orelse {
        // The list moved under the popup: a poll landed between the paint
        // and the key, and the row the user chose is not there any more.
        wv.setNotice(w.shared, "[no session on that row]");
        return null;
    };
    if (paneOf(w, host, name)) |at| {
        // Already on the wall: the answer is the pane they meant, not a
        // second tile onto one session.
        wv.setFocus(w.liveTiles(), w.shared, at);
        return at;
    }
    // Enter IS the ask, `pickBirth`'s reason: the row may be the one the
    // poller calls unreachable, and choosing it means starting that daemon.
    var target = h.spec.target;
    if (target == .hand) target.hand.asked = true;
    const anchor = wall_layout.anchorTile(w.livePresent(), w.shared.sel);
    const has_anchor = wv.presentCount(w.livePresent()) > 0;
    const at = wv.birthTile(w, .{
        // `name` is this stack's buffer until the wall takes the tile —
        // see `Birth.borrowed`.
        // No `-A`: the popup can cross to a host the user never offered an
        // agent, and joining a session is not the place to hand one over.
        .r = .{ .target = target, .label = "", .session = name, .agent = false },
        .from = anchor,
        .place = .beside_focus,
        // JOINED: the daemon already has this session, and a create would
        // ask for a name it has.
        .creates = false,
        .born_from = if (has_anchor) anchor else null,
        // A refusal is one pane's, not the wall's: the popup put this here,
        // and an empty wall must survive the daemon saying no.
        .keeps_wall = true,
        .host = host,
        .borrowed = true,
    }) orelse {
        wv.setNotice(w.shared, "[no room on the wall for another pane]");
        return null;
    };
    wv.spawnPump(&w.tiles[at]);
    wv.setFocus(w.liveTiles(), w.shared, at);
    wall_layout.persist(w);
    return at;
}

/// `x` on a session row: the daemon's two-step, from a side connection of
/// its own — the session may have no pane on this wall, so there is no pump
/// to ask through. The first press on a session others hold is refused with
/// the count and arms 3 s; a second press inside that window forces. Every
/// other refusal arms NOTHING: only "others attached" is a question the
/// user can answer by pressing again.
pub fn pickEnd(w: Wall, host: usize, row: usize, now: i64) void {
    if (host >= w.hosts.len) return;
    const h = &w.hosts[host];
    var list_buf: [proto.sessions_reply_max]u8 = undefined;
    const list = h.poll.snapshot(&list_buf);
    var name_buf: [proto.session_name_max]u8 = undefined;
    const name = nameAt(list, row, &name_buf) orelse {
        wv.setNotice(w.shared, "[no session on that row]");
        return;
    };
    const armed = w.shared.pick_end.armedFor(host, name, now);
    // The POLLER's recipe, never the row's own target: an end must not
    // start a daemon and must not reach for a terminal. `poll_target`
    // carries ssh's `BatchMode=yes` and a connect timeout and has `asked`
    // false, so a dark host fails in seconds instead of parking the
    // keyboard thread in the TCP retry schedule with a password prompt
    // going to /dev/tty under the popup.
    const out = client.endSession(w.alloc, h.spec.poll_target, name, armed) catch |e| {
        // A daemon with no `end_req` arm answers an unknown frame with
        // silence, so the budget running out IS the answer: the box is too
        // old, in the sentence the pump's own chord uses. Asked on the wire
        // rather than guessed from the absence of a `# holds` line — the
        // released v0.0.1-16 daemon answers `end_req` and sends no holds
        // line, and refusing it up front refused a daemon that works.
        if (e == error.Timeout) {
            wv.setNotice(w.shared, "[daemon too old to end a session]");
            return;
        }
        var buf: [96]u8 = undefined;
        wv.setNotice(w.shared, std.fmt.bufPrint(
            &buf,
            "[could not ask {s} to end {s}: {s}]",
            .{ h.spec.spelling, name, @errorName(e) },
        ) catch "[could not ask the daemon]");
        return;
    };
    var buf: [96]u8 = undefined;
    if (out.accepted) {
        // An accepted end DISARMS: the window must not outlive its session,
        // and the row's number is about to belong to something else.
        w.shared.pick_end.clear();
        wv.setNotice(w.shared, std.fmt.bufPrint(
            &buf,
            "[ending {s} on {s}]",
            .{ name, h.spec.spelling },
        ) catch "[ending the session]");
    } else if (out.others > 0) {
        w.shared.pick_end.arm(host, name, now + wv.end_arm_ms);
        wv.setNotice(w.shared, std.fmt.bufPrint(&buf, "[{s}: {d} other{s} attached - x again to end]", .{
            name, out.others, if (out.others == 1) "" else "s",
        }) catch "[others attached - x again to end]");
    } else {
        // Not a two-step: the daemon has not got that session, or could not
        // read the frame. Arming here would leave the next `x` forcing an
        // end nobody said was blocked. Said in THIS client's words —
        // `parseEndReply` hands back the peer's bytes unfiltered.
        w.shared.pick_end.clear();
        wv.setNotice(w.shared, std.fmt.bufPrint(
            &buf,
            "[{s}: {s}]",
            .{ name, wall_pump.endRefusalWord(out.reason()) },
        ) catch "[the daemon refused to end that session]");
    }
    // The row must leave the list without waiting out a poll: the end is
    // the daemon's to carry out, and this is what asks it when.
    h.poll.poke.store(true, .release);
}

/// Enter or `c`: a new session on the SELECTED host, tile or no tile.
/// Null when nothing was made.
pub fn pickBirth(w: Wall, sel: usize) ?usize {
    // The tile creates on attach exactly as a chord-born one does: no side
    // connection, no second road onto the wall. A FORGOTTEN host's poller has
    // exited, so a tile born on one could never be confirmed or vanished — it
    // would sit there naming a machine the user just removed.
    if (sel >= w.hosts.len or w.hosts[sel].forgotten.load(.acquire)) {
        // The one key the footer advertises, on a wall with nothing to
        // birth on: an Enter that closes the popup and does nothing reads
        // as a broken key rather than as an empty hosts file.
        wv.setNotice(w.shared, "[no hosts to start a session on - a adds one]");
        return null;
    }
    const h = &w.hosts[sel];
    // Enter IS the ask, and this COPY is where that is written down: the row is
    // often the one the poller calls unreachable, and starting that daemon is
    // what choosing it means. The spec is untouched, since the poller re-dials
    // off it every second and must not resurrect a stopped daemon.
    var target = h.spec.target;
    if (target == .hand) target.hand.asked = true;
    var list_buf: [proto.sessions_reply_max]u8 = undefined;
    const list = h.poll.snapshot(&list_buf);
    // The daemon's own naming, off the daemon's own list: the name the
    // `c` chord would have landed on, reached without a pump to ask.
    var name_buf: [proto.session_name_max]u8 = undefined;
    const name = client.nextFreeName(&name_buf, list);
    // The name is free on the DAEMON, which is not the same as free on this
    // WALL. A daemon that restarted answers an empty list while the panes
    // it used to serve stand there wearing `gone`, so the next free name is
    // `0` and this would put a second `box#0` beside the first — two panes
    // spelling one leaf, which `seedLayout` refuses as a repeat and which
    // therefore costs the user the whole file at the next start. The pane
    // that already spells it is the answer, and Enter there is the key that
    // re-creates the session in its own rect.
    if (paneOf(w, sel, name)) |at| {
        wv.setFocus(w.liveTiles(), w.shared, at);
        var buf: [96]u8 = undefined;
        wv.setNotice(w.shared, std.fmt.bufPrint(
            &buf,
            "[{s} is already a pane - Enter there re-creates it]",
            .{name},
        ) catch "[that session is already a pane]");
        return at;
    }
    const anchor = wall_layout.anchorTile(w.livePresent(), w.shared.sel);
    const has_anchor = wv.presentCount(w.livePresent()) > 0;
    // `-A` is inherited only within one host. A chord inherits it because
    // the new session is on the machine the offer was already made to; the
    // picker can cross to a host the user never offered an agent, and a
    // popup must not be the thing that hands a stranger the keys.
    const agent = has_anchor and w.tiles[anchor].host != null and
        w.tiles[anchor].host.? == sel and w.tiles[anchor].r.agent;
    const at = wv.birthTile(w, .{
        // `name` is this stack's buffer until the wall takes the tile —
        // see `Birth.borrowed`.
        .r = .{ .target = target, .label = "", .session = name, .agent = agent },
        .from = anchor,
        .place = .beside_focus,
        .creates = true,
        // Nowhere to hand a refusal back to on an empty wall, so `keeps_wall`
        // is what tells `endAction` the wall outlives this tile.
        .born_from = if (has_anchor) anchor else null,
        .keeps_wall = true,
        .host = sel,
        .borrowed = true,
    }) orelse {
        wv.setNotice(w.shared, "[no room on the wall for another session]");
        return null;
    };
    wv.spawnPump(&w.tiles[at]);
    // The pane is the wall's the moment it is born, not once its dial
    // answers: a birth onto a box that turns out to be dark is still a pane
    // the user authored, and the next start owes them it.
    wall_layout.persist(w);
    // The new session must not wait out a poll to be confirmed by the list
    // that will also stop the diff from vanishing it.
    h.poll.poke.store(true, .release);
    return at;
}

/// `x`: the host leaves the file, its poller stops and its tiles go.
pub fn pickForget(w: Wall, sel: usize, path: ?[]const u8) void {
    // The SESSIONS keep running: forgetting a daemon is `mux hosts rm`
    // typed from inside, and that ends nothing.
    if (sel >= w.hosts.len) return;
    const h = &w.hosts[sel];
    if (h.forgotten.load(.acquire)) return;
    // The file first, for `addHost`'s reason. What the file SAID is what the
    // notice says: a `false` is a line that was not there to remove, and the
    // tiles go either way — so a flat `[forgot ...]` would leave the wall and
    // the file disagreeing with nobody told.
    var gone_from_file = true;
    var why: ?anyerror = null;
    if (path) |p| {
        gone_from_file = hosts.forget(w.alloc, p, h.spec.spelling) catch |e| blk: {
            why = e;
            break :blk false;
        };
    }
    h.forgotten.store(true, .release);
    for (0..w.live.*) |i| {
        if (w.present[i] and wall_host.ownedBy(&w.tiles[i], sel))
            wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, i, null);
    }
    // The host left the hosts file above; its panes leave the layout file
    // here, in one write for the whole loop. A forget the file did not
    // record would put every one of them back on the next start.
    wall_layout.persist(w);
    var buf: [96]u8 = undefined;
    // A run that saves no layout (`Shared.layout_path` null — a pipe, a
    // `--via` wall, a file this run refused) has just taken the panes off
    // the SCREEN and nothing off the file, so the next start puts every one
    // of them back. Said here, where the forget is, rather than left for
    // the user to discover on a wall they thought they had changed.
    const tail: []const u8 = if (w.shared.layout_path == null) " - layout not updated" else "";
    const said = if (why) |e|
        std.fmt.bufPrint(&buf, "[hosts file not updated: {s}{s}]", .{ hosts.reason(e), tail }) catch "[hosts file not updated]"
    else if (!gone_from_file)
        std.fmt.bufPrint(&buf, "[{s} was not on the wall{s}]", .{ h.spec.spelling, tail }) catch "[that host was not on the wall]"
    else
        std.fmt.bufPrint(&buf, "[forgot {s}{s}]", .{ h.spec.spelling, tail }) catch "[forgot the host]";
    wv.setNotice(w.shared, said);
}

/// Which of the popup's two lists is on screen, and where its selection is.
/// The host index is `sel`, which both levels use — the session level lists
/// the sessions of the host `sel` names.
pub const PickerView = struct {
    level: interact.PrefixFilter.PickLevel = .hosts,
    /// The selected SESSION row. Unread at the host level, where `sel` is
    /// the selection.
    row: usize = 0,
};

/// The popup, painted by the KEYBOARD thread — the only one that knows the
/// picker exists — on open, on every key, and on every list that lands under
/// it, so the state column is live while the user reads it.
pub fn paintPicker(w: Wall, sel: usize, view: PickerView, line: ?[]const u8) void {
    const shared = w.shared;
    const host_table = w.hosts;
    if (!shared.is_tty) return;
    // The flag is set before the lock and READ under it (`tilePaintBegin`),
    // which is what orders the two: a pump either takes `paint_mu` first and
    // finishes its rect before this paint starts, or takes it after and sees
    // the flag already set.
    shared.picker_open.store(true, .release);
    shared.paint_mu.lock();
    defer shared.paint_mu.unlock();
    const cols = shared.size.cols;
    const rows = shared.size.rows;
    const box_w: u16 = @min(cols, @as(u16, picker_row_max));
    const left: u16 = (cols -| box_w) / 2;
    var body: PickerBody = .{};
    // The selected row differs by level: at the host level it is wherever
    // the selected HOST landed in the list a forgotten one has left; at the
    // session level the row IS the selection.
    var sel_row: usize = 0;
    var title_buf: [picker_row_max]u8 = undefined;
    var title: []const u8 = " hosts";
    if (view.level == .sessions) {
        sessionRows(&body, w, sel, view.row, box_w);
        sel_row = @min(view.row, body.n -| 1);
        const spelling = if (sel < host_table.len) host_table[sel].spec.spelling else "";
        title = std.fmt.bufPrint(&title_buf, " sessions on {s}", .{spelling}) catch " sessions";
    } else {
        pickerRows(&body, host_table, sel, box_w);
        for (body.host[0..body.n], 0..) |hi, i| {
            if (hi == sel) sel_row = i;
        }
    }
    // Too small for a box is not too small for an answer: the header alone
    // still says which popup has the keyboard.
    const cramped = cols < picker_min_cols or rows < picker_min_rows;
    const want: u16 = @intCast(@min(@as(usize, rows), body.n + 2));
    const height: u16 = if (cramped) 1 else want;
    const top: u16 = (rows -| height) / 2;
    // The window onto the rows when the terminal cannot hold them all: the
    // selection stays visible, because it is what every other key acts on.
    const shown: usize = height -| 2;
    var first: usize = 0;
    if (shown > 0 and sel_row >= shown) first = sel_row - shown + 1;
    // The notice wins the footer over the legend and the editor's line: a
    // refusal just earned cannot wait for the next keystroke. PEEKED, because
    // the stamp below can still refuse the frame; taken once the bytes are out.
    var notice_buf: [96]u8 = undefined;
    const notice = peekNoticeLocked(shared, &notice_buf);
    const foot: []const u8 = if (notice.len > 0)
        notice
    else if (line) |l|
        l
    else if (view.level == .sessions)
        " Enter add to wall   c new session   x end   Esc back"
    else
        " Enter sessions   c new session   x forget   a add   Esc";

    var out: [picker_frame_max]u8 = undefined;
    var fbs = std.io.fixedBufferStream(&out);
    const wr = fbs.writer();
    // The rows of the box that is on the screen NOW which this one does not
    // cover, erased before the new box is written. Nothing else would: tiles
    // do not paint while the popup is up, so a box that shrank — a host list
    // whose Enter descends into a one-session list, an `x` that shortens the
    // hosts — used to leave its outer rows standing until the close, and the
    // user read four host rows with one session row painted over the middle
    // of them.
    const prev = shared.picker_frame;
    // A box whose columns do not hold every column the old one wrote leaves
    // stale cells on a row it otherwise covers. Only a width change does
    // that, and a WINCH clears the whole screen through `relayout`, which
    // forgets the frame before the next paint runs, so no clear is owed at
    // all on that path. The check is here so a future paint that changes the
    // box's width without clearing the screen cannot leave a stale column.
    const spans = left <= prev.left and left + box_w >= prev.left + prev.cols;
    if (prev.stamp != 0) {
        var cleared = false;
        var pr: u16 = prev.top;
        while (pr < prev.top + prev.rows) : (pr += 1) {
            if (spans and pr >= top and pr < top + height) continue;
            if (!cleared) {
                // Default rendition first: ECH erases with the CURRENT one,
                // and every row of the box it is erasing wears reverse video.
                wr.writeAll("\x1b[0m") catch return;
                cleared = true;
            }
            clearSpan(wr, pr, prev.left, prev.cols);
        }
    }
    // Where the box itself starts. The stamp below is taken over THIS much
    // and no more, so the hash stays a function of the box alone: a repaint
    // whose rows are unchanged is refused whether or not the write before it
    // owed a clear, and a refused frame is one whose geometry matches, which
    // is one that owes no clear.
    const box_at = fbs.getWritten().len;
    // The cursor is hidden for as long as the popup owns the screen: a
    // caret left blinking in a tile says the keys are going there.
    wr.writeAll("\x1b[?25l") catch return;
    pickerLine(wr, top, left, box_w, title);
    if (!cramped) {
        var r: u16 = 0;
        while (r < shown and first + r < body.n) : (r += 1)
            pickerLine(wr, top + 1 + r, left, box_w, body.row(first + r));
        pickerLine(wr, top + height - 1, left, box_w, foot);
    }
    const written = fbs.getWritten();
    // Nothing changed, nothing written. The pollers report once a second
    // per host and every one of them repaints this box; rewriting an
    // identical screen at that rate is a terminal that never goes quiet.
    const stamp = std.hash.Wyhash.hash(0, written[box_at..]);
    if (stamp == shared.picker_frame.stamp) return;
    shared.picker_frame = .{
        .stamp = stamp,
        .top = top,
        .left = left,
        .rows = height,
        .cols = box_w,
    };
    shared.notice_len = 0;
    proto.writeAllFd(shared.out_fd, written) catch {};
}

/// What the popup owes the screen on a pass with no keystroke: whether to
/// paint at all, and the footer to paint with.
const PickerRepaint = struct { due: bool, line: ?[]const u8 };

/// `prompting` is not a term: `relayout` clears the whole screen on any
/// pane a poll vanishes while the box is up, and a paint skipped for the
/// spelling editor leaves the user typing into an editor the screen no
/// longer shows — every key still reaching it.
pub fn pickerRepaint(buf: []u8, prefix: *const interact.PrefixFilter, trigger: bool) PickerRepaint {
    if (!prefix.picking) return .{ .due = false, .line = null };
    const line: ?[]const u8 = if (prefix.prompting)
        (std.fmt.bufPrint(buf, ": {s}_", .{prefix.promptLine()}) catch "")
    else
        null;
    return .{ .due = trigger, .line = line };
}

/// One row of the box: reverse video, padded to the box width. The same
/// chrome a label bar and a rail wear, so the popup never reads as a
/// session's own output.
fn pickerLine(wr: anytype, row: u16, left: u16, w: u16, text: []const u8) void {
    wr.print("\x1b[{d};{d}H\x1b[7m", .{ row + 1, left + 1 }) catch return;
    const take = @min(text.len, @as(usize, w));
    wr.writeAll(text[0..take]) catch return;
    var i: usize = take;
    while (i < w) : (i += 1) wr.writeByte(' ') catch break;
    wr.writeAll("\x1b[0m") catch return;
}

/// One row of a box that is no longer there. Span-bounded ECH over the
/// columns that box held and not one more, the same rule every tile clear
/// obeys: the rest of the row can hold a neighbouring tile's cells or a
/// rail, and a line-wide erase would take them with it.
fn clearSpan(wr: anytype, row: u16, left: u16, w: u16) void {
    if (w == 0) return;
    wr.print("\x1b[{d};{d}H\x1b[{d}X", .{ row + 1, left + 1, w }) catch return;
}

pub fn peekNoticeLocked(shared: *const Shared, out: []u8) []const u8 {
    const n = @min(shared.notice_len, out.len);
    @memcpy(out[0..n], shared.notice[0..n]);
    return out[0..n];
}

/// The hosts still on the wall, in file order — the picker's rows, as
/// indices into the table a forgotten host never leaves.
fn pickerVisible(host_table: []Host, out: *[wv.max_tiles]usize) []const usize {
    var n: usize = 0;
    for (host_table, 0..) |*h, hi| {
        if (h.forgotten.load(.acquire)) continue;
        out[n] = hi;
        n += 1;
    }
    return out[0..n];
}

/// The host `d` rows away. CLAMPED, not wrapped: a held key that rolled
/// the list round would birth on whatever it stopped on.
pub fn pickerStep(host_table: []Host, sel: usize, d: i8) usize {
    var buf: [wv.max_tiles]usize = undefined;
    const vis = pickerVisible(host_table, &buf);
    if (vis.len == 0) return sel;
    var row: usize = 0;
    for (vis, 0..) |hi, i| {
        if (hi == sel) row = i;
    }
    const moved = @as(isize, @intCast(row)) + d;
    const clamped: usize = if (moved < 0) 0 else @min(@as(usize, @intCast(moved)), vis.len - 1);
    return vis[clamped];
}

/// The host a picker row number names, or null when the list is shorter.
pub fn pickerAt(host_table: []Host, row: usize) ?usize {
    var buf: [wv.max_tiles]usize = undefined;
    const vis = pickerVisible(host_table, &buf);
    return if (row < vis.len) vis[row] else null;
}

/// Where the selection rests when the picker opens: the wanted host if it
/// is still on the wall, else the first row — never a forgotten slot.
pub fn pickerNearest(host_table: []Host, want: usize) usize {
    var buf: [wv.max_tiles]usize = undefined;
    const vis = pickerVisible(host_table, &buf);
    for (vis) |hi| {
        if (hi == want) return want;
    }
    return if (vis.len > 0) vis[0] else want;
}

/// The picker's body: one row per host still on the wall, in file order.
/// `sel` is a HOST index, not a row — a forgotten host keeps its slot
/// (a poller holds the pointer and `Tile.host` indexes it) and only
/// leaves the list.
pub fn pickerRows(body: *PickerBody, host_table: []Host, sel: usize, cols: u16) void {
    body.n = 0;
    // Counted before anything is spelled: the padding is the TABLE's, and a
    // row cannot know from its own number whether a 10 is coming.
    var listed: usize = 0;
    for (host_table) |*h| {
        if (!h.forgotten.load(.acquire)) listed += 1;
    }
    const wide = listed >= 10;
    for (host_table, 0..) |*h, hi| {
        if (h.forgotten.load(.acquire)) continue;
        // Wide enough for an `unreachable` carrying a whole `handoff.Reason`.
        // The state gets what the row can SPARE, never all it could fill:
        // `pickerRow` cuts the spelling and never the state, so an unbudgeted
        // 71-byte reason leaves three columns of the host name at 80 columns.
        // The floor is the bare word, which is what a narrow row always had.
        var state_buf: ["unreachable: ".len + handoff.reason_max]u8 = undefined;
        const spare = @as(usize, cols) -| rowHeadLen(wide) -| h.spec.spelling.len -| 2;
        const room = @max("unreachable".len, @min(spare, state_buf.len));
        const state = hostState(state_buf[0..room], h);
        const at = body.n;
        body.host[at] = hi;
        body.lens[at] = pickerRow(&body.text[at], at + 1, wide, h.spec.spelling, state, hi == sel, cols).len;
        body.n += 1;
    }
}