a73x

src/tui/interact.zig

Ref:   Size: 219.5 KiB   History

//! What happens between a user at a terminal and one attached session: the
//! `Ctrl-\` chord layer, the mouse/wheel splitter and alternate scroll,
//! speculative echo, the side channels a session drives on the host terminal,
//! and the terminal ownership those depend on.
//!
//! Not here: how a transport is BUILT (client.zig) or what a chord MEANS —
//! `PrefixFilter` says which action was typed and each driver decides what it
//! does. The transport is `anytype`, naming only `writeFrame`, so this loop
//! is transport-blind and testable with no dial behind it.
//!
//! Keeper of two CLAUDE.md invariants: prediction never enters the replica,
//! and `replica.zig` is the one applier.

const std = @import("std");
const grid_mod = @import("term").grid;
const Grid = grid_mod.Grid;
const Replica = @import("term").replica.Replica;
const proto = @import("term").protocol;
const predict = @import("predict.zig");
const client_core = @import("client").core;
const client_os = @import("client_os");
// Named `paint_mod` because paintOverlay holds a local ArrayList called
// `paint`, which a container-level `paint` would collide with.
const paint_mod = @import("paint.zig");
const askpass = @import("client").askpass;
const select = @import("client").selection;
// The command prefix byte also cancels a dial, so client owns it rather than
// either surface.
const detach_key = @import("client").interrupt.detach_key;

/// What `select` answers, in the shape `paint` asks for. Selection policy lives
/// in `client.selection`; this adapter converts its absolute rows to the wall's
/// grid rows before painting.
///
/// It CONVERTS as well as adapts: `select` speaks absolute rows and a painter
/// speaks grid rows, so the history count of the frame being painted turns one
/// into the other — read from the replica the paint is walking, or the
/// highlight lands on another frame's lines.
pub const Highlight = struct {
    drag: *const select.Drag,
    /// Which tile the drag belongs to. Zero for a focused `Core`, which is
    /// the only session on its screen; the wall passes the real index.
    tile: usize,
    history_rows: u32,

    /// A paint with no selection on it asks nothing per row and emits
    /// exactly the bytes it emitted before this feature existed.
    pub fn sink(self: *const Highlight) paint_mod.Highlight {
        if (self.drag.range() == null) return .{};
        return .{ .ctx = @constCast(self), .span = &spanOf };
    }

    fn spanOf(ctx: ?*anyopaque, row: u16, cols: u16) ?paint_mod.Span {
        const self: *const Highlight = @ptrCast(@alignCast(ctx.?));
        const s = self.drag.span(self.tile, self.history_rows + row, cols) orelse return null;
        return .{ .from = s.from, .to = s.to };
    }
};

/// The attached client's keybinding layer: `Ctrl-\` selects a command rather
/// than acting on its own. `d` or a second `Ctrl-\` detach, `c` creates a
/// session, `n`/`p` step, `l` skips to the last visited, `w` shows the wall;
/// any other key is dropped with the prefix.
///
/// Only what is typed at an ESTABLISHED session passes through: keystrokes
/// `attach` carried across the handshake go out as input, since there was no
/// session to command yet. Public so the wall's focused tile shares the one
/// table — what each action MEANS is the caller's, which byte spells it is not.
pub const PrefixFilter = struct {
    pub const Dir = enum { left, down, up, right };

    /// The picker's two lists: the hosts file's daemons, and one daemon's
    /// own sessions.
    pub const PickLevel = enum { hosts, sessions };

    /// Callers switch on it, so a new variant is additive.
    pub const Action = union(enum) {
        none,
        detach,
        new_session,
        next_session,
        prev_session,
        wall,
        // The digit pressed (1-9): `Ctrl-\ 3` focuses tile 3. Four bits,
        // because nine values do not fit in three.
        focus: u4,
        /// `Ctrl-\ x`: take the focused pane off THIS wall. The session is
        /// left running on its daemon for whoever else holds it, so the key
        /// is reversible — the picker puts the pane back.
        remove_pane,
        /// `Ctrl-\ X`: ask the focused pane's own daemon to END its session.
        /// The shift is the whole difference between the reversible key and
        /// the one that kills a shell, which is why they are two actions and
        /// not one with a flag.
        end_focused,
        focus_dir: Dir,
        split_right,
        split_below,
        fullscreen,
        resize: Dir,
        /// The picker's `a`, then a line and Enter: a host spelling to add
        /// to the wall. Borrows the filter's buffer until the next feed.
        add_tile: []const u8,
        /// `Ctrl-\ s`: open the host picker. The wall shows live sessions,
        /// so a host with none has nowhere else to be named.
        pick_open,
        /// One row up (-1) or down (+1) in the picker.
        pick_move: i8,
        /// The digit pressed (1-9): the picker's row, not the wall's tile.
        pick_select: u4,
        pick_birth,
        pick_forget,
        pick_add_open,
        pick_close,
        /// Enter. At the host level it opens that host's session list; at
        /// the session level it chooses a session and closes the popup.
        /// One action, because the filter has already flipped `pick_level`
        /// by the time the caller reads it — the NEW level says which of
        /// the two just happened.
        pick_enter,
        /// Esc at the session level: back to the hosts, popup still up.
        pick_back,
        /// `x` at the session level: end that session on its daemon.
        pick_end,
        /// ssh asked something and the user answered. Borrows the filter's
        /// buffer until the next feed, as `add_tile` does. An EMPTY answer
        /// is an answer: a key whose passphrase is empty is a real key.
        ask_answer: []const u8,
        /// Esc or Ctrl-C on a prompt. ssh reads it as a refusal and gives
        /// up, which is what the user just said.
        ask_decline,
    };

    pub const Out = struct { forward: []const u8, action: Action };

    /// A sun_path is at most 108 bytes and the hostnames people type are
    /// short, so 256 holds every spelling the grammar accepts in practice.
    /// A longer one is cut at the cap, not refused.
    pub const prompt_max: usize = 256;

    // The banner buffer must hold a whole prompt line — the `": "` and the
    // cursor `"_"` on top of the spelling — and a comment cannot fail a
    // build. Asserted here because the import only runs this way: interact
    // imports paint, never the reverse.
    comptime {
        std.debug.assert(prompt_max + ": ".len + "_".len <= paint_mod.banner_label_max);
    }

    /// A prefix arrived at the end of a read and its command key has not
    /// been typed yet. Held across reads so a chord split by a read
    /// boundary is still one chord.
    pending: bool = false,

    /// `Ctrl-\ r` enters resize mode: bare hjkl trade cells without a
    /// fresh prefix. Esc leaves silently; any other byte leaves and is
    /// processed normally — the mode never eats prose.
    resizing: bool = false,

    /// The picker's `a` opens the prompt: a line editor for one spelling.
    /// Every byte is the prompt's until Enter or Esc — the mode eats prose
    /// on purpose, unlike resize mode. A layer OVER `picking`, not a mode
    /// beside it: both ends of the editor go back to the rows.
    prompting: bool = false,
    line: [prompt_max]u8 = undefined,
    line_len: usize = 0,

    /// `Ctrl-\ s` opens the host picker, and every byte is the popup's
    /// until it closes — so a `j` aimed at the rows can never reach a
    /// shell. The driver paints; this owns which key means what.
    picking: bool = false,
    /// Which list the popup shows. The filter owns it because Enter and Esc
    /// mean different things at each level, and a key must resolve to ONE
    /// action without the wall's help.
    pick_level: PickLevel = .hosts,

    /// ssh is waiting on an answer. Over EVERYTHING, `picking` included: a
    /// prompt is not a mode the user chose, it arrived. Every byte is the
    /// popup's — a `j` typed at a password box must not reach a shell.
    asking: bool = false,
    /// What ssh said it was asking for. The filter carries it to the
    /// painter and decides nothing: a secret is starred, a confirmation is
    /// read back by the user, and a notice takes no answer at all.
    kind: askpass.Kind = .secret,
    /// Its OWN buffer, not `line`: a prompt can arrive while the picker's
    /// spelling editor holds a half-typed host, and sharing one buffer
    /// would eat that line to answer a question about another machine.
    ask_line: [askpass.answer_max]u8 = undefined,
    ask_len: usize = 0,

    pub fn askLine(self: *const PrefixFilter) []const u8 {
        return self.ask_line[0..self.ask_len];
    }

    /// Whether the answer is painted behind stars.
    pub fn masks(self: *const PrefixFilter) bool {
        return self.kind == .secret;
    }

    pub fn askOpen(self: *PrefixFilter, kind: askpass.Kind) void {
        self.asking = true;
        self.kind = kind;
        self.ask_len = 0;
        @memset(&self.ask_line, 0);
    }

    /// Called by the driver once the answer has been handed on.
    pub fn askClose(self: *PrefixFilter) void {
        // Which is what makes the zeroing a definite point: `feed` cannot
        // do it — the action it returns borrows this buffer.
        self.asking = false;
        self.ask_len = 0;
        @memset(&self.ask_line, 0);
    }

    pub fn promptLine(self: *const PrefixFilter) []const u8 {
        return self.line[0..self.line_len];
    }

    /// An up/down arrow at the head of `tail`, in both forms a terminal
    /// sends it: `[A` after the Esc, `OA` in application cursor mode.
    fn arrowMove(tail: []const u8) ?i8 {
        if (tail.len < 2) return null;
        if (tail[0] != '[' and tail[0] != 'O') return null;
        return switch (tail[1]) {
            'A' => -1,
            'B' => 1,
            else => null,
        };
    }

    /// Filters one raw stdin chunk in place — the layer only removes bytes, so
    /// survivors compact leftwards over the same buffer. An ACTION ends the
    /// chunk and whatever was typed behind it is dropped: those bytes were
    /// typed at the OLD session, and forwarding them puts them in the wrong
    /// shell while returning them races a detach already on its way.
    pub fn feed(self: *PrefixFilter, buf: []u8) Out {
        var kept: usize = 0;
        // The picker and its editor are MODES, so `s` and `a` do not end the
        // read: the bytes behind them were typed AT the mode that keystroke
        // opened. Remembered, and reported only if nothing later said more.
        var opened = false;
        var editing = false;
        for (buf, 0..) |b, i| {
            // FIRST, and above the picker's own layer: ssh is blocked on
            // this answer, and a byte that reached a session while a
            // password box was up would be typed into a shell in the clear.
            if (self.asking) {
                switch (b) {
                    '\r', '\n' => {
                        self.asking = false;
                        return .{ .forward = buf[0..kept], .action = .{ .ask_answer = self.ask_line[0..self.ask_len] } };
                    },
                    // The Esc that heads an arrow key takes its tail with it:
                    // the chunk ends here, so `[A` never reaches a shell.
                    0x1b, 0x03 => {
                        self.asking = false;
                        return .{ .forward = buf[0..kept], .action = .ask_decline };
                    },
                    0x7f, 0x08 => self.ask_len -|= 1,
                    // High bytes too, unlike the spelling editor: a password is
                    // bytes and ssh takes any of them. The star count is per
                    // byte — feedback, not an inventory.
                    0x20...0x7e, 0x80...0xff => if (self.ask_len < askpass.answer_max) {
                        self.ask_line[self.ask_len] = b;
                        self.ask_len += 1;
                    },
                    else => {},
                }
                continue;
            }
            if (self.prompting) {
                switch (b) {
                    // Submit and cancel both end the read: an Esc heading an
                    // arrow key must take its tail rather than hand `[A` to the
                    // shell, and Ctrl-C is the reflex cancel.
                    '\r', '\n' => {
                        self.prompting = false;
                        if (self.line_len == 0) return .{ .forward = buf[0..kept], .action = .none };
                        return .{ .forward = buf[0..kept], .action = .{ .add_tile = self.line[0..self.line_len] } };
                    },
                    0x1b, 0x03 => {
                        self.prompting = false;
                        return .{ .forward = buf[0..kept], .action = .none };
                    },
                    0x7f, 0x08 => self.line_len -|= 1,
                    0x20...0x7e => if (self.line_len < prompt_max) {
                        self.line[self.line_len] = b;
                        self.line_len += 1;
                    },
                    else => {},
                }
                continue;
            }
            if (self.picking) {
                switch (b) {
                    0x1b => {
                        // A terminal writes an arrow key as three bytes of one
                        // read, so the tail decides which key this Esc was.
                        // Nothing is held across reads: a bare Escape closes now.
                        const tail = buf[i + 1 ..];
                        if (arrowMove(tail)) |d|
                            return .{ .forward = buf[0..kept], .action = .{ .pick_move = d } };
                        // A CSI or SS3 head the popup has no key for is not an
                        // Escape: mouse modes stay armed under the box, so a
                        // wheel notch would shut a popup being read. The tail
                        // leaves too — its digits read as row selections.
                        if (tail.len > 0 and (tail[0] == '[' or tail[0] == 'O'))
                            return .{ .forward = buf[0..kept], .action = .none };
                        // One level at a time: the session list was reached
                        // through the host list, and an Esc that closed the
                        // whole popup would cost a reopen to change machine.
                        if (self.pick_level == .sessions) {
                            self.pick_level = .hosts;
                            return .{ .forward = buf[0..kept], .action = .pick_back };
                        }
                        self.picking = false;
                        return .{ .forward = buf[0..kept], .action = .pick_close };
                    },
                    'j' => return .{ .forward = buf[0..kept], .action = .{ .pick_move = 1 } },
                    'k' => return .{ .forward = buf[0..kept], .action = .{ .pick_move = -1 } },
                    '1'...'9' => return .{ .forward = buf[0..kept], .action = .{ .pick_select = @intCast(b - '0') } },
                    '\r', '\n' => {
                        // Enter descends, then chooses. The level is flipped
                        // BEFORE the caller sees the action, so `.sessions`
                        // means "the list just opened" and `.hosts` means "a
                        // session was picked and the popup is closing".
                        if (self.pick_level == .hosts) {
                            self.pick_level = .sessions;
                            return .{ .forward = buf[0..kept], .action = .pick_enter };
                        }
                        self.picking = false;
                        self.pick_level = .hosts;
                        return .{ .forward = buf[0..kept], .action = .pick_enter };
                    },
                    // A new session on the selected host, from either level:
                    // the host is what a birth needs, and the session list
                    // is exactly where a user decides none of them will do.
                    'c' => {
                        self.picking = false;
                        self.pick_level = .hosts;
                        return .{ .forward = buf[0..kept], .action = .pick_birth };
                    },
                    'x' => {
                        // An end leaves the popup up: the daemon's first
                        // answer is a count to read, and the second press
                        // that forces has to land on the same row.
                        if (self.pick_level == .sessions)
                            return .{ .forward = buf[0..kept], .action = .pick_end };
                        // Closed, like a birth: a forget takes the host's tiles
                        // off the wall, and the user is owed the wall that made.
                        self.picking = false;
                        return .{ .forward = buf[0..kept], .action = .pick_forget };
                    },
                    'a' => {
                        self.prompting = true;
                        self.line_len = 0;
                        editing = true;
                        continue;
                    },
                    's', 0x03 => {
                        self.picking = false;
                        self.pick_level = .hosts;
                        return .{ .forward = buf[0..kept], .action = .pick_close };
                    },
                    else => {},
                }
                continue;
            }
            if (self.resizing) {
                switch (b) {
                    'h' => return .{ .forward = buf[0..kept], .action = .{ .resize = .left } },
                    'j' => return .{ .forward = buf[0..kept], .action = .{ .resize = .down } },
                    'k' => return .{ .forward = buf[0..kept], .action = .{ .resize = .up } },
                    'l' => return .{ .forward = buf[0..kept], .action = .{ .resize = .right } },
                    0x1b => {
                        self.resizing = false;
                        continue;
                    },
                    else => {
                        self.resizing = false;
                        // Fall through to normal processing of this byte.
                    },
                }
            }
            if (self.pending) {
                self.pending = false;
                switch (b) {
                    'd', detach_key => return .{ .forward = buf[0..kept], .action = .detach },
                    'c' => return .{ .forward = buf[0..kept], .action = .new_session },
                    'n' => return .{ .forward = buf[0..kept], .action = .next_session },
                    'p' => return .{ .forward = buf[0..kept], .action = .prev_session },
                    'h' => return .{ .forward = buf[0..kept], .action = .{ .focus_dir = .left } },
                    'j' => return .{ .forward = buf[0..kept], .action = .{ .focus_dir = .down } },
                    'k' => return .{ .forward = buf[0..kept], .action = .{ .focus_dir = .up } },
                    'l' => return .{ .forward = buf[0..kept], .action = .{ .focus_dir = .right } },
                    '|', '\\' => return .{ .forward = buf[0..kept], .action = .split_right },
                    '-' => return .{ .forward = buf[0..kept], .action = .split_below },
                    'f' => return .{ .forward = buf[0..kept], .action = .fullscreen },
                    'r' => {
                        self.resizing = true;
                        continue;
                    },
                    's' => {
                        self.picking = true;
                        opened = true;
                        continue;
                    },
                    'w' => return .{ .forward = buf[0..kept], .action = .wall },
                    '1'...'9' => return .{ .forward = buf[0..kept], .action = .{ .focus = @intCast(b - '0') } },
                    'x' => return .{ .forward = buf[0..kept], .action = .remove_pane },
                    'X' => return .{ .forward = buf[0..kept], .action = .end_focused },
                    else => {},
                }
                continue;
            }
            if (b == detach_key) {
                self.pending = true;
                continue;
            }
            buf[kept] = b;
            kept += 1;
        }
        if (editing) return .{ .forward = buf[0..kept], .action = .pick_add_open };
        return .{ .forward = buf[0..kept], .action = if (opened) .pick_open else .none };
    }
};

/// The most one read of the session's stdin can hand `Core.forward`. The
/// driver owns the read (the wall's is `wallview.mailbox_max`); this is
/// what the mouse filter's scratch is sized from, so a chunk that outgrew
/// it would be one the filter could not hold a candidate across.
const stdin_chunk = 16 * 1024;

/// How many rows one wheel notch moves the scrollback view. Three is what
/// every terminal's own scrollback does per notch, so it is what a user's
/// hand already expects; a page per notch (the granularity the scroll KEYS
/// use) overshoots so far that finding a line means hunting for it.
const wheel_rows: u32 = 3;

/// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones
/// into scrollback movement.
///
/// TWO ROLES, not alternatives: every tile's `Core` owns one for a focused
/// session's bytes, bypassed entirely while an application in that session
/// has asked for the mouse; and the wall owns one to hit-test a press for
/// focus, passing the report bytes on so the drag's Core still sees them.
/// Only the SGR form is recognised — the only form the client asks for.
///
/// It never holds a bare `ESC` across a read boundary: that is the complete
/// parse, and it would leave a bare Escape typed in vim sitting here until
/// the next keystroke. The hold starts at `ESC [ <`, three bytes no keyboard
/// produces, so only a split inside those three leaks through as input.
pub const MouseFilter = struct {
    /// Longest report worth holding: `ESC [ <` plus three parameters. Public
    /// because a caller must size `feed`'s buffer at `in.len + max_held`.
    pub const max_held = 24;

    /// One report the filter understood, in this client's coordinates.
    /// `button` is the SGR word verbatim: what a click MEANS differs between
    /// the wall's keyboard and a focused tile, so decoding is the driver's.
    pub const Event = struct {
        /// `wheel` is a report a driver re-emits and never selects on: the
        /// notch it means is already counted in `Out.wheel`, and a
        /// selection that scrolled as it grew would be one nobody made.
        pub const Kind = enum { press, motion, release, wheel };

        kind: Kind,
        button: u16,
        /// Zero-based, converted from the wire's one-based columns and rows
        /// exactly here: every coordinate this client owns downstream
        /// (grid rows, `Stripe.top`) counts from zero, and one conversion
        /// at the edge beats one at every use.
        col: u16,
        row: u16,
        /// How many bytes of `Out.forward` had been emitted when this report
        /// completed — its position among the KEYS, not its index among the
        /// events. `feed` returns two flat lists, so without this the
        /// interleaving is lost: one read holding `\r` and a click focuses the
        /// tile the click selected rather than the one the user chose.
        at: usize,
    };

    /// The most reports one `feed` can produce: the shortest complete SGR
    /// report is NINE bytes, and a candidate held from the previous read can
    /// complete at most one more. Sized rather than coalesced so the filter is
    /// total — a drag that outran a cap would lose its release. The nine is
    /// counted: ten leaves the array 181 short of a full chunk.
    const max_events = stdin_chunk / 9 + 1;

    pub const Out = struct {
        /// The bytes that were not mouse reports, in order.
        forward: []const u8,
        /// Net wheel notches: positive is up, into history.
        wheel: i32,
        /// The reports this chunk carried, in order. Borrows the filter's
        /// own storage and is valid only until the next `feed`.
        events: []const Event,
    };

    held: [max_held]u8 = undefined,
    len: usize = 0,
    events: [max_events]Event = undefined,

    pub fn reset(self: *MouseFilter) void {
        self.len = 0;
    }

    /// Filter one raw stdin chunk into `out`, which must hold
    /// `in.len + max_held`: a candidate held from the previous read comes back
    /// ahead of this chunk when it turns out not to have been a report. Both
    /// size preconditions are ASSERTED, not merely stated here.
    pub fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out {
        std.debug.assert(in.len <= stdin_chunk);
        std.debug.assert(out.len >= in.len + max_held);
        var kept: usize = 0;
        var wheel: i32 = 0;
        var evs: usize = 0;
        for (in) |b| {
            if (self.len > 0) {
                if (b == 'M' or b == 'm') {
                    self.held[self.len] = b;
                    const seq = self.held[0 .. self.len + 1];
                    wheel += wheelNotches(seq);
                    if (decodeEvent(seq)) |ev| {
                        self.events[evs] = ev;
                        // `kept` is already past the rewind that started this
                        // candidate, so this is a position in the FINAL forward
                        // and not a provisional one that later shrinks.
                        self.events[evs].at = kept;
                        evs += 1;
                    }
                    self.len = 0;
                    continue;
                }
                if ((std.ascii.isDigit(b) or b == ';') and self.len + 1 < max_held) {
                    self.held[self.len] = b;
                    self.len += 1;
                    continue;
                }
                // Not a report: give the held bytes back in the order they
                // were typed, then let `b` take its chances below — it may
                // be the ESC of the next candidate.
                @memcpy(out[kept..][0..self.len], self.held[0..self.len]);
                kept += self.len;
                self.len = 0;
            }
            out[kept] = b;
            kept += 1;
            if (kept >= 3 and std.mem.eql(u8, out[kept - 3 .. kept], "\x1b[<")) {
                kept -= 3;
                @memcpy(self.held[0..3], "\x1b[<");
                self.len = 3;
            }
        }
        return .{ .forward = out[0..kept], .wheel = wheel, .events = self.events[0..evs] };
    }

    /// The event one complete SGR report means, or null for one this
    /// terminal malformed. A wheel report comes back as `.wheel`, its notch
    /// already spent by `wheelNotches`: a driver that scrolls on the notch
    /// must not also select on the event, and a driver that hands the
    /// mouse to an application must still pass the wheel along.
    fn decodeEvent(seq: []const u8) ?Event {
        var it = std.mem.splitScalar(u8, seq[3 .. seq.len - 1], ';');
        const button = std.fmt.parseInt(u16, it.first(), 10) catch return null;
        const col = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null;
        const row = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null;
        // One-based on the wire. A zero is a terminal talking nonsense, and
        // decrementing it would wrap to 65535 — a coordinate that would
        // pass every later bound check by being enormous.
        if (col == 0 or row == 0) return null;
        return .{
            // A release says so with its final byte whatever the button
            // bits claim; only a press can also be a drag (bit 5).
            .kind = if (seq[seq.len - 1] == 'm')
                .release
            else if (button & 0x40 != 0)
                .wheel
            else if (button & 0x20 != 0)
                .motion
            else
                .press,
            .button = button,
            .col = col - 1,
            .row = row - 1,
            // Filled by `feed`, which is the only thing that knows how much
            // of `forward` exists yet.
            .at = 0,
        };
    }

    /// The wheel movement one complete SGR report means, or 0 for anything
    /// else. Discarding those is the point: with no application asking for the
    /// mouse, forwarding one types `[<0;40;12M` into the user's shell.
    fn wheelNotches(seq: []const u8) i32 {
        // Wheel events are presses; a release cannot be one.
        if (seq[seq.len - 1] != 'M') return 0;
        var it = std.mem.splitScalar(u8, seq[3 .. seq.len - 1], ';');
        const button = std.fmt.parseInt(u16, it.first(), 10) catch return 0;
        // Bit 6 marks the wheel buttons, bit 5 motion (a drag with the wheel
        // held is not a scroll); the low two bits pick vertical from
        // horizontal. Modifiers are ignored, so Ctrl+wheel scrolls.
        if (button & 0x40 == 0 or button & 0x20 != 0) return 0;
        return switch (button & 0x03) {
            0 => 1,
            1 => -1,
            else => 0,
        };
    }
};

var winch_flag = std.atomic.Value(bool).init(false);

fn onWinch(_: c_int) callconv(.c) void {
    winch_flag.store(true, .release);
}

/// Arm SIGWINCH, so `winchRaised` has something to answer.
///
/// The keyboard thread polls the flag and relayouts; there is no single
/// pump that owns the screen, so no pump consumes the signal.
pub fn watchWinch() void {
    var sa: std.posix.Sigaction = .{
        .handler = .{ .handler = onWinch },
        .mask = std.posix.sigemptyset(),
        .flags = 0,
    };
    std.posix.sigaction(std.posix.SIG.WINCH, &sa, null);
}

/// Whether SIGWINCH fired since the last check. Swaps to false.
pub fn winchRaised() bool {
    return winch_flag.swap(false, .acq_rel);
}

/// This terminal's size, or null when there is no terminal to measure.
/// Public because the wall cuts its stripes before it has a Core, and a
/// second copy would drift on exactly the 0x0 case below.
pub fn ttySize(fd: std.posix.fd_t) ?proto.Size {
    if (!std.posix.isatty(fd)) return null;
    const ws = client_os.winSize(fd) orelse return null;
    // A pty can report 0x0 and a zero-sized grid is invalid for the engine,
    // so that is "unknown". The floor is the daemon's own, read rather than
    // respelled: a size it refuses to move is as unusable as no size at all.
    if (ws.col < proto.min_session_cols or ws.row < proto.min_session_rows) return null;
    return .{ .cols = ws.col, .rows = ws.row };
}

// ---- side channels -----------------------------------------------------
// Everything below turns a typed semantic value into bytes for the host
// terminal. Untrusted wire validation belongs to client_core; these adapters
// only perform the operation that shared core selected.

/// Everything a driver does TO the host terminal to own a SCREEN, in order:
/// push the title, enter the alternate screen, hide the cursor, disable
/// autowrap. One half of a pair — `terminal_teardown` undoes each, and one
/// test pins them together so neither drifts alone. Written once for a
/// driver's LIFETIME, not per session: the wall holds the screen while focus
/// moves and a tile writes only `session_claim`. Autowrap goes off so an
/// oversized row clips at the right edge instead of shifting the paint down.
const terminal_frame_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l";

/// The half about holding a SESSION on somebody's terminal: the mouse modes
/// its wheel is read out of, and nothing else. A tile's focus claim writes
/// this, then the session's own modes on top. One shared constant, so a mode
/// added here is added to every claim and one dropped from `session_release`
/// is dropped from every path that undoes one.
///
/// The enables are the CLIENT's own: with no mouse reporting on, a host
/// terminal answers the wheel by synthesising arrow keys (DEC 1007), which
/// move the shell's history instead of the view.
const session_claim = client_mouse_setup;

/// The wall's own capture, exported for the KEYBOARD to re-arm right after
/// it writes `session_release` on a focus move. The release lowers every
/// mouse mode — the wall's three included — and the re-arm used to be the
/// incoming tile's claim, a pump-side write. A pump parked in `dial`'s
/// backoff never runs its claim, so focusing an unreachable tile left the
/// terminal deaf to every later click, including the one that would have
/// moved focus back off it. The wall hears clicks for as long as it owns
/// the screen, no pump required; a claim that follows re-asserts the same
/// modes, which is idempotent.
pub const wall_mouse_capture = client_mouse_setup;

/// The mouse modes the client asks its own terminal for when no application
/// in the session wants them: presses (1000) and motion with a button down
/// (1002), in SGR (1006).
///
/// 1002 is held for the whole run because there is no drag-length to arm it
/// for: motion reporting has to be on BEFORE the press whose drag it reports.
/// The cost is a report per motion event; nvim with `set mouse=a` pays the
/// same. 1003 stays refused — motion with NO button down answers no question
/// this client asks.
///
/// The LIST is the fact, not the escape: `appendMouseModes` level-sets the
/// same modes, so one spelled here and not there is turned on then off.
const client_mouse_capture = [_]u16{ 1000, 1002, 1006 };

const client_mouse_setup = blk: {
    var s: []const u8 = "";
    for (client_mouse_capture) |dec| s = s ++ std.fmt.comptimePrint("\x1b[?{d}h", .{dec});
    break :blk s;
};

/// Whether `dec` is one of the modes the client asks for on its own behalf.
fn inClientCapture(comptime dec: u16) bool {
    for (client_mouse_capture) |c| {
        if (c == dec) return true;
    }
    return false;
}

/// Everything the client must undo on its way out, in one literal: leaving
/// any of these set hands the user a terminal that behaves oddly long after
/// mux exited. `?2004l` leads because a SESSION asked for it.
///
/// That `?2004l` restores 2004 to the power-on default rather than to what
/// the outer program had, because mux never asked. Correct by observation:
/// zsh and readline both DISARM bracketed paste before running each command,
/// so host 2004 is already off for the whole time mux runs.
///
/// The title pop (`23;0t`) is the one entry restoring the user's OWN value,
/// off the terminal's stack, because mux cannot read a title back. It sits
/// SECOND TO LAST: `?1049l` must remain the final bytes a tty client writes,
/// which the e2e's pty capture asserts by dropping exactly that tail.
const terminal_teardown = session_release ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l";

/// Undoes `session_claim` and everything a SESSION asked this terminal for
/// while it held it: bracketed paste, and the whole mouse table, since a
/// focused tile's application can have asked for modes the client never did.
///
/// Every route out of a terminal claim writes these bytes, and one test pins
/// all four. Public because the focus move writes it from another thread: the
/// release must be ORDERED against the next tile's claim, not merely happen.
pub const session_release = "\x1b[?2004l" ++ mouse_teardown;

/// What a driver that lends its screen to one session at a time writes on the
/// way in and out. The wall is the driver: it holds the alternate screen for
/// its whole life, so the screen half is written once and never by a tile.
///
/// The teardown IS `terminal_teardown`, by design: `Ctrl-\ d` ends the run
/// from any state, so every mode a session set through a tile has to come off
/// here as well as at the focus handover.
///
/// The TITLE is deliberately not symmetric. A focus move leaves the session's
/// title standing; the wall pushed one for its whole life and pops it here.
/// Restoring per focus move would need a title to restore TO, and mux cannot
/// read one back. That push is exactly one deep — an unmatched POP restores
/// somebody else's title — and this write and that pop are the only two.
pub const wall_setup = terminal_frame_setup ++ "\x1b[H\x1b[2J";
pub const wall_teardown = terminal_teardown;

/// Built from the wire table, so a mode added there cannot be missed here
/// and leave the user's shell reporting clicks after mux exits.
const mouse_teardown = blk: {
    var s: []const u8 = "";
    for (proto.mouse_modes) |m| s = s ++ std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec});
    break :blk s;
};

/// Render validated terminal state as the DECSET/DECRST writes it implies.
fn appendTermState(
    out: *std.ArrayList(u8),
    alloc: std.mem.Allocator,
    state: client_core.State,
) !void {
    switch (state) {
        .terminal_modes => |modes| {
            try out.appendSlice(
                alloc,
                if (modes.bracketed_paste) "\x1b[?2004h" else "\x1b[?2004l",
            );
            try appendMouseModes(out, alloc, modes);
        },
    }
}

/// Who owns the wheel. A level-set, not a diff: the terminal may
/// be one mux never set up.
fn appendMouseModes(
    out: *std.ArrayList(u8),
    alloc: std.mem.Allocator,
    modes: proto.TermModes,
) !void {
    const app = modes.appMouse();
    inline for (proto.mouse_modes) |m| {
        // The client's own capture set comes from the one list that
        // `client_mouse_setup` is built from, so the startup write and this
        // level-set cannot disagree about what "ours" is.
        const on = if (app) @field(modes, m.field) else comptime inClientCapture(m.dec);
        try out.appendSlice(alloc, if (on)
            comptime std.fmt.comptimePrint("\x1b[?{d}h", .{m.dec})
        else
            comptime std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec}));
    }
}

/// Re-validated, not trusted: `wasm_core.zig` default-initialises
/// an invalid `ClipboardSet`.
fn appendHostEffect(
    out: *std.ArrayList(u8),
    alloc: std.mem.Allocator,
    effect: client_core.Effect,
) !void {
    switch (effect) {
        .clipboard_set => |clip| {
            if (!client_core.validClipboard(clip.target, clip.base64)) return;
            try out.appendSlice(alloc, "\x1b]52;");
            try out.append(alloc, clip.target);
            try out.append(alloc, ';');
            try out.appendSlice(alloc, clip.base64);
            try out.append(alloc, 0x07);
        },
        .bell => try out.append(alloc, 0x07),
    }
}

/// `owns_terminal` is true by design: the claim gates a session's
/// event, not this drag.
pub fn writeSelectionCopy(
    alloc: std.mem.Allocator,
    out_fd: std.posix.fd_t,
    text: []const u8,
) !void {
    const b64 = try alloc.alloc(u8, std.base64.standard.Encoder.calcSize(text.len));
    defer alloc.free(b64);
    _ = std.base64.standard.Encoder.encode(b64, text);
    try writeSideChannel(alloc, out_fd, true, client_core.Effect, .{ .clipboard_set = .{
        .target = 'c',
        .base64 = b64,
    } }, appendHostEffect);
}

/// An empty title CLEARS the host terminal's; the peer need not
/// be this daemon version.
fn appendTermTitle(
    out: *std.ArrayList(u8),
    alloc: std.mem.Allocator,
    payload: []const u8,
) !void {
    if (payload.len == 0 or payload.len > proto.term_title_max) return;
    for (payload) |b| if (b < 0x20 or b == 0x7f) return;
    try out.appendSlice(alloc, "\x1b]0;");
    try out.appendSlice(alloc, payload);
    try out.append(alloc, 0x07);
}

/// `owns_terminal` gates every channel: the claim arms the teardown, so a
/// mode set under `.none` is one nothing undoes. `append` is declared, not
/// `anytype`: allocation is a builder's only failure, so empty means refusal.
fn writeSideChannel(
    alloc: std.mem.Allocator,
    stdout_fd: std.posix.fd_t,
    owns_terminal: bool,
    comptime Value: type,
    value: Value,
    comptime append: fn (*std.ArrayList(u8), std.mem.Allocator, Value) std.mem.Allocator.Error!void,
) !void {
    if (!owns_terminal) return;
    var esc: std.ArrayList(u8) = .empty;
    defer esc.deinit(alloc);
    try append(&esc, alloc, value);
    if (esc.items.len > 0) try proto.writeAllFd(stdout_fd, esc.items);
}

// ---- prediction --------------------------------------------------------
// Nothing below writes to the replica (see the invariants at the top), which
// is why the replica stays comparable to `mux d dump` at every instant.

/// What the replica shows at one cell — the `prev_ch` a prediction is judged
/// against later. Read at the PREDICTED cursor, not the replica's own:
/// mid-burst the wrong one turns "not answered yet" into "contradicted".
fn replicaCellChar(alloc: std.mem.Allocator, replica: *const Grid, at: predict.CursorPos) u8 {
    const plain = replica.dumpPlain(alloc) catch return ' ';
    defer alloc.free(plain);
    const text: predict.PlainGrid = .{ .text = plain, .cols = replica.cols };
    return text.cellChar(at.y, at.x) orelse ' ';
}

/// Judge the overlay against the replica as it stands — after the
/// replica has taken the frame, never before.
fn reconcileOverlay(
    alloc: std.mem.Allocator,
    overlay: *predict.Overlay,
    replica: *const Grid,
    seq: u64,
    now_ms: i64,
) predict.Verdict {
    const plain = replica.dumpPlain(alloc) catch return .none;
    defer alloc.free(plain);
    return overlay.reconcile(
        predict.PlainGrid{ .text = plain, .cols = replica.cols },
        seq,
        now_ms,
    );
}

/// Idempotent, and called after every authoritative paint: a delta
/// repaints whole rows and would wipe an outstanding prediction.
fn paintOverlay(
    alloc: std.mem.Allocator,
    overlay: *predict.Overlay,
    base: grid_mod.CursorPos,
    vp: paint_mod.Viewport,
    out_fd: std.posix.fd_t,
) void {
    if (!overlay.visible() or overlay.pendingCount() == 0) return;
    var paint: std.ArrayList(u8) = .empty;
    defer paint.deinit(alloc);
    paint.appendSlice(alloc, paint_mod.sync_begin) catch return;

    var i: usize = 0;
    while (i < overlay.pendingCount()) : (i += 1) {
        const cell = overlay.pendingAt(i).cell;
        if (cell.row >= vp.rows or cell.col >= vp.cols) continue;
        var b: [32]u8 = undefined;
        // Underlined, so a prediction is visibly a prediction until the
        // daemon's own row content replaces it.
        const s = std.fmt.bufPrint(&b, "\x1b[{d};{d}H\x1b[4m{c}\x1b[0m", .{
            cell.row + vp.top + 1,
            cell.col + vp.left + 1,
            cell.ch,
        }) catch continue;
        paint.appendSlice(alloc, s) catch return;
        // Counted here rather than at prediction time, because this is
        // where a cell actually reaches the screen — including one queued
        // while unconfident that a promotion has since made visible. The
        // overlay counts it once however often this redraws it.
        overlay.markPainted(i);
    }

    const pc = overlay.predictedCursor(.{ .x = base.x, .y = base.y });
    const cur = paint_mod.clampCursor(.{ .x = pc.x, .y = pc.y }, vp);
    var cbuf: [32]u8 = undefined;
    const tail = std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H" ++ paint_mod.sync_end, .{
        cur.y + vp.top + 1,
        cur.x + vp.left + 1,
    }) catch return;
    paint.appendSlice(alloc, tail) catch return;
    proto.writeAllFd(out_fd, paint.items) catch {};
}

/// The chunk goes to the daemon unchanged whatever happens here:
/// prediction alters what the screen shows, never what the shell reads.
fn offerKeystroke(
    alloc: std.mem.Allocator,
    overlay: *predict.Overlay,
    replica: *const Grid,
    chunk: []const u8,
    vp: paint_mod.Viewport,
    out_fd: std.posix.fd_t,
) void {
    if (chunk.len != 1) {
        // An escape, a multi-byte character or a paste: none is one cell's
        // worth of change. Counted HERE, because a paste's lead byte is
        // printable and `predictAt` would speculate on it.
        overlay.recordSuppressed();
        return;
    }

    const base = replica.cursor;
    const at = overlay.predictedCursor(.{ .x = base.x, .y = base.y });
    const out = overlay.predictAt(.{
        .cursor = at,
        .ch = chunk[0],
        .prev_ch = replicaCellChar(alloc, replica, at),
        .now_ms = std.time.milliTimestamp(),
    });
    switch (out) {
        .display => paintOverlay(alloc, overlay, base, vp, out_fd),
        // Queued but unearned, or refused outright: either way nothing is
        // drawn, which is the entire safety property.
        .hidden, .suppressed => {},
    }
}

/// The one machine-readable line `MUX_PREDICT_STATS=1` produces. A pure
/// function so the format the e2e greps for is pinned by a test rather than
/// by whatever the process happened to print.
fn formatPredictStats(buf: []u8, c: predict.Counters) ![]const u8 {
    return std.fmt.bufPrint(
        buf,
        "predict made={d} displayed={d} confirmed={d} contradicted={d}" ++
            " expired={d} abandoned={d} suppressed={d} local={d}",
        .{
            c.made,    c.displayed, c.confirmed,  c.contradicted,
            c.expired, c.abandoned, c.suppressed, c.local,
        },
    );
}

const predict_stats_len = 192;

/// The prediction counters, re-exported. A driver that has to carry them
/// across a thread boundary (wallview's wall, whose tile Cores never reach
/// a `deinit` that could print them) names the type through the module it
/// already talks to, rather than taking a module edge for one struct.
pub const PredictCounters = predict.Counters;

/// The `MUX_PREDICT_STATS` line, on the way out. A tile's Core lives on a
/// detached pump the process exit kills where it stands, so the driver that
/// owns the exit prints it — on the normal screen, not a discarded one.
pub fn dumpPredictStats(c: predict.Counters) void {
    const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return;
    if (!std.mem.eql(u8, want, "1")) return;
    var buf: [predict_stats_len]u8 = undefined;
    const line = formatPredictStats(&buf, c) catch return;
    std.debug.print("{s}\n", .{line});
}

/// Turn wheel notches into the arrow keys an alt-screen application reads,
/// `wheel_rows` per notch. Sent as input, never predicted — a guess at a
/// full-screen application's cursor is about a layout mux cannot see — and
/// batched, since one frame per arrow would put a hundred on the wire.
fn sendAltScroll(transport: anytype, wheel: i32, app_cursor: bool) !void {
    const seq = altScrollSeq(wheel, app_cursor);
    var buf: [alt_scroll_batch * 3]u8 = undefined;
    var left: u32 = @as(u32, @intCast(@abs(wheel))) * wheel_rows;
    while (left > 0) {
        const n = @min(left, alt_scroll_batch);
        for (0..n) |i| @memcpy(buf[i * 3 ..][0..3], seq);
        try transport.writeFrame(.input, buf[0 .. n * 3]);
        left -= n;
    }
}

/// Arrows per alternate-scroll frame. Twenty-one notches' worth, which no
/// hand produces in one read; the batching exists to bound the buffer, not
/// to pace anything.
const alt_scroll_batch: u32 = 64;

/// DECCKM decides what an arrow key IS, and getting it wrong is silent:
/// `less` reads `ESC O A` and ignores `ESC [ A`. Three bytes either
/// way, as `sendAltScroll` assumes.
fn altScrollSeq(wheel: i32, app_cursor: bool) *const [3]u8 {
    if (app_cursor) return if (wheel > 0) "\x1bOA" else "\x1bOB";
    return if (wheel > 0) "\x1b[A" else "\x1b[B";
}

fn requestScrollPage(
    transport: anytype,
    rep: *const Replica,
    rows_up: u32,
    size: proto.Size,
) !void {
    // The view is `size.rows` rows starting `rows_up` above the live
    // viewport top; the row math lives with the replica's history_rows
    // (replica.zig).
    const start = rep.scrollStart(rows_up);
    try transport.writeFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows));
}
// ---- the interaction core ----------------------------------------------

/// What a step that touched the transport found there. Reported rather than
/// thrown: a dead link is an ordinary event a driver answers by rebuilding
/// it, not an error that should unwind past the replica — the replica is
/// the whole reason a dropped link can be a non-event.
pub const Step = enum { ok, lost };

/// Whether `frame`'s per-type handler changed anything. Internal to the
/// routing: `frame` is what turns it into a `Routed` the driver reads.
const Pass = enum { carry_on, skip };

/// What a `selection_reply` turned out to be worth — `Core.selectionCopy`'s
/// answer, and the whole of what a driver has to decide about.
pub const Copy = union(enum) {
    /// Nothing to do, and nothing to say: somebody else's reply, one whose
    /// highlight is gone, an empty selection, or a refusal the user cannot act
    /// on. None is a sentence worth putting over their work.
    none,
    /// The selected text, borrowing the frame payload and valid only while
    /// it is.
    text: []const u8,
    /// `.ok`, and past what OSC 52 can carry. Said out loud, because
    /// `appendHostEffect` refuses by writing NOTHING and a silent stop looks
    /// exactly like a copy that worked. Never truncated: the user would find
    /// out when they paste it.
    too_large,
};

/// What one frame turned out to be, once the Core has done its half. The
/// exhaustive switch over `proto.MsgType` lives in `Core.frame` and nowhere
/// else; this is the narrow set a driver still has to act on.
const Routed = enum {
    /// Done here; the driver's pass carries on.
    handled,
    /// Done here, and it changed nothing — the driver's `continue`.
    skip,
    /// The replica took state from it: a snapshot, or a delta it accepted.
    /// Named apart from `handled` because the driver hangs its own
    /// bookkeeping off the first state of an attach — the tile narrates
    /// `[up]` on its label bar.
    state,
    /// The replica took state and then refused what arrived: it can no longer
    /// be trusted. The driver re-attaches quoting no seq and no epoch, since
    /// what it holds is exactly what is untrusted.
    resync,
    /// Not the Core's: what a lifecycle frame or a session list MEANS is
    /// entirely the driver's — an exit ends a client and merely relabels a
    /// tile — so the Core names them and stops. Agent frames join them from
    /// the other end, since the driver holds their local socket.
    ///
    /// `selection_reply` is the one the driver must call BACK about: the
    /// terminal it copies to is the wall's, and the tile that asked is
    /// usually not the focused one.
    not_mine,
};

/// Where a Core's paints are allowed to land. A tile's terminal is shared
/// with N stripes on N threads, so every paint asks first and the answer is
/// held until it finishes: a focus move mid-paint would put one session's
/// rows on another's screen. Side channels gate on `Core.claim` instead.
pub const Sink = struct {
    ctx: ?*anyopaque = null,
    /// True when the Core may paint, with whatever lock makes that true
    /// now held. Null means "always, holding nothing".
    begin: ?*const fn (?*anyopaque) bool = null,
    /// Releases what `begin` took. Called only after a `begin` said true.
    end: ?*const fn (?*anyopaque) void = null,
};

/// Whether a Core holds a claim on its terminal, and so whether there is a
/// teardown to write. A Core never owns a SCREEN — the driver lends one to a
/// session at a time — so a claim is only what a SESSION brings.
///
/// It ARMS the undo, which is the load-bearing part: nothing may write a mode
/// to a terminal this is `.none` on, because `.none` is the state in which
/// nothing is arranged to unset it. A session that arms bracketed paste and
/// then dies never sends the frame that would clear it, so this teardown is
/// the only `?2004l` mux is certain to write.
pub const Claim = enum { none, session };

/// One session's interaction state and everything done to a terminal on its
/// behalf: the replica frames are replayed into, the prediction overlay over
/// it, the chord and mouse filters, and the terminal it happens on.
///
/// The driver owns the transport and the loop; the Core owns each event:
///
///   * `initSized` / `deinit` — the Core owns its Grid, its overlay and
///     whatever terminal claim it still holds, and puts all three back.
///   * `claimTerminal` / `releaseTerminal` — the terminal a tile BORROWS
///     while focused. Raw mode and SIGWINCH belong to the driver.
///   * per pass `idle`; per frame `frame(type, payload)`, which routes and
///     returns only what is LEFT, the replica's apply included.
///   * per read of stdin: the DRIVER's. It feeds its own `PrefixFilter` and
///     calls `forward` with the bytes that were not a chord.
///   * around a reconnect: `dropScrollView` before, `reattached` after.
///
/// It depends on a Grid, a Replica, the overlay, the painter and the
/// shared decoder — never a transport type. Nothing here is a singleton: a
/// tile brings its own Core, so there is never a second applier for one tile.
pub const Core = struct {
    /// The tile index a `Core` selects under: it is the only session on its
    /// rect, so there is exactly one. Named because three sites must agree —
    /// `hitTest` stamps it, `highlight` passes it, `paintDragChange` reads it.
    const focus_tile: usize = 0;

    alloc: std.mem.Allocator,
    /// The user's terminal. `in_fd` is the tty this session is typed at,
    /// and the Core asks it exactly one question — whether it IS one —
    /// because the keyboard belongs to the driver. `out_fd` is everything
    /// this paints on.
    in_fd: std.posix.fd_t,
    out_fd: std.posix.fd_t,
    /// Whether there is a terminal to claim at all. A session whose stdin
    /// is a pipe asks nobody for mouse reports and claims nothing — see
    /// `writeSideChannel` for what that costs.
    is_tty: bool,
    /// The local clip size. The replica follows the daemon's authoritative
    /// grid, which under latest-wins may differ from this; paints are
    /// clipped to this.
    size: proto.Size,
    /// The terminal row this tile's rect starts at. A wall tile paints
    /// inside its own rect, not the screen's origin; the plain client and
    /// `mux a` leave it 0 and paint from row 1 as before.
    row_off: u16 = 0,
    /// The terminal column this tile's rect starts at. A beside-neighbour
    /// would be blanked by a whole-line clear, so every painter addresses
    /// its own column origin. The plain client and `mux a` leave it 0.
    col_off: u16 = 0,
    /// Whether this Core may clear the whole screen. A piped wall owns
    /// every row; a tile on a tty sits under its label bar and does not.
    /// Set by the driver that knows the layout, not inferred from `row_off`.
    owns_screen: bool = true,
    /// The replay core (replica.zig). Public because the driver reads it: a
    /// reconnect quotes `last_seq`/`session_epoch`, and `state_since_attach`
    /// tells a refusal from a shell exiting. The Core owns the Grid.
    rep: Replica,
    /// Speculative echo. Born `.never` and stays there until a daemon tells
    /// it otherwise, so an old daemon that has never heard of pty_mode gets
    /// a client that predicts nothing at all.
    overlay: predict.Overlay,
    /// Semantic terminal state and host effects are decoded once here, then
    /// handed to the native adapters above. The web client owns another
    /// instance of this same platform-neutral state machine.
    semantic: client_core.ClientCore = .{},
    /// Mouse reports arrive in the same reads as keystrokes; this splits
    /// them back out, across read boundaries.
    mouse: MouseFilter = .{},
    /// The mux selection over THIS session, while this Core holds the
    /// terminal. A wall tile that is not focused has `claim == .none` and so never
    /// sees a report at all: the drag belongs to the focused tile, on its
    /// own hit-test.
    drag: select.Drag = .{},
    /// The last id this Core put on the wire. Per-Core and not global: one
    /// link carries one conversation, and `client_core` correlates against
    /// the pending one it was handed.
    sel_id: u32 = 0,
    /// `history_rows` as of the request in flight, and the whole staleness
    /// test: an eviction between ask and answer renames the coordinate space
    /// and yields text that is `.ok` and not what was highlighted. Only a
    /// LOWER count is evidence — output raises it without moving row zero.
    sel_watermark: u32 = 0,
    /// The selection the request was taken from, compared against what is
    /// still held when the answer comes back. A reply that outlived its own
    /// highlight — a relayout, a forget, a focus move, a resync — is text for
    /// rows nobody is looking at any more.
    sel_range: ?select.Range = null,
    /// Scroll mode: 0 = live; N = the screenful whose bottom sits N rows above
    /// live. Rows, not pages: the wheel moves a few lines and the keys move a
    /// screen. View state, not replay state, so it stays out of the Replica.
    scroll_rows: u32 = 0,
    /// What this Core holds on the terminal, and therefore what it may
    /// write there and what it must undo. See `Claim`.
    claim: Claim = .none,
    /// Whether this Core may paint right now, and what to hold while it
    /// does. Set by a driver that shares its terminal; see `Sink`.
    sink: Sink = .{},
    /// Whether `deinit` is the right place to print the prediction stats.
    /// False for a tile's Core, which lives on a detached pump: `deinit` is
    /// not reliably reached, and when it is it prints onto a screen about to
    /// be discarded and then a second time from the driver that owns the exit.
    owns_stats: bool = true,
    /// The first paint after a reconnect must be a full one, so the
    /// [reconnecting] banner goes away with everything else now stale.
    repaint_after_resync: bool = false,
    /// The mouse filter's scratch — sized to hold one read of stdin plus
    /// whatever a previous read left mid-report (see MouseFilter.feed).
    mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined,
    /// The read rebuilt with its reports in the pane's coordinates, for
    /// the application that owns the mouse. Sized like `mouse_buf`: a
    /// relocated report is never longer than the one it replaces, since
    /// an origin only comes off a coordinate and a clamp only lowers it.
    mouse_out: [stdin_chunk + MouseFilter.max_held]u8 = undefined,

    /// Born at a size the driver measured: the grid has to be born at
    /// the size the first paint clips to.
    pub fn initSized(
        alloc: std.mem.Allocator,
        in_fd: std.posix.fd_t,
        out_fd: std.posix.fd_t,
        size: proto.Size,
    ) !Core {
        // The driver's layout was cut from ONE reading of the terminal, so
        // a second ioctl here would clip a tile to rows the wall
        // does not believe in.
        const g = try Grid.init(alloc, size.cols, size.rows);
        return .{
            .alloc = alloc,
            .in_fd = in_fd,
            .out_fd = out_fd,
            .is_tty = std.posix.isatty(in_fd),
            .size = size,
            .rep = Replica.init(alloc, g),
            .overlay = predict.Overlay.init(alloc, size.cols, size.rows),
        };
    }

    /// The terminal claim goes back FIRST, so the stats line and whatever
    /// the driver prints on its way out land on a terminal already out of
    /// this session's modes.
    pub fn deinit(self: *Core) void {
        // Only undo what was claimed: the screen belongs to the driver, which
        // is still using it. `.write` always — this is the path a pump takes
        // when its SESSION ends while holding the terminal, so nobody else
        // wrote the release.
        self.releaseTerminal(.write);
        if (self.owns_stats) dumpPredictStats(self.overlay.counters);
        self.overlay.deinit();
        self.rep.grid.deinit();
    }

    /// A pump calls this before claiming, to follow a resize.
    pub fn adoptSize(self: *Core, size: proto.Size) void {
        self.size = size;
    }

    /// Take the terminal for this session ALONE. Under the SINK for ORDER,
    /// not painting: the wall writes the previous holder's release under the
    /// same lock, and a claim outside it can land AFTER that release, leaving
    /// modes nothing undoes. The session's own modes go on top, because a
    /// claim's resize is answered by `resyncSnapshot`, which carries none.
    pub fn claimTerminal(self: *Core) bool {
        if (!self.is_tty or self.claim != .none) return false;
        if (!self.beginPaint()) return false;
        defer self.endPaint();
        // Set BEFORE the writes: it is what admits them (`writeSideChannel`
        // refuses everything under `.none`), and what arms taking them off.
        self.claim = .session;
        proto.writeAllFd(self.out_fd, session_claim) catch {};
        // Written raw rather than through the wrapper `semanticFrame` uses:
        // the sink is not reentrant and this call already holds it.
        writeSideChannel(
            self.alloc,
            self.out_fd,
            true,
            client_core.State,
            .{ .terminal_modes = self.semantic.terminal_modes },
            appendTermState,
        ) catch {};
        return true;
    }

    /// Who writes the undo when a claim is given up. `.already_written`
    /// exists because a shared terminal's handover must be ORDERED: the wall
    /// writes the release on the thread that moves the focus. An argument
    /// rather than a second method, so every call site has to say which —
    /// the wrong one is a terminal left reporting clicks.
    const Undo = enum { write, already_written };

    /// Give the terminal back: the release, and every other way out. Nothing
    /// goes on the WIRE, but plenty comes off the terminal — a wall left
    /// reporting clicks into the user's shell is what this pairs against.
    /// The scroll view goes with it either way, or the resuming stripe comes
    /// back showing nothing. Idempotent, and the teardown follows the claim.
    pub fn releaseTerminal(self: *Core, undo: Undo) void {
        const held = self.claim;
        self.claim = .none;
        self.dropScrollView();
        // The highlight goes with the screen it was drawn on: kept, it would
        // reappear over rows chosen in another session's lifetime, and a reply
        // still in flight would copy that text.
        self.drag.clear();
        if (undo == .already_written) return;
        switch (held) {
            .none => {},
            .session => proto.writeAllFd(self.out_fd, session_release) catch {},
        }
    }

    /// The grid this Core's replica paints from.
    pub fn grid(self: *Core) *Grid {
        return self.rep.grid;
    }

    /// Returned BY VALUE and kept on the caller's stack for the length of
    /// the paint: `sink()` hands the painter a pointer to it.
    fn highlight(self: *Core) Highlight {
        return .{ .drag = &self.drag, .tile = focus_tile, .history_rows = self.rep.history_rows };
    }

    /// May this Core paint now, and hold that until `endPaint`? Every
    /// GRID write goes through the pair.
    fn beginPaint(self: *Core) bool {
        const b = self.sink.begin orelse return true;
        return b(self.sink.ctx);
    }

    fn endPaint(self: *Core) void {
        if (self.sink.end) |e| e(self.sink.ctx);
    }

    /// The rect this Core owns, in the shape every painter takes.
    pub fn viewport(self: *const Core) paint_mod.Viewport {
        return .{
            .top = self.row_off,
            .left = self.col_off,
            .rows = self.size.rows,
            .cols = self.size.cols,
        };
    }

    /// The whole screen from the replica — a local repaint at zero round
    /// trips. The overlay goes back on top, because the rows just drawn have
    /// overwritten predictions still outstanding.
    pub fn repaint(self: *Core) !void {
        if (!self.beginPaint()) return;
        defer self.endPaint();
        const hl = self.highlight();
        try paint_mod.renderClipped(self.alloc, self.rep.grid, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd);
        paintOverlay(self.alloc, &self.overlay, self.rep.grid.cursor, self.viewport(), self.out_fd);
    }

    /// The terminal cursor the painters end on — the same clamp-then-offset
    /// math. An overlay may park ahead; the replica cursor is the
    /// approximation.
    pub fn screenCursor(self: *Core) grid_mod.CursorPos {
        const vp = self.viewport();
        const c = paint_mod.clampCursor(self.rep.grid.cursor, vp);
        return .{ .x = c.x + vp.left, .y = c.y + vp.top };
    }

    /// Repaint the rows a drag report changed, and only those: the anchor
    /// does not move, so only the rows between the two ends can change. Every
    /// row is still ASKED rather than reasoned about — a wrong answer is a
    /// highlight with a hole in it. Painting the whole screen instead cost
    /// 4.8 KB a cell at 120x40. The overlay goes back on top, as in `repaint`.
    fn paintDragChange(self: *Core, was: ?select.Range) !void {
        // Scroll mode owns the screen: `renderScrollback` paints rows this
        // Core does not hold, so a row from the live replica would land on a
        // page it cannot address. Leaving scroll mode repaints in full.
        if (self.scroll_rows > 0) return;
        const now = self.drag.range();
        if (std.meta.eql(was, now)) return;

        const cols: u16 = @intCast(self.rep.grid.cols);
        const grid_rows: u16 = @intCast(self.rep.grid.rows);
        const limit = @min(grid_rows, self.size.rows);
        var rows: std.ArrayList(u16) = .empty;
        defer rows.deinit(self.alloc);
        var y: u16 = 0;
        while (y < limit) : (y += 1) {
            const abs = self.rep.history_rows + y;
            const before = if (was) |r| r.span(focus_tile, abs, cols) else null;
            const after = if (now) |r| r.span(focus_tile, abs, cols) else null;
            if (!std.meta.eql(before, after)) try rows.append(self.alloc, y);
        }
        if (rows.items.len == 0) return;

        if (!self.beginPaint()) return;
        defer self.endPaint();
        const hl = self.highlight();
        try paint_mod.renderRowsClipped(
            self.alloc,
            self.rep.grid,
            self.viewport(),
            hl.sink(),
            rows.items,
            self.out_fd,
        );
        paintOverlay(self.alloc, &self.overlay, self.rep.grid.cursor, self.viewport(), self.out_fd);
    }

    /// The whole screen from the replica, with nothing put back on top —
    /// every rollback path, where the overlay has just been abandoned.
    fn paintFull(self: *Core) !void {
        if (!self.beginPaint()) return;
        defer self.endPaint();
        const hl = self.highlight();
        try paint_mod.renderClipped(self.alloc, self.rep.grid, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd);
    }

    /// A one-line marker in the corner, painted over by the next full
    /// repaint. ASCII only: `bannerText` places the label by byte length.
    pub fn banner(self: *Core, text: []const u8) void {
        if (!self.is_tty or !self.beginPaint()) return;
        defer self.endPaint();
        paint_mod.paintBanner(self.out_fd, self.size.cols, text, self.row_off, self.col_off);
    }

    /// The only thing that can retire a prediction the application
    /// answered by going quiet: no frame is coming, so reconcile never
    /// runs again.
    pub fn idle(self: *Core) !void {
        if (self.overlay.expire(std.time.milliTimestamp()) == .contradicted and self.scroll_rows == 0) {
            try self.paintFull();
        }
    }

    /// The replica has taken a snapshot: tell the overlay, rebuild under it.
    /// A snapshot answers a resize and ends a reconnect; neither says a
    /// prediction was WRONG, only that we can no longer find out — so the
    /// queue goes and the counters do not move.
    fn snapshotTaken(self: *Core) !void {
        self.overlay.setGrid(self.rep.grid.cols, self.rep.grid.rows);
        self.overlay.setResizePending(false);
        self.overlay.flush();
        self.overlay.noteSeq(self.rep.last_seq);
        if (self.scroll_rows == 0) {
            try self.paintFull();
            self.repaint_after_resync = false; // banner painted over
        }
    }

    /// The replica has taken a delta: judge the overlay against it and
    /// paint. `payload` is the same frame the replica was fed — the row
    /// deltas are what `paintDeltaClipped` draws.
    fn deltaTaken(self: *Core, payload: []const u8) !void {
        // Judged against the replica the frame has just been fed into,
        // which is the only authority there is.
        const verdict = reconcileOverlay(
            self.alloc,
            &self.overlay,
            self.rep.grid,
            self.rep.last_seq,
            std.time.milliTimestamp(),
        );
        // While scrolled the replica still tracks live output; the repaint
        // on scroll exit comes from it.
        if (self.scroll_rows == 0 and self.beginPaint()) {
            defer self.endPaint();
            if (self.repaint_after_resync or verdict == .contradicted) {
                // First frame back after a reconnect: the daemon sent only
                // what changed, but the screen still carries the banner. A
                // contradiction takes the same route — the queue has just been
                // abandoned, and a full repaint is the rollback that is
                // certainly right. Painted raw, not through `paintFull`: this
                // and the overlay below are ONE hold of a non-reentrant sink.
                const hl = self.highlight();
                try paint_mod.renderClipped(self.alloc, self.rep.grid, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd);
                self.repaint_after_resync = false;
            } else {
                const hl = self.highlight();
                try paint_mod.paintDeltaClipped(self.alloc, payload, self.rep.grid, self.viewport(), hl.sink(), self.out_fd);
            }
            // Last, and after either paint: the rows the daemon just sent
            // have overwritten anything drawn on them, including predictions
            // that are still outstanding.
            paintOverlay(self.alloc, &self.overlay, self.rep.grid.cursor, self.viewport(), self.out_fd);
        }
    }

    /// The session handed the terminal back and forth, or took it over. Mode
    /// churn is ordinary — readline does it around every command — so the
    /// repaint is spent only when the flush took something off the screen.
    fn ptyModeChanged(self: *Core, payload: []const u8) !Pass {
        const flags = proto.decodePtyMode(payload) catch return .skip;
        const had_pending = self.overlay.pendingCount() > 0;
        self.overlay.setMode(flags);
        if (had_pending and self.overlay.pendingCount() == 0 and self.scroll_rows == 0) {
            try self.paintFull();
        }
        return .carry_on;
    }

    /// A page of history, answering the request `forward` sent when the
    /// view moved. Ignored once the view is live again: the page would be
    /// painted over a screen it no longer describes.
    fn scrollbackPage(self: *Core, payload: []const u8) !Pass {
        // 6 = the `scrollback_chunk` header (u32 start, u16 count); the
        // CellRows follow it. Short of that there is nothing to render.
        if (self.scroll_rows == 0 or payload.len < 6) return .skip;
        const count = std.mem.readInt(u16, payload[4..6], .little);
        // The daemon encoded these at ITS grid width, which is the width the
        // live grid holds too: a client never asks for history at a size it
        // did not attach at.
        const rows = grid_mod.decodeRows(self.alloc, payload[6..], count, self.rep.grid.cols) catch return .skip;
        defer grid_mod.freeRows(self.alloc, rows);
        if (!self.beginPaint()) return .carry_on;
        defer self.endPaint();
        try paint_mod.renderScrollback(self.alloc, rows, self.rep.grid.cols, self.viewport(), self.owns_screen, self.out_fd);
        return .carry_on;
    }

    /// A `term_event` or `term_modes` frame: decoded by the shared core, then
    /// rendered by this platform's adapters. The DECODE happens whatever the
    /// sink says and the WRITE does not — an unfocused tile tracks its
    /// session's modes for the claim that will need them.
    ///
    /// The claim answers ORDER, the sink answers ATOMICITY: a 64 KiB OSC 52
    /// payload leaves here as a write loop, and another thread's screen clear
    /// spliced into it leaves the terminal hunting for a string terminator.
    fn semanticFrame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !void {
        const decoded = self.semantic.receive(frame_type, payload);
        if (!self.beginPaint()) return;
        defer self.endPaint();
        switch (decoded) {
            .ignored => {},
            .state => |state| {
                // Mode samples are deliberately not deduplicated: reasserting
                // DECSET/DECRST 2004 is a harmless level-set that restores the
                // host after a new connection. Occurrences take the arm below.
                try writeSideChannel(
                    self.alloc,
                    self.out_fd,
                    self.claim != .none,
                    client_core.State,
                    state,
                    appendTermState,
                );
            },
            .effect => |effect| try writeSideChannel(
                self.alloc,
                self.out_fd,
                self.claim != .none,
                client_core.Effect,
                effect,
                appendHostEffect,
            ),
            // Nothing to write: a `.reply` answers something this client
            // ASKED for, and the one there is goes out through
            // `selectionCopy`. Named, so a second kind of reply is an edit here.
            .reply => {},
        }
    }

    /// The session's window title. Repeats are expected — every attach
    /// resends it — and harmless: setting a title to the value it already
    /// holds has no counter or stack behind it, and the daemon never sends
    /// an empty one, so a repeat cannot clear what the user is looking at.
    fn titleFrame(self: *Core, payload: []const u8) !void {
        // Under the sink, `semanticFrame`'s atomicity reason: a title is
        // bounded at `term_title_max` but it is still an OSC with a
        // terminator, and a clear spliced into one is the same lost screen.
        if (!self.beginPaint()) return;
        defer self.endPaint();
        try writeSideChannel(
            self.alloc,
            self.out_fd,
            self.claim != .none,
            []const u8,
            payload,
            appendTermTitle,
        );
    }

    /// One frame from the daemon, routed. The exhaustive switch over the wire
    /// lives HERE and nowhere else — including the replica's apply, because a
    /// driver applying on its own is a second switch over the same enum.
    ///
    /// What comes back is only what is LEFT (see `Routed`). Errors are the
    /// replica's, unchanged, and the two snapshot failures are told apart the
    /// way `Replica.apply` documents them: `error.BadPayload` left the grid
    /// untouched, so it is `.skip`; `error.SnapshotAborted` did not — the grid
    /// is blank and `last_seq` claims to be current — so it goes out to the
    /// driver, which ends the tile rather than paint from a replica that
    /// holds nothing.
    pub fn frame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !Routed {
        switch (frame_type) {
            .snapshot => {
                _ = self.rep.apply(.snapshot, payload) catch |err| switch (err) {
                    error.BadPayload => return .skip,
                    error.SnapshotAborted => return err,
                    else => |e| return e,
                };
                try self.snapshotTaken();
                return .state;
            },
            .delta => {
                // `apply` marks state BEFORE it can refuse, so a delta the
                // replica could not compose still proves the attach landed
                // — which is why `.resync` counts as state to its driver.
                const applied = try self.rep.apply(.delta, payload);
                if (applied == .resync) return .resync;
                try self.deltaTaken(payload);
                return .state;
            },
            .pty_mode => return switch (try self.ptyModeChanged(payload)) {
                .skip => .skip,
                .carry_on => .handled,
            },
            .scrollback_chunk => return switch (try self.scrollbackPage(payload)) {
                .skip => .skip,
                .carry_on => .handled,
            },
            .term_event, .term_modes => {
                try self.semanticFrame(frame_type, payload);
                return .handled;
            },
            .term_title => {
                try self.titleFrame(payload);
                return .handled;
            },
            .exit_status, .taken_over, .sessions_reply, .end_reply => return .not_mine,
            // The correlation, the watermark and the size refusal are the
            // Core's (`selectionCopy`); WHERE the text goes is not. At the
            // wall the tile that asked is not the focused one every time, and
            // the clipboard it copies to belongs to the wall's screen.
            .selection_reply => return .not_mine,
            // The driver owns local fds and the Core owns the replica, so
            // an agent channel is never the Core's: these bytes go to a
            // socket, not the grid.
            .agent_open, .agent_data, .agent_close => return .not_mine,
            // Named rather than swept into the `else`, so giving one a meaning
            // is an edit here and not a new switch elsewhere. Each belongs to
            // another client conversation or travels the other way.
            .stats_reply,
            .endpoint_reply,
            .cmd_state,
            .await_reply,
            .status_reply,
            .dump_reply,
            .attach,
            .input,
            .resize,
            .detach,
            .fetch_scrollback,
            .stats_req,
            .stop_req,
            .endpoint_req,
            .await_req,
            .status_req,
            .selection_req,
            .sessions_req,
            .agent_offer,
            .forward_hello,
            .forward_open,
            .forward_data,
            .forward_credit,
            .forward_half_close,
            .forward_reset,
            .forward_ready,
            .forward_open_result,
            .debug_dump,
            .upgrade_req,
            .upgrade_reply,
            .end_req,
            .create_req,
            .create_reply,
            => return .skip,
            // MsgType is open (`_`): a daemon newer than this client can
            // send a type this build has never heard of. Ignoring it is the
            // forward-compatible answer and always has been.
            _ => return .skip,
        }
    }

    /// Everything that happens to typed bytes on their way to the session:
    /// the mouse split, alternate scroll, the scrollback view, and finally
    /// the prediction and the input frame.
    pub fn forward(self: *Core, transport: anytype, typed: []const u8) !Step {
        // Who the wheel belongs to is the SESSION's to say: an application
        // that asked for mouse reporting gets every mouse byte verbatim, and
        // the filter resets so a report straddling the handover is not
        // half-eaten. The claim gates it for a second reason — a client whose
        // stdin is a PIPE never asked for mouse reports, so nothing it reads
        // can be one, and `\x1b[<64;10;5M` in a heredoc is text to send.
        var keys = typed;
        var wheel: i32 = 0;
        // A selection this read finished, asked for below rather than here
        // so the ask happens once for the read and outside the branch that
        // decides who the mouse belongs to.
        var copy: ?select.Range = null;
        // What is on screen before this read's reports are read, so the
        // repaint below is spent only on a highlight that actually moved.
        // A drag reports on every cell the pointer crosses.
        const was = self.drag.range();
        if (self.claim == .none) {
            self.mouse.reset();
            self.drag.clear();
        } else if (self.semantic.terminal_modes.appMouse()) {
            // The application asked for the mouse, so it gets the drag and
            // there is no mux selection; Shift+drag stays the terminal's own
            // escape hatch. CLEARED, not ignored: a selection made before the
            // ask would sit inverted on a screen that is no longer its own.
            self.drag.clear();
            // What it gets is the report in ITS coordinates. The terminal
            // numbers the whole screen, and a tile that does not start at
            // the screen's origin is a pane whose application has no row 60
            // in its 37: passed through as read, every press in such a tile
            // landed outside the application's grid and did nothing (found
            // on a live wall, 2026-09-02). Pixel reports are left alone —
            // a cell origin cannot come off a pixel — and so are the X10,
            // UTF-8 and urxvt spellings, which the filter does not read; an
            // application that asks for the mouse without 1006 still gets
            // the screen's numbering. The filter's hold stays armed across
            // the handover from selection, so a report split across two
            // reads is whole for whichever side reads it.
            if (!self.semantic.terminal_modes.mouse_sgr_pixels) {
                const m = self.mouse.feed(typed, &self.mouse_buf);
                keys = self.relocateReports(m, &self.mouse_out);
            }
        } else {
            const m = self.mouse.feed(typed, &self.mouse_buf);
            keys = m.forward;
            wheel = m.wheel;
            copy = self.dragReports(m.events);
        }
        // This Core owns its own screen and its own thread — a tile's pump
        // is what runs `forward` — so the highlight is painted here rather
        // than left in shared state for somebody else to draw. The wall's
        // keyboard cannot do that and does not (the Core sink does).
        try self.paintDragChange(was);
        // Drag copies on release, tmux's `copy-pipe-and-cancel` rule and
        // the reason there is no copy chord: `Ctrl+Shift+C` cannot reach a
        // tty application at all. This thread owns the transport, so the
        // ask goes out from where it stands — no relay, unlike the wall's.
        if (copy) |r| self.requestSelection(transport, r) catch return .lost;

        // Alternate scroll. The alt screen has no scrollback, so the rows this
        // notch would move do not exist — and a pager that did not ask for the
        // mouse is still what the wheel is pointed at, so the notch becomes
        // arrow keys it understands. Without this the notch is consumed and
        // dropped. Only at the live view: a client already scrolled into
        // history owns its wheel.
        // Both modes come off the daemon's sample rather than off this
        // client's replica engine, so the rule holds for a client that
        // parses no VT of its own.
        if (wheel != 0 and self.scroll_rows == 0 and self.semantic.terminal_modes.alt_screen) {
            sendAltScroll(transport, wheel, self.semantic.terminal_modes.cursor_keys) catch return .lost;
            wheel = 0; // spent on the session, not on our view
        }

        const scroll_up = "\x1b[5;2~"; // Shift+PageUp
        const scroll_dn = "\x1b[6;2~"; // Shift+PageDown
        const key_up = std.mem.eql(u8, keys, scroll_up);
        const key_dn = std.mem.eql(u8, keys, scroll_dn);
        // Rows to move back into history, from both devices at once: a
        // chunk can hold a notch and a keystroke, and dropping either would
        // be a scroll the user made and did not get. The keys move a
        // screenful, the wheel a few rows.
        var by: i64 = @as(i64, wheel) * wheel_rows;
        if (key_up) by += self.size.rows;
        if (key_dn) by -= self.size.rows;
        // Whether this read began at the live view, remembered before the
        // scroll below can make it false. It decides what a keystroke in
        // the same read MEANS: see the exit rule at the bottom.
        const was_live = self.scroll_rows == 0;
        if (by != 0 or key_dn) {
            const next: u32 = if (by > 0)
                @min(self.scroll_rows +| @as(u32, @intCast(by)), self.rep.history_rows)
            else
                self.scroll_rows -| @as(u32, @intCast(-by));
            // Shift+PageDown returns to live even when already there: it is
            // the only key that clears an overlay left in scroll mode by a
            // reconnect. The wheel has no such arm — a notch at the live view
            // would repaint the whole screen for nothing.
            if (next == 0 and (self.scroll_rows > 0 or key_dn)) {
                self.scroll_rows = 0;
                self.overlay.setScrollMode(false);
                try self.paintFull();
            } else if (next != self.scroll_rows) {
                self.scroll_rows = next;
                // The cursor is no longer where the user is looking, so a
                // prediction painted at it would land in the middle of
                // history.
                self.overlay.setScrollMode(true);
                requestScrollPage(transport, &self.rep, self.scroll_rows, self.size) catch return .lost;
            }
        }
        // A chunk that was nothing but a chord, a scroll key or a wheel
        // notch owes the pty nothing.
        if (keys.len == 0 or key_up or key_dn) return .ok;
        if (self.scroll_rows > 0 and !was_live) {
            // Any other key exits scroll mode, swallowed rather than
            // forwarded. `was_live` keeps that honest when a notch and a
            // keystroke share one read: a key typed before the wheel moved the
            // view was typed at the SHELL, and swallowing it loses input.
            self.scroll_rows = 0;
            self.overlay.setScrollMode(false);
            try self.paintFull();
        } else {
            // Speculate before sending, so the glyph is on screen while the
            // keystroke is in flight. The bytes go out whatever the sink says
            // — a tile that lost focus still owes its session what was typed
            // at it — but an overlay glyph would be graffiti on the new holder.
            if (self.beginPaint()) {
                defer self.endPaint();
                offerKeystroke(self.alloc, &self.overlay, self.rep.grid, keys, self.viewport(), self.out_fd);
            }
            transport.writeFrame(.input, keys) catch return .lost;
            // These keystrokes are lost with the transport, by the same
            // policy that drops what is typed while disconnected.
        }
        return .ok;
    }

    /// Left button only: middle is the terminal's own paste and right its
    /// menu. A plain click is a defined no-op — a tile is the only session on
    /// its rect. What comes back is the selection a release FINISHED, and one
    /// read can hold several: the last is the answer, as latest-wins says.
    fn dragReports(self: *Core, events: []const MouseFilter.Event) ?select.Range {
        var done: ?select.Range = null;
        for (events) |ev| {
            if (ev.button & 0b11 != 0) continue;
            const cell: select.Cell = .{ .row = ev.row, .col = ev.col };
            switch (ev.kind) {
                .press => self.drag.press(cell, self.hitTest(ev)),
                .motion => self.drag.motion(cell, self.hitTest(ev)),
                .release => switch (self.drag.release()) {
                    .selection => |r| done = r,
                    .nothing, .click => {},
                },
                .wheel => {},
            }
        }
        return done;
    }

    /// Ask the daemon for the text under a finished selection. Called from
    /// `forward`, on the focused tile's pump — the thread that owns this
    /// transport. The wall's KEYBOARD may not: it posts the range instead.
    pub fn requestSelection(self: *Core, transport: anytype, r: select.Range) !void {
        self.sel_id +%= 1;
        // Sampled HERE, from the replica the coordinates were resolved
        // against, and not from the reply: see `sel_watermark`.
        self.sel_watermark = self.rep.history_rows;
        self.sel_range = r;
        const req = self.semantic.beginSelection(.{
            .id = self.sel_id,
            .anchor = .{ .row = r.from.row, .col = r.from.col },
            .active = .{ .row = r.to.row, .col = r.to.col },
        });
        try transport.writeFrame(.selection_req, &req);
    }

    /// What a `selection_reply` is worth, given the selection still held. The
    /// caller supplies the range because it reads `Core.drag` under its lock.
    /// Correlation is `client_core`'s: a reply answering no pending request is
    /// `.ignored` there and never reaches the tests below.
    pub fn selectionCopy(self: *Core, payload: []const u8, held: ?select.Range) Copy {
        const reply = switch (self.semantic.receive(.selection_reply, payload)) {
            .reply => |r| r.selection,
            else => return .none,
        };
        // The eviction test. STRICTLY lower, because ordinary output raises
        // the count without moving row zero — an equality test would refuse
        // every copy taken from a session that is still writing.
        if (reply.history_rows < self.sel_watermark) return .none;
        // The highlight the ask was taken from is gone or has moved on, so
        // whatever came back describes rows nobody is looking at.
        if (!std.meta.eql(held, self.sel_range)) return .none;
        switch (reply.status) {
            .ok => {},
            .too_large => return .too_large,
            .invalid, .unavailable => return .none,
        }
        if (reply.text.len == 0) return .none;
        // The daemon sends up to `selection_text_max` (1 MiB) and OSC 52 stops
        // at `clipboard_base64_max` (64 KiB) of base64: everything between the
        // two round-trips `.ok` and copies nothing at all.
        if (std.base64.standard.Encoder.calcSize(reply.text.len) > proto.clipboard_base64_max)
            return .too_large;
        return .{ .text = reply.text };
    }

    /// The read rebuilt for an application that owns the mouse: the keys as
    /// they came, and each report re-spelled with the pane's origin taken
    /// off its coordinates. A coordinate outside the pane CLAMPS to its
    /// edge rather than dropping the report, as a terminal reports a drag
    /// that left its window — dropping would leave the application holding
    /// a button the hand released over a neighbour. The edge is what the
    /// pane SHOWS, the smaller of the grid and the clip, like `hitTest`.
    fn relocateReports(self: *Core, m: MouseFilter.Out, out: []u8) []const u8 {
        const rows = @min(@as(u16, @intCast(self.rep.grid.rows)), self.size.rows);
        const cols = @min(@as(u16, @intCast(self.rep.grid.cols)), self.size.cols);
        var len: usize = 0;
        var from: usize = 0;
        for (m.events) |ev| {
            @memcpy(out[len..][0 .. ev.at - from], m.forward[from..ev.at]);
            len += ev.at - from;
            from = ev.at;
            const col = @min(ev.col -| self.col_off, cols -| 1);
            const row = @min(ev.row -| self.row_off, rows -| 1);
            const spelt = std.fmt.bufPrint(out[len..], "\x1b[<{d};{d};{d}{c}", .{
                ev.button,
                col + 1,
                row + 1,
                @as(u8, if (ev.kind == .release) 'm' else 'M'),
            }) catch unreachable;
            len += spelt.len;
        }
        @memcpy(out[len..][0 .. m.forward.len - from], m.forward[from..]);
        len += m.forward.len - from;
        return out[0..len];
    }

    /// Which line of this session a report landed on, or null. The mapping is
    /// trivial, which is why selection lives in the Core: a terminal cell is a
    /// grid cell less the tile's origin, plus the history under it.
    fn hitTest(self: *Core, ev: MouseFilter.Event) ?select.Hit {
        // Scrolled back, nothing here is addressable: `renderScrollback`
        // paints decoded history rows this Core does not hold. Refused rather
        // than half-answered, or the coordinates name the live view instead.
        if (self.scroll_rows > 0) return null;
        // A tile paints at `row_off`/`col_off`, so a terminal cell is a grid
        // cell only after the origin comes off — on BOTH axes. Above or left
        // of the tile is a neighbour's cell, not a cell of this grid.
        if (ev.row < self.row_off or ev.col < self.col_off) return null;
        const grow = ev.row - self.row_off;
        const gcol = ev.col - self.col_off;
        const grid_rows: u16 = @intCast(self.rep.grid.rows);
        const grid_cols: u16 = @intCast(self.rep.grid.cols);
        // Rows past the grid (a terminal taller than the daemon's grid)
        // hold no session line: `renderClipped` never painted them.
        if (grow >= @min(grid_rows, self.size.rows)) return null;
        return .{
            .tile = focus_tile,
            .row = self.rep.history_rows + grow,
            // Columns CLAMP rather than refuse: the right edge is where a hand
            // overshoots, and a round trip spent being told `.invalid` is a
            // copy the user does not get. Clamped to what the tile SHOWS.
            .col = @min(gcol, @min(grid_cols, self.size.cols) -| 1),
        };
    }

    /// Go back to the live view without painting it — a resync's own
    /// repaint is what arrives.
    pub fn dropScrollView(self: *Core) void {
        self.scroll_rows = 0;
        // `flush()` leaves the mode bit alone, so nothing else clears it:
        // an overlay left suppressing has no page to suppress for, and
        // only Shift+PageDown would ever turn it back on.
        self.overlay.setScrollMode(false);
    }

    /// A new transport is up and an attach frame has gone out on it. Only the
    /// driver knows, so the Replica's contract makes this clear the caller's.
    /// The overlay's contents were predicted against a connection that no
    /// longer exists — dropping them is no accusation, so counters hold.
    pub fn reattached(self: *Core) void {
        // The absolute row space is counted from the oldest row the daemon
        // retains, and a resync renames it outright — so a highlight kept
        // across one is an inversion over rows nobody selected.
        self.drag.clear();
        self.rep.state_since_attach = false;
        self.repaint_after_resync = true;
        self.overlay.flush();
    }
};

test "interact: a chord in one read detaches and forwards what preceded it" {
    var f: PrefixFilter = .{};
    var chunk = "ab\x1cd".*;
    const out = f.feed(&chunk);
    try std.testing.expectEqual(PrefixFilter.Action.detach, out.action);
    try std.testing.expectEqualStrings("ab", out.forward);
}

test "interact: a doubled prefix detaches" {
    var f: PrefixFilter = .{};
    var chunk = "\x1c\x1c".*;
    const out = f.feed(&chunk);
    try std.testing.expectEqual(PrefixFilter.Action.detach, out.action);
    try std.testing.expectEqualStrings("", out.forward);
}

test "interact: a chord split across two reads is still one chord" {
    var f: PrefixFilter = .{};
    var first = "ab\x1c".*;
    const a = f.feed(&first);
    try std.testing.expectEqual(PrefixFilter.Action.none, a.action);
    try std.testing.expectEqualStrings("ab", a.forward);
    var second = "dz".*;
    const b = f.feed(&second);
    try std.testing.expectEqual(PrefixFilter.Action.detach, b.action);
    try std.testing.expectEqualStrings("", b.forward);
}

test "interact: an unknown command key is swallowed with its prefix" {
    var f: PrefixFilter = .{};
    var chunk = "a\x1czb".*;
    const out = f.feed(&chunk);
    try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
    try std.testing.expectEqualStrings("ab", out.forward);
    // Back to normal: the next `d` is an ordinary keystroke, not a command.
    var after = "d".*;
    const next = f.feed(&after);
    try std.testing.expectEqual(PrefixFilter.Action.none, next.action);
    try std.testing.expectEqualStrings("d", next.forward);
}

test "asking: printable bytes accumulate into the answer" {
    var f: PrefixFilter = .{};
    f.askOpen(.secret);
    var chunk = "hunter2".*;
    const out = f.feed(&chunk);
    try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
    try std.testing.expectEqualStrings("hunter2", f.askLine());
}

test "asking: Enter answers with the line, and backspace unspells it" {
    var f: PrefixFilter = .{};
    f.askOpen(.secret);
    var typed = "hunterX\x7f2\r".*;
    const out = f.feed(&typed);
    switch (out.action) {
        .ask_answer => |a| try std.testing.expectEqualStrings("hunter2", a),
        else => {
            std.debug.print("Enter on a prompt gave .{s}, not the line\n", .{@tagName(out.action)});
            return error.TestUnexpectedResult;
        },
    }
    // Closed on the answer: the next byte is the session's again.
    try std.testing.expect(!f.asking);
}

test "asking: an EMPTY Enter is an answer, not a cancel" {
    // A key with no passphrase asks anyway, and ssh takes "" as the try.
    // Reporting `.none` here would leave ssh blocked on a socket forever
    // with the popup already gone.
    var f: PrefixFilter = .{};
    f.askOpen(.secret);
    var enter = "\r".*;
    const out = f.feed(&enter);
    switch (out.action) {
        .ask_answer => |a| try std.testing.expectEqualStrings("", a),
        else => {
            std.debug.print("an empty Enter gave .{s}, not an empty answer\n", .{@tagName(out.action)});
            return error.TestUnexpectedResult;
        },
    }
}

test "asking: Esc and Ctrl-C both decline, and Esc eats an arrow's tail" {
    var f: PrefixFilter = .{};
    f.askOpen(.confirm);
    var esc = "\x1b[A".*;
    const out = f.feed(&esc);
    try std.testing.expectEqual(PrefixFilter.Action.ask_decline, out.action);
    // The `[A` left with the Esc. Forwarded, it would arrive at whatever
    // session had the focus as a cursor key nobody pressed.
    try std.testing.expectEqualStrings("", out.forward);

    f.askOpen(.confirm);
    var ctrlc = "\x03".*;
    try std.testing.expectEqual(PrefixFilter.Action.ask_decline, f.feed(&ctrlc).action);
    try std.testing.expect(!f.asking);
}

test "asking: NO byte reaches a session while ssh is waiting" {
    // The whole reason the mode eats prose: the bytes being typed at a
    // password box are a password, and one that reached a shell would be
    // in that shell's history in the clear.
    var f: PrefixFilter = .{};
    f.askOpen(.secret);
    var chunk = "abc\x03".*;
    const out = f.feed(&chunk);
    try std.testing.expectEqual(PrefixFilter.Action.ask_decline, out.action);
    try std.testing.expectEqual(@as(usize, 0), out.forward.len);
}

test "asking: a prompt over the picker's editor keeps the half-typed spelling" {
    // ssh does not wait for the picker to close, so the two layers are live
    // at once. Sharing one buffer would answer the password prompt and lose
    // the host the user was halfway through naming.
    var f: PrefixFilter = .{};
    var open_editor = "\x1csa--sock /run/a".*;
    _ = f.feed(&open_editor);
    try std.testing.expectEqualStrings("--sock /run/a", f.promptLine());

    f.askOpen(.secret);
    var pw = "s3cret\r".*;
    const out = f.feed(&pw);
    switch (out.action) {
        .ask_answer => |a| try std.testing.expectEqualStrings("s3cret", a),
        else => return error.TestUnexpectedResult,
    }
    // ...and the editor is still open, still holding what was typed at it.
    try std.testing.expect(f.prompting);
    try std.testing.expectEqualStrings("--sock /run/a", f.promptLine());
    var rest = ".sock\r".*;
    const done = f.feed(&rest);
    switch (done.action) {
        .add_tile => |t| try std.testing.expectEqualStrings("--sock /run/a.sock", t),
        else => return error.TestUnexpectedResult,
    }
}

test "asking: an answer longer than the cap is cut, never overruns" {
    var f: PrefixFilter = .{};
    f.askOpen(.secret);
    var long: [askpass.answer_max + 8]u8 = @splat('x');
    const out = f.feed(&long);
    try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
    try std.testing.expectEqual(askpass.answer_max, f.askLine().len);
}

test "interact: bytes with no prefix pass through untouched" {
    var f: PrefixFilter = .{};
    var chunk = "hello\x1b[A".*;
    const out = f.feed(&chunk);
    try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
    try std.testing.expectEqualStrings("hello\x1b[A", out.forward);
}

test "interact: two chords in one buffer are consumed independently" {
    var f: PrefixFilter = .{};
    var chunk = "\x1czq".*;
    const out = f.feed(&chunk);
    try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
    try std.testing.expectEqualStrings("q", out.forward);
    var chunk2 = "\x1cz\x1cd".*;
    const out2 = f.feed(&chunk2);
    try std.testing.expectEqual(PrefixFilter.Action.detach, out2.action);
    try std.testing.expectEqualStrings("", out2.forward);
}

test "interact: hjkl under the prefix answer directional focus" {
    var f: PrefixFilter = .{};
    var buf = "\x1chZZ".*;
    const out = f.feed(&buf);
    try std.testing.expectEqual(PrefixFilter.Action{ .focus_dir = .left }, out.action);
    try std.testing.expectEqual(@as(usize, 0), out.forward.len);
}

test "interact: the split chords and fullscreen answer, backslash aliases the pipe" {
    inline for (.{
        .{ "\x1c|", PrefixFilter.Action.split_right },
        .{ "\x1c\\", PrefixFilter.Action.split_right },
        .{ "\x1c-", PrefixFilter.Action.split_below },
        .{ "\x1cf", PrefixFilter.Action.fullscreen },
    }) |case| {
        var f: PrefixFilter = .{};
        var buf = case[0].*;
        try std.testing.expectEqual(case[1], f.feed(&buf).action);
    }
}

test "interact: resize mode is sticky across reads and Esc leaves it silently" {
    var f: PrefixFilter = .{};
    var enter = "\x1cr".*;
    try std.testing.expectEqual(PrefixFilter.Action.none, f.feed(&enter).action);
    var grow = "l".*;
    try std.testing.expectEqual(PrefixFilter.Action{ .resize = .right }, f.feed(&grow).action);
    var again = "j".*;
    try std.testing.expectEqual(PrefixFilter.Action{ .resize = .down }, f.feed(&again).action);
    var esc = "\x1b".*;
    const out = f.feed(&esc);
    try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
    try std.testing.expectEqual(@as(usize, 0), out.forward.len);
    var plain = "l".*;
    try std.testing.expectEqual(@as(usize, 1), f.feed(&plain).forward.len);
}

test "interact: prose ends resize mode and goes to the session" {
    var f: PrefixFilter = .{};
    var enter = "\x1cr".*;
    _ = f.feed(&enter);
    var typed = "vim".*;
    const out = f.feed(&typed);
    try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
    try std.testing.expectEqualStrings("vim", out.forward);
}

test "interact: the prompt appends, backspaces, and Enter emits add_tile" {
    var f: PrefixFilter = .{};
    var open = "\x1csa".*;
    try std.testing.expectEqual(PrefixFilter.Action.pick_add_open, f.feed(&open).action);
    try std.testing.expect(f.prompting);
    var typed = "boxX\x7f\r".*;
    const out = f.feed(&typed);
    try std.testing.expectEqualStrings("box", out.action.add_tile);
    try std.testing.expectEqualStrings("", out.forward);
    try std.testing.expect(!f.prompting);
}

test "interact: Esc cancels the prompt and drops the rest of the read" {
    var f: PrefixFilter = .{};
    var open = "\x1csa".*;
    _ = f.feed(&open);
    var line = "ab".*;
    _ = f.feed(&line);
    // An arrow key is Esc [ A in one read: the Esc cancels, the [A must
    // neither reach the session nor survive in the line.
    var esc = "\x1b[A".*;
    const out = f.feed(&esc);
    try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
    try std.testing.expectEqualStrings("", out.forward);
    try std.testing.expect(!f.prompting);
    // Back in the picker, not in the session: the editor is a layer over
    // the popup, so its cancel undoes one layer and not two.
    try std.testing.expect(f.picking);
}

test "interact: Ctrl-C cancels the prompt like Esc" {
    var f: PrefixFilter = .{};
    var open = "\x1csa".*;
    _ = f.feed(&open);
    var line = "ab".*;
    _ = f.feed(&line);
    // The reflex cancel. It ends the read like Esc's does, so one answer
    // covers both — but for its own reason: Esc must swallow an arrow
    // key's tail, and Ctrl-C has no tail to swallow.
    var ctrl_c = "\x03".*;
    const out = f.feed(&ctrl_c);
    try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
    try std.testing.expectEqualStrings("", out.forward);
    try std.testing.expect(!f.prompting);
    // Not typed at the shell either: the byte was aimed at the prompt.
    try std.testing.expect(f.picking);
}

test "interact: a prompt split across reads is still one line" {
    var f: PrefixFilter = .{};
    var a = "\x1c".*;
    _ = f.feed(&a);
    var b = "s".*;
    try std.testing.expectEqual(PrefixFilter.Action.pick_open, f.feed(&b).action);
    var e = "a".*;
    try std.testing.expectEqual(PrefixFilter.Action.pick_add_open, f.feed(&e).action);
    var b2 = "--sock ".*;
    try std.testing.expectEqual(PrefixFilter.Action.none, f.feed(&b2).action);
    var c = "/tmp/x\n".*;
    try std.testing.expectEqualStrings("--sock /tmp/x", f.feed(&c).action.add_tile);
}

test "interact: the prompt line stops at prompt_max" {
    var f: PrefixFilter = .{};
    var open = "\x1csa".*;
    _ = f.feed(&open);
    var many: [PrefixFilter.prompt_max + 1]u8 = undefined;
    @memset(&many, 'a');
    _ = f.feed(&many);
    try std.testing.expectEqual(PrefixFilter.prompt_max, f.promptLine().len);
    var enter = "\r".*;
    try std.testing.expectEqual(PrefixFilter.prompt_max, f.feed(&enter).action.add_tile.len);
}

test "interact: an empty Enter cancels the prompt, it is not an add_tile" {
    var f: PrefixFilter = .{};
    var open = "\x1csa".*;
    _ = f.feed(&open);
    var enter = "\r".*;
    const out = f.feed(&enter);
    try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
    try std.testing.expect(!f.prompting);
}

test "interact: bytes typed behind the prompt's Enter are dropped" {
    var f: PrefixFilter = .{};
    var open = "\x1csa".*;
    _ = f.feed(&open);
    var typed = "hb\rtail".*;
    const out = f.feed(&typed);
    try std.testing.expectEqualStrings("hb", out.action.add_tile);
    try std.testing.expectEqualStrings("", out.forward);
    // The picker took the keyboard back, so `q` is a row key and not a
    // letter for the shell.
    var next = "q".*;
    try std.testing.expectEqualStrings("", f.feed(&next).forward);
}

test "interact: backspace on an empty prompt line stays empty" {
    var f: PrefixFilter = .{};
    var open = "\x1csa".*;
    _ = f.feed(&open);
    // A backspace past the start is a no-op, not an underflow: the length
    // saturates, and the prompt is still open for the spelling.
    var rubout = "\x7f\x7f".*;
    _ = f.feed(&rubout);
    try std.testing.expectEqual(@as(usize, 0), f.promptLine().len);
    try std.testing.expect(f.prompting);
    var typed = "a\r".*;
    try std.testing.expectEqualStrings("a", f.feed(&typed).action.add_tile);
}

test "interact: control bytes are ignored by the prompt" {
    var f: PrefixFilter = .{};
    var open = "\x1csa".*;
    _ = f.feed(&open);
    // A `\x1c` typed INSIDE the prompt is dropped like any other control
    // byte — it does not re-arm the prefix and take the next key as a chord.
    // 0x03 is not among them any more; it cancels, and has its own test.
    var typed = "a\x09\x1cb\r".*;
    try std.testing.expectEqualStrings("ab", f.feed(&typed).action.add_tile);
}

test "interact: prose before the prefix is forwarded, the prompt's Enter still ends the read" {
    var f: PrefixFilter = .{};
    var read = "ab\x1csa".*;
    try std.testing.expectEqualStrings("ab", f.feed(&read).forward);
    var typed = "x\rzz".*;
    const out = f.feed(&typed);
    try std.testing.expectEqualStrings("", out.forward);
    try std.testing.expectEqualStrings("x", out.action.add_tile);
}

fn refuseSink(_: ?*anyopaque) bool {
    return false;
}

test "claimTerminal: a sink that refuses leaves the claim unheld, so the caller must retry" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try Core.initSized(alloc, -1, p[1], .{ .cols = 80, .rows = 24 });
    defer core.deinit();
    core.is_tty = true;
    // The picker refuses every tile paint while its popup owns the screen, and
    // a claim writes the session's modes THROUGH that sink. False and `.none`
    // together tell the pump to re-arm; read as "already held", the tile stays
    // focused owning no terminal until the focus moves away and back.
    core.sink = .{ .begin = refuseSink };
    try std.testing.expect(!core.claimTerminal());
    try std.testing.expectEqual(Claim.none, core.claim);
    var buf: [64]u8 = undefined;
    try std.testing.expectEqual(@as(usize, 0), std.posix.read(p[0], &buf) catch 0);
    // ...and the same call lands once the sink lets it through.
    core.sink = .{};
    try std.testing.expect(core.claimTerminal());
    try std.testing.expectEqual(Claim.session, core.claim);
}

test "interact: Ctrl-\\ s opens the host picker" {
    var f: PrefixFilter = .{};
    var buf = "\x1cs".*;
    try std.testing.expectEqual(PrefixFilter.Action.pick_open, f.feed(&buf).action);
    try std.testing.expect(f.picking);
}

test "interact: the picker's move keys are jk and the arrows" {
    const cases = [_]struct { []const u8, i8 }{
        .{ "j", 1 },
        .{ "k", -1 },
        .{ "\x1b[B", 1 },
        .{ "\x1b[A", -1 },
        // Application cursor mode sends the same two keys as ESC O A/B,
        // and a terminal may be in it when the picker opens.
        .{ "\x1bOB", 1 },
        .{ "\x1bOA", -1 },
    };
    for (cases) |c| {
        var f: PrefixFilter = .{};
        var open = "\x1cs".*;
        _ = f.feed(&open);
        var keys: [8]u8 = undefined;
        @memcpy(keys[0..c[0].len], c[0]);
        const out = f.feed(keys[0..c[0].len]);
        try std.testing.expectEqual(PrefixFilter.Action{ .pick_move = c[1] }, out.action);
        try std.testing.expect(f.picking);
    }
}

test "interact: a digit selects a picker row and leaves the picker open" {
    var f: PrefixFilter = .{};
    var open = "\x1cs".*;
    _ = f.feed(&open);
    var three = "3".*;
    try std.testing.expectEqual(PrefixFilter.Action{ .pick_select = 3 }, f.feed(&three).action);
    try std.testing.expect(f.picking);
}

test "interact: c births on the selected host and closes the picker; Enter no longer does" {
    var f: PrefixFilter = .{};
    var open = "\x1cs".*;
    _ = f.feed(&open);
    var key = "c".*;
    try std.testing.expectEqual(PrefixFilter.Action.pick_birth, f.feed(&key).action);
    try std.testing.expect(!f.picking);
    // Enter is the descend key now: it opens the selected host's sessions
    // and births nothing, so a birth is `c` and only `c`.
    for ([_][]const u8{ "\r", "\n" }) |ent| {
        var g: PrefixFilter = .{};
        _ = g.feed(&open);
        var keys: [2]u8 = undefined;
        @memcpy(keys[0..ent.len], ent);
        try std.testing.expectEqual(PrefixFilter.Action.pick_enter, g.feed(keys[0..ent.len]).action);
        try std.testing.expect(g.picking);
    }
}

test "interact: picker levels: Enter on a host opens its sessions, Esc backs out a level, Enter on a session closes with pick_enter" {
    var f = PrefixFilter{};
    var open = [_]u8{ detach_key, 's' };
    try std.testing.expectEqual(PrefixFilter.Action.pick_open, f.feed(&open).action);
    try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);

    var enter = [_]u8{'\r'};
    try std.testing.expectEqual(PrefixFilter.Action.pick_enter, f.feed(&enter).action);
    try std.testing.expect(f.picking);
    try std.testing.expectEqual(PrefixFilter.PickLevel.sessions, f.pick_level);

    // Esc at the session level backs out one level rather than closing:
    // the list of machines is where the user came from.
    var esc = [_]u8{0x1b};
    try std.testing.expectEqual(PrefixFilter.Action.pick_back, f.feed(&esc).action);
    try std.testing.expect(f.picking);
    try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);

    _ = f.feed(&enter);
    try std.testing.expectEqual(PrefixFilter.Action.pick_enter, f.feed(&enter).action);
    try std.testing.expect(!f.picking);
    // The level goes home with the close, so the next open is a host list.
    try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);

    // ...and a second Esc, back at the host level, closes.
    _ = f.feed(&open);
    try std.testing.expectEqual(PrefixFilter.Action.pick_close, f.feed(&esc).action);
    try std.testing.expect(!f.picking);
}

test "interact: picker levels: x forgets at the host level and ends at the session level; c births at either; every byte stays the popup's" {
    var f = PrefixFilter{};
    var open = [_]u8{ detach_key, 's' };
    _ = f.feed(&open);
    var x = [_]u8{'x'};
    try std.testing.expectEqual(PrefixFilter.Action.pick_forget, f.feed(&x).action);
    try std.testing.expect(!f.picking);

    _ = f.feed(&open);
    var enter = [_]u8{'\r'};
    _ = f.feed(&enter);
    const ended = f.feed(&x);
    try std.testing.expectEqual(PrefixFilter.Action.pick_end, ended.action);
    try std.testing.expectEqual(@as(usize, 0), ended.forward.len);
    try std.testing.expect(f.picking); // the popup stays up to show the count or the end

    var c = [_]u8{'c'};
    try std.testing.expectEqual(PrefixFilter.Action.pick_birth, f.feed(&c).action);
    try std.testing.expect(!f.picking);
    try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
}

test "interact: x forgets the selected host and closes the picker" {
    var f: PrefixFilter = .{};
    var open = "\x1cs".*;
    _ = f.feed(&open);
    var key = "x".*;
    try std.testing.expectEqual(PrefixFilter.Action.pick_forget, f.feed(&key).action);
    // Closed, because a forget re-shapes the wall under the popup: the
    // host's tiles leave, and the user is owed the wall that produced.
    try std.testing.expect(!f.picking);
}

test "interact: a opens the spelling editor inside the picker" {
    var f: PrefixFilter = .{};
    var open = "\x1csa".*;
    try std.testing.expectEqual(PrefixFilter.Action.pick_add_open, f.feed(&open).action);
    try std.testing.expect(f.prompting);
    // Still the picker's: Enter adds the host and hands the keyboard back
    // to the rows, so the editor is a layer over the popup and not a mode
    // beside it.
    try std.testing.expect(f.picking);
    var typed = "box\r".*;
    try std.testing.expectEqualStrings("box", f.feed(&typed).action.add_tile);
    try std.testing.expect(!f.prompting);
    try std.testing.expect(f.picking);
}

test "interact: Esc in the editor returns to the picker, not to the session" {
    var f: PrefixFilter = .{};
    var open = "\x1csazz".*;
    _ = f.feed(&open);
    var esc = "\x1b".*;
    try std.testing.expectEqual(PrefixFilter.Action.none, f.feed(&esc).action);
    try std.testing.expect(!f.prompting);
    try std.testing.expect(f.picking);
}

test "interact: Esc, s and Ctrl-C all close the picker" {
    // Ctrl-C is the reflex cancel and closes the popup like Esc, for the
    // reason it cancels the editor: a user reaching for it wants OUT, and
    // a mode that eats every byte must not eat the way out of itself.
    for ([_][]const u8{ "\x1b", "s", "\x03" }) |key| {
        var f: PrefixFilter = .{};
        var open = "\x1cs".*;
        _ = f.feed(&open);
        var keys: [1]u8 = undefined;
        keys[0] = key[0];
        try std.testing.expectEqual(PrefixFilter.Action.pick_close, f.feed(&keys).action);
        try std.testing.expect(!f.picking);
    }
}

test "interact: a mouse report and an arrow the popup has no key for are not the Esc that closes it" {
    // The prefix filter runs ahead of the mouse filter, and nothing releases
    // the focused session's mouse modes while the box is up, so a wheel
    // notch and a click both arrive here as an Esc with a tail.
    for ([_][]const u8{ "\x1b[<64;10;5M", "\x1b[M !!", "\x1b[C", "\x1b[H", "\x1bOP" }) |seq| {
        var f: PrefixFilter = .{};
        var open = "\x1cs".*;
        _ = f.feed(&open);
        var keys: [16]u8 = undefined;
        @memcpy(keys[0..seq.len], seq);
        const out = f.feed(keys[0..seq.len]);
        try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
        if (!f.picking) return error.WheelClosedThePicker;
        // The tail went with it: left in the chunk, a report's digits are
        // row selections and its letters are the popup's own keys.
        try std.testing.expectEqualStrings("", out.forward);
    }
}

test "interact: the picker eats prose, so a key never reaches a session" {
    var f: PrefixFilter = .{};
    var open = "\x1cs".*;
    _ = f.feed(&open);
    var prose = "hello".*;
    const out = f.feed(&prose);
    try std.testing.expectEqualStrings("", out.forward);
    try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
    // The `\n` behind it is the picker's own descend key, and the letters
    // ahead of it went nowhere at all — no shell saw one.
    var enter = "\n".*;
    const done = f.feed(&enter);
    try std.testing.expectEqualStrings("", done.forward);
    try std.testing.expectEqual(PrefixFilter.Action.pick_enter, done.action);
}

test "interact: the colon chord is gone, the picker's a replaced it" {
    var f: PrefixFilter = .{};
    var buf = "\x1c:".*;
    try std.testing.expectEqual(PrefixFilter.Action.none, f.feed(&buf).action);
    try std.testing.expect(!f.prompting);
    // ...and the byte is eaten like any other unbound chord key, never
    // handed to the session as a stray colon.
    try std.testing.expectEqualStrings("", f.feed(&buf).forward);
}

test "interact: l is not last-session any more" {
    var f: PrefixFilter = .{};
    var buf = "\x1cl".*;
    try std.testing.expectEqual(PrefixFilter.Action{ .focus_dir = .right }, f.feed(&buf).action);
}

test "interact: a wheel report becomes a scroll and never reaches the pty" {
    var f: MouseFilter = .{};
    var out: [64]u8 = undefined;

    // Button 64 press: wheel up, into history. Button 65: wheel down.
    const up = f.feed("\x1b[<64;10;5M", &out);
    try std.testing.expectEqual(@as(i32, 1), up.wheel);
    try std.testing.expectEqualStrings("", up.forward);

    const dn = f.feed("\x1b[<65;10;5M", &out);
    try std.testing.expectEqual(@as(i32, -1), dn.wheel);
    try std.testing.expectEqualStrings("", dn.forward);

    // A terminal sends a burst when the wheel is spun; they add up rather
    // than the last one winning, or a fast spin would move one notch.
    const burst = f.feed("\x1b[<64;1;1M\x1b[<64;1;1M\x1b[<64;1;1M\x1b[<65;1;1M", &out);
    try std.testing.expectEqual(@as(i32, 2), burst.wheel);
    try std.testing.expectEqualStrings("", burst.forward);

    // Ctrl+wheel is still a wheel: no font-size change exists here to claim it.
    const ctrl = f.feed("\x1b[<80;1;1M", &out);
    try std.testing.expectEqual(@as(i32, 1), ctrl.wheel);
}

test "interact: alternate scroll spells its arrows the way the session reads them" {
    // Normal cursor keys: the CSI form. Application mode (DECCKM, what
    // `less` and every curses program set): the SS3 form. A client that
    // sent the CSI form to an application-mode reader would scroll nothing
    // and say nothing — measured on `less +G` before this existed.
    try std.testing.expectEqualStrings("\x1b[A", altScrollSeq(1, false));
    try std.testing.expectEqualStrings("\x1b[B", altScrollSeq(-1, false));
    try std.testing.expectEqualStrings("\x1bOA", altScrollSeq(1, true));
    try std.testing.expectEqualStrings("\x1bOB", altScrollSeq(-1, true));
}

test "interact: the wheel becomes arrows from the sampled modes, not from an engine" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: InputTransport = .{};

    // The daemon says the alternate screen is up and DECCKM is set. No
    // escape is fed to the replica here on purpose: the rule must hold for
    // a client that parses no VT at all.
    core.semantic.terminal_modes = .{
        .bracketed_paste = false,
        .alt_screen = true,
        .cursor_keys = true,
    };
    try mouse(&core, &tr, 64, 3, 2, 'M');
    try std.testing.expectEqualStrings("\x1bOA" ** wheel_rows, tr.take());
    try mouse(&core, &tr, 65, 3, 2, 'M');
    try std.testing.expectEqualStrings("\x1bOB" ** wheel_rows, tr.take());

    // DECCKM off with the alternate screen still up is the CSI spelling.
    core.semantic.terminal_modes.cursor_keys = false;
    try mouse(&core, &tr, 64, 3, 2, 'M');
    try std.testing.expectEqualStrings("\x1b[A" ** wheel_rows, tr.take());

    // The inverse: the modes say the primary screen, so the notch is this
    // client's own view to move and the session reads nothing at all.
    core.semantic.terminal_modes.alt_screen = false;
    try mouse(&core, &tr, 64, 3, 2, 'M');
    try std.testing.expectEqualStrings("", tr.take());
}

test "interact: clicks, drags and releases are discarded rather than typed at the shell" {
    var f: MouseFilter = .{};
    var out: [64]u8 = undefined;

    // Left press, left release, drag (motion bit 32 with button 0), and the
    // wheel's horizontal cousins. Nobody asked for any of them — with no
    // application wanting the mouse there is nobody to send them to.
    for ([_][]const u8{
        "\x1b[<0;40;12M",
        "\x1b[<0;40;12m",
        "\x1b[<32;41;12M",
        "\x1b[<66;1;1M",
        "\x1b[<67;1;1M",
        "\x1b[<64;1;1m",
    }) |report| {
        const r = f.feed(report, &out);
        try std.testing.expectEqual(@as(i32, 0), r.wheel);
        try std.testing.expectEqualStrings("", r.forward);
    }
}

test "interact: a wheel report split across reads is still one report" {
    var f: MouseFilter = .{};
    var out: [64]u8 = undefined;

    // The split is after the `ESC [ <` that starts the hold — the only
    // place the filter holds, deliberately (see MouseFilter).
    const a = f.feed("typed\x1b[<64;", &out);
    try std.testing.expectEqual(@as(i32, 0), a.wheel);
    try std.testing.expectEqualStrings("typed", a.forward);

    const b = f.feed("10;5M", &out);
    try std.testing.expectEqual(@as(i32, 1), b.wheel);
    try std.testing.expectEqualStrings("", b.forward);
}

test "interact: keystrokes survive the mouse filter, in order and unheld" {
    var f: MouseFilter = .{};
    var out: [64]u8 = undefined;

    // A bare Escape is forwarded on the read it arrived in. This is the
    // property the filter gives up completeness for: held, it would strand
    // every Escape in vim until the next keystroke.
    const esc = f.feed("\x1b", &out);
    try std.testing.expectEqualStrings("\x1b", esc.forward);

    // An arrow key is `ESC [ A`: it starts like a report and is not one.
    const arrow = f.feed("\x1b[A", &out);
    try std.testing.expectEqualStrings("\x1b[A", arrow.forward);

    // Typing either side of a wheel notch keeps its order.
    const mixed = f.feed("ab\x1b[<64;1;1Mcd", &out);
    try std.testing.expectEqual(@as(i32, 1), mixed.wheel);
    try std.testing.expectEqualStrings("abcd", mixed.forward);

    // The exact chunk the scroll block's `was_live` rule is about: a notch
    // and a keystroke in one read. The filter must hand back BOTH — the
    // notch to move the view and the `x` to reach the pty — because a
    // filter that dropped either would make that rule undecidable.
    const both = f.feed("\x1b[<64;1;1Mx", &out);
    try std.testing.expectEqual(@as(i32, 1), both.wheel);
    try std.testing.expectEqualStrings("x", both.forward);
}

test "interact: a button press becomes an event, still never reaching the pty" {
    var f: MouseFilter = .{};
    var out: [64]u8 = undefined;

    // SGR press of button 0 at the terminal's column 40, row 12. The
    // reports the filter used to answer with a wheel notch or silence are
    // the same bytes; only what it can SAY about them is new.
    const r = f.feed("\x1b[<0;40;12M", &out);

    // The old contract, unchanged: a report is not input.
    try std.testing.expectEqualStrings("", r.forward);
    try std.testing.expectEqual(@as(i32, 0), r.wheel);

    try std.testing.expectEqual(@as(usize, 1), r.events.len);
    try std.testing.expectEqual(MouseFilter.Event.Kind.press, r.events[0].kind);
    try std.testing.expectEqual(@as(u16, 0), r.events[0].button);
    // Zero-based here, one-based on the wire: every coordinate this client
    // owns is zero-based (grid rows, `Stripe.top`), so the conversion
    // happens once, at the edge, rather than at each use.
    try std.testing.expectEqual(@as(u16, 39), r.events[0].col);
    try std.testing.expectEqual(@as(u16, 11), r.events[0].row);
}

test "interact: a wheel report is a notch and a wheel event, never a press" {
    var f: MouseFilter = .{};
    var out: [64]u8 = undefined;

    // Button 64 is wheel-up. It has always been a notch; what it must not
    // ALSO be is a press, or a driver would scroll the view and move a
    // selection endpoint on the same turn of the wheel. It is an event of
    // its own kind so the driver that hands the mouse to an application
    // can re-spell it in the pane's coordinates like any other report.
    const up = f.feed("\x1b[<64;1;1M", &out);
    try std.testing.expectEqual(@as(i32, 1), up.wheel);
    try std.testing.expectEqual(@as(usize, 1), up.events.len);
    try std.testing.expectEqual(MouseFilter.Event.Kind.wheel, up.events[0].kind);

    // 65 is wheel-down, 66/67 the horizontal pair this client does not
    // handle — all wheel, none of them a press or a motion.
    for ([_][]const u8{ "\x1b[<65;1;1M", "\x1b[<66;1;1M", "\x1b[<67;1;1M" }) |seq| {
        const r = f.feed(seq, &out);
        try std.testing.expectEqual(@as(usize, 1), r.events.len);
        try std.testing.expectEqual(MouseFilter.Event.Kind.wheel, r.events[0].kind);
    }
}

test "interact: a wheel turned mid-drag moves no endpoint of the selection" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    // The wheel is an event now, so this is where the old filter-level
    // silence has to be said again: the drag's far end is where the hand
    // last moved, not where the wheel turned.
    try mouse(&core, &tr, 0, 3, 2, 'M');
    try mouse(&core, &tr, 32, 7, 2, 'M');
    try mouse(&core, &tr, 64, 12, 4, 'M');
    try std.testing.expectEqual(@as(u16, 6), core.drag.range().?.to.col);
    try std.testing.expectEqual(@as(u32, 1), core.drag.range().?.to.row);
}

test "interact: one drag is press, motion and release, in the order typed" {
    var f: MouseFilter = .{};
    var out: [64]u8 = undefined;

    // A whole drag can arrive in one read — the terminal writes a report
    // per event and the pty delivers up to 16 KiB — so the filter has to
    // hand back all three, ordered, rather than the last one it saw.
    const r = f.feed("\x1b[<0;1;1M\x1b[<32;3;1M\x1b[<0;5;2m", &out);

    try std.testing.expectEqualStrings("", r.forward);
    try std.testing.expectEqual(@as(usize, 3), r.events.len);

    try std.testing.expectEqual(MouseFilter.Event.Kind.press, r.events[0].kind);
    try std.testing.expectEqual(@as(u16, 0), r.events[0].col);

    // Bit 5 (32) marks motion-with-button-held. Same button, still down.
    try std.testing.expectEqual(MouseFilter.Event.Kind.motion, r.events[1].kind);
    try std.testing.expectEqual(@as(u16, 2), r.events[1].col);

    // A release says so with its final `m`, whatever the button bits are.
    try std.testing.expectEqual(MouseFilter.Event.Kind.release, r.events[2].kind);
    try std.testing.expectEqual(@as(u16, 4), r.events[2].col);
    try std.testing.expectEqual(@as(u16, 1), r.events[2].row);
}

test "interact: an event knows where it fell among the keys" {
    var f: MouseFilter = .{};
    var out: [64]u8 = undefined;

    // `forward` flattens the keys and drops the reports, so two flat lists
    // cannot say whether the click came before or after the Enter — at the
    // wall, the difference between two tiles getting the focus.
    const key_first = f.feed("\r\x1b[<0;3;2M", &out);
    try std.testing.expectEqualStrings("\r", key_first.forward);
    try std.testing.expectEqual(@as(usize, 1), key_first.events.len);
    // One byte of `forward` was already emitted when this report finished.
    try std.testing.expectEqual(@as(usize, 1), key_first.events[0].at);

    // Same two things, the other way round: nothing had been emitted yet.
    const report_first = f.feed("\x1b[<0;3;2M\r", &out);
    try std.testing.expectEqualStrings("\r", report_first.forward);
    try std.testing.expectEqual(@as(usize, 1), report_first.events.len);
    try std.testing.expectEqual(@as(usize, 0), report_first.events[0].at);
}

test "interact: a chunk packed with the shortest report fills, and does not overrun" {
    var f: MouseFilter = .{};
    // The cap this pins is the filter's totality claim, so the input is the
    // worst case: a whole stdin chunk of the SHORTEST complete report there
    // is. `\x1b[<0;1;1M` is NINE bytes — counted, because ten is 181 short.
    const shortest = "\x1b[<0;1;1M";
    try std.testing.expectEqual(@as(usize, 9), shortest.len);

    var in: [stdin_chunk]u8 = undefined;
    var i: usize = 0;
    while (i + shortest.len <= in.len) : (i += shortest.len) {
        @memcpy(in[i..][0..shortest.len], shortest);
    }
    // The tail that does not fit a whole report is padding no report claims.
    @memset(in[i..], 'z');

    var out: [stdin_chunk + MouseFilter.max_held]u8 = undefined;
    const r = f.feed(&in, &out);

    try std.testing.expectEqual(stdin_chunk / shortest.len, r.events.len);
    try std.testing.expect(r.events.len <= MouseFilter.max_events);
    // Every one decoded, none of them a wheel, and the padding still typed.
    try std.testing.expectEqual(MouseFilter.Event.Kind.press, r.events[r.events.len - 1].kind);
    try std.testing.expectEqualStrings(in[i..], r.forward);
}

test "interact: a report a terminal malformed is dropped, not decoded" {
    var f: MouseFilter = .{};
    var out: [64]u8 = undefined;

    // Column and row are one-based on the wire. A zero is nonsense, and
    // the decrement that makes it zero-based would wrap it to 65535 — a
    // coordinate large enough to pass every later bound check by being
    // enormous rather than by being right.
    try std.testing.expectEqual(@as(usize, 0), f.feed("\x1b[<0;0;1M", &out).events.len);
    try std.testing.expectEqual(@as(usize, 0), f.feed("\x1b[<0;1;0M", &out).events.len);

    // Short of its three parameters, and unparseable in the first.
    try std.testing.expectEqual(@as(usize, 0), f.feed("\x1b[<0;1M", &out).events.len);
    try std.testing.expectEqual(@as(usize, 0), f.feed("\x1b[<;1;1M", &out).events.len);
}

test "interact: a candidate that turns out not to be a report is given back whole" {
    var f: MouseFilter = .{};
    var out: [64]u8 = undefined;

    // `ESC [ <` with a letter behind it is not a mouse report — nothing may
    // be swallowed on the strength of a guess.
    const broken = f.feed("\x1b[<12x", &out);
    try std.testing.expectEqual(@as(i32, 0), broken.wheel);
    try std.testing.expectEqualStrings("\x1b[<12x", broken.forward);

    // Held bytes from a previous read come back ahead of this read's, in
    // the order they were typed.
    const held = f.feed("\x1b[<9", &out);
    try std.testing.expectEqualStrings("", held.forward);
    const rest = f.feed("q", &out);
    try std.testing.expectEqualStrings("\x1b[<9q", rest.forward);

    // A candidate longer than any real report is abandoned, not held for
    // ever.
    var long: [MouseFilter.max_held * 2]u8 = undefined;
    @memcpy(long[0..3], "\x1b[<");
    @memset(long[3..], '1');
    var big_out: [long.len + MouseFilter.max_held]u8 = undefined;
    const over = f.feed(&long, &big_out);
    try std.testing.expectEqual(@as(i32, 0), over.wheel);
    try std.testing.expectEqualStrings(&long, over.forward);
}

test "interact: resetting the filter drops a half-read report" {
    var f: MouseFilter = .{};
    var out: [64]u8 = undefined;

    // What the handover to a mouse-hungry application does: the rest of the
    // report belongs to the application, so the head of it must not be
    // pushed back into its input.
    _ = f.feed("\x1b[<64;", &out);
    f.reset();
    const after = f.feed("hi", &out);
    try std.testing.expectEqualStrings("hi", after.forward);
}

test "interact: Ctrl-\\ c asks for a new session and ends the chunk" {
    var f: PrefixFilter = .{};
    var chunk = "ab\x1ccz".*;
    const out = f.feed(&chunk);
    try std.testing.expectEqual(PrefixFilter.Action.new_session, out.action);
    // "ab" was typed at the session we are leaving and has already been
    // sent; the "z" behind the chord was typed at it too and is dropped,
    // because the only sessions left to put it in are the wrong ones.
    try std.testing.expectEqualStrings("ab", out.forward);
}

test "interact: Ctrl-\\ n and Ctrl-\\ p step the session ring" {
    var f: PrefixFilter = .{};
    var fwd = "ab\x1cnz".*;
    const n = f.feed(&fwd);
    try std.testing.expectEqual(PrefixFilter.Action.next_session, n.action);
    // Same reason `c` drops its tail: "z" was typed at the session we are
    // leaving, and the only sessions left to put it in are the wrong ones.
    try std.testing.expectEqualStrings("ab", n.forward);
    var back = "\x1cp".*;
    const p = f.feed(&back);
    try std.testing.expectEqual(PrefixFilter.Action.prev_session, p.action);
    try std.testing.expectEqualStrings("", p.forward);
}

test "interact: Ctrl-\\ w asks for the wall and ends the chunk" {
    var f: PrefixFilter = .{};
    var chunk = "ab\x1cwz".*;
    const out = f.feed(&chunk);
    try std.testing.expectEqual(PrefixFilter.Action.wall, out.action);
    // The drop the user is most likely to notice, because unlike a switch
    // they come BACK to this session: "z" is gone, and the reason is that
    // it was typed before the wall took the terminal.
    try std.testing.expectEqualStrings("ab", out.forward);
}

// The chord layer only NAMES an intent; what a driver does with it is the
// driver's. What is pinned here is the naming: the byte is parsed, the
// chunk ends, and the action that comes out is the one the key promises.
test "interact: Ctrl-\\ 1 is focus, x ends the session, w is the wall, n/p walk it" {
    var f: PrefixFilter = .{};
    var one = "\x1c1".*;
    const a = f.feed(&one);
    // focus carries the digit pressed; the wall does not act on it yet, but
    // the chord still ends the chunk, so nothing is forwarded.
    try std.testing.expectEqual(PrefixFilter.Action{ .focus = 1 }, a.action);
    try std.testing.expectEqualStrings("", a.forward);

    var g: PrefixFilter = .{};
    var ex = "\x1cx".*;
    const b = g.feed(&ex);
    // `x` takes the pane off this wall and asks the daemon nothing.
    try std.testing.expectEqual(PrefixFilter.Action.remove_pane, b.action);
    try std.testing.expectEqualStrings("", b.forward);

    // Shift-x is a DIFFERENT action, not the same one reached by a second
    // spelling: `x` is the reversible key and `X` ends a session on its
    // daemon, so a table that folded the two would make every removal a
    // kill. Neither byte is forwarded — both end the chord.
    var gx: PrefixFilter = .{};
    var exx = "\x1cX".*;
    const bx = gx.feed(&exx);
    try std.testing.expectEqual(PrefixFilter.Action.end_focused, bx.action);
    try std.testing.expectEqualStrings("", bx.forward);

    // The three keys that must not have moved under `x`'s feet: `w` is
    // still the wall and `n`/`p` are still the walk, so a user who learned
    // the row learned it once.
    var h: PrefixFilter = .{};
    var w = "\x1cw".*;
    try std.testing.expectEqual(PrefixFilter.Action.wall, h.feed(&w).action);
    var i: PrefixFilter = .{};
    var n = "\x1cn".*;
    try std.testing.expectEqual(PrefixFilter.Action.next_session, i.feed(&n).action);
    var j: PrefixFilter = .{};
    var p = "\x1cp".*;
    try std.testing.expectEqual(PrefixFilter.Action.prev_session, j.feed(&p).action);
}

fn devNull() !std.posix.fd_t {
    return std.posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0);
}

/// The plain client's rect: one tile filling the screen. Offset 0 is a case
/// stated here, not one a fixture assumes — the tile at an offset is next
/// to it, and both paths are walked.
const full_vp: paint_mod.Viewport = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 };

/// Only the tests reach for an engine now: one authors the screens a client
/// would be sent, and one plays the terminal the client paints onto.
const Engine = @import("engine").Engine;

/// An engine and the grid it mirrors into. A test describes a screen in VT,
/// and what the code under test reads is the cells — through the daemon's
/// encoder and the replica's decoder, which is the only way a client grid is
/// ever filled. Re-mirrored on every feed, so the two never drift.
const AuthoredScreen = struct {
    eng: *Engine,
    grid: *Grid,

    fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !AuthoredScreen {
        const g = try Grid.init(alloc, cols, rows);
        errdefer g.deinit();
        const e = try Engine.init(alloc, .{ .cols = cols, .rows = rows });
        return .{ .eng = e, .grid = g };
    }

    fn feed(self: *AuthoredScreen, bytes: []const u8) void {
        self.eng.feed(bytes);
        self.eng.mirrorInto(self.grid) catch unreachable;
    }

    fn deinit(self: *AuthoredScreen) void {
        self.eng.deinit();
        self.grid.deinit();
    }
};

/// One CellRow of default-styled ASCII: the shape a delta row carries, for
/// a test that hands a Core a frame rather than a screen.
fn cellRow(alloc: std.mem.Allocator, text: []const u8) ![]u8 {
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(alloc);
    var w = try proto.CellRowWriter.begin(&out, alloc);
    errdefer w.deinit();
    for (text) |ch| try w.cell(.{}, .narrow, &[_]u8{ch});
    w.finish();
    return out.toOwnedSlice(alloc);
}

/// Author a screen straight into a grid somebody else owns — a Core's
/// replica, where a test wants the Core to hold content it never received.
fn authorScreen(alloc: std.mem.Allocator, g: *Grid, bytes: []const u8) !void {
    const e = try Engine.init(alloc, .{ .cols = g.cols, .rows = g.rows });
    defer e.deinit();
    e.feed(bytes);
    try e.mirrorInto(g);
}

test "prediction: prev_ch is read at the predicted cursor, not the replica's" {
    const alloc = std.testing.allocator;
    const null_fd = try devNull();
    defer std.posix.close(null_fd);

    // A real engine, fed real VT bytes — the one part of the prediction
    // contract no test inside predict.zig can reach, because that module
    // has never heard of an engine.
    var scr = try AuthoredScreen.init(alloc, 80, 24);
    defer scr.deinit();
    const replica = scr.grid;
    // Content with the cursor parked ON a character and a DIFFERENT
    // character in the cell after it. That difference is the whole test:
    // with nothing pending the replica's cursor and the predicted one agree,
    // and mid-burst they do not.
    scr.feed("abcXY\x1b[1;4H");
    try std.testing.expectEqual(@as(u16, 3), replica.cursor.x);

    var ov = predict.Overlay.init(alloc, 80, 24);
    defer ov.deinit();
    ov.setMode(.{ .icanon = true, .echo = true });
    ov.noteSeq(1);

    offerKeystroke(alloc, &ov, replica, "d", full_vp, null_fd);
    offerKeystroke(alloc, &ov, replica, "e", full_vp, null_fd);

    try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
    try std.testing.expectEqual(@as(u8, 'X'), ov.pendingAt(0).prev_ch);
    // The second keystroke lands in the cell AFTER the first prediction,
    // and takes that cell's content as its prev_ch.
    try std.testing.expectEqual(@as(u16, 4), ov.pendingAt(1).cell.col);
    try std.testing.expectEqual(@as(u8, 'Y'), ov.pendingAt(1).prev_ch);

    // A frame that changed neither cell: the daemon has not seen the
    // keystrokes, so both predictions must survive it. Read `prev_ch` from the
    // wrong cell and the frame reads as a contradiction and flushes the burst.
    try std.testing.expectEqual(
        predict.Verdict.none,
        reconcileOverlay(alloc, &ov, replica, 2, 0),
    );
    try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
    try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);

    // And when the daemon does answer, they confirm against the real grid.
    scr.feed("\x1b[1;4Hde");
    try std.testing.expectEqual(
        predict.Verdict.confirmed,
        reconcileOverlay(alloc, &ov, replica, 3, 0),
    );
    try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
    try std.testing.expectEqual(@as(u64, 2), ov.counters.confirmed);
}

test "prediction: a burst advances the predicted cursor one cell per keystroke" {
    const alloc = std.testing.allocator;
    const null_fd = try devNull();
    defer std.posix.close(null_fd);

    var scr = try AuthoredScreen.init(alloc, 80, 24);
    defer scr.deinit();
    const replica = scr.grid;

    var ov = predict.Overlay.init(alloc, 80, 24);
    defer ov.deinit();
    ov.setMode(.{ .icanon = true, .echo = true });

    for ("hello") |ch| offerKeystroke(alloc, &ov, replica, &.{ch}, full_vp, null_fd);

    // The replica's own cursor has not moved — the daemon has answered
    // nothing — so every one of these came from the overlay.
    try std.testing.expectEqual(@as(u16, 0), replica.cursor.x);
    try std.testing.expectEqual(@as(usize, 5), ov.pendingCount());
    for ("hello", 0..) |ch, i| {
        const p = ov.pendingAt(i);
        try std.testing.expectEqual(@as(u16, @intCast(i)), p.cell.col);
        try std.testing.expectEqual(ch, p.cell.ch);
        try std.testing.expectEqual(@as(u8, ' '), p.prev_ch);
    }
    try std.testing.expectEqual(
        predict.CursorPos{ .x = 5, .y = 0 },
        ov.predictedCursor(.{ .x = 0, .y = 0 }),
    );
}

test "prediction paints underlined, and parks the cursor past what it drew" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe();
    defer std.posix.close(p[0]);

    var scr = try AuthoredScreen.init(alloc, 80, 24);
    defer scr.deinit();
    const replica = scr.grid;
    scr.feed("\x1b[1;4H"); // cursor at column 3 (0-based)

    var ov = predict.Overlay.init(alloc, 80, 24);
    defer ov.deinit();
    ov.setMode(.{ .icanon = true, .echo = true });

    offerKeystroke(alloc, &ov, replica, "z", full_vp, p[1]);
    std.posix.close(p[1]);

    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(alloc);
    var rbuf: [4096]u8 = undefined;
    while (true) {
        const n = try std.posix.read(p[0], &rbuf);
        if (n == 0) break;
        try out.appendSlice(alloc, rbuf[0..n]);
    }

    // Drawn at the predicted cell, underlined so a speculation is visibly
    // one, and with the SGR closed again so it cannot bleed into the rest.
    try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[1;4H\x1b[4mz\x1b[0m") != null);
    // Cursor left one past it: the typist's next character goes there, and
    // if it did not the shell's own cursor would appear to lag a column
    // behind everything they typed.
    try std.testing.expect(std.mem.endsWith(u8, out.items, "\x1b[1;5H\x1b[?25h\x1b[?2026l"));
    // Wrapped in one synchronized update, so no terminal ever shows the
    // half-drawn state.
    try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2026h"));
}

test "a keystroke's prediction lands in the typist's own tile" {
    // Without both offsets a tile below or right of the wall's corner
    // echoes its own typing onto a neighbour's session.
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe();
    defer std.posix.close(p[0]);
    var scr = try AuthoredScreen.init(alloc, tile_cols, tile_rows);
    defer scr.deinit();
    const replica = scr.grid;
    var ov = predict.Overlay.init(alloc, tile_cols, tile_rows);
    defer ov.deinit();
    ov.setMode(.{ .icanon = true, .echo = true });

    offerKeystroke(alloc, &ov, replica, "z", .{
        .top = drag_row_off,
        .left = drag_col_off,
        .rows = tile_rows,
        .cols = tile_cols,
    }, p[1]);
    std.posix.close(p[1]);

    var rbuf: [4096]u8 = undefined;
    const n = try std.posix.read(p[0], &rbuf);
    const screen = try tileScreen(alloc);
    defer screen.deinit();
    screen.feed(rbuf[0..n]);
    const plain = try screen.dumpPlain(alloc);
    defer alloc.free(plain);

    var want: [tile_screen_cols]u8 = undefined;
    @memcpy(&want, tile_seed_row);
    want[drag_col_off] = 'z';
    var it = std.mem.splitScalar(u8, plain, '\n');
    var y: u16 = 0;
    while (it.next()) |line| : (y += 1) {
        try std.testing.expectEqualStrings(if (y == drag_row_off) &want else tile_seed_row, line);
    }
    // The caret parks one past the speculated cell, or the typist's next
    // character appears a column behind the one before it.
    try std.testing.expectEqual(
        Engine.CursorPos{ .x = drag_col_off + 1, .y = drag_row_off },
        screen.cursorPos(),
    );
}

test "prediction: nothing is drawn for a context that has not earned it" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe();
    defer std.posix.close(p[0]);

    var scr = try AuthoredScreen.init(alloc, 80, 24);
    defer scr.deinit();
    const replica = scr.grid;

    var ov = predict.Overlay.init(alloc, 80, 24);
    defer ov.deinit();
    ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned

    offerKeystroke(alloc, &ov, replica, "z", full_vp, p[1]);
    std.posix.close(p[1]);

    // Queued, so it can be judged and earn the next one its visibility...
    try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
    try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);

    // ...and not one byte went to the terminal. The effect, not the counter:
    // this is the assertion that a password prompt depends on.
    var rbuf: [64]u8 = undefined;
    try std.testing.expectEqual(@as(usize, 0), try std.posix.read(p[0], &rbuf));
}

test "prediction: a chunk that is not one printable byte is never speculated about" {
    const alloc = std.testing.allocator;
    const null_fd = try devNull();
    defer std.posix.close(null_fd);

    var scr = try AuthoredScreen.init(alloc, 80, 24);
    defer scr.deinit();
    const replica = scr.grid;

    var ov = predict.Overlay.init(alloc, 80, 24);
    defer ov.deinit();
    ov.setMode(.{ .icanon = true, .echo = true });

    // An arrow key: three bytes, and predicting its lead byte would paint an
    // escape character on the screen.
    offerKeystroke(alloc, &ov, replica, "\x1b[A", full_vp, null_fd);
    // A multi-byte character, whose display width we do not know.
    offerKeystroke(alloc, &ov, replica, "é", full_vp, null_fd);
    // And a lone control byte, which goes down the single-byte path and is
    // refused there.
    offerKeystroke(alloc, &ov, replica, "\r", full_vp, null_fd);

    try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
    try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
    try std.testing.expectEqual(@as(u64, 3), ov.counters.suppressed);

    // A paste: several printable bytes in one read. Its lead byte sails
    // through the printability check, so the length guard is the only thing
    // refusing it — and a paste is not one cell's worth of change.
    offerKeystroke(alloc, &ov, replica, "abc", full_vp, null_fd);
    try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
    try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
    // Counted like every other refusal. The decision is the client's — the
    // overlay never sees the chunk — but the counter is about decisions,
    // not about which side of the interface made them.
    try std.testing.expectEqual(@as(u64, 4), ov.counters.suppressed);
}

test "prediction: a repaint never reveals what was never shown" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe();
    defer std.posix.close(p[0]);

    var scr = try AuthoredScreen.init(alloc, 80, 24);
    defer scr.deinit();
    const replica = scr.grid;
    const null_fd = try devNull();
    defer std.posix.close(null_fd);

    var ov = predict.Overlay.init(alloc, 80, 24);
    defer ov.deinit();
    ov.setMode(.{ .icanon = false, .echo = false }); // raw: unconfident
    offerKeystroke(alloc, &ov, replica, "a", full_vp, null_fd);
    offerKeystroke(alloc, &ov, replica, "b", full_vp, null_fd);
    try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
    try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);

    // Every authoritative paint re-lays the overlay on top, because a delta's
    // row content wipes anything drawn over it — a second chance to show a
    // prediction, so it asks the keystroke path's question and gets its answer.
    paintOverlay(alloc, &ov, replica.cursor, full_vp, p[1]);
    std.posix.close(p[1]);

    var rbuf: [64]u8 = undefined;
    try std.testing.expectEqual(@as(usize, 0), try std.posix.read(p[0], &rbuf));
}

test "prediction: a promotion mid-burst counts the cell it makes visible" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe();
    defer std.posix.close(p[0]);
    const null_fd = try devNull();
    defer std.posix.close(null_fd);

    var scr = try AuthoredScreen.init(alloc, 80, 24);
    defer scr.deinit();
    const replica = scr.grid;

    var ov = predict.Overlay.init(alloc, 80, 24);
    defer ov.deinit();
    ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned

    // The keystrokes are stamped with the real clock, so the frames that
    // confirm them land 100ms later: a remote path. At 0 the overlay would
    // measure a 0ms round trip and hide the promotion this test is about.
    const later = std.time.milliTimestamp() + 100;

    // One confirm banked, one short of promotion.
    offerKeystroke(alloc, &ov, replica, "a", full_vp, null_fd);
    scr.feed("a");
    try std.testing.expectEqual(
        predict.Verdict.confirmed,
        reconcileOverlay(alloc, &ov, replica, 1, later),
    );

    // Two more typed while still invisible, and the promoting confirmation
    // lands while the second of them is outstanding.
    offerKeystroke(alloc, &ov, replica, "b", full_vp, null_fd);
    offerKeystroke(alloc, &ov, replica, "c", full_vp, null_fd);
    try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
    scr.feed("b");
    try std.testing.expectEqual(
        predict.Verdict.confirmed,
        reconcileOverlay(alloc, &ov, replica, 2, later),
    );
    try std.testing.expect(ov.visible());
    try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
    // Nothing has been drawn yet: it was queued invisible and no repaint
    // has happened since the promotion.
    try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);

    // The post-frame re-lay is where it reaches the screen — nobody typed
    // anything to make that happen, so counting only at prediction time
    // would lose it.
    paintOverlay(alloc, &ov, replica.cursor, full_vp, p[1]);
    std.posix.close(p[1]);
    try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);

    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(alloc);
    var rbuf: [4096]u8 = undefined;
    while (true) {
        const n = try std.posix.read(p[0], &rbuf);
        if (n == 0) break;
        try out.appendSlice(alloc, rbuf[0..n]);
    }
    try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[4mc\x1b[0m") != null);
}

test "the predict stats line is one greppable row of counters" {
    var buf: [predict_stats_len]u8 = undefined;
    const line = try formatPredictStats(&buf, .{
        .made = 5,
        .displayed = 4,
        .confirmed = 3,
        .contradicted = 2,
        .expired = 1,
        .abandoned = 7,
        .suppressed = 6,
        .local = 8,
    });
    // Pinned exactly: test/e2e.sh greps these key=value pairs, so a rename
    // or a reorder is a broken suite rather than a cosmetic change. The
    // units differ between them — see predict.Counters — which is exactly
    // why every one of them is on the line rather than a chosen few.
    try std.testing.expectEqualStrings(
        "predict made=5 displayed=4 confirmed=3 contradicted=2" ++
            " expired=1 abandoned=7 suppressed=6 local=8",
        line,
    );
}

/// Stands in a caller's buffer before a refusal, so "wrote nothing" is
/// distinguishable from "never writes anything". Not base64, not part of
/// any escape the builder emits.
const refusal_sentinel: u8 = 0xfe;

test "interact: a validated clipboard effect becomes an OSC 52 write" {
    const alloc = std.testing.allocator;
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(alloc);

    try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
        .target = 'c',
        .base64 = "aGk=",
    } });
    // BEL rather than ESC-backslash: it is what most emitters in the wild
    // use, and every terminal that accepts one accepts it.
    try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", out.items);
}

test "interact: a validated bell effect becomes a BEL" {
    const alloc = std.testing.allocator;
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(alloc);

    try appendHostEffect(&out, alloc, .bell);
    try std.testing.expectEqualStrings("\x07", out.items);
}

test "interact: xterm Pc targets retain their exact OSC 52 spelling" {
    const alloc = std.testing.allocator;

    for ([_]u8{ 'c', 'p', 'q', 's', '0', '7' }) |target| {
        var out: std.ArrayList(u8) = .empty;
        defer out.deinit(alloc);

        try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
            .target = target,
            .base64 = "aGk=",
        } });
        const want = [_]u8{ 0x1b, ']', '5', '2', ';', target, ';', 'a', 'G', 'k', '=', 0x07 };
        try std.testing.expectEqualSlices(u8, &want, out.items);
    }
}

test "interact: terminal mode state turns bracketed paste on and off on the host" {
    const alloc = std.testing.allocator;
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(alloc);

    // The mouse level-set follows every mode sample, so the paste bytes are
    // asserted as a prefix and the mouse half gets its own test below.
    try appendTermState(&out, alloc, .{ .terminal_modes = .{ .bracketed_paste = true } });
    try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2004h"));

    out.clearRetainingCapacity();
    try appendTermState(&out, alloc, .{ .terminal_modes = .{ .bracketed_paste = false } });
    try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2004l"));
}

test "interact: with no application asking, the client keeps the mouse for the wheel" {
    const alloc = std.testing.allocator;
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(alloc);

    // A format mode alone is not a claim on the mouse: nothing asked for an
    // event, so the client's own capture set stays ours and 1005 goes back
    // off. 1002 is inside that set — a drag cannot be escalated once it has
    // begun, so motion reporting is on before the press that needs it.
    try appendMouseModes(&out, alloc, .{ .bracketed_paste = false, .mouse_utf8 = true });
    try std.testing.expectEqualStrings(
        "\x1b[?9l\x1b[?1000h\x1b[?1002h\x1b[?1003l" ++
            "\x1b[?1005l\x1b[?1006h\x1b[?1015l\x1b[?1016l",
        out.items,
    );
}

test "interact: an application that asked for the mouse gets exactly the modes it asked for" {
    const alloc = std.testing.allocator;
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(alloc);

    // vim's `set mouse=a` asks for exactly the set the client holds for
    // itself, so these bytes match the no-application answer above. That
    // identity is what the feature rests on, not a coincidence to route around.
    try appendMouseModes(&out, alloc, .{
        .bracketed_paste = false,
        .mouse_normal = true,
        .mouse_button = true,
        .mouse_sgr = true,
    });
    try std.testing.expectEqualStrings(
        "\x1b[?9l\x1b[?1000h\x1b[?1002h\x1b[?1003l" ++
            "\x1b[?1005l\x1b[?1006h\x1b[?1015l\x1b[?1016l",
        out.items,
    );

    // ...so the override has to be proven where the two sets part, or the
    // leg above would still pass against a function that ignored its
    // argument. 1003 (report motion with no button down) is a mode the
    // client never wants and an application can still have.
    out.clearRetainingCapacity();
    try appendMouseModes(&out, alloc, .{
        .bracketed_paste = false,
        .mouse_normal = true,
        .mouse_any = true,
        .mouse_sgr = true,
    });
    try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?1003h") != null);

    // An application on the legacy format gets it by SUBTRACTION: our own
    // 1006 would spell every click in a shape it cannot parse, and 1002 would
    // send drags it never asked for. The leg proving the override removes.
    out.clearRetainingCapacity();
    try appendMouseModes(&out, alloc, .{ .bracketed_paste = false, .mouse_normal = true });
    try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?1006l") != null);
    try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?1002l") != null);
}

test "interact: a title becomes an OSC 0 write, and empty or control bytes are refused" {
    const alloc = std.testing.allocator;
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(alloc);

    try appendTermTitle(&out, alloc, "vim");
    try std.testing.expectEqualStrings("\x1b]0;vim\x07", out.items);

    // Seeded, not merely emptied: `len == 0` on a buffer that started empty
    // also passes for a builder that appends nothing ever.
    out.clearRetainingCapacity();
    try out.append(alloc, refusal_sentinel);
    // A BEL inside the title would terminate the OSC early and paint the
    // rest — here a shell command — on the user's screen as text.
    try appendTermTitle(&out, alloc, "vim\x07rm -rf");
    try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);

    // ESC is the other terminator half (ST), and DEL is the control byte
    // that is not below 0x20 — both are refused by the same check.
    out.clearRetainingCapacity();
    try out.append(alloc, refusal_sentinel);
    try appendTermTitle(&out, alloc, "vim\x1b]52;c;AAAA\x07");
    try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);

    out.clearRetainingCapacity();
    try out.append(alloc, refusal_sentinel);
    try appendTermTitle(&out, alloc, "vim\x7f");
    try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);

    // Empty is refused rather than written: `ESC]0;BEL` would CLEAR the
    // host terminal's title, and no daemon of any version has a reason to
    // ask for that. See sampleTermTitle for the daemon half of this policy.
    out.clearRetainingCapacity();
    try out.append(alloc, refusal_sentinel);
    try appendTermTitle(&out, alloc, "");
    try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
}

test "interact: the title cap is a cap, not an off-by-one" {
    const alloc = std.testing.allocator;
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(alloc);

    const at_cap = try alloc.alloc(u8, proto.term_title_max);
    defer alloc.free(at_cap);
    @memset(at_cap, 'x');
    try appendTermTitle(&out, alloc, at_cap);
    // "\x1b]0;" is four bytes and the BEL is one.
    try std.testing.expectEqual(proto.term_title_max + 5, out.items.len);

    const over = try alloc.alloc(u8, proto.term_title_max + 1);
    defer alloc.free(over);
    @memset(over, 'x');
    out.clearRetainingCapacity();
    try out.append(alloc, refusal_sentinel);
    try appendTermTitle(&out, alloc, over);
    try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
}

test "interact: the exit teardown unsets every mode mux turned on, and pops the title" {
    // A multiplexer that leaves your terminal in a mode it enabled is worse
    // than one that pastes badly, so the teardown string is pinned as a
    // literal rather than assembled from the constants it writes.
    try std.testing.expectEqualStrings(
        "\x1b[?2004l" ++
            "\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l" ++
            "\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l" ++
            "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l",
        terminal_teardown,
    );
    // Without its push, the pop is worthless and pops a stranger's title. The
    // two live far apart — one startup write against a teardown on every way
    // out — so they are pinned together and deleting either one fails.
    try std.testing.expectEqualStrings(
        "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++
            "\x1b[H\x1b[2J",
        wall_setup,
    );
    // Every mode the setup turns on has an `l` for it in the teardown. The
    // mouse half is the one that can drift, because the daemon can ask for
    // modes this string never mentions.
    inline for (proto.mouse_modes) |m| {
        try std.testing.expect(std.mem.indexOf(
            u8,
            terminal_teardown,
            comptime std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec}),
        ) != null);
    }
}

test "interact: a borrowed terminal's claim is a session's, and every teardown undoes it" {
    // A tile's focus claim is the mouse modes and nothing else — the SCREEN
    // was taken once, by the wall — and the release heads every teardown. This
    // pairing turns over once per focus move where the screen's turns over
    // once per process, so an exit-path-only half leaks on the first move.
    try std.testing.expectEqualStrings("\x1b[?1000h\x1b[?1002h\x1b[?1006h", session_claim);
    try std.testing.expect(std.mem.startsWith(u8, terminal_teardown, session_release));
    try std.testing.expect(std.mem.indexOf(u8, wall_teardown, session_release) != null);
    // And it is exported, because the release is written by a thread that
    // holds no Core: wallview's `setFocus`. A release that only a Core could
    // write is a release that cannot be ordered against the next Core's
    // claim.
    try std.testing.expectEqualStrings(
        "\x1b[?2004l\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l",
        session_release,
    );
    // The wall no longer claims the mouse itself: the focused tile's
    // `claimTerminal` does. So `wall_setup` is the screen half only — no
    // mouse modes — and the teardown still clears them all because it is
    // built from the whole wire table.
    try std.testing.expect(std.mem.indexOf(u8, wall_setup, client_mouse_setup) == null);
    try std.testing.expect(std.mem.startsWith(u8, wall_setup, "\x1b[22;0t\x1b[?1049h"));
    // ...and every mouse mode comes off on the way out. `wall_teardown`
    // is `terminal_teardown`, which is built from the whole wire table, so
    // this holds for a mode added to the capture set tomorrow as well.
    inline for (client_mouse_capture) |dec| {
        try std.testing.expect(std.mem.indexOf(
            u8,
            wall_teardown,
            comptime std.fmt.comptimePrint("\x1b[?{d}l", .{dec}),
        ) != null);
    }

    // Every mode the tile's claim turns ON has an `l` for it in the
    // release...
    inline for (client_mouse_capture) |dec| {
        try std.testing.expect(std.mem.indexOf(
            u8,
            session_claim,
            comptime std.fmt.comptimePrint("\x1b[?{d}h", .{dec}),
        ) != null);
        try std.testing.expect(std.mem.indexOf(
            u8,
            session_release,
            comptime std.fmt.comptimePrint("\x1b[?{d}l", .{dec}),
        ) != null);
    }
    // ...and so does every mode the DAEMON can ask a focused tile to
    // mirror, which is the half that drifts: a focused tile's application
    // can turn on 1002 or 1003, modes the client's own capture set never
    // names and nothing else would take back off.
    inline for (proto.mouse_modes) |m| {
        try std.testing.expect(std.mem.indexOf(
            u8,
            session_release,
            comptime std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec}),
        ) != null);
    }
}

/// Everything on a NON-BLOCKING pipe, as one slice: these are emitted
/// one escape sequence per `write`, so a reader that took the first
/// for the whole answer would pin half a pair.
fn drainPipe(fd: std.posix.fd_t, buf: []u8) []const u8 {
    var n: usize = 0;
    while (std.posix.read(fd, buf[n..])) |got| {
        if (got == 0) break;
        n += got;
    } else |_| {}
    return buf[0..n];
}

test "interact: a promote takes the mouse, a demote gives it back, a demote twice writes nothing" {
    const alloc = std.testing.allocator;
    // Non-blocking, so a half of the pair that stopped writing FAILS here
    // rather than parking this test on a read that will never return. A
    // wedged test step prints nothing at all, and a mutation that hangs the
    // suite is a mutation whose answer nobody gets.
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);

    var core = try Core.initSized(alloc, -1, p[1], .{ .cols = 80, .rows = 24 });
    // A pipe is not a tty, and a Core with no terminal claims nothing —
    // which is the case this test is NOT about. Set by hand rather than
    // built on a pty fixture, because what is under test is four escape
    // sequences and a flag.
    core.is_tty = true;
    defer core.deinit();

    var buf: [512]u8 = undefined;

    try std.testing.expect(core.claimTerminal());
    try std.testing.expectEqual(Claim.session, core.claim);
    // Two writes, drained as one: the claim itself and then the SESSION's
    // modes level-set on top of it.
    const claimed = drainPipe(p[0], &buf);
    try std.testing.expect(std.mem.startsWith(u8, claimed, session_claim));
    // A mode the claim never names, so its presence can only be the level-set
    // — the half a claim needs because `resyncSnapshot` carries no
    // `term_modes`. 1003 not 1002: the claim writes 1002 itself.
    try std.testing.expect(std.mem.indexOf(u8, claimed, "\x1b[?1003l") != null);

    // A claim onto the tile already focused re-asserts the grid, not the
    // modes: nothing is written a second time, so nothing has to come off
    // twice either. `false` says so — the same answer a claim gets when
    // the focus moved out from under it.
    try std.testing.expect(!core.claimTerminal());

    core.releaseTerminal(.write);
    try std.testing.expectEqual(Claim.none, core.claim);
    try std.testing.expectEqualStrings(session_release, drainPipe(p[0], &buf));

    // A release with nothing claimed writes nothing. That is what makes
    // `deinit` safe on a tile nobody ever claimed — and what stops a wall
    // from unsetting a mode the SESSION never set, on a terminal the wall
    // is about to hand back to the user.
    core.releaseTerminal(.write);
    try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));

    // `.already_written` drops the claim and writes NOTHING, because the
    // bytes were somebody else's to emit and they already did. A shared
    // terminal's handover is ordered by that split; a second copy of the
    // release from here is the race it exists to remove.
    try std.testing.expect(core.claimTerminal());
    _ = drainPipe(p[0], &buf);
    core.releaseTerminal(.already_written);
    try std.testing.expectEqual(Claim.none, core.claim);
    try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
}

/// Swallows everything: `forward` writes the keystrokes it did not
/// consume, and no test here is about them.
const NullTransport = struct {
    fn writeFrame(_: *NullTransport, _: proto.MsgType, _: []const u8) !void {}
};

/// A transport that keeps the selection requests and swallows the rest —
/// for the tests whose subject is what went on the WIRE rather than what
/// went on the screen. `forward` also writes keystrokes, and those are
/// nobody's business here.
const SelectionTransport = struct {
    n: usize = 0,
    buf: [proto.selection_req_len]u8 = undefined,

    fn writeFrame(self: *SelectionTransport, ty: proto.MsgType, payload: []const u8) !void {
        if (ty != .selection_req or payload.len != self.buf.len) return;
        self.n += 1;
        @memcpy(&self.buf, payload);
    }
};

/// A real pts pair — the OS answering about the OS, which is the only
/// thing `ttySize`'s own read of the terminal can be judged against. The
/// opening is `client_os.openPtyPair`'s; this name stays because the tests
/// below read as a story about a pts pair rather than about a platform row.
fn ptsPair() !client_os.PtyPair {
    return client_os.openPtyPair();
}

fn setTtySize(master: std.posix.fd_t, cols: u16, rows: u16) !void {
    return client_os.setWinSize(master, .{ .col = cols, .row = rows, .xpixel = 0, .ypixel = 0 });
}

test "interact: a terminal under the daemon's floor measures as unknown" {
    // Not "under 2": under whatever the daemon will run a session at. A
    // client that quotes a size `resolveSession` refuses attaches to a
    // session that never moves again — so the floor has one owner, and
    // this is what fails when a second copy of it drifts.
    const pair = ptsPair() catch return error.SkipZigTest;
    defer std.posix.close(pair.master);
    defer std.posix.close(pair.slave);

    try setTtySize(pair.master, proto.min_session_cols - 1, proto.min_session_rows);
    try std.testing.expect(ttySize(pair.slave) == null);
    try setTtySize(pair.master, proto.min_session_cols, proto.min_session_rows - 1);
    try std.testing.expect(ttySize(pair.slave) == null);

    // At the floor exactly it is a usable terminal, read back off the
    // ioctl rather than echoed from the value just written.
    try setTtySize(pair.master, proto.min_session_cols, proto.min_session_rows);
    try std.testing.expectEqual(
        proto.Size{ .cols = proto.min_session_cols, .rows = proto.min_session_rows },
        ttySize(pair.slave).?,
    );
}

/// The fixture's pane starts twelve columns into the screen, so every test
/// that drags through it exercises the offset. Standing, not opt-in:
/// `col_off` had eighteen uses in this file and not one inside a test body,
/// and that is how a pane that painted across the rail shipped.
const drag_col_off: u16 = 12;

/// And four rows down, for the same reason on the other axis: a tile with a
/// neighbour ABOVE it is the case where a paint or a hit-test that forgets
/// the row origin lands on somebody else's session.
const drag_row_off: u16 = 4;

/// CHA to a 1-based PANE column: the sequence is screen-absolute, so the
/// pane's origin is in it.
fn cha(comptime col: u16) []const u8 {
    return std.fmt.comptimePrint("\x1b[{d}G", .{col + drag_col_off});
}

/// CUP to the pane's left edge on a 1-based PANE row, screen-absolute like
/// the CHA above.
fn cup(comptime row: u16) []const u8 {
    return std.fmt.comptimePrint("\x1b[{d};{d}H", .{ row + drag_row_off, drag_col_off + 1 });
}

/// Forward one SGR mouse report at a 1-based PANE column and row. A test
/// says where the hand went in the pane; this says where that is on the
/// screen, which is the conversion `hitTest` has to undo.
fn mouse(core: *Core, tr: anytype, btn: u16, col: u16, row: u16, kind: u8) !void {
    var buf: [32]u8 = undefined;
    const rep = std.fmt.bufPrint(&buf, "\x1b[<{d};{d};{d}{c}", .{
        btn,
        col + drag_col_off,
        row + drag_row_off,
        kind,
    }) catch unreachable;
    _ = try core.forward(tr, rep);
}

/// A Core on a pipe, with a claim, ready to be dragged over.
fn dragFixture(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core {
    var core = try Core.initSized(alloc, -1, out_fd, .{ .cols = 20, .rows = 6 });
    core.is_tty = true;
    core.col_off = drag_col_off;
    core.row_off = drag_row_off;
    // A pane with neighbours around it, which is what an offset means: a
    // whole-screen clear here would blank the pane across the rail.
    core.owns_screen = false;
    // A drag only happens on a terminal this Core took over — which is
    // exactly the focused-tile case, and the reason a non-focused tile
    // never reaches any of this.
    _ = core.claimTerminal();
    // Row 4 carries wide cells, kept OFF the rows the column assertions use:
    // a wide glyph shifts every column right of it, so folding one into row 1
    // means re-deriving fifteen hand-checked numbers.
    try authorScreen(alloc, core.rep.grid, "row-zero\r\nrow-one\r\nrow-two\r\nrow-three\r\nw\u{6f22}\u{5b57}x");
    return core;
}

test "interact: a drag at the focus tile inverts what it crossed, and a click does not" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf); // the claim

    // Press on row 1, column 2. Nothing is painted: a press that turns out
    // to be a click must not flicker an inversion on its way past.
    try mouse(&core, &tr, 0, 3, 2, 'M');
    try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));

    // Drag to column 6 of the same row.
    try mouse(&core, &tr, 32, 7, 2, 'M');
    const painted = drainPipe(p[0], &buf);
    // Columns 2..6 of grid row 1, addressed by column and inverted: the
    // span opens at column 3 (1-based) and the tail resumes at column 8.
    // The head is plain, the span opens at column 3 (1-based) with a CHA and
    // an inversion, and the row closes with a full reset so the inversion
    // cannot run on into whatever the terminal draws next. There is no tail
    // to resume: columns 2 to 6 are the last of `row-one`, and a row stops at
    // its last non-blank cell. The test below covers a selection that leaves
    // content after it.
    try std.testing.expect(std.mem.indexOf(
        u8,
        painted,
        comptime "\x1b[0mro" ++ cha(3) ++ "\x1b[0m\x1b[7mw-one\x1b[0m",
    ) != null);
    try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[7m"));
    // The neighbouring rows are not touched at all: only the row whose
    // span changed is redrawn, so an untouched row keeps whatever it
    // already had on screen — which is why it must not appear here, and
    // why the inversion count above is the whole story.
    try std.testing.expect(std.mem.indexOf(u8, painted, "row-zero") == null);
    try std.testing.expect(std.mem.indexOf(u8, painted, "row-two") == null);
    try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[20X"));

    // The release does not move the highlight, so it does not repaint one.
    try mouse(&core, &tr, 0, 7, 2, 'm');
    try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));

    // A press elsewhere puts the selection away — and the repaint that
    // says so carries no inversion at all.
    try mouse(&core, &tr, 0, 1, 4, 'M');
    const cleared = drainPipe(p[0], &buf);
    // The row that CARRIED the highlight is the row that gets redrawn —
    // dropping a selection changes the same rows that making it did — and
    // it comes back with no inversion on it.
    try std.testing.expect(std.mem.indexOf(u8, cleared, "row-one") != null);
    try std.testing.expect(std.mem.indexOf(u8, cleared, "\x1b[7m") == null);
    // ...and that press is a click, which a focused tile answers with
    // nothing: there is one session on this screen to select.
    try mouse(&core, &tr, 0, 1, 4, 'm');
    try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
}

test "interact: a selection that ends mid-row resumes the tail plain, at its own column" {
    // The three-piece shape: plain head, inverted span, plain tail. The tail
    // is what a substring search cannot see going missing — the cells are
    // painted either way, and only the CHA says the cursor went back to the
    // column they belong in rather than running on from the span.
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf); // the claim

    // Row 1 is `row-one`; the drag covers columns 2 to 4, leaving `n` and `e`
    // after it.
    try mouse(&core, &tr, 0, 3, 2, 'M');
    try mouse(&core, &tr, 32, 5, 2, 'M');
    const painted = drainPipe(p[0], &buf);
    try std.testing.expect(std.mem.indexOf(
        u8,
        painted,
        comptime "\x1b[0mro" ++ cha(3) ++ "\x1b[0m\x1b[7mw-o\x1b[0m" ++ cha(6) ++ "ne\x1b[0m",
    ) != null);
    try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[7m"));
}

test "interact: the application that asked for the mouse gets the drag, and mux keeps no selection" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    try mouse(&core, &tr, 0, 3, 2, 'M');
    try mouse(&core, &tr, 32, 7, 2, 'M');
    try std.testing.expect(std.mem.indexOf(u8, drainPipe(p[0], &buf), "\x1b[7m") != null);

    // vim's `set mouse=a` arrives mid-drag. The reports are the
    // application's from here, and the selection standing on its screen is
    // not mux's to keep.
    core.semantic.terminal_modes = .{
        .bracketed_paste = false,
        .mouse_normal = true,
        .mouse_button = true,
        .mouse_sgr = true,
    };
    try mouse(&core, &tr, 32, 9, 2, 'M');
    const after = drainPipe(p[0], &buf);
    try std.testing.expect(std.mem.indexOf(u8, after, "row-one") != null);
    try std.testing.expect(std.mem.indexOf(u8, after, "\x1b[7m") == null);
    // A further drag under the application selects nothing at all.
    try mouse(&core, &tr, 32, 11, 2, 'M');
    try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
}

/// A transport that keeps the keystroke frames — what `forward` sends the
/// SESSION — for the tests whose subject is the bytes an application reads.
const InputTransport = struct {
    buf: [256]u8 = undefined,
    len: usize = 0,

    fn writeFrame(self: *InputTransport, ty: proto.MsgType, payload: []const u8) !void {
        if (ty != .input) return;
        @memcpy(self.buf[self.len..][0..payload.len], payload);
        self.len += payload.len;
    }

    fn take(self: *InputTransport) []const u8 {
        defer self.len = 0;
        return self.buf[0..self.len];
    }
};

/// The fixture with its application holding the mouse, which is the case
/// where the report goes to the session instead of into a selection.
fn appMouseFixture(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core {
    var core = try dragFixture(alloc, out_fd);
    core.semantic.terminal_modes = .{
        .bracketed_paste = false,
        .mouse_normal = true,
        .mouse_button = true,
        .mouse_sgr = true,
    };
    return core;
}

test "interact: the application's report is in the pane's coordinates, not the screen's" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try appMouseFixture(alloc, p[1]);
    defer core.deinit();
    var tr: InputTransport = .{};

    // The terminal reports where the hand went on ITS screen. The
    // application has a 20x6 grid that starts four rows down and twelve
    // columns in, so a report it can act on names the same cell in the
    // grid's own numbering — press, drag, release and wheel alike.
    try mouse(&core, &tr, 0, 3, 2, 'M');
    try std.testing.expectEqualStrings("\x1b[<0;3;2M", tr.take());
    try mouse(&core, &tr, 32, 4, 2, 'M');
    try std.testing.expectEqualStrings("\x1b[<32;4;2M", tr.take());
    try mouse(&core, &tr, 0, 4, 2, 'm');
    try std.testing.expectEqualStrings("\x1b[<0;4;2m", tr.take());
    try mouse(&core, &tr, 64, 3, 2, 'M');
    try std.testing.expectEqualStrings("\x1b[<64;3;2M", tr.take());

    // Keys sharing the read with a report keep their order around it.
    _ = try core.forward(&tr, "a\x1b[<0;15;6Mb");
    try std.testing.expectEqualStrings("a\x1b[<0;3;2Mb", tr.take());
}

test "interact: a report past the pane's edge clamps to the edge, as a terminal clamps at its own" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try appMouseFixture(alloc, p[1]);
    defer core.deinit();
    var tr: InputTransport = .{};

    // A drag that leaves the pane keeps reporting, at the edge it left
    // by: a real terminal reports a drag past its window the same way, and
    // dropping the report instead would leave the application holding a
    // button the hand has let go of. Screen (1,1) is above and left of the
    // pane; pane column 30 and row 9 are past its 20x6.
    _ = try core.forward(&tr, "\x1b[<32;1;1M");
    try std.testing.expectEqualStrings("\x1b[<32;1;1M", tr.take());
    try mouse(&core, &tr, 32, 30, 9, 'M');
    try std.testing.expectEqualStrings("\x1b[<32;20;6M", tr.take());
}

test "interact: a pixel report is the terminal's to place and passes untouched" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try appMouseFixture(alloc, p[1]);
    defer core.deinit();
    core.semantic.terminal_modes.mouse_sgr_pixels = true;
    var tr: InputTransport = .{};

    // Pixel coordinates are not cells, so the pane's cell origin cannot
    // come off them; an application that asked for pixels reads the
    // screen's. A wrong translation here would be worse than none.
    _ = try core.forward(&tr, "\x1b[<0;150;60M");
    try std.testing.expectEqualStrings("\x1b[<0;150;60M", tr.take());
}

test "interact: a drag while scrolled back selects nothing" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    // Viewing history: `renderScrollback` paints decoded history rows this
    // Core does not hold, so nothing on this screen is a row it can name.
    // Refused outright rather than answered against the live view the user
    // is not looking at.
    core.scroll_rows = 4;
    try mouse(&core, &tr, 0, 3, 2, 'M');
    try mouse(&core, &tr, 32, 7, 2, 'M');
    try std.testing.expect(core.drag.range() == null);
    try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
}

test "interact: a press while scrolled back does not repaint over the history" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf); // the claim

    // A selection made at the LIVE view and left standing. The test above
    // starts with none, so `was` and `now` are both null there and the
    // scroll gate it names is not the thing keeping it green.
    try mouse(&core, &tr, 0, 3, 2, 'M');
    try mouse(&core, &tr, 32, 7, 2, 'M');
    try mouse(&core, &tr, 0, 7, 2, 'm');
    try std.testing.expect(core.drag.range() != null);
    _ = drainPipe(p[0], &buf);

    // Wheel up, then a click on the history page. `hitTest` refuses while
    // scrolled, so the press clears the held range — and the rows it used
    // to cover must NOT be redrawn from the live replica onto a screen
    // `renderScrollback` composed.
    core.scroll_rows = 4;
    try mouse(&core, &tr, 0, 5, 2, 'M');
    try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
}

test "interact: a reattach drops the highlight, because the rows have been renamed" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    try mouse(&core, &tr, 0, 3, 2, 'M');
    try mouse(&core, &tr, 32, 7, 3, 'M');
    try std.testing.expect(core.drag.range() != null);
    _ = drainPipe(p[0], &buf);

    // Absolute rows count from the oldest row the daemon still retains,
    // and a resync renames that space outright.
    core.reattached();
    try std.testing.expect(core.drag.range() == null);
    try core.repaint();
    try std.testing.expect(std.mem.indexOf(u8, drainPipe(p[0], &buf), "\x1b[7m") == null);
}

/// The tile's own columns, and the screen it is one tile of.
const tile_cols: u16 = 20;
const tile_rows: u16 = 6;
const tile_screen_cols: u16 = 40;
const tile_screen_rows: u16 = 12;

/// One untouched screen row. Anything the tile's paint changes outside its
/// own columns is a cell of somebody else's session.
const tile_seed_row = "L" ** (drag_col_off - 1) ++ "|" ++ "R" ** tile_cols ++ "|" ++
    "X" ** (tile_screen_cols - drag_col_off - tile_cols - 1);

/// The seed on every row, ready to be painted onto.
fn tileScreen(alloc: std.mem.Allocator) !*Engine {
    const screen = try Engine.init(alloc, .{ .cols = tile_screen_cols, .rows = tile_screen_rows });
    // What the client sets at attach; it is also why an overrun HIDES,
    // piling at the screen edge instead of wrapping into view.
    screen.feed("\x1b[?7l");
    var r: u16 = 0;
    while (r < tile_screen_rows) : (r += 1) {
        var nb: [16]u8 = undefined;
        screen.feed(std.fmt.bufPrint(&nb, "\x1b[{d};1H", .{r + 1}) catch unreachable);
        screen.feed(tile_seed_row);
    }
    return screen;
}

/// A Core whose rect is that tile, with one distinct digit per grid row so
/// a row landing in the wrong band is legible in the failure.
fn tileCore(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core {
    var core = try Core.initSized(alloc, -1, out_fd, .{ .cols = tile_cols, .rows = tile_rows });
    core.is_tty = true;
    core.row_off = drag_row_off;
    core.col_off = drag_col_off;
    core.owns_screen = false;
    _ = core.claimTerminal();
    var body: std.ArrayList(u8) = .empty;
    defer body.deinit(alloc);
    var y: u16 = 0;
    while (y < tile_rows) : (y += 1) {
        var row: [tile_cols]u8 = undefined;
        @memset(&row, '0' + @as(u8, @intCast(y)));
        try body.appendSlice(alloc, &row);
        if (y + 1 < tile_rows) try body.appendSlice(alloc, "\r\n");
    }
    try authorScreen(alloc, core.rep.grid, body.items);
    return core;
}

test "interact: a Core paints inside its rect and onto no neighbour" {
    // Both offsets at once, judged on the grid the paint lands on. The
    // byte search this replaces asked whether the tile's own address was
    // emitted, which is true of a paint that ALSO wrote a row above the
    // tile or a cell across the rail — and both have shipped.
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try tileCore(alloc, p[1]);
    defer core.deinit();
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    try core.repaint();
    const screen = try tileScreen(alloc);
    defer screen.deinit();
    screen.feed(drainPipe(p[0], &buf));
    const plain = try screen.dumpPlain(alloc);
    defer alloc.free(plain);

    var it = std.mem.splitScalar(u8, plain, '\n');
    var y: u16 = 0;
    while (it.next()) |line| : (y += 1) {
        if (y < drag_row_off or y >= drag_row_off + tile_rows) {
            try std.testing.expectEqualStrings(tile_seed_row, line);
            continue;
        }
        var want: [tile_screen_cols]u8 = undefined;
        @memcpy(&want, tile_seed_row);
        @memset(want[drag_col_off..][0..tile_cols], '0' + @as(u8, @intCast(y - drag_row_off)));
        try std.testing.expectEqualStrings(&want, line);
    }
    // The caret rides both offsets, or a shell's prompt and its cursor sit
    // in different tiles. The grid's cursor is parked past the last row's
    // twenty columns, which DECAWM off holds at the tile's last column.
    try std.testing.expectEqual(
        Engine.CursorPos{ .x = drag_col_off + tile_cols - 1, .y = drag_row_off + tile_rows - 1 },
        screen.cursorPos(),
    );
}

test "interact: a Core's banner parks in its own tile's corner" {
    // A status marker belongs in the FOCUSED TILE's top-right corner. On
    // the screen's, it sits on whatever tile owns row 1.
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try tileCore(alloc, p[1]);
    defer core.deinit();
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    const label = "[hi]";
    core.banner(label);
    const screen = try tileScreen(alloc);
    defer screen.deinit();
    // A cursor with somewhere to be: the banner saves and restores it so a
    // shell's caret does not visibly jump to the corner.
    screen.feed("\x1b[9;3H");
    screen.feed(drainPipe(p[0], &buf));
    const plain = try screen.dumpPlain(alloc);
    defer alloc.free(plain);

    // `bannerText` right-aligns on a 1-BASED column, so the label stops one
    // cell short of the tile's right edge — the plain client's banner does
    // the same, and `renderScrollback at row 0 is byte-identical to the
    // plain client` is what would object to changing it.
    var want: [tile_screen_cols]u8 = undefined;
    @memcpy(&want, tile_seed_row);
    @memcpy(want[drag_col_off + tile_cols - label.len - 1 ..][0..label.len], label);

    var it = std.mem.splitScalar(u8, plain, '\n');
    var y: u16 = 0;
    while (it.next()) |line| : (y += 1) {
        try std.testing.expectEqualStrings(if (y == drag_row_off) &want else tile_seed_row, line);
    }
    try std.testing.expectEqual(Engine.CursorPos{ .x = 2, .y = 8 }, screen.cursorPos());
}

test "interact: the anchor is an absolute row, and the paint converts it back" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    // A session with history behind it, which is every used session. With
    // none retained a grid row and an absolute row are the same integer, and
    // neither half of the conversion is under test.
    core.rep.history_rows = 500;

    try mouse(&core, &tr, 0, 3, 2, 'M');
    try mouse(&core, &tr, 32, 7, 2, 'M');
    const r = core.drag.range().?;
    // Terminal row 1 is grid row 1 is the 501st retained row — the space
    // `protocol.SelectionReq` speaks, and the one a scroll cannot rename.
    try std.testing.expectEqual(@as(u32, 501), r.from.row);
    try std.testing.expectEqual(@as(u32, 501), r.to.row);
    // ...and the paint puts the inversion back on grid row 1, which is the
    // other half of the same arithmetic: a painter that took the absolute
    // row literally would invert a row 500 below the one pointed at, or
    // none at all.
    const painted = drainPipe(p[0], &buf);
    try std.testing.expect(std.mem.indexOf(u8, painted, comptime cup(2) ++ "\x1b[20X\x1b[0mro" ++ cha(3) ++ "\x1b[0m\x1b[7m") != null);
}

test "interact: a drag repaint puts the predictions back on top" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf); // the claim

    // Cooked with echo is the mode the overlay is confident enough to draw
    // in, and therefore the only state where there is a prediction on
    // screen to lose.
    core.overlay.setGrid(core.rep.grid.cols, core.rep.grid.rows);
    core.overlay.setMode(.{ .icanon = true, .echo = true });
    // One byte at a time: a chunk wider than a cell is a paste, and
    // prediction refuses those.
    _ = try core.forward(&tr, "z");
    _ = try core.forward(&tr, "z");
    const typed = drainPipe(p[0], &buf);
    try std.testing.expect(std.mem.indexOf(u8, typed, "\x1b[4m") != null);

    // The drag repaints from the replica, which has never heard of a
    // prediction. These are still outstanding — the daemon has not
    // answered — so they have to go back on top: `paintFull` is the
    // ROLLBACK painter, and a drag rolls nothing back.
    try mouse(&core, &tr, 0, 3, 2, 'M');
    try mouse(&core, &tr, 32, 7, 2, 'M');
    const painted = drainPipe(p[0], &buf);
    try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[7m") != null);
    try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[4m") != null);
}

test "interact: a delta under a held selection stays a delta" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [16384]u8 = undefined;
    _ = drainPipe(p[0], &buf); // the claim

    // A finished selection, still held — the state a hand leaves behind
    // between the release and the next click, and the one a session goes
    // on producing output underneath.
    try mouse(&core, &tr, 0, 3, 2, 'M');
    try mouse(&core, &tr, 32, 7, 2, 'M');
    try mouse(&core, &tr, 0, 7, 2, 'm');
    _ = drainPipe(p[0], &buf);
    try std.testing.expect(core.drag.range() != null);

    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);
    try proto.appendDeltaHeader(&payload, alloc, .{
        .seq = 1,
        .history_rows = 0,
        .cursor_x = 0,
        .cursor_y = 0,
        .row_count = 1,
    });
    const delta_row = try cellRow(alloc, "row-one");
    defer alloc.free(delta_row);
    try proto.appendDeltaRow(&payload, alloc, 1, delta_row);
    _ = try core.frame(.delta, payload.items);

    const painted = drainPipe(p[0], &buf);
    // Still a delta: the screen-clear that opens a full repaint is absent.
    // A held selection used to force the full-repaint arm on every frame,
    // which cost the whole screen for a row the daemon had already sent.
    try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[2J") == null);
    // ...and the row the daemon just rewrote comes back inverted anyway,
    // which is the reason the full repaint was there in the first place.
    try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[7m") != null);
}

test "interact: a drag repaints the rows it changed, not the screen" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [16384]u8 = undefined;
    _ = drainPipe(p[0], &buf); // the claim

    try mouse(&core, &tr, 0, 3, 2, 'M');
    _ = drainPipe(p[0], &buf);

    // One cell along the same row. The anchor has not moved, so exactly
    // one row's span changed — and a drag reports on every cell the
    // pointer crosses, which is what makes the difference between a row
    // and a screen worth having.
    try mouse(&core, &tr, 32, 4, 2, 'M');
    const painted = drainPipe(p[0], &buf);

    try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[7m") != null);
    // No screen clear: that is what opens a full repaint.
    try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[2J") == null);
    // One row addressed and cleared, and it is the row under the pointer.
    try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[20X"));
    try std.testing.expect(std.mem.indexOf(u8, painted, comptime cup(2) ++ "\x1b[20X") != null);
}

test "interact: a drag edge inside a wide cell repaints the row unshifted" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [16384]u8 = undefined;
    _ = drainPipe(p[0], &buf); // the claim

    // Row 4 is `w漢字x`: column 2 is the spacer tail of 漢 and column 3 the
    // first half of 字, so BOTH edges of this drag fall inside a wide cell.
    try mouse(&core, &tr, 0, 3, 5, 'M');
    try mouse(&core, &tr, 32, 4, 5, 'M');
    const painted = drainPipe(p[0], &buf);

    // Snapped outwards to whole glyphs: the inversion opens at column 2
    // (0-based 1, where 漢 starts), not at the column the pointer stopped in.
    try std.testing.expect(std.mem.indexOf(u8, painted, comptime cha(2) ++ "\x1b[0m\x1b[7m") != null);

    // The oracle: replay what the client actually WROTE through a fresh engine
    // and read the row back. Asserting escape bytes cannot see this class of
    // bug — a row emitting 漢 twice still contains every expected substring.
    var screen = try Engine.init(alloc, .{ .cols = 20, .rows = 6 });
    defer screen.deinit();
    screen.feed(painted);
    const plain = try screen.dumpPlain(alloc);
    defer alloc.free(plain);
    try std.testing.expect(std.mem.indexOf(u8, plain, "w\u{6f22}\u{5b57}x") != null);
}

test "interact: only the left button drags" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    // Middle button (1) and right (2). One is the terminal's own paste and
    // the other its menu; stealing either would be a surprise with no
    // answer, and the button word arrives with the motion bit set on a
    // drag — 33 is a middle-button drag, not a press of button 33.
    try mouse(&core, &tr, 1, 3, 2, 'M');
    try mouse(&core, &tr, 33, 7, 2, 'M');
    try std.testing.expect(core.drag.range() == null);
    try mouse(&core, &tr, 2, 3, 3, 'M');
    try mouse(&core, &tr, 34, 7, 3, 'M');
    try std.testing.expect(core.drag.range() == null);
    try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
}

test "interact: a press left of the pane names no line, and the clamp is the pane's" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    // Screen column 1 is a cell of the neighbour across the rail: the column
    // axis refuses left of the tile as the row axis refuses above it. The row
    // here is inside the tile's band, so only the column can refuse.
    _ = try core.forward(&tr, comptime std.fmt.comptimePrint("\x1b[<0;1;{d}M", .{drag_row_off + 2}));
    try std.testing.expect(core.drag.range() == null);

    // The pane's own first column does name a line, and names it zero.
    try mouse(&core, &tr, 0, 1, 2, 'M');
    try mouse(&core, &tr, 32, 4, 2, 'M');
    try std.testing.expectEqual(@as(u16, 0), core.drag.range().?.from.col);

    // A pane narrower than the grid clamps to what it SHOWS: the grid's
    // remaining columns are behind the rail, where no hand can reach.
    core.size.cols = 8;
    try mouse(&core, &tr, 32, 15, 2, 'M');
    try std.testing.expectEqual(@as(u16, 7), core.drag.range().?.to.col);
}

test "interact: a row past the grid names no line, and a column past it clamps" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    // A terminal taller and wider than the daemon's grid, which latest-wins
    // leaves routinely.
    var core = try Core.initSized(alloc, -1, p[1], .{ .cols = 40, .rows = 12 });
    core.is_tty = true;
    core.col_off = drag_col_off;
    core.row_off = drag_row_off;
    core.owns_screen = false;
    _ = core.claimTerminal();
    defer core.deinit();
    var tr: NullTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    // The grid is smaller than the Core's clip size: 20x6 against 40x12.
    try core.rep.grid.resize(20, 6);
    try authorScreen(alloc, core.rep.grid, "row-zero\r\nrow-one\r\nrow-two");

    // Row 8 is inside the terminal and past the grid: `renderClipped`
    // never painted a session line there.
    try mouse(&core, &tr, 0, 3, 9, 'M');
    try std.testing.expect(core.drag.range() == null);

    // Column 34 is past the grid's right edge. It clamps to the last
    // column instead of refusing — the right edge is where a hand
    // overshoots — and an out-of-range column is what makes the daemon
    // answer `.invalid` when the selection is asked for.
    try mouse(&core, &tr, 0, 35, 2, 'M');
    try mouse(&core, &tr, 32, 3, 2, 'M');
    const r = core.drag.range().?;
    try std.testing.expectEqual(@as(u16, 19), r.to.col);
    try std.testing.expectEqual(@as(u16, 2), r.from.col);
}

test "interact: a release at the focus tile asks the daemon for what it highlighted" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: SelectionTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    // History behind the session, which is every session that has been
    // used: with none retained a grid row and an absolute row are the same
    // integer and the request could be built out of either.
    core.rep.history_rows = 500;

    try mouse(&core, &tr, 0, 3, 2, 'M');
    try mouse(&core, &tr, 32, 7, 3, 'M');
    // Nothing is asked for while the button is still down: the far end of
    // the drag is still moving, and a request per cell crossed is a round
    // trip per cell crossed.
    try std.testing.expectEqual(@as(usize, 0), tr.n);

    try mouse(&core, &tr, 0, 7, 3, 'm');
    try std.testing.expectEqual(@as(usize, 1), tr.n);
    const req = try proto.decodeSelectionReq(&tr.buf);
    try std.testing.expectEqual(@as(u32, 501), req.anchor.row);
    try std.testing.expectEqual(@as(u16, 2), req.anchor.col);
    try std.testing.expectEqual(@as(u32, 502), req.active.row);
    try std.testing.expectEqual(@as(u16, 6), req.active.col);
    try std.testing.expectEqual(@as(?u32, req.id), core.semantic.pending_selection_id);
    // The watermark comes from the replica the coordinates were resolved
    // against, not from whatever the reply says.
    try std.testing.expectEqual(@as(u32, 500), core.sel_watermark);

    // A click asks for nothing. It highlights nothing either, so there is
    // no text under it to want — and a round trip per click is a round
    // trip per time the user puts a selection away.
    try mouse(&core, &tr, 0, 3, 2, 'M');
    try mouse(&core, &tr, 0, 3, 2, 'm');
    try std.testing.expectEqual(@as(usize, 1), tr.n);
}

/// Laid out by hand, not encoded: a layout change must fail a test,
/// not agree with itself.
fn replyBytes(
    buf: []u8,
    id: u32,
    status: proto.SelectionStatus,
    history_rows: u32,
    text: []const u8,
) []const u8 {
    std.mem.writeInt(u32, buf[0..4], id, .little);
    buf[4] = @intFromEnum(status);
    std.mem.writeInt(u32, buf[5..9], history_rows, .little);
    @memset(buf[9..proto.selection_reply_prefix_len], 0);
    @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text);
    return buf[0 .. proto.selection_reply_prefix_len + text.len];
}

/// Drag over the fixture and let go, leaving one request in flight. Returns
/// the selection that is now held, which is what a reply is judged against.
fn askedFor(core: *Core, tr: *SelectionTransport) !select.Range {
    try mouse(core, tr, 0, 3, 2, 'M');
    try mouse(core, tr, 32, 7, 3, 'M');
    try mouse(core, tr, 0, 7, 3, 'm');
    return core.drag.range().?;
}

test "interact: a reply is the text that was asked for, or it is nothing" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: SelectionTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);
    core.rep.history_rows = 500;

    var rbuf: [64]u8 = undefined;
    var held = try askedFor(&core, &tr);
    const id = core.semantic.pending_selection_id.?;

    // The reply this request asked for.
    switch (core.selectionCopy(replyBytes(&rbuf, id, .ok, 500, "row-one"), held)) {
        .text => |text| try std.testing.expectEqualStrings("row-one", text),
        else => return error.ExpectedText,
    }

    // A watermark LOWER than the one sampled at the ask: the daemon evicted
    // a page in between, so absolute row zero is a different line and the
    // text is for rows nobody pointed at. `.ok`, valid UTF-8, and wrong.
    held = try askedFor(&core, &tr);
    const evicted = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 499, "wrong-rows");
    try std.testing.expectEqual(Copy.none, core.selectionCopy(evicted, held));

    // A HIGHER one is ordinary output, which appends below and renames
    // nothing. Refusing it would refuse every copy from a live session.
    held = try askedFor(&core, &tr);
    const grew = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 900, "row-one");
    switch (core.selectionCopy(grew, held)) {
        .text => |text| try std.testing.expectEqualStrings("row-one", text),
        else => return error.ExpectedText,
    }

    // A reply that outlived its own highlight. Whatever cleared it — a
    // relayout, a forget, a focus move, a resync — the rows it names are not on
    // anybody's screen now, and a copy from it is a copy the user did not
    // ask for and cannot see.
    _ = try askedFor(&core, &tr);
    const orphan = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 500, "row-one");
    try std.testing.expectEqual(Copy.none, core.selectionCopy(orphan, null));

    // ...and a reply that arrived over a DIFFERENT selection is the same
    // thing said the other way: the highlight moved on before the answer
    // came back.
    held = try askedFor(&core, &tr);
    var other = held;
    other.to.row += 1;
    const stale = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 500, "row-one");
    try std.testing.expectEqual(Copy.none, core.selectionCopy(stale, other));

    // Somebody else's id — a reply to a request this drag replaced.
    held = try askedFor(&core, &tr);
    const wrong_id = replyBytes(&rbuf, core.semantic.pending_selection_id.? +% 1, .ok, 500, "row-one");
    try std.testing.expectEqual(Copy.none, core.selectionCopy(wrong_id, held));

    // An empty selection is nothing to put on a clipboard. OSC 52 with an
    // empty payload is the CLEAR form, so proxying it would wipe whatever
    // the human last copied (`client_core.validClipboard`).
    held = try askedFor(&core, &tr);
    const empty = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 500, "");
    try std.testing.expectEqual(Copy.none, core.selectionCopy(empty, held));

    // The daemon's own refusals. Neither is a sentence the user can act on.
    for ([_]proto.SelectionStatus{ .invalid, .unavailable }) |status| {
        held = try askedFor(&core, &tr);
        const refused = replyBytes(&rbuf, core.semantic.pending_selection_id.?, status, 500, "");
        try std.testing.expectEqual(Copy.none, core.selectionCopy(refused, held));
    }
}

test "interact: a demote drops the highlight, and the reply it was waiting for" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: SelectionTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    _ = try askedFor(&core, &tr);
    var rbuf: [64]u8 = undefined;
    const id = core.semantic.pending_selection_id.?;

    // The focus moved on. This Core's screen belongs to somebody else now —
    // the wall's stripes, or another tile — so the inversion it was
    // holding is over rows nobody can see, and the answer still in flight
    // is for a highlight that has stopped existing.
    core.releaseTerminal(.already_written);
    try std.testing.expect(core.drag.range() == null);
    const late = replyBytes(&rbuf, id, .ok, 500, "row-one");
    try std.testing.expectEqual(Copy.none, core.selectionCopy(late, core.drag.range()));
}

test "interact: a selection too big for OSC 52 is refused out loud, never trimmed" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var core = try dragFixture(alloc, p[1]);
    defer core.deinit();
    var tr: SelectionTransport = .{};
    var buf: [8192]u8 = undefined;
    _ = drainPipe(p[0], &buf);

    // Base64 is 4 bytes per 3, so `protocol.clipboard_base64_max` is reached
    // by three quarters of it in text — far under the 1 MiB the daemon will
    // send, which is the gap this exists to fall into.
    const fits = proto.clipboard_base64_max / 4 * 3;
    const rbuf = try alloc.alloc(u8, proto.selection_reply_prefix_len + fits + 1);
    defer alloc.free(rbuf);
    const big = try alloc.alloc(u8, fits + 1);
    defer alloc.free(big);
    @memset(big, 'x');

    var held = try askedFor(&core, &tr);
    const at_cap = replyBytes(rbuf, core.semantic.pending_selection_id.?, .ok, 0, big[0..fits]);
    switch (core.selectionCopy(at_cap, held)) {
        .text => |text| try std.testing.expectEqual(fits, text.len),
        else => return error.ExpectedText,
    }

    // One byte more, and `validClipboard` would refuse the encoding — with
    // `appendHostEffect` writing nothing and saying nothing, which is the
    // silence this exists to break.
    held = try askedFor(&core, &tr);
    const over = replyBytes(rbuf, core.semantic.pending_selection_id.?, .ok, 0, big);
    try std.testing.expectEqual(Copy.too_large, core.selectionCopy(over, held));

    // The daemon's own cap, for a selection past `selection_text_max`. It
    // arrives with no text at all, and it means the same thing to the user.
    held = try askedFor(&core, &tr);
    const daemons = replyBytes(rbuf, core.semantic.pending_selection_id.?, .too_large, 0, "");
    try std.testing.expectEqual(Copy.too_large, core.selectionCopy(daemons, held));
}

test "interact: the copy leaves as OSC 52, through the one writer of it" {
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var buf: [8192]u8 = undefined;

    try writeSelectionCopy(alloc, p[1], "hi");
    // `c` is the clipboard, which is what tmux's `set-clipboard external`
    // sets and what a paste reads back.
    try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", drainPipe(p[0], &buf));
}

test "interact: only what the driver has an answer of its own for comes back" {
    const alloc = std.testing.allocator;
    // No terminal at either end: every paint and every side channel is
    // refused, so what this exercises is the ROUTING and nothing else.
    var core = try Core.initSized(alloc, -1, -1, .{ .cols = 80, .rows = 24 });
    defer core.deinit();

    // The set, named. Adding to it is asking every driver to learn a new
    // meaning, so it is pinned rather than described. `selection_reply` is
    // the one the Core has already half-answered — see `Routed.not_mine`.
    const drivers_own = [_]proto.MsgType{
        .exit_status,
        .taken_over,
        .sessions_reply,
        .end_reply,
        .agent_open,
        .agent_data,
        .agent_close,
        .selection_reply,
    };
    for (drivers_own) |t| {
        try std.testing.expectEqual(Routed.not_mine, try core.frame(t, ""));
    }

    // The offer is the client's own frame travelling the other way, so
    // receiving one means nothing here.
    try std.testing.expectEqual(Routed.skip, try core.frame(.agent_offer, ""));

    const forwarding = [_]proto.MsgType{
        .forward_hello,
        .forward_open,
        .forward_data,
        .forward_credit,
        .forward_half_close,
        .forward_reset,
        .forward_ready,
        .forward_open_result,
    };
    for (forwarding) |t| {
        try std.testing.expectEqual(Routed.skip, try core.frame(t, ""));
    }

    // ...and nothing else is, over every type the enum names.
    inline for (@typeInfo(proto.MsgType).@"enum".fields) |f| {
        const t: proto.MsgType = @enumFromInt(f.value);
        var driver_owns = false;
        for (drivers_own) |l| {
            if (l == t) driver_owns = true;
        }
        if (!driver_owns) {
            // An error is an answer too — it is certainly not the Core
            // declining to route — so a type that fails on an empty payload
            // counts as one the Core owns.
            const r = core.frame(t, "") catch Routed.handled;
            try std.testing.expect(r != .not_mine);
        }
    }
}

test "interact: a frame the Core answers itself never reaches the driver" {
    const alloc = std.testing.allocator;
    var core = try Core.initSized(alloc, -1, -1, .{ .cols = 80, .rows = 24 });
    defer core.deinit();

    // A title is looked at, acted on (refused here — no terminal claim) and
    // done with. The driver's loop carries on without a branch for it.
    try std.testing.expectEqual(Routed.handled, try core.frame(.term_title, "hello"));
    // A snapshot too short to read left the replica untouched, so there is
    // nothing to paint and nothing to record: the driver's `continue`.
    try std.testing.expectEqual(Routed.skip, try core.frame(.snapshot, ""));
    try std.testing.expect(!core.rep.state_since_attach);
    // A page of history for a view that is already live would be painted
    // over a screen it no longer describes.
    try std.testing.expectEqual(Routed.skip, try core.frame(.scrollback_chunk, "\x00\x00\x00\x00\x01\x00"));
    // A delta the replica could not compose is a resync — and STATE to its
    // driver anyway, because `apply` marks the attach landed before it can
    // refuse. A shell dying in the same read as its first delta must not lose it.
    try std.testing.expectEqual(Routed.resync, try core.frame(.delta, ""));
    try std.testing.expect(core.rep.state_since_attach);
}

test "interact: a snapshot that blanked the grid before failing is not .skip" {
    // The other snapshot failure. `.skip` would leave the tile painting from
    // a grid this frame cleared, over a last_seq it also adopted; the error
    // goes out to the driver, which ends the tile.
    const alloc = std.testing.allocator;
    var core = try Core.initSized(alloc, -1, -1, .{ .cols = 8, .rows = 2 });
    defer core.deinit();

    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);
    var pbuf: [proto.snapshot_prefix_len]u8 = undefined;
    proto.writeSnapshotPrefix(&pbuf, .{
        .seq = 5,
        .history_rows = 0,
        .cols = 8,
        .rows = 2,
        .epoch = 1,
    });
    try payload.appendSlice(alloc, &pbuf);
    var cbuf: [proto.snapshot_cursor_len]u8 = undefined;
    proto.writeSnapshotCursor(&cbuf, 0, 0);
    try payload.appendSlice(alloc, &cbuf);
    // A prefix good enough to resize and clear on, then a body that is not a
    // CellRow at all.
    try payload.appendSlice(alloc, "\x1b[1mVT");

    try std.testing.expectError(error.SnapshotAborted, core.frame(.snapshot, payload.items));
}

test "interact: an unvalidated clipboard effect writes nothing" {
    const alloc = std.testing.allocator;
    var out: std.ArrayList(u8) = .empty;
    defer out.deinit(alloc);

    // Exactly the value wasm_core.zig default-initialises its borrowed
    // clipboard slot to, and exactly what client_core.validClipboard
    // refuses. Written verbatim it is `ESC]52;<NUL>;BEL` on a real tty.
    try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
        .target = 0,
        .base64 = &.{},
    } });
    try std.testing.expectEqual(@as(usize, 0), out.items.len);

    // The refusal is the whole value, not just its target: a legal target
    // carrying bytes outside the base64 alphabet is the injection this
    // check exists for.
    try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
        .target = 'c',
        .base64 = "aGk=\x1b]0;pwned\x07",
    } });
    try std.testing.expectEqual(@as(usize, 0), out.items.len);
}

test "interact: a clipboard effect at the cap retains its exact framing" {
    const alloc = std.testing.allocator;

    const at_cap = try alloc.alloc(u8, proto.clipboard_base64_max);
    defer alloc.free(at_cap);
    @memset(at_cap, 'A');

    // The boundary itself is ACCEPTED — stated because `>` and `>=` are one
    // keystroke apart and the wrong one silently truncates the largest copy
    // the daemon is willing to send.
    {
        var out: std.ArrayList(u8) = .empty;
        defer out.deinit(alloc);

        try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
            .target = 'c',
            .base64 = at_cap,
        } });
        // "\x1b]52;c;" ++ payload ++ BEL
        try std.testing.expectEqual(at_cap.len + 8, out.items.len);
    }
}

fn appendNothing(
    _: *std.ArrayList(u8),
    _: std.mem.Allocator,
    _: void,
) std.mem.Allocator.Error!void {}

test "interact: side channels write nothing before terminal ownership" {
    const pipe = try std.posix.pipe();
    defer std.posix.close(pipe[0]);
    var write_open = true;
    defer if (write_open) std.posix.close(pipe[1]);

    try writeSideChannel(
        std.testing.allocator,
        pipe[1],
        false,
        client_core.Effect,
        .{ .bell = {} },
        appendHostEffect,
    );
    std.posix.close(pipe[1]);
    write_open = false;

    var byte: [1]u8 = undefined;
    try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte));
}

test "interact: an empty side-channel rendering writes nothing" {
    const pipe = try std.posix.pipe();
    defer std.posix.close(pipe[0]);
    var write_open = true;
    defer if (write_open) std.posix.close(pipe[1]);

    try writeSideChannel(
        std.testing.allocator,
        pipe[1],
        true,
        void,
        {},
        appendNothing,
    );
    std.posix.close(pipe[1]);
    write_open = false;

    var byte: [1]u8 = undefined;
    try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte));
}

test "interact: an allocation failure discards a partially built side channel" {
    const pipe = try std.posix.pipe();
    defer std.posix.close(pipe[0]);

    // The OSC introducer gets the first allocation. Growing for the payload
    // then fails both its resize and allocation fallback, after real escape
    // bytes exist in writeSideChannel's private buffer.
    var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{
        .fail_index = 1,
        .resize_fail_index = 0,
    });
    var payload: [128]u8 = undefined;
    @memset(&payload, 'A');
    const result = writeSideChannel(
        failing.allocator(),
        pipe[1],
        true,
        client_core.Effect,
        .{ .clipboard_set = .{
            .target = 'c',
            .base64 = &payload,
        } },
        appendHostEffect,
    );
    std.posix.close(pipe[1]);

    try std.testing.expectError(error.OutOfMemory, result);
    try std.testing.expectEqual(@as(usize, 1), failing.allocations);
    try std.testing.expect(failing.has_induced_failure);
    var byte: [1]u8 = undefined;
    try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte));
}

// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
    std.testing.refAllDeclsRecursive(@This());
}