a73x

src/engine/engine.zig

Ref:   Size: 111.3 KiB   History

//! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal
//! and TerminalStream behind the small surface mux needs.
//!
//! This is the root of the `engine` module and the ONE file under src/ that
//! imports ghostty-vt. It sits above `term` rather than inside it so that a
//! client — the CLI wall, the browser core, the fixtures — can hold a
//! replica of a session without linking a terminal emulator. `delta.zig` is
//! its child: deciding which rows a client is missing needs the engine, and
//! nothing else does.
// Rationale: ghostty-vt's grid is fed and read in VT bytes — this module IS the VT, and its selection formatter emits SGR.
const std = @import("std");
const vt = @import("ghostty-vt");
const proto = @import("term").protocol;
/// The client-side grid this engine is the oracle for.
const Grid = @import("term").grid.Grid;

/// The daemon's row-diffing, re-exported so `engine` is the whole of what a
/// daemon needs from this folder.
pub const delta = @import("delta.zig");

/// MuxHandler's own `vt` method shadows the `vt` import inside its body,
/// so the dep types it names are spelled through these aliases.
const StockHandler = vt.TerminalStream.Handler;
const StreamAction = vt.StreamAction;

/// The stock ghostty-vt handler forwards OSC 133 and drops the exit code —
/// there is no semantic-prompt callback in its Effects. So mux intercepts the
/// one action it wants and forwards everything, terminal state unchanged.
pub const MuxHandler = struct {
    inner: StockHandler,

    pub fn deinit(self: *MuxHandler) void {
        self.inner.deinit();
    }

    pub fn vt(
        self: *MuxHandler,
        comptime action: StreamAction.Tag,
        value: StreamAction.Value(action),
    ) void {
        const eng = self.engineOf();
        if (comptime action == .print_repeat) {
            // Ghostty implements REP as repeated print calls. Route each
            // through the same tracking hook, retaining its character state
            // and width/wrap implementation instead of predicting the moves.
            if (eng.term.previous_char) |c| for (0..@max(value, 1)) |_| self.vt(.print, .{ .cp = c });
            return;
        }
        const active_before = eng.term.screens.active_key;
        const print_x = if (comptime action == .print) eng.term.screens.active.cursor.x else 0;
        const print_y = if (comptime action == .print) eng.term.screens.active.cursor.y else 0;
        const print_at_scroll_bottom = if (comptime action == .print) print_y == eng.term.scrolling_region.bottom and
            print_x >= eng.term.scrolling_region.left and print_x <= eng.term.scrolling_region.right else false;
        // The source token guards a client coordinate pair while it crosses
        // the wire to become tracked pins.  It is deliberately narrower than
        // "received bytes": a counter repaint elsewhere must not make a
        // completed drag refuse to register.  These are the stream actions
        // which can remap a coordinate to another cell identity.  Test the
        // terminal state before forwarding because index/reverse-index and a
        // pending wrap decide whether this invocation actually scrolls.
        const source_before = self.movesCoordinateIdentity(action);
        if (source_before) eng.selection_source +%= 1;
        // A narrow character at the right margin only arms pending wrap, but
        // a wide character can wrap and discard the top row immediately. Mark
        // the possible victims before Ghostty remaps their pins, then retire
        // them only when the cursor proves that this print really wrapped.
        if (comptime action == .print) eng.markPossiblePrintDiscard();
        eng.prepareTrackedMutation(action, value);
        // Ghostty's full reset frees the alternate Screen. Its allocator may
        // reuse the exact address on the next alternate entry, so pointer
        // equality alone cannot make a stale tracked pin safe to deinit.
        if (comptime action == .full_reset)
            eng.alternate_generation +%= 1;
        if (comptime action == .semantic_prompt) self.onSemanticPrompt(value);
        if (comptime action == .clipboard_contents) self.onClipboard(value);
        if (comptime action == .bell) self.onBell();
        self.inner.vt(action, value);
        if (comptime action == .print) {
            const cursor = eng.term.screens.active.cursor;
            // A narrow glyph at the last column merely arms pending wrap; it
            // does not yet remap a coordinate. Actual wrapping moves left or
            // to another row, including a wide glyph that cannot fit.
            const wrapped = cursor.x < print_x or cursor.y != print_y;
            if (!source_before and wrapped)
                eng.selection_source +%= 1;
            if (wrapped and print_at_scroll_bottom) eng.commitTrackedMutation() else eng.clearTrackedMutation();
        } else eng.commitTrackedMutation();
        if (comptime action == .full_reset)
            eng.screen_epoch +%= 1;
        if (eng.term.screens.active_key != active_before) {
            eng.screen_epoch +%= 1;
            eng.selection_source +%= 1;
        }
        // After the stock handler, so the mode is on when the first report goes
        // out. A bare vt answers DECRQM 2048 as recognised but never speaks the
        // report, and an app that turns it on then ignores SIGWINCH and waits.
        if (comptime action == .set_mode) {
            if (value.mode == .in_band_size_reports) self.engineOf().reportSize();
        }
    }

    fn engineOf(self: *MuxHandler) *Engine {
        const stream_ptr: *MuxStream = @fieldParentPtr("handler", self);
        // @alignCast for wasm32, for the reason spelled out on onWritePty.
        return @alignCast(@fieldParentPtr("stream", stream_ptr));
    }

    fn movesCoordinateIdentity(self: *MuxHandler, comptime action: StreamAction.Tag) bool {
        const term = &self.engineOf().term;
        const cursor = term.screens.active.cursor;
        const in_horizontal_region = cursor.x >= term.scrolling_region.left and
            cursor.x <= term.scrolling_region.right;
        return switch (action) {
            // These either shift cells/lines directly, or can move retained
            // rows into or out of scrollback.  A no-op is conservatively a
            // new source, which only rejects a racing registration.
            .insert_lines,
            .delete_lines,
            .insert_blanks,
            .delete_chars,
            .scroll_up,
            .scroll_down,
            .erase_display_complete,
            .erase_display_scrollback,
            .erase_display_scroll_complete,
            .full_reset,
            => true,

            // A pending wrap necessarily invokes printWrap. For an otherwise
            // ordinary print, `vt` compares pre/post cursor coordinates.
            .print => term.modes.get(.insert) or cursor.pending_wrap,
            .linefeed, .index, .next_line => in_horizontal_region and cursor.y == term.scrolling_region.bottom,
            .reverse_index => in_horizontal_region and cursor.y == term.scrolling_region.top,
            else => false,
        };
    }

    fn onSemanticPrompt(
        self: *MuxHandler,
        value: StreamAction.Value(.semantic_prompt),
    ) void {
        const kind: Engine.MarkEvent.Kind = switch (value.action) {
            .fresh_line_new_prompt => .prompt_start, // 'A'
            .end_input_start_output => .command_start, // 'C'
            .end_command => .command_end, // 'D'
            else => return, // L/N/P/B/I: prompt furniture, not boundaries
        };
        const eng = self.engineOf();
        // Only `D` carries a code, and even then only when the shell put one
        // in the mark; everything else has none to read.
        const raw = if (kind == .command_end) value.readOption(.exit_code) else null;
        // Masked to the low byte, which is what waitpid would have reported:
        // a shell is free to spell `D;300`, and truncating is the same answer
        // the kernel gives rather than a refusal to parse.
        const exit_code: ?u8 = if (raw) |code| @intCast(@as(u32, @bitCast(code)) & 0xff) else null;
        // Load-bearing catch: under OOM we drop the mark rather than fail
        // the feed. A dropped mark costs precision, not correctness —
        // await falls back to pgid/settle when no boundary arrives.
        eng.mark_events.append(eng.alloc, .{
            .kind = kind,
            .row = eng.historyRows() + eng.cursorPos().y,
            .exit_code = exit_code,
        }) catch {};
    }

    /// OSC 52. SET only: the `?` QUERY form is a deliberate refusal.
    fn onClipboard(
        self: *MuxHandler,
        value: StreamAction.Value(.clipboard_contents),
    ) void {
        if (std.mem.eql(u8, value.data, "?")) return;
        // A zero-length payload is the OSC 52 "clear" form, and also what an
        // interrupted copy looks like. mux is not obliged to proxy a clear, and
        // an accident must not wipe what the human last copied.
        if (value.data.len == 0) return;
        const eng = self.engineOf();
        if (value.data.len > eng.clipboard_max) return;
        // ghostty's slice dies with the callback, so the queue owns a copy.
        const owned = eng.alloc.dupe(u8, value.data) catch return;
        // Load-bearing catch, for mark_events' reason: under OOM we drop the
        // event rather than fail the feed.
        eng.side_events.append(eng.alloc, .{
            .kind = .clipboard,
            .target = value.kind,
            .payload = owned,
        }) catch eng.alloc.free(owned);
    }

    /// A bell has no payload and no state — it is the purest event in the
    /// set, and the reason `payload` defaults to empty.
    fn onBell(self: *MuxHandler) void {
        const eng = self.engineOf();
        // Load-bearing catch, for mark_events' reason: under OOM we drop the
        // event rather than fail the feed. Nothing to free on the way out,
        // unlike onClipboard — a bell owns nothing — and a dropped bell costs
        // one ring nobody hears, not correctness.
        eng.side_events.append(eng.alloc, .{ .kind = .bell }) catch {};
    }
};

pub const MuxStream = vt.Stream(MuxHandler);

pub const Engine = struct {
    alloc: std.mem.Allocator,
    term: vt.Terminal,
    stream: MuxStream,
    /// Response bytes the terminal wants written back to the PTY
    /// (cursor position reports, device attributes, ...). Owner drains
    /// via ptyOutput()/clearPtyOutput().
    pty_out: std.ArrayList(u8),
    /// OSC 133 mark events observed since the last clear. Drained by the
    /// server after each feed, exactly like pty_out.
    mark_events: std.ArrayList(MarkEvent),
    /// Side-channel events observed since the last clear. Drained by the
    /// server after each feed, exactly like pty_out and mark_events.
    side_events: std.ArrayList(SideEvent),
    /// Copied from Options: the interception path reads it per event.
    clipboard_max: usize,
    /// Full primary scrollback preserves pins only while the screen retains
    /// scrolled-off rows. With no scrollback, that same scroll discards them.
    scrollback_enabled: bool,
    /// Monotonic coordinate-identity token. Clients use this to identify the
    /// exact source state at which a tracked selection was installed.
    selection_source: u64,
    /// Changes when screen identity or geometry is reset, switched, or resized.
    /// Ordinary output leaves this stable so tracked pins can follow it.
    screen_epoch: u64,
    /// Lifetime of Ghostty's lazily allocated alternate Screen. Full reset
    /// destroys it; a new alternate may reuse its address.
    alternate_generation: u64,
    tracked_head: ?*TrackedSelection = null,

    pub const MarkEvent = struct {
        pub const Kind = enum(u8) { prompt_start, command_start, command_end };
        kind: Kind,
        /// Absolute screen-space row at mark time. A best-effort locator, not a
        /// durable anchor: pruning shifts the origin, resize reflow renumbers
        /// history, and alt-screen marks live in another coordinate space. Point
        /// a human at output with it, never key durable state.
        row: u32,
        /// Only ever set on command_end, and only when the mark carried one.
        exit_code: ?u8,
    };

    /// A side channel consumed by this engine that the client must replay
    /// onto the host terminal. `payload` is OWNED — ghostty's slice does
    /// not outlive the callback — and freed by clearSideEvents/deinit.
    pub const SideEvent = struct {
        pub const Kind = enum(u8) { clipboard, bell };
        kind: Kind,
        /// The OSC 52 target byte ('c', 'p', ...), meaningless for bell.
        /// Attacker-chosen and unvalidated: whatever byte sat between the two
        /// semicolons, passed through unchanged. The base64 half is re-validated
        /// on the wire and this byte is not, so the CLIENT must whitelist it.
        target: u8 = 0,
        /// Base64 as it arrived, undecoded. Empty for bell.
        payload: []const u8 = &.{},
    };

    pub const Options = struct {
        cols: u16,
        rows: u16,
        max_scrollback: usize = 10_000,
        /// The largest OSC 52 payload to queue and forward, in base64 bytes.
        /// Supplied by the caller because `protocol.zig` owns the wire's shape
        /// and `engine` cannot import it; must equal `clipboard_base64_max`,
        /// pinned by a test in `server.zig`.
        ///
        /// It bounds what mux retains and forwards, NOT ghostty's own parse
        /// buffer: the callback fires only on the terminator, so an unterminated
        /// OSC 52 grows that buffer regardless of this cap.
        clipboard_max: usize = 64 * 1024,
    };

    /// Result of copying a terminal selection into caller-owned memory.
    /// `text` is present only for `.ok`, including a valid empty selection.
    pub const SelectionExtract = struct {
        pub const Status = enum { ok, invalid, too_large };

        status: Status,
        /// `historyRows()` of the screen this extraction resolved against,
        /// sampled here so it cannot name a different moment than the text does.
        /// A page eviction renames every absolute row; this is how a requester
        /// notices.
        history_rows: u32 = 0,
        text: ?[]u8 = null,

        pub fn deinit(self: SelectionExtract, alloc: std.mem.Allocator) void {
            if (self.text) |value| alloc.free(value);
        }
    };

    /// A selection whose endpoints are pinned in one active Ghostty screen.
    /// The daemon keeps this per client, rather than using Screen.selection,
    /// so independent clients cannot overwrite one another's selection.
    const SelectionPoints = struct { anchor: proto.SelectionPoint, active: proto.SelectionPoint };

    pub const TrackedSelection = struct {
        engine: *Engine,
        screen: *vt.Screen,
        screen_key: vt.ScreenSet.Key,
        screen_epoch: u64,
        alternate_generation: u64,
        cols: u16,
        rows: u16,
        selection: vt.Selection,
        prev: ?*TrackedSelection = null,
        next: ?*TrackedSelection = null,
        valid: bool = true,
        discard_candidate: bool = false,
        remap: ?SelectionPoints = null,
        rotate_rows: u16 = 0,

        pub fn deinit(self: *TrackedSelection) void {
            self.unlink();
            // A full reset may destroy the alternate Screen and its pins.
            // Its allocator can then recreate it at this exact address; the
            // explicit generation prevents an ABA untrack in that new Screen.
            if (self.screen_key == .alternate and
                self.engine.alternate_generation != self.alternate_generation)
                return self.engine.alloc.destroy(self);
            const current = self.engine.term.screens.all.get(self.screen_key) orelse {
                self.engine.alloc.destroy(self);
                return;
            };
            if (current == self.screen) self.selection.deinit(self.screen);
            self.engine.alloc.destroy(self);
        }

        fn unlink(self: *TrackedSelection) void {
            if (self.prev) |prev| prev.next = self.next else if (self.engine.tracked_head == self) self.engine.tracked_head = self.next;
            if (self.next) |next| next.prev = self.prev;
            self.prev = null;
            self.next = null;
        }

        /// Current screen-space endpoints. A garbage pin, screen switch, or
        /// geometry change retires the durable selection.
        pub fn points(self: *const TrackedSelection) ?SelectionPoints {
            if (!self.valid or self.engine.term.screens.active_key != self.screen_key or
                self.engine.screen_epoch != self.screen_epoch or
                self.engine.term.cols != self.cols or self.engine.term.rows != self.rows)
                return null;

            const anchor_pin = self.selection.start();
            const active_pin = self.selection.end();
            if (anchor_pin.garbage or active_pin.garbage) return null;
            const anchor = self.screen.pages.pointFromPin(.screen, anchor_pin) orelse return null;
            const active = self.screen.pages.pointFromPin(.screen, active_pin) orelse return null;
            const anchor_coord = switch (anchor) {
                .screen => |coord| coord,
                else => return null,
            };
            const active_coord = switch (active) {
                .screen => |coord| coord,
                else => return null,
            };
            return .{
                .anchor = .{ .row = anchor_coord.y, .col = @intCast(anchor_coord.x) },
                .active = .{ .row = active_coord.y, .col = @intCast(active_coord.x) },
            };
        }

        /// Extract from the endpoints' current positions. Null means the
        /// tracked pins no longer name the original active screen geometry.
        pub fn extract(
            self: *const TrackedSelection,
            alloc: std.mem.Allocator,
            max_bytes: usize,
        ) !?SelectionExtract {
            const p = self.points() orelse return null;
            return try self.engine.extractSelection(
                alloc,
                p.anchor.row,
                p.anchor.col,
                p.active.row,
                p.active.col,
                max_bytes,
            );
        }
    };

    /// Heap-allocates: stream.handler holds a pointer to `term`, so an
    /// Engine must never move after init.
    pub fn init(alloc: std.mem.Allocator, opts: Options) !*Engine {
        const self = try alloc.create(Engine);
        errdefer alloc.destroy(self);

        self.* = .{
            .alloc = alloc,
            .term = try vt.Terminal.init(alloc, .{
                .cols = @intCast(opts.cols),
                .rows = @intCast(opts.rows),
                .max_scrollback = opts.max_scrollback,
            }),
            .stream = undefined,
            .pty_out = .empty,
            .mark_events = .empty,
            .side_events = .empty,
            .clipboard_max = opts.clipboard_max,
            .scrollback_enabled = opts.max_scrollback != 0,
            .selection_source = 1,
            .screen_epoch = 1,
            .alternate_generation = 1,
            .tracked_head = null,
        };
        errdefer self.term.deinit(alloc);

        self.stream = .initAlloc(alloc, .{ .inner = .{ .terminal = &self.term } });
        self.stream.handler.inner.effects.write_pty = &onWritePty;
        // Silence here is a TIMEOUT, not a degradation: DA1 is the barrier TUIs
        // send after their capability probes and block on. The color and termcap
        // queries stay silent on purpose — a headless engine inventing a
        // background colour would lie to theme detection.
        self.stream.handler.inner.effects.device_attributes = &onDeviceAttributes;
        return self;
    }

    pub fn deinit(self: *Engine) void {
        while (self.tracked_head) |tracked| tracked.deinit();
        self.pty_out.deinit(self.alloc);
        self.clearSideEvents();
        self.side_events.deinit(self.alloc);
        self.mark_events.deinit(self.alloc);
        self.stream.deinit();
        self.term.deinit(self.alloc);
        self.alloc.destroy(self);
    }

    pub fn feed(self: *Engine, bytes: []const u8) void {
        self.stream.nextSlice(bytes);
    }

    fn prepareTrackedMutation(self: *Engine, comptime action: StreamAction.Tag, value: StreamAction.Value(action)) void {
        if (self.tracked_head == null) return;
        const t = &self.term;
        const c = t.screens.active.cursor;
        const in_region = c.x >= t.scrolling_region.left and c.x <= t.scrolling_region.right;
        switch (action) {
            .print => if (t.modes.get(.insert)) self.invalidateActive(),
            .scroll_up => self.prepareScrollUp(value),
            .scroll_down => self.shiftSelectionRows(t.scrolling_region.top, t.scrolling_region.bottom, value, .down),
            .index, .linefeed, .next_line => if (in_region and c.y == t.scrolling_region.bottom) self.prepareScrollUp(1),
            .reverse_index => if (in_region and c.y == t.scrolling_region.top) self.shiftSelectionRows(t.scrolling_region.top, t.scrolling_region.bottom, 1, .down),
            .insert_lines, .delete_lines => if (in_region and c.y >= t.scrolling_region.top and c.y <= t.scrolling_region.bottom) {
                self.shiftSelectionRows(c.y, t.scrolling_region.bottom, value, if (action == .insert_lines) .down else .up);
            },
            // Horizontal edits need a different range policy from vertical moves.
            .insert_blanks, .delete_chars => self.invalidateActive(),
            .erase_display_complete, .erase_display_scrollback, .erase_display_scroll_complete, .full_reset => self.invalidateActive(),
            else => {},
        }
    }
    fn prepareScrollUp(self: *Engine, count: usize) void {
        const t = &self.term;
        if (count == 0) return;
        if (t.scrolling_region.top == 0 and t.scrolling_region.left == 0 and t.scrolling_region.right == t.cols - 1) {
            // Ghostty moves retained history itself. A partial bottom also
            // rotates the untouched rows below it; those external pins need
            // the same displacement after Ghostty has grown/pruned pages.
            const n: u16 = @intCast(@min(count, t.scrolling_region.bottom + 1));
            const origin = self.historyRows();
            const bottom = origin + t.scrolling_region.bottom;
            var it = self.tracked_head;
            while (it) |tracked| : (it = tracked.next) if (tracked.points()) |points| {
                const first = @min(points.anchor.row, points.active.row);
                const last = @max(points.anchor.row, points.active.row);
                if (first <= bottom and last > bottom) tracked.discard_candidate = true;
                if (first > bottom) tracked.rotate_rows = n;
                if (!self.scrollback_enabled or t.screens.active_key == .alternate)
                    tracked.discard_candidate = tracked.discard_candidate or first < n;
            };
            return;
        }
        self.shiftSelectionRows(t.scrolling_region.top, t.scrolling_region.bottom, count, .up);
    }
    const ShiftDirection = enum { up, down };
    fn shiftSelectionRows(self: *Engine, first: u16, last: u16, count: usize, direction: ShiftDirection) void {
        if (count == 0 or self.tracked_head == null) return;
        const t = &self.term;
        // A rectangular margin can split a linear selection. Retire it until
        // that separate selection policy is supported.
        if (t.scrolling_region.left != 0 or t.scrolling_region.right != t.cols - 1) return self.invalidateActive();
        const origin = self.historyRows();
        const top = origin + first;
        const bottom = origin + last;
        const n: u32 = @intCast(@min(count, last - first + 1));
        var it = self.tracked_head;
        while (it) |tracked| : (it = tracked.next) if (tracked.points()) |points| {
            const start = @min(points.anchor.row, points.active.row);
            const end = @max(points.anchor.row, points.active.row);
            if (start <= bottom and end >= top and (start < top or end > bottom)) {
                tracked.discard_candidate = true;
                continue;
            }
            var moved = points;
            for ([_]*proto.SelectionPoint{ &moved.anchor, &moved.active }) |point| {
                if (point.row < top or point.row > bottom) continue;
                switch (direction) {
                    .up => if (point.row < top + n) {
                        tracked.discard_candidate = true;
                    } else {
                        point.row -= n;
                    },
                    .down => if (point.row + n > bottom) {
                        tracked.discard_candidate = true;
                    } else {
                        point.row += n;
                    },
                }
            }
            tracked.remap = moved;
        };
    }
    fn markPossiblePrintDiscard(self: *Engine) void {
        if (self.tracked_head == null) return;
        const t = &self.term;
        const c = t.screens.active.cursor;
        if (c.y == t.scrolling_region.bottom and c.x >= t.scrolling_region.left and c.x <= t.scrolling_region.right and
            (c.x == t.scrolling_region.right or c.pending_wrap)) self.prepareScrollUp(1);
    }
    fn commitTrackedMutation(self: *Engine) void {
        var it = self.tracked_head;
        while (it) |tracked| : (it = tracked.next) {
            if (tracked.discard_candidate) tracked.valid = false;
            if (tracked.rotate_rows != 0) {
                if (tracked.points()) |points| {
                    var moved = points;
                    moved.anchor.row += tracked.rotate_rows;
                    moved.active.row += tracked.rotate_rows;
                    tracked.remap = moved;
                } else tracked.valid = false;
            }
            if (tracked.valid) if (tracked.remap) |points| {
                // IL/DL copy row contents without remapping arbitrary pins.
                // Rebind after the action, also covering index's fast path
                // without duplicating Ghostty's choice of row-copy algorithm.
                const a = tracked.screen.pages.pin(.{ .screen = .{ .x = points.anchor.col, .y = points.anchor.row } });
                const b = tracked.screen.pages.pin(.{ .screen = .{ .x = points.active.col, .y = points.active.row } });
                if (a != null and b != null) {
                    tracked.selection.startPtr().* = a.?;
                    tracked.selection.endPtr().* = b.?;
                } else tracked.valid = false;
            };
            tracked.discard_candidate = false;
            tracked.remap = null;
            tracked.rotate_rows = 0;
        }
    }
    fn clearTrackedMutation(self: *Engine) void {
        var it = self.tracked_head;
        while (it) |tracked| : (it = tracked.next) {
            tracked.discard_candidate = false;
            tracked.remap = null;
            tracked.rotate_rows = 0;
        }
    }
    fn invalidateActive(self: *Engine) void {
        var it = self.tracked_head;
        while (it) |tracked| : (it = tracked.next) {
            if (tracked.screen == self.term.screens.active) tracked.valid = false;
        }
    }

    pub fn selectionSource(self: *const Engine) u64 {
        return self.selection_source;
    }

    pub fn ptyOutput(self: *const Engine) []const u8 {
        return self.pty_out.items;
    }

    pub fn clearPtyOutput(self: *Engine) void {
        self.pty_out.clearRetainingCapacity();
    }

    pub fn markEvents(self: *const Engine) []const MarkEvent {
        return self.mark_events.items;
    }

    pub fn clearMarkEvents(self: *Engine) void {
        self.mark_events.clearRetainingCapacity();
    }

    pub fn sideEvents(self: *const Engine) []const SideEvent {
        return self.side_events.items;
    }

    pub fn clearSideEvents(self: *Engine) void {
        for (self.side_events.items) |ev| self.alloc.free(ev.payload);
        self.side_events.clearRetainingCapacity();
    }

    /// Visible screen as plain UTF-8 text. Caller frees.
    pub fn dumpPlain(self: *Engine, alloc: std.mem.Allocator) ![]const u8 {
        return self.term.plainString(alloc);
    }

    /// A selection spanning viewport rows [y0, y1] in full. Without such a
    /// selection, the formatter walks the entire PageList — scrollback
    /// included — so dumps and snapshots would grow with session history.
    fn viewportRows(self: *Engine, y0: u16, y1: u16) ?vt.Selection {
        const screen = self.term.screens.active;
        const tl = screen.pages.pin(.{ .viewport = .{ .x = 0, .y = y0 } }) orelse return null;
        const br = screen.pages.pin(.{ .viewport = .{
            .x = @intCast(self.term.cols - 1),
            .y = y1,
        } }) orelse return null;
        return vt.Selection.init(tl, br, false);
    }

    /// A selection spanning exactly the visible viewport.
    fn viewportSelection(self: *Engine) ?vt.Selection {
        return self.viewportRows(0, @intCast(self.term.rows - 1));
    }

    /// No palette/mode side effects, so a host terminal keeps its theme.
    /// Null writes nothing.
    fn writeSelection(
        self: *Engine,
        w: *std.Io.Writer,
        opts: vt.formatter.Options,
        sel: ?vt.Selection,
    ) !void {
        const s = sel orelse return;
        var f = vt.formatter.TerminalFormatter.init(&self.term, opts);
        f.extra = .none;
        f.content = .{ .selection = s };
        try f.format(w);
    }

    /// `prefix`, then `sel` as styled VT bytes. Caller frees.
    fn formatSelection(
        self: *Engine,
        alloc: std.mem.Allocator,
        prefix: []const u8,
        sel: ?vt.Selection,
    ) ![]u8 {
        var aw: std.Io.Writer.Allocating = .init(alloc);
        defer aw.deinit();
        // Callers needing self-contained output pass an explicit reset
        // prefix; the formatter emits its own only for styled rows, hence
        // the occasional doubling.
        try aw.writer.writeAll(prefix);
        try self.writeSelection(&aw.writer, .vt, sel);
        return try aw.toOwnedSlice();
    }

    /// Viewport only, SGR preserved, no palette/mode side effects. History
    /// stays daemon-side, fetched by `encodeScrollback`.
    pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 {
        return self.formatSelection(alloc, "", self.viewportSelection());
    }

    /// `dumpVt` from row `y0` down: the viewport with its top rows cut off,
    /// formatted exactly as a grid of that height would be. The e2e render
    /// fixture uses it to drop a tty client's label bar, so the rows under
    /// the bar stay byte-diffable against the daemon's own dump.
    pub fn dumpVtFrom(self: *Engine, alloc: std.mem.Allocator, y0: u16) ![]u8 {
        std.debug.assert(y0 < self.term.rows);
        return self.formatSelection(alloc, "", self.viewportRows(y0, @intCast(self.term.rows - 1)));
    }

    /// Full terminal state as a canonical VT byte sequence — the Snapshot
    /// payload body. Feeding it into a fresh engine of the same size
    /// reconstructs the state.
    ///
    /// On the alt screen the primary's visible content is emitted FIRST, since
    /// the replica starts on the primary after its reset; the mode section then
    /// switches before the alt content lands, so leaving the alt screen reveals
    /// real primary content. The primary's saved-cursor lands at the end of that
    /// content rather than the pre-TUI spot.
    pub fn dumpState(self: *Engine, alloc: std.mem.Allocator) ![]u8 {
        var aw: std.Io.Writer.Allocating = .init(alloc);
        defer aw.deinit();

        if (self.onAltScreen()) primary: {
            const primary = self.term.screens.get(.primary) orelse break :primary;
            const rows: u32 = self.term.rows;
            const tl = primary.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }) orelse break :primary;
            const br = primary.pages.pin(.{ .active = .{
                .x = @intCast(self.term.cols - 1),
                .y = @intCast(rows - 1),
            } }) orelse break :primary;
            var pf = vt.formatter.ScreenFormatter.init(primary, .vt);
            pf.extra = .none;
            pf.content = .{ .selection = vt.Selection.init(tl, br, false) };
            try pf.format(&aw.writer);
        }

        var f = vt.formatter.TerminalFormatter.init(&self.term, .vt);
        f.extra = .all;
        // Visible grid only, per the handoff's lazy-scrollback rule:
        // snapshots must not grow with session history.
        f.content = .{ .selection = self.viewportSelection() };
        try f.format(&aw.writer);
        // The formatter emits scrolling region (DECSTBM homes the cursor)
        // and tabstops (HTS walks the cursor) *after* the screen section's
        // CUP, so the dump's final cursor position is wrong. Re-assert it.
        const cur = self.cursorPos();
        // folder rule 4 exemption: The authoritative VT engine produces and consumes escape sequences.
        try aw.writer.print("\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 });
        return try aw.toOwnedSlice();
    }

    /// True when the alternate screen is active (TUIs).
    pub fn onAltScreen(self: *const Engine) bool {
        return self.term.screens.active_key != .primary;
    }

    /// Sampled DEC 2004: only the host terminal can bracket a paste, so the
    /// client mirrors it.
    pub fn bracketedPaste(self: *const Engine) bool {
        return self.term.modes.get(.bracketed_paste);
    }

    /// DECCKM changes what an arrow key IS on the wire: `ESC O A`, not
    /// `ESC [ A`.
    pub fn cursorKeys(self: *const Engine) bool {
        return self.term.modes.get(.cursor_keys);
    }

    /// Tracking modes AND report formats: a report in a spelling the
    /// application did not ask for arrives as garbage in its input. Field
    /// names, not `protocol`'s — the engine does not import the wire
    /// contract.
    pub const MouseModes = struct {
        x10: bool = false,
        normal: bool = false,
        button: bool = false,
        any: bool = false,
        utf8: bool = false,
        sgr: bool = false,
        urxvt: bool = false,
        sgr_pixels: bool = false,
    };

    pub fn mouseModes(self: *const Engine) MouseModes {
        const m = &self.term.modes;
        return .{
            .x10 = m.get(.mouse_event_x10),
            .normal = m.get(.mouse_event_normal),
            .button = m.get(.mouse_event_button),
            .any = m.get(.mouse_event_any),
            .utf8 = m.get(.mouse_format_utf8),
            .sgr = m.get(.mouse_format_sgr),
            .urxvt = m.get(.mouse_format_urxvt),
            .sgr_pixels = m.get(.mouse_format_sgr_pixels),
        };
    }

    /// OSC 0/2, sampled. Empty and "never set" are the same answer here.
    pub fn title(self: *const Engine) []const u8 {
        return self.term.getTitle() orelse "";
    }

    /// Number of history (scrolled-off) rows above the viewport on the
    /// active screen. Alt screens have no scrollback: returns 0.
    pub fn historyRows(self: *const Engine) u32 {
        const screen = self.term.screens.active;
        const top = screen.pages.pin(.{ .viewport = .{ .x = 0, .y = 0 } }) orelse return 0;
        const pt = screen.pages.pointFromPin(.screen, top) orelse return 0;
        return @intCast(pt.screen.y);
    }

    pub const EncodedRows = struct { first: u32, count: u16, bytes: []u8 };

    fn packColor(col: anytype) u32 {
        return switch (col) {
            .none => proto.color_none,
            .palette => |p| proto.colorPalette(p),
            .rgb => |c| proto.colorRgb(c.r, c.g, c.b),
        };
    }

    fn packStyle(style: vt.Style) proto.CellStyle {
        return .{
            .fg = packColor(style.fg_color),
            .bg = packColor(style.bg_color),
            .ul = packColor(style.underline_color),
            .flags = @bitCast(style.flags),
        };
    }

    /// One row of the active screen at `pt` as a CellRow. The last cell sent
    /// is the last one that is not a default blank; a bare-background cell
    /// counts as content, or a coloured EL would vanish.
    fn encodeRowAt(self: *Engine, alloc: std.mem.Allocator, pt: vt.point.Point) ![]u8 {
        const screen = self.term.screens.active;
        var list: std.ArrayList(u8) = .empty;
        errdefer list.deinit(alloc);
        var w = try proto.CellRowWriter.begin(&list, alloc);
        errdefer w.deinit();
        const pin = screen.pages.pin(pt) orelse {
            w.finish();
            return list.toOwnedSlice(alloc);
        };
        const page = &pin.node.data;
        const rac = pin.rowAndCell();
        const cells = page.getCells(rac.row);
        // A page is built at the terminal's width, but the min keeps a
        // mismatch a short row rather than a read past the row's cells.
        const cols: usize = @min(@as(usize, self.term.cols), cells.len);

        // Find the last cell worth sending.
        var last: usize = 0;
        var any = false;
        for (cells[0..cols], 0..) |c, x| {
            const blank = c.style_id == 0 and c.wide == .narrow and switch (c.content_tag) {
                .codepoint => c.content.codepoint == 0 or c.content.codepoint == ' ',
                .codepoint_grapheme => false,
                .bg_color_palette, .bg_color_rgb => false,
            };
            if (!blank) {
                last = x;
                any = true;
            }
        }
        if (!any) {
            w.finish();
            return list.toOwnedSlice(alloc);
        }

        var text: [proto.cell_text_max]u8 = undefined;
        for (cells[0 .. last + 1]) |*c| {
            var style: proto.CellStyle = if (c.style_id == 0) .{} else packStyle(page.styles.get(page.memory, c.style_id).*);
            var len: usize = 0;
            switch (c.content_tag) {
                .codepoint, .codepoint_grapheme => {
                    if (c.content.codepoint != 0) {
                        len += std.unicode.utf8Encode(@intCast(c.content.codepoint), text[len..]) catch 0;
                    } else if (c.wide == .narrow) {
                        // A cell nothing ever wrote holds codepoint 0, not a
                        // space. It paints as a space and the plain dump
                        // renders it as one, so it goes on the wire as one:
                        // an empty cell would drop its whole run out of the
                        // ascii form. Every ncurses program paints by erasing
                        // and jumping, so these sit between the glyphs of an
                        // ordinary htop or vim row. A spacer keeps its empty
                        // text — the wide cell beside it carries the glyph.
                        text[0] = ' ';
                        len = 1;
                    }
                    if (c.hasGrapheme()) {
                        if (page.lookupGrapheme(c)) |cps| {
                            for (cps) |cp| {
                                var buf: [4]u8 = undefined;
                                const n = std.unicode.utf8Encode(@intCast(cp), &buf) catch continue;
                                if (len + n > proto.cell_text_max) break;
                                @memcpy(text[len .. len + n], buf[0..n]);
                                len += n;
                            }
                        }
                    }
                },
                .bg_color_palette => style.bg = proto.colorPalette(c.content.color_palette),
                .bg_color_rgb => style.bg = proto.colorRgb(c.content.color_rgb.r, c.content.color_rgb.g, c.content.color_rgb.b),
            }
            const wide: proto.Wide = switch (c.wide) {
                .narrow => .narrow,
                .wide => .wide,
                .spacer_tail => .spacer_tail,
                .spacer_head => .spacer_head,
            };
            // A space is sent AS a space, not as the empty text a blank cell
            // carries. Both paint the same, but only a one-byte narrow cell
            // qualifies for the run's ascii form, and one empty cell drops the
            // whole run out of it — every remaining cell then pays a head byte.
            // Blanking interior spaces cost 36% on a prose screen for nothing
            // (measured 2026-09-04, decisions.md). Trailing blanks are already
            // gone: the row ends at the last cell that is not one.
            const t: []const u8 = text[0..len];
            try w.cell(style, wide, t);
        }
        w.finish();
        return list.toOwnedSlice(alloc);
    }

    pub fn encodeViewportRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 {
        std.debug.assert(y < self.term.rows);
        return self.encodeRowAt(alloc, .{ .viewport = .{ .x = 0, .y = y } });
    }

    /// Screen-space rows [start, start+count) on the active screen, clamped
    /// to what exists, dense. Row 0 is the oldest retained history row.
    pub fn encodeScrollback(self: *Engine, alloc: std.mem.Allocator, start: u32, count: u16) !EncodedRows {
        const total: u32 = self.historyRows() + self.term.rows;
        const first = @min(start, total -| 1);
        const last = @min(first + count -| 1, total -| 1);
        var list: std.ArrayList(u8) = .empty;
        errdefer list.deinit(alloc);
        var y = first;
        var n: u16 = 0;
        while (y <= last and total > 0) : (y += 1) {
            const row = try self.encodeRowAt(alloc, .{ .screen = .{ .x = 0, .y = y } });
            defer alloc.free(row);
            try list.appendSlice(alloc, row);
            n += 1;
        }
        return .{ .first = first, .count = n, .bytes = try list.toOwnedSlice(alloc) };
    }

    /// Encode every viewport row into `g` — the test bridge between an
    /// authored screen and the grid a client would hold, and the only way a
    /// harness gets a Grid from bytes without a daemon.
    pub fn mirrorInto(self: *Engine, g: *Grid) !void {
        if (g.cols != self.term.cols or g.rows != self.term.rows)
            try g.resize(@intCast(self.term.cols), @intCast(self.term.rows));
        var y: u16 = 0;
        while (y < self.term.rows) : (y += 1) {
            const row = try self.encodeViewportRow(self.alloc, y);
            defer self.alloc.free(row);
            try g.applyRow(y, row);
        }
        const cur = self.cursorPos();
        g.cursor = .{ .x = cur.x, .y = cur.y };
    }

    /// Pin a selection to the currently active screen. The returned owner
    /// must be deinitialized by the caller, even when the terminal later
    /// makes either endpoint unavailable.
    pub fn trackSelection(
        self: *Engine,
        anchor: proto.SelectionPoint,
        active: proto.SelectionPoint,
    ) !?*TrackedSelection {
        if (anchor.col >= self.term.cols or active.col >= self.term.cols)
            return null;

        const screen = self.term.screens.active;
        const start = screen.pages.pin(.{ .screen = .{
            .x = anchor.col,
            .y = anchor.row,
        } }) orelse return null;
        const end = screen.pages.pin(.{ .screen = .{
            .x = active.col,
            .y = active.row,
        } }) orelse return null;
        const selection = vt.Selection.init(start, end, false);
        const pinned = try selection.track(screen);
        errdefer pinned.deinit(screen);
        const tracked = try self.alloc.create(TrackedSelection);
        tracked.* = .{
            .engine = self,
            .screen = screen,
            .screen_key = self.term.screens.active_key,
            .screen_epoch = self.screen_epoch,
            .alternate_generation = self.alternate_generation,
            .cols = @intCast(self.term.cols),
            .rows = @intCast(self.term.rows),
            .selection = pinned,
        };
        tracked.next = self.tracked_head;
        if (self.tracked_head) |head| head.prev = tracked;
        self.tracked_head = tracked;
        return tracked;
    }

    /// Screen-space rows, zero being the oldest retained. Formatting writes
    /// into a fixed-size allocation: hostile coordinates cannot blow it up.
    pub fn extractSelection(
        self: *Engine,
        alloc: std.mem.Allocator,
        anchor_row: u32,
        anchor_col: u16,
        active_row: u32,
        active_col: u16,
        max_bytes: usize,
    ) !SelectionExtract {
        const history_rows = self.historyRows();
        if (anchor_col >= self.term.cols or active_col >= self.term.cols) {
            return .{ .status = .invalid, .history_rows = history_rows };
        }

        const screen = self.term.screens.active;
        const anchor = screen.pages.pin(.{ .screen = .{
            .x = anchor_col,
            .y = anchor_row,
        } }) orelse return .{ .status = .invalid, .history_rows = history_rows };
        const active = screen.pages.pin(.{ .screen = .{
            .x = active_col,
            .y = active_row,
        } }) orelse return .{ .status = .invalid, .history_rows = history_rows };

        // Selection owns endpoint normalization and inclusive wide-cell
        // semantics. Keeping the original direction also leaves those rules
        // centralized in ghostty-vt instead of reimplementing them here.
        const selection = vt.Selection.init(anchor, active, false);

        // Sized from the selection's own extent, not from the cap: a three-row
        // copy has no business allocating a megabyte on the pty pump. Four bytes
        // per cell covers every single-codepoint cell but NOT a grapheme
        // cluster, so this is a first ATTEMPT — exhausting it retries at the real
        // cap, and only that second writer may say `.too_large`.
        const span: u64 = @as(u64, @max(anchor_row, active_row) -
            @min(anchor_row, active_row)) + 1;
        const estimate: usize = @intCast(@min(
            @as(u64, max_bytes),
            span *| (@as(u64, self.term.cols) * 4 + 1),
        ));

        var cap = estimate;
        while (true) {
            const buffer = try alloc.alloc(u8, cap);
            defer alloc.free(buffer);
            var writer: std.Io.Writer = .fixed(buffer);

            var formatter = vt.formatter.ScreenFormatter.init(screen, .{
                .emit = .plain,
                .unwrap = true,
                .trim = true,
            });
            formatter.content = .{ .selection = selection };
            if (formatter.format(&writer)) |_| {
                return .{
                    .status = .ok,
                    .history_rows = history_rows,
                    .text = try alloc.dupe(u8, writer.buffered()),
                };
            } else |err| switch (err) {
                // A fixed writer's sole failure mode is exhausting `buffer`.
                error.WriteFailed => {
                    if (cap >= max_bytes)
                        return .{ .status = .too_large, .history_rows = history_rows };
                    cap = max_bytes;
                },
            }
        }
    }

    pub const CursorPos = struct { x: u16, y: u16 };

    /// 0-based cursor position on the active screen.
    pub fn cursorPos(self: *const Engine) CursorPos {
        const cur = self.term.screens.active.cursor;
        return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) };
    }

    /// Full reset (RIS). Also DISCARDS queued side_events: drain them first.
    pub fn reset(self: *Engine) void {
        self.selection_source +%= 1;
        self.screen_epoch +%= 1;
        self.alternate_generation +%= 1;
        self.term.fullReset();
        self.clearSideEvents();
    }

    pub fn resize(self: *Engine, cols: u16, rows: u16) !void {
        self.selection_source +%= 1;
        self.screen_epoch +%= 1;
        try self.term.resize(self.alloc, @intCast(cols), @intCast(rows));
        if (self.term.modes.get(.in_band_size_reports)) self.reportSize();
    }

    /// The mode-2048 in-band size report, queued for the pty like any other
    /// answer. Pixel fields are 0: a headless engine has no cell metrics,
    /// and the protocol allows it.
    fn reportSize(self: *Engine) void {
        var buf: [48]u8 = undefined;
        // folder rule 4 exemption: The authoritative VT engine produces and consumes escape sequences.
        const rep = std.fmt.bufPrint(&buf, "\x1b[48;{d};{d};0;0t", .{ self.term.rows, self.term.cols }) catch return;
        self.pty_out.appendSlice(self.alloc, rep) catch {};
    }

    /// The Attributes type is not re-exported from the vt module root, so it
    /// is derived from the effects field it must match — which also keeps
    /// this compiling if the dep renames its internals.
    const DeviceAttributes = ret: {
        const F = @typeInfo(@FieldType(
            vt.TerminalStream.Handler.Effects,
            "device_attributes",
        )).optional.child;
        break :ret @typeInfo(@typeInfo(F).pointer.child).@"fn".return_type.?;
    };

    /// Wired even though it returns the default: an unanswered DA1 blocks
    /// TUIs.
    fn onDeviceAttributes(_: *vt.TerminalStream.Handler) DeviceAttributes {
        return .{};
    }

    fn onWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void {
        const mh: *MuxHandler = @fieldParentPtr("inner", handler);
        const stream_ptr: *MuxStream = @fieldParentPtr("handler", mh);
        // The @alignCast is for wasm32, where pointers default to 4-byte
        // alignment while Engine needs 8. Sound: the parent really is
        // 8-aligned — every Engine comes from alloc.create (init's
        // never-moves contract). A no-op assert on native.
        const self: *Engine = @alignCast(@fieldParentPtr("stream", stream_ptr));
        self.pty_out.appendSlice(self.alloc, data) catch {};
    }
};

test "Engine: dumpVtFrom of the rows under a bar equals dumpVt of the bare grid" {
    // The equivalence the e2e convergence check stands on: a tty client's
    // screen is one label-bar row on top of the session grid, so the
    // harness renders the client stream, drops the bar, and diffs the rest
    // against the daemon's dump. That diff is byte-exact only if slicing
    // the viewport formats identically to a grid that never had the row.
    const alloc = std.testing.allocator;
    var barred = try Engine.init(alloc, .{ .cols = 20, .rows = 3 });
    defer barred.deinit();
    barred.feed("\x1b[7m 1> x [up]\x1b[0m\r\n\x1b[31mred\x1b[0m row\r\nplain");
    var bare = try Engine.init(alloc, .{ .cols = 20, .rows = 2 });
    defer bare.deinit();
    bare.feed("\x1b[31mred\x1b[0m row\r\nplain");

    const sliced = try barred.dumpVtFrom(alloc, 1);
    defer alloc.free(sliced);
    const whole = try bare.dumpVt(alloc);
    defer alloc.free(whole);
    try std.testing.expectEqualStrings(whole, sliced);

    // From row 0 it is dumpVt itself.
    const all = try barred.dumpVtFrom(alloc, 0);
    defer alloc.free(all);
    const vt_dump = try barred.dumpVt(alloc);
    defer alloc.free(vt_dump);
    try std.testing.expectEqualStrings(vt_dump, all);
}

test "Engine: selection extraction normalizes direction and unwraps soft wraps" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 5, .rows = 3 });
    defer e.deinit();

    e.feed("abcdeFG");
    var forward = try e.extractSelection(alloc, 0, 3, 1, 1, 64);
    defer forward.deinit(alloc);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.ok, forward.status);
    try std.testing.expectEqualStrings("deFG", forward.text.?);

    var reverse = try e.extractSelection(alloc, 1, 1, 0, 3, 64);
    defer reverse.deinit(alloc);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.ok, reverse.status);
    try std.testing.expectEqualStrings("deFG", reverse.text.?);
}

test "Engine: selection extraction preserves hard newlines and trims trailing spaces" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 5, .rows = 3 });
    defer e.deinit();

    e.feed("abc\r\nxyz");
    var hard = try e.extractSelection(alloc, 0, 0, 1, 2, 64);
    defer hard.deinit(alloc);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.ok, hard.status);
    try std.testing.expectEqualStrings("abc\nxyz", hard.text.?);

    e.reset();
    e.feed("ab   \r\ncd");
    var trimmed = try e.extractSelection(alloc, 0, 0, 1, 1, 64);
    defer trimmed.deinit(alloc);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.ok, trimmed.status);
    try std.testing.expectEqualStrings("ab\ncd", trimmed.text.?);
}

test "Engine: selection extraction treats wide-cell continuations as their glyph" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 5, .rows = 3 });
    defer e.deinit();
    e.feed("A漢B");

    var forward = try e.extractSelection(alloc, 0, 1, 0, 2, 64);
    defer forward.deinit(alloc);
    try std.testing.expectEqualStrings("漢", forward.text.?);

    var reverse = try e.extractSelection(alloc, 0, 2, 0, 1, 64);
    defer reverse.deinit(alloc);
    try std.testing.expectEqualStrings("漢", reverse.text.?);

    var continuation = try e.extractSelection(alloc, 0, 2, 0, 2, 64);
    defer continuation.deinit(alloc);
    try std.testing.expectEqualStrings("漢", continuation.text.?);
}

test "Engine: selection extraction rejects invalid coordinates without clamping" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 5, .rows = 3 });
    defer e.deinit();
    e.feed("hello");

    const cases = [_][4]u32{
        .{ 0, 5, 0, 4 },
        .{ std.math.maxInt(u32), 0, 0, 0 },
        .{ 0, 0, std.math.maxInt(u32), 0 },
    };
    for (cases) |coords| {
        const result = try e.extractSelection(
            alloc,
            coords[0],
            @intCast(coords[1]),
            coords[2],
            @intCast(coords[3]),
            64,
        );
        try std.testing.expectEqual(Engine.SelectionExtract.Status.invalid, result.status);
        try std.testing.expectEqual(@as(?[]u8, null), result.text);
    }
}

test "Engine: selection extraction enforces byte cap before accumulation" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3 });
    defer e.deinit();
    e.feed("A漢B");

    const too_small = try e.extractSelection(alloc, 0, 0, 0, 3, 4);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.too_large, too_small.status);
    try std.testing.expectEqual(@as(?[]u8, null), too_small.text);

    var exact = try e.extractSelection(alloc, 0, 0, 0, 3, 5);
    defer exact.deinit(alloc);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.ok, exact.status);
    try std.testing.expectEqualStrings("A漢B", exact.text.?);

    const zero = try e.extractSelection(alloc, 0, 0, 0, 0, 0);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.too_large, zero.status);
    try std.testing.expectEqual(@as(?[]u8, null), zero.text);

    var empty = try e.extractSelection(alloc, 2, 7, 2, 7, 0);
    defer empty.deinit(alloc);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.ok, empty.status);
    try std.testing.expectEqualStrings("", empty.text.?);
}

test "Engine: selection extraction allocates from its extent, not from the cap" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 5, .rows = 3 });
    defer e.deinit();
    e.feed("one\r\ntwo\r\nsix");

    // A budget far under the 1 MiB the daemon offers, and far over what
    // three five-column rows can need. Sizing the format buffer from the
    // cap made this an OutOfMemory on the single-threaded pty pump; sizing
    // it from the selection makes it an ordinary copy.
    var budget: [4096]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&budget);

    var small = try e.extractSelection(fba.allocator(), 0, 0, 2, 2, 1024 * 1024);
    defer small.deinit(fba.allocator());
    try std.testing.expectEqual(Engine.SelectionExtract.Status.ok, small.status);
    try std.testing.expectEqualStrings("one\ntwo\nsix", small.text.?);
}

test "Engine: a grapheme past the extent estimate still extracts whole" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 5, .rows = 3 });
    defer e.deinit();

    // One cell, far more than the four bytes the extent estimate budgets
    // for it: this is the case that proves the estimate is an attempt and
    // not the cap. U+0301 is two bytes each.
    var typed: std.ArrayList(u8) = .empty;
    defer typed.deinit(alloc);
    try typed.append(alloc, 'a');
    for (0..15) |_| try typed.appendSlice(alloc, "\u{0301}");
    e.feed(typed.items);

    var result = try e.extractSelection(alloc, 0, 0, 0, 0, 1024);
    defer result.deinit(alloc);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.ok, result.status);
    try std.testing.expect(result.text.?.len > 5 * 4 + 1);
    try std.testing.expectEqualStrings(typed.items, result.text.?);
}

test "Engine: selection extraction returns independent owned results" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3 });
    defer e.deinit();
    e.feed("first");

    var first = try e.extractSelection(alloc, 0, 0, 0, 4, 64);
    defer first.deinit(alloc);
    e.feed("\rsecond");
    var second = try e.extractSelection(alloc, 0, 0, 0, 5, 64);
    defer second.deinit(alloc);

    try std.testing.expectEqualStrings("first", first.text.?);
    try std.testing.expectEqualStrings("second", second.text.?);
    try std.testing.expect(first.text.?.ptr != second.text.?.ptr);
}

test "Engine: selection extraction addresses retained history in screen space" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 4 });
    defer e.deinit();
    e.feed("one\r\ntwo\r\nthree\r\nfour\r\nfive");

    try std.testing.expect(e.historyRows() > 0);
    var history_to_viewport = try e.extractSelection(alloc, 0, 0, 2, 4, 64);
    defer history_to_viewport.deinit(alloc);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.ok, history_to_viewport.status);
    try std.testing.expectEqualStrings("one\ntwo\nthree", history_to_viewport.text.?);

    const total_rows = e.historyRows() + e.term.rows;
    const outside = try e.extractSelection(alloc, total_rows, 0, total_rows, 0, 64);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.invalid, outside.status);
    try std.testing.expectEqual(@as(?[]u8, null), outside.text);
}

test "Engine: selection extraction uses only the active alternate screen" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3 });
    defer e.deinit();
    e.feed("primary\r\nhistory\r\nmore\r\n");
    try std.testing.expect(e.historyRows() > 0);

    e.feed("\x1b[?1049h");
    e.feed("\x1b[Halt");
    var active = try e.extractSelection(alloc, 0, 0, 0, 2, 64);
    defer active.deinit(alloc);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.ok, active.status);
    try std.testing.expectEqualStrings("alt", active.text.?);

    const unavailable = try e.extractSelection(alloc, 3, 0, 3, 0, 64);
    try std.testing.expectEqual(Engine.SelectionExtract.Status.invalid, unavailable.status);
    try std.testing.expectEqual(@as(?[]u8, null), unavailable.text);
}

test "Engine: tracked selection follows output and preserves duplicate row identity" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 16, .rows = 3, .max_scrollback = 16 });
    defer e.deinit();

    e.feed("DUPLICATE\r\nsame\r\nDUPLICATE\r\nother");
    var tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 8 })).?;
    defer tracked.deinit();
    var other = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 8 })).?;
    defer other.deinit();
    try std.testing.expect(tracked.points().?.anchor.row != other.points().?.anchor.row);
    var first = (try tracked.extract(alloc, 64)).?;
    defer first.deinit(alloc);
    try std.testing.expectEqualStrings("DUPLICATE", first.text.?);

    // More output advances the viewport and creates another identical row;
    // tracked pins keep naming the first row rather than matching by text.
    e.feed("\r\nnew-1\r\nnew-2\r\nnew-3");
    var after_output = (try tracked.extract(alloc, 64)).?;
    defer after_output.deinit(alloc);
    try std.testing.expectEqualStrings("DUPLICATE", after_output.text.?);
    try std.testing.expect(after_output.history_rows > first.history_rows);
    var other_after = (try other.extract(alloc, 64)).?;
    defer other_after.deinit(alloc);
    try std.testing.expectEqualStrings("DUPLICATE", other_after.text.?);
}

test "Engine: selection source advances only for coordinate remapping actions" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 4, .rows = 3 });
    defer e.deinit();

    const initial = e.selectionSource();
    // Side effects and ordinary redraws leave coordinates meaningful. This is
    // the live-counter case: a completed drag elsewhere must still register.
    e.feed("\x07x\rY\n");
    try std.testing.expectEqual(initial, e.selectionSource());

    // Insert mode shifts the remainder of the row even away from its edge.
    e.feed("\x1b[4hZ\x1b[4l");
    const inserted = e.selectionSource();
    try std.testing.expect(inserted != initial);

    // Full-row repainting reaches the last column but only arms pending-wrap;
    // it must not reject a drag elsewhere, such as a live counter redraw.
    e.feed("\x1b[1;1H1234");
    try std.testing.expectEqual(inserted, e.selectionSource());

    // A wide glyph at the right margin cannot fit, so Ghostty immediately
    // wraps and remaps coordinates. The post-print cursor proves that path.
    e.feed("\x1b[1;4H漢");
    const wrapped = e.selectionSource();
    try std.testing.expect(wrapped != inserted);

    // Index at the bottom scrolls; the same LF away from the bottom above did
    // not. ED 3 clears scrollback and changes absolute row identities.
    e.feed("\x1b[3;1H\n");
    const scrolled = e.selectionSource();
    try std.testing.expect(scrolled != wrapped);
    e.feed("\x1b[3J");
    try std.testing.expect(e.selectionSource() != scrolled);
}

test "Engine: tracked selection rejects active screen and geometry changes" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3 });
    defer e.deinit();
    e.feed("tracked");

    var tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 })).?;
    defer tracked.deinit();
    try std.testing.expect(tracked.points() != null);

    // Switching away and back in one feed still retires the source identity;
    // comparing only the final active screen key would miss this.
    e.feed("\x1b[?1049halt\x1b[?1049l");
    try std.testing.expect(tracked.points() == null);

    // Ordinary alternate leave retains its Screen and pins, but switching
    // away/re-entering still retires this selection's presentation epoch.
    e.feed("\x1b[?1049h");
    var alt_tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 3 })).?;
    defer alt_tracked.deinit();
    e.feed("\x1b[?1049l\x1b[?1049h");
    try std.testing.expect(alt_tracked.points() == null);
    e.feed("\x1b[?1049l");

    var resized = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 })).?;
    defer resized.deinit();
    try e.resize(9, 3);
    try std.testing.expect(resized.points() == null);
    try e.resize(8, 3);
    try std.testing.expect(resized.points() == null);

    var reset = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 })).?;
    defer reset.deinit();
    e.reset();
    try std.testing.expect(reset.points() == null);
}

test "Engine: alternate tracked pin deinit rejects a reset ABA lifetime" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3 });
    defer e.deinit();

    e.feed("\x1b[?1049halt");
    var tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 2 })).?;
    const lifetime = tracked.alternate_generation;
    e.reset();
    try std.testing.expect(e.alternate_generation != lifetime);
    // This must be a no-op even if Ghostty's next alternate allocation takes
    // the former address. The generation, not allocator behavior, is proof.
    tracked.deinit();
    e.feed("\x1b[?1049hnew-alt");
}

test "Engine: bounded scroll retires discarded endpoints but keeps shifted rows" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 4 });
    defer e.deinit();
    e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD");
    const discarded = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?;
    defer discarded.deinit();
    const kept = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 0 })).?;
    defer kept.deinit();
    // Alt screen never receives scrollback: CSI S discards its top row.
    e.feed("\x1b[1S");
    try std.testing.expect(discarded.points() == null);
    try std.testing.expectEqual(@as(u32, 1), kept.points().?.anchor.row);
}

test "Engine: print wrapping and REP retire an alternate discarded row" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 4, .rows = 3 });
    defer e.deinit();

    e.feed("\x1b[?1049hA\r\nB\r\nC");
    const wide_discarded = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?;
    defer wide_discarded.deinit();
    // A wide glyph at the final cell wraps immediately; it does not first set
    // pending_wrap, so the candidate must be remembered before Ghostty scrolls.
    e.feed("\x1b[3;4H漢");
    try std.testing.expect(wide_discarded.points() == null);

    e.reset();
    e.feed("\x1b[?1049hA\r\nB\r\nC");
    const repeated_discarded = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?;
    defer repeated_discarded.deinit();
    // REP at the margin prints twice: the second character consumes pending
    // wrap and scrolls the alternate screen.
    e.feed("\x1b[3;4H\x1b[2b");
    try std.testing.expect(repeated_discarded.points() == null);
}

test "Engine: a non-bottom wrap retains primary history selection" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 4, .rows = 4, .max_scrollback = 16 });
    defer e.deinit();
    e.feed("zero\r\none\r\ntwo\r\nthree\r\nfour\r\nfive");
    const history = e.historyRows();
    try std.testing.expect(history > 0);
    const tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?;
    defer tracked.deinit();
    // This is an actual wide-character wrap, but it lands on the next row
    // above the bottom of the full scrolling region and cannot discard text.
    e.feed("\x1b[2;4H漢");
    try std.testing.expect(tracked.points() != null);
}

test "Engine: full primary scroll without scrollback retires its top row" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3, .max_scrollback = 0 });
    defer e.deinit();
    e.feed("top\r\nmid\r\nbottom");
    const tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 2 })).?;
    defer tracked.deinit();
    e.feed("\x1b[3;1H\n");
    try std.testing.expect(tracked.points() == null);
}

test "Engine: partial primary discard compares absolute rows with history" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 4, .max_scrollback = 16 });
    defer e.deinit();

    e.feed("zero\r\none\r\ntwo\r\nthree\r\nfour\r\nfive");
    const history = e.historyRows();
    try std.testing.expect(history > 0);
    const discarded = (try e.trackSelection(.{ .row = history + 1, .col = 0 }, .{ .row = history + 1, .col = 0 })).?;
    defer discarded.deinit();
    const kept = (try e.trackSelection(.{ .row = history + 2, .col = 0 }, .{ .row = history + 2, .col = 0 })).?;
    defer kept.deinit();
    e.feed("\x1b[2;4r\x1b[1S");
    try std.testing.expect(discarded.points() == null);
    try std.testing.expectEqual(history + 1, kept.points().?.anchor.row);
    const text = (try kept.extract(alloc, 100)).?;
    defer text.deinit(alloc);
    try std.testing.expectEqualStrings("f", text.text.?);
}

test "Engine: retained region selection follows IL DL index and reverse scrolling" {
    const alloc = std.testing.allocator;
    const cases = .{
        .{ "\x1b[2S", @as(u32, 1) },
        .{ "\x1b[1T", @as(u32, 4) },
        .{ "\x1b[3;1H\x1b[1L", @as(u32, 4) },
        .{ "\x1b[2;1H\x1b[1M", @as(u32, 2) },
        .{ "\x1b[6;1H\n", @as(u32, 2) },
        .{ "\x1b[44m\x1b[6;1H\n", @as(u32, 2) },
        .{ "\x1b[2;1H\x1bM", @as(u32, 4) },
        .{ "\x1b[6;8H漢", @as(u32, 2) },
    };
    inline for (cases) |case| {
        var e = try Engine.init(alloc, .{ .cols = 8, .rows = 6 });
        defer e.deinit();
        e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD\r\nE\r\nF\x1b[2;6r");
        const kept = (try e.trackSelection(.{ .row = 3, .col = 0 }, .{ .row = 3, .col = 0 })).?;
        defer kept.deinit();
        e.feed(case[0]);
        try std.testing.expectEqual(case[1], kept.points().?.anchor.row);
        const text = (try kept.extract(alloc, 100)).?;
        defer text.deinit(alloc);
        try std.testing.expectEqualStrings("D", text.text.?);
    }
}

test "Engine: REP uses the normal print tracking path" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 4 });
    defer e.deinit();
    e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD");
    const kept = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 0 })).?;
    defer kept.deinit();
    const source = e.selectionSource();
    e.feed("\x1b[1;2Hx\x1b[3b");
    try std.testing.expectEqual(source, e.selectionSource());
    try std.testing.expectEqual(@as(u32, 2), kept.points().?.anchor.row);
    e.feed("\x1b[4;8H\x1b[2b");
    try std.testing.expectEqual(@as(u32, 1), kept.points().?.anchor.row);
    const text = (try kept.extract(alloc, 100)).?;
    defer text.deinit(alloc);
    try std.testing.expectEqualStrings("C", text.text.?);
}

test "Engine: split region ranges and insert-mode prints retire selection" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 6 });
    defer e.deinit();
    e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD\r\nE\r\nF");
    const crossing = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 4, .col = 0 })).?;
    defer crossing.deinit();
    e.feed("\x1b[2;4r\x1b[1S");
    try std.testing.expect(crossing.points() == null);
    e.feed("\x1b[r\x1b[1;1Hhello");
    const inserted = (try e.trackSelection(.{ .row = 0, .col = 1 }, .{ .row = 0, .col = 3 })).?;
    defer inserted.deinit();
    e.feed("\x1b[1;1H\x1b[4hZ");
    try std.testing.expect(inserted.points() == null);
}

test "Engine: top-zero partial-bottom scrolling preserves selected occurrence" {
    const alloc = std.testing.allocator;
    inline for (.{ "\x1b[1S", "\x1b[4;1H\n" }) |action| {
        inline for (.{ true, false }) |alternate| {
            var e = try Engine.init(alloc, .{ .cols = 8, .rows = 6 });
            defer e.deinit();
            if (alternate) e.feed("\x1b[?1049h");
            e.feed("A\r\nB\r\nC\r\nD\r\nE\r\nF\x1b[1;4r");
            const kept = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 0 })).?;
            defer kept.deinit();
            const below = (try e.trackSelection(.{ .row = 4, .col = 0 }, .{ .row = 5, .col = 0 })).?;
            defer below.deinit();
            const crossing = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 4, .col = 0 })).?;
            defer crossing.deinit();
            e.feed(action);
            try std.testing.expect(crossing.points() == null);
            try std.testing.expectEqual(e.historyRows() + 4, below.points().?.anchor.row);
            const outside_text = (try below.extract(alloc, 100)).?;
            defer outside_text.deinit(alloc);
            try std.testing.expectEqualStrings("E\nF", outside_text.text.?);
            try std.testing.expectEqual(e.historyRows() + 1, kept.points().?.anchor.row);
            const text = (try kept.extract(alloc, 100)).?;
            defer text.deinit(alloc);
            try std.testing.expectEqualStrings("C", text.text.?);
        }
    }
}

test "Engine: history eviction makes tracked pins unavailable" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 3, .max_scrollback = 3 });
    defer e.deinit();

    e.feed("old\r\none\r\ntwo\r\nthree\r\nfour");
    const oldest = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 2 })).?;
    defer oldest.deinit();
    // Scrollback is page-backed. Drive past several pages rather than
    // assuming max_scrollback is a line count.
    for (0..1600) |_| e.feed("discard\r\n");
    try std.testing.expect(oldest.points() == null);
}

test "ghostty-vt boots headless and text lands in the grid" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();
    e.feed("hello");
    const s = try e.dumpPlain(alloc);
    defer alloc.free(s);
    try std.testing.expectEqualStrings("hello", s);
}

test "Engine: wide CJK chars dump byte-correct" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();

    e.feed("漢字 wide");
    const s = try e.dumpPlain(alloc);
    defer alloc.free(s);
    try std.testing.expectEqualStrings("漢字 wide", s);
}

test "Engine: ZWJ emoji grapheme cluster dumps byte-correct" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();

    // Woman-astronaut: woman + ZWJ + rocket, one grapheme cluster.
    e.feed("\u{1F469}\u{200D}\u{1F680}x");
    const s = try e.dumpPlain(alloc);
    defer alloc.free(s);
    try std.testing.expectEqualStrings("\u{1F469}\u{200D}\u{1F680}x", s);
}

test "Engine: SGR attributes survive a vt dump round-trip" {
    const alloc = std.testing.allocator;
    var a = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer a.deinit();

    a.feed("\x1b[1;31mbold red\x1b[0m plain \x1b[4;38;5;42munderline\x1b[0m");
    const dump_a = try a.dumpVt(alloc);
    defer alloc.free(dump_a);

    // Feed A's styled dump into a fresh engine; it must reproduce the
    // same grid, and re-dumping must be a fixed point.
    var b = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer b.deinit();
    b.feed(dump_a);

    const plain_a = try a.dumpPlain(alloc);
    defer alloc.free(plain_a);
    const plain_b = try b.dumpPlain(alloc);
    defer alloc.free(plain_b);
    try std.testing.expectEqualStrings(plain_a, plain_b);

    const dump_b = try b.dumpVt(alloc);
    defer alloc.free(dump_b);
    try std.testing.expectEqualStrings(dump_a, dump_b);
}

test "Engine: DA1/DA2 queries are answered — nvim waits a full second at startup AND exit on DA1 silence" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();

    e.feed("\x1b[c");
    try std.testing.expectEqualStrings("\x1b[?62;22c", e.ptyOutput());
    e.clearPtyOutput();

    e.feed("\x1b[>c");
    try std.testing.expect(std.mem.startsWith(u8, e.ptyOutput(), "\x1b[>"));
    try std.testing.expect(std.mem.endsWith(u8, e.ptyOutput(), "c"));
}

test "Engine: DSR cursor-position query response lands in ptyOutput" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();

    e.feed("\x1b[6n");
    try std.testing.expectEqualStrings("\x1b[1;1R", e.ptyOutput());
    e.clearPtyOutput();
    try std.testing.expectEqual(@as(usize, 0), e.ptyOutput().len);
}

test "Engine: full-state snapshot restores grid, style, and cursor in a fresh engine" {
    const alloc = std.testing.allocator;
    var a = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer a.deinit();

    a.feed("line one\r\n\x1b[1;35mmagenta\x1b[0m\r\n");
    a.feed("\x1b[2;5H"); // park cursor at row 2, col 5 (1-based)
    const state = try a.dumpState(alloc);
    defer alloc.free(state);

    var b = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer b.deinit();
    b.feed(state);

    const plain_a = try a.dumpPlain(alloc);
    defer alloc.free(plain_a);
    const plain_b = try b.dumpPlain(alloc);
    defer alloc.free(plain_b);
    try std.testing.expectEqualStrings(plain_a, plain_b);

    try std.testing.expectEqual(a.cursorPos().x, b.cursorPos().x);
    try std.testing.expectEqual(a.cursorPos().y, b.cursorPos().y);
    try std.testing.expectEqual(@as(u16, 4), b.cursorPos().x); // 0-based
    try std.testing.expectEqual(@as(u16, 1), b.cursorPos().y);
}

test "Engine: reset clears grid and cursor for snapshot rebuild" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();

    e.feed("\x1b[7;9Hstale content\x1b[1;31m");
    e.reset();

    const s = try e.dumpPlain(alloc);
    defer alloc.free(s);
    try std.testing.expectEqualStrings("", s);
    try std.testing.expectEqual(@as(u16, 0), e.cursorPos().x);
    try std.testing.expectEqual(@as(u16, 0), e.cursorPos().y);
}

test "Engine: reset clears queued side events" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();

    // reset() is what replica.zig/wasm_core.zig call before replaying a
    // snapshot; a side event from before the reset must not survive it and
    // be replayed against the reconstructed state.
    e.feed("\x1b]52;c;aGVsbG8=\x07");
    try std.testing.expectEqual(@as(usize, 1), e.sideEvents().len);
    e.reset();
    try std.testing.expectEqual(@as(usize, 0), e.sideEvents().len);
}

test "Engine: alt-screen state survives snapshot into fresh engine" {
    const alloc = std.testing.allocator;
    var a = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer a.deinit();

    a.feed("primary text");
    a.feed("\x1b[?1049h"); // enter alt screen (what vim/less do)
    a.feed("alt screen text");
    const state = try a.dumpState(alloc);
    defer alloc.free(state);

    var b = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer b.deinit();
    b.feed(state);

    const plain_b = try b.dumpPlain(alloc);
    defer alloc.free(plain_b);
    try std.testing.expect(std.mem.indexOf(u8, plain_b, "alt screen text") != null);

    // Leaving the alt screen on the replica reveals the primary content —
    // dumpState carries both screens.
    b.feed("\x1b[?1049l");
    const primary_b = try b.dumpPlain(alloc);
    defer alloc.free(primary_b);
    try std.testing.expect(std.mem.indexOf(u8, primary_b, "primary text") != null);
}

test "Engine: historyRows counts scrolled-off lines, zero on alt screen" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();

    try std.testing.expectEqual(@as(u32, 0), e.historyRows());

    var i: usize = 1;
    while (i <= 100) : (i += 1) {
        var line: [32]u8 = undefined;
        e.feed(std.fmt.bufPrint(&line, "line-{d}\r\n", .{i}) catch unreachable);
    }
    // 100 lines + prompt row - 24 visible = 77 in history.
    try std.testing.expectEqual(@as(u32, 77), e.historyRows());

    e.feed("\x1b[?1049h"); // alt screen: no scrollback there
    try std.testing.expectEqual(@as(u32, 0), e.historyRows());
    e.feed("\x1b[?1049l");
    try std.testing.expectEqual(@as(u32, 77), e.historyRows());
}

test "Engine: dumps and snapshots cover the viewport, never scrollback" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();

    var i: usize = 1;
    while (i <= 100) : (i += 1) {
        var line: [32]u8 = undefined;
        e.feed(std.fmt.bufPrint(&line, "line-{d}\r\n", .{i}) catch unreachable);
    }

    // line-1 has scrolled into history; no dump may contain it.
    const styled = try e.dumpVt(alloc);
    defer alloc.free(styled);
    try std.testing.expect(std.mem.indexOf(u8, styled, "line-1\r") == null);
    try std.testing.expect(std.mem.indexOf(u8, styled, "line-100") != null);

    const state = try e.dumpState(alloc);
    defer alloc.free(state);
    try std.testing.expect(std.mem.indexOf(u8, state, "line-1\r") == null);
    try std.testing.expect(std.mem.indexOf(u8, state, "line-100") != null);

    const plain = try e.dumpPlain(alloc);
    defer alloc.free(plain);
    try std.testing.expect(std.mem.indexOf(u8, plain, "line-1\n") == null);
    try std.testing.expect(std.mem.indexOf(u8, plain, "line-100") != null);
}

test "Engine: onAltScreen reflects 1049 switches" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();
    try std.testing.expect(!e.onAltScreen());
    e.feed("\x1b[?1049h");
    try std.testing.expect(e.onAltScreen());
    e.feed("\x1b[?1049l");
    try std.testing.expect(!e.onAltScreen());
}

test "Engine: bracketed paste is readable as sampled state" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();
    try std.testing.expect(!e.bracketedPaste());
    e.feed("\x1b[?2004h");
    try std.testing.expect(e.bracketedPaste());
    e.feed("\x1b[?2004l");
    try std.testing.expect(!e.bracketedPaste());
}

test "Engine: the mouse modes an application asks for are readable as sampled state" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();
    try std.testing.expectEqual(Engine.MouseModes{}, e.mouseModes());

    // What vim writes for `set mouse=a` on a terminal it believes does SGR.
    e.feed("\x1b[?1000h\x1b[?1002h\x1b[?1006h");
    try std.testing.expectEqual(
        Engine.MouseModes{ .normal = true, .button = true, .sgr = true },
        e.mouseModes(),
    );
    // And what it writes on its way out. Asserted because the client hands
    // the wheel back to its own scrollback on exactly this transition.
    e.feed("\x1b[?1000l\x1b[?1002l\x1b[?1006l");
    try std.testing.expectEqual(Engine.MouseModes{}, e.mouseModes());

    // Each remaining mode is its own bit rather than a synonym for another.
    e.feed("\x1b[?9h\x1b[?1003h\x1b[?1005h\x1b[?1015h\x1b[?1016h");
    try std.testing.expectEqual(
        Engine.MouseModes{
            .x10 = true,
            .any = true,
            .utf8 = true,
            .urxvt = true,
            .sgr_pixels = true,
        },
        e.mouseModes(),
    );
}

test "Engine: the title is readable as sampled state" {
    const alloc = std.testing.allocator;
    var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer eng.deinit();

    try std.testing.expectEqualStrings("", eng.title());
    eng.feed("\x1b]0;hello\x07");
    try std.testing.expectEqualStrings("hello", eng.title());
    // OSC 2 is the same window-title operation as OSC 0 to this engine, and
    // the second title replaces the first rather than stacking.
    eng.feed("\x1b]2;there\x07");
    try std.testing.expectEqualStrings("there", eng.title());
}

test "Engine: resize" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();
    try e.resize(120, 40);
}

test "Engine: OSC 133 marks surface as events with rows and exit codes" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();

    e.feed("$ "); // a prompt on row 0
    e.feed("\x1b]133;C\x07"); // command starts
    e.feed("output line\r\n");
    e.feed("\x1b]133;D;1\x07"); // command returns, exit 1
    e.feed("\x1b]133;A\x07"); // next prompt

    const evs = e.markEvents();
    try std.testing.expectEqual(@as(usize, 3), evs.len);

    try std.testing.expectEqual(Engine.MarkEvent.Kind.command_start, evs[0].kind);
    try std.testing.expectEqual(@as(u32, 0), evs[0].row);
    try std.testing.expectEqual(@as(?u8, null), evs[0].exit_code);

    try std.testing.expectEqual(Engine.MarkEvent.Kind.command_end, evs[1].kind);
    try std.testing.expectEqual(@as(u32, 1), evs[1].row); // cursor moved past the output line
    try std.testing.expectEqual(@as(?u8, 1), evs[1].exit_code);

    try std.testing.expectEqual(Engine.MarkEvent.Kind.prompt_start, evs[2].kind);

    e.clearMarkEvents();
    try std.testing.expectEqual(@as(usize, 0), e.markEvents().len);
}

test "Engine: mark rows are absolute screen rows, not viewport rows" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();

    // Scroll 100 lines into history, then mark: the row must include them.
    var i: usize = 1;
    while (i <= 100) : (i += 1) {
        var line: [32]u8 = undefined;
        e.feed(std.fmt.bufPrint(&line, "line-{d}\r\n", .{i}) catch unreachable);
    }
    const hist = e.historyRows(); // 77 per the historyRows test
    e.feed("\x1b]133;C\x07");
    const evs = e.markEvents();
    try std.testing.expectEqual(@as(usize, 1), evs.len);
    try std.testing.expectEqual(hist + e.cursorPos().y, evs[0].row);
}

test "Engine: a D mark with no exit code yields a null code" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();
    e.feed("\x1b]133;C\x07\x1b]133;D\x07");
    const evs = e.markEvents();
    try std.testing.expectEqual(@as(usize, 2), evs.len);
    try std.testing.expectEqual(@as(?u8, null), evs[1].exit_code);
}

test "Engine: intercepting a mark still forwards it to the stock handler" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();

    // The row the cursor sits on carries no prompt state yet.
    try std.testing.expectEqual(
        vt.page.Row.SemanticPrompt.none,
        e.term.screens.active.cursor.page_row.semantic_prompt,
    );

    e.feed("\x1b]133;A\x07");

    // MuxHandler.vt must forward every action it intercepts: the stock
    // handler is what runs Terminal.semanticPrompt, and that is what marks
    // the cursor's row as a prompt row. Swallowing the action instead of
    // forwarding it leaves this .none while the event still lands.
    try std.testing.expectEqual(
        vt.page.Row.SemanticPrompt.prompt,
        e.term.screens.active.cursor.page_row.semantic_prompt,
    );
    try std.testing.expectEqual(@as(usize, 1), e.markEvents().len);
}

test "Engine: exit codes truncate to a byte and malformed ones are null" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();

    // The mark's exit code is whatever i32 the shell wrote, and PTY bytes
    // are attacker-influenced: without the mask, 256 is a Debug-mode
    // @intCast panic rather than a wrapped byte.
    e.feed("\x1b]133;C\x07\x1b]133;D;256\x07");
    try std.testing.expectEqual(@as(?u8, 0), e.markEvents()[1].exit_code);
    e.clearMarkEvents();

    e.feed("\x1b]133;C\x07\x1b]133;D;-1\x07");
    try std.testing.expectEqual(@as(?u8, 255), e.markEvents()[1].exit_code);
    e.clearMarkEvents();

    e.feed("\x1b]133;C\x07\x1b]133;D;notanumber\x07");
    try std.testing.expectEqual(@as(?u8, null), e.markEvents()[1].exit_code);
}

test "Engine: non-133 OSC and the ignored 133 subcommands emit no events" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();
    e.feed("\x1b]0;a title\x07"); // OSC 0, not semantic
    e.feed("\x1b]133;B\x07\x1b]133;P\x07\x1b]133;L\x07"); // B/P/L: not ours
    try std.testing.expectEqual(@as(usize, 0), e.markEvents().len);
}

test "Engine: an OSC 52 set is queued with its target and payload intact" {
    const alloc = std.testing.allocator;
    var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer eng.deinit();

    eng.feed("\x1b]52;c;aGVsbG8=\x07");

    const evs = eng.sideEvents();
    try std.testing.expectEqual(@as(usize, 1), evs.len);
    try std.testing.expectEqual(Engine.SideEvent.Kind.clipboard, evs[0].kind);
    try std.testing.expectEqual(@as(u8, 'c'), evs[0].target);
    try std.testing.expectEqualStrings("aGVsbG8=", evs[0].payload);
}

test "Engine: an empty OSC 52 payload is dropped, not proxied as a clear" {
    const alloc = std.testing.allocator;
    var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer eng.deinit();

    // A zero-length payload is the "clear the clipboard" form. It also
    // arrives by accident — a copy that starts and is then interrupted by
    // another escape on the same pty leaves this behind. Proxying it would
    // let an accident silently wipe whatever the human last copied.
    eng.feed("\x1b]52;c;\x07");
    try std.testing.expectEqual(@as(usize, 0), eng.sideEvents().len);
}

test "Engine: an OSC 52 query is refused, never answered" {
    const alloc = std.testing.allocator;
    var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer eng.deinit();

    // `?` asks the terminal to send the clipboard back on the INPUT stream.
    // Honouring it would let anything in any session — a remote box, an
    // agent-driven session — read whatever the human last copied.
    eng.feed("\x1b]52;c;?\x07");

    try std.testing.expectEqual(@as(usize, 0), eng.sideEvents().len);
    // And nothing was written back toward the pty, which is the half that
    // would actually leak.
    try std.testing.expectEqual(@as(usize, 0), eng.ptyOutput().len);
}

test "Engine: an oversized clipboard payload is dropped, not truncated" {
    const alloc = std.testing.allocator;
    // The cap is injected, so this test states its own premise and costs 9
    // bytes instead of 64 KiB. A test that has to allocate the production
    // limit to prove a refusal is a test nobody runs twice.
    var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24, .clipboard_max = 8 });
    defer eng.deinit();

    // One byte past the cap. Truncating would put a corrupt payload in the
    // user's clipboard, which is worse than putting nothing there.
    eng.feed("\x1b]52;c;AAAAAAAAA\x07");
    try std.testing.expectEqual(@as(usize, 0), eng.sideEvents().len);

    // And the boundary itself is accepted, so the cap is a cap and not an
    // off-by-one that quietly rejects the largest legal payload.
    eng.feed("\x1b]52;c;AAAAAAAA\x07");
    try std.testing.expectEqual(@as(usize, 1), eng.sideEvents().len);
}

test "Engine: an escape split across two feeds still produces one event" {
    const alloc = std.testing.allocator;
    var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer eng.deinit();

    // The parser is stateful across feeds and a pty read can land anywhere.
    // This is the boundary a hand-rolled byte scanner would get wrong, and
    // pinning it is what says we did not write one.
    eng.feed("\x1b]52;c;aGVs");
    eng.feed("bG8=\x07");

    const evs = eng.sideEvents();
    try std.testing.expectEqual(@as(usize, 1), evs.len);
    try std.testing.expectEqualStrings("aGVsbG8=", evs[0].payload);
}

test "Engine: a BEL from the session is queued as a side event" {
    const alloc = std.testing.allocator;
    var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer eng.deinit();

    eng.feed("ding\x07");

    const evs = eng.sideEvents();
    try std.testing.expectEqual(@as(usize, 1), evs.len);
    try std.testing.expectEqual(Engine.SideEvent.Kind.bell, evs[0].kind);
    try std.testing.expectEqual(@as(usize, 0), evs[0].payload.len);
}

test "Engine: clearing side events frees their payloads" {
    // The allocator in std.testing fails the test on a leak, so this test
    // IS the assertion: a payload duped on the way in must be freed here.
    const alloc = std.testing.allocator;
    var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer eng.deinit();

    eng.feed("\x1b]52;c;aGVsbG8=\x07");
    try std.testing.expectEqual(@as(usize, 1), eng.sideEvents().len);
    eng.clearSideEvents();
    try std.testing.expectEqual(@as(usize, 0), eng.sideEvents().len);
}

// Plain, not recursive: this module reaches ghostty-vt's namespace, and a
// recursive walk hits unrelated comptime errors in that external dependency
// (lib/types.zig's exhaustive switch on `type`, lib/union.zig's CValue
// codegen) that have nothing to do with mux's own code.
test {
    std.testing.refAllDecls(@This());
    // The child file's own tests. `refAllDecls` names a decl without
    // reaching its tests, so without this line every test in delta.zig
    // passes by not running.
    _ = delta;
}

test "mode 2048: setting in-band size reports answers with the size at once" {
    var e = try Engine.init(std.testing.allocator, .{ .cols = 80, .rows = 26 });
    defer e.deinit();
    e.feed("\x1b[?2048h");
    try std.testing.expectEqualStrings("\x1b[48;26;80;0;0t", e.ptyOutput());
}

test "mode 2048: a resize reports the new size in-band, and only when asked" {
    var e = try Engine.init(std.testing.allocator, .{ .cols = 80, .rows = 26 });
    defer e.deinit();
    try e.resize(100, 30);
    try std.testing.expectEqualStrings("", e.ptyOutput());
    e.feed("\x1b[?2048h");
    e.clearPtyOutput();
    try e.resize(120, 40);
    try std.testing.expectEqualStrings("\x1b[48;40;120;0;0t", e.ptyOutput());
    e.clearPtyOutput();
    e.feed("\x1b[?2048l");
    try e.resize(90, 28);
    try std.testing.expectEqualStrings("", e.ptyOutput());
}

fn decodeAll(alloc: std.mem.Allocator, bytes: []const u8) ![]proto.DecodedCell {
    var out: std.ArrayList(proto.DecodedCell) = .empty;
    var r = try proto.CellRowReader.init(bytes);
    while (try r.next()) |c| try out.append(alloc, c);
    return out.toOwnedSlice(alloc);
}

test "encodeViewportRow: ascii, a wide glyph with its spacer, a grapheme, and trailing blanks dropped" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 12, .rows = 2 });
    defer e.deinit();
    e.feed("ab漢e\u{301}");
    const row = try e.encodeViewportRow(alloc, 0);
    defer alloc.free(row);
    const cells = try decodeAll(alloc, row);
    defer alloc.free(cells);
    // a b 漢 (spacer) é — five cells; the seven blanks after are not sent.
    try std.testing.expectEqual(@as(usize, 5), cells.len);
    try std.testing.expectEqualStrings("a", cells[0].text);
    try std.testing.expectEqual(proto.Wide.wide, cells[2].wide);
    try std.testing.expectEqualStrings("漢", cells[2].text);
    try std.testing.expectEqual(proto.Wide.spacer_tail, cells[3].wide);
    try std.testing.expectEqualStrings("e\u{301}", cells[4].text);
}

test "encodeViewportRow: styles pack as the wire says, and a bg-only cell is not blank" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
    defer e.deinit();
    // bold red on palette-4 blue 'x', then EL with the blue background held:
    // the cells after x carry a background and no glyph.
    e.feed("\x1b[1;31;44mx\x1b[0m\x1b[44m\x1b[K");
    const row = try e.encodeViewportRow(alloc, 0);
    defer alloc.free(row);
    const cells = try decodeAll(alloc, row);
    defer alloc.free(cells);
    try std.testing.expectEqual(@as(usize, 8), cells.len);
    try std.testing.expectEqual(proto.colorPalette(1), cells[0].style.fg);
    try std.testing.expectEqual(proto.colorPalette(4), cells[0].style.bg);
    try std.testing.expectEqual(@as(u16, 1), cells[0].style.flags & 1);
    try std.testing.expectEqualStrings("", cells[7].text);
    try std.testing.expectEqual(proto.colorPalette(4), cells[7].style.bg);
}

test "encodeViewportRow: an interior space keeps the row in one ascii run" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
    defer e.deinit();
    e.feed("a b");
    const row = try e.encodeViewportRow(alloc, 0);
    defer alloc.free(row);
    // Sending the space as empty text would drop this run out of the ascii
    // form and cost a head byte on every cell of it — 36% of a prose screen.
    try std.testing.expectEqualSlices(u8, &[_]u8{
        3, 0, // ncells
        3,   0,   0x80, // run header: default style, ascii
        'a', ' ', 'b',
    }, row);
}

test "encodeViewportRow: a row drawn by cursor motion is still one ascii run" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
    defer e.deinit();
    // How ncurses paints: erase, then jump to each field. The cells between
    // the two glyphs were never written, so they hold codepoint 0 rather
    // than a space — the same blank on screen, and it must be the same
    // blank on the wire, or every cell of this run pays a head byte.
    e.feed("\x1b[2J\x1b[1;1Ha\x1b[1;5Hb");
    const row = try e.encodeViewportRow(alloc, 0);
    defer alloc.free(row);
    try std.testing.expectEqualSlices(u8, &[_]u8{
        5, 0, // ncells
        5,   0,   0x80, // run header: default style, ascii
        'a', ' ', ' ',
        ' ', 'b',
    }, row);
}

test "encodeViewportRow: an empty row is ncells 0 and nothing else" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 1 });
    defer e.deinit();
    const row = try e.encodeViewportRow(alloc, 0);
    defer alloc.free(row);
    try std.testing.expectEqualSlices(u8, &[_]u8{ 0, 0 }, row);
}

test "encodeScrollback: clamps a range past the end and returns dense rows" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 2, .max_scrollback = 10 });
    defer e.deinit();
    e.feed("one\r\ntwo\r\nthree\r\nfour");
    // history: one two; viewport: three four. Ask for 3 rows from 1: two three four.
    const got = try e.encodeScrollback(alloc, 1, 3);
    defer alloc.free(got.bytes);
    try std.testing.expectEqual(@as(u32, 1), got.first);
    try std.testing.expectEqual(@as(u16, 3), got.count);
    var r = try proto.CellRowReader.init(got.bytes);
    try std.testing.expectEqualStrings("t", (try r.next()).?.text);
    // Past the end clamps to what exists.
    const tail = try e.encodeScrollback(alloc, 100, 5);
    defer alloc.free(tail.bytes);
    try std.testing.expectEqual(@as(u32, 3), tail.first);
    try std.testing.expectEqual(@as(u16, 1), tail.count);
}

fn cellBytes(alloc: std.mem.Allocator, e: *Engine) !usize {
    var n: usize = 0;
    var y: u16 = 0;
    while (y < e.term.rows) : (y += 1) {
        const b = try e.encodeViewportRow(alloc, y);
        defer alloc.free(b);
        n += b.len;
    }
    return n;
}

// The VT half of this measurement is gone with the VT row dumps it called.
// Its figures, and the ratios the spec's gate was decided on, were recorded
// in docs/decisions.md on 2026-09-04; what stays is the cell size itself, so
// a change to the encoder that doubles a screen still shows up in a run.
test "cells: wire size per screen (measurement; the VT comparison it was decided against is in decisions.md, 2026-09-04)" {
    const alloc = std.testing.allocator;
    const Screen = struct { name: []const u8, feed: []const u8 };
    const prose = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor in\r\n" ** 24;
    const vim = ("\x1b[33m 12 \x1b[0m\x1b[34mfn\x1b[0m main() \x1b[35m{\x1b[0m \x1b[32m// a comment that runs on\x1b[0m \x1b[31mreturn\x1b[0m 0;\r\n") ** 24;
    const htop = ("\x1b[42m 1 \x1b[0m\x1b[7m[||||||||    12.3%]\x1b[0m \x1b[36m1234\x1b[0m \x1b[33mroot\x1b[0m \x1b[1m20\x1b[0m   0 \x1b[32m 12.0\x1b[0m \x1b[31m 0.4\x1b[0m /usr/bin/thing --flag\r\n") ** 24;
    const shell = "$ ls\r\nbuild.zig  docs  src  test\r\n$ \r\n";

    // The shape the four above cannot see: a screen erased and then painted
    // field by field with cursor motion, which is what ncurses does and so
    // what htop and vim really are. The gaps between the fields are cells
    // nothing wrote, and how those go on the wire decides whether the row is
    // one run or thirty.
    var curses: std.ArrayList(u8) = .empty;
    defer curses.deinit(alloc);
    try curses.appendSlice(alloc, "\x1b[2J");
    {
        var y: u16 = 1;
        while (y <= 24) : (y += 1) {
            var buf: [128]u8 = undefined;
            try curses.appendSlice(alloc, try std.fmt.bufPrint(
                &buf,
                "\x1b[{d};1Hinodes total\x1b[{d};20H\x1b[33mbackground\x1b[0m\x1b[{d};45Hfilesystem road",
                .{ y, y, y },
            ));
        }
    }

    const screens = [_]Screen{
        .{ .name = "prose", .feed = prose },
        .{ .name = "vim", .feed = vim },
        .{ .name = "htop", .feed = htop },
        .{ .name = "shell", .feed = shell },
        .{ .name = "curses", .feed = curses.items },
    };
    for (screens) |s| {
        var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
        defer e.deinit();
        e.feed(s.feed);
        const c = try cellBytes(alloc, e);
        std.debug.print("\ncells-measure {s}: cells={d}\n", .{ s.name, c });
    }
}

/// The engine's plain dump with each row's trailing spaces removed, which is
/// the form a client can hold. ghostty dumps with `trim = false`, so a space
/// a program actually wrote at the end of a row survives into
/// `Engine.dumpPlain`; no replica ever held one, because the VT formatter
/// that fed the old wire trimmed trailing whitespace, and the e2e convergence
/// diff strips it as a formatting difference between two correct grids. The
/// grid trims per row for the same reason, so the oracle
/// compares through the same trim rather than pretending the two dumps agree
/// on bytes nothing downstream distinguishes.
fn trimRowTails(alloc: std.mem.Allocator, s: []const u8) ![]const u8 {
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(alloc);
    var it = std.mem.splitScalar(u8, s, '\n');
    var first = true;
    while (it.next()) |line| {
        if (!first) try out.append(alloc, '\n');
        first = false;
        try out.appendSlice(alloc, std.mem.trimRight(u8, line, " "));
    }
    return out.toOwnedSlice(alloc);
}

test "grid oracle: the grid's dumpPlain and cursor agree with the engine's for every screen shape" {
    const alloc = std.testing.allocator;
    const screens = [_][]const u8{
        "plain text\r\nsecond line",
        "w\u{6f22}\u{5b57}x e\u{301} \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467} end",
        "\x1b[1;31mred bold\x1b[0m \x1b[44mblue bg\x1b[0m\x1b[K",
        "short\r\n\r\n\r\nafter blanks",
        "\x1b[?1049h\x1b[HTUI on alt\x1b[5;10Hcursor here",
        "line one\r\nline two\r\n" ** 30, // scrolled: history exists, viewport is the tail
        "\x1b[3;1Hcol\x1b[3;40Hfar\x1b[8;1H",
        "hello   \r\nworld  ", // typed default spaces at both row ends
        "\x1b[1mbold  \x1b[0m", // a styled trailing space: text on the wire, not a blank
        "\x1b[44mpad  \x1b[0m", // a bg-coloured pad, the shape every status bar ends with
    };
    for (screens) |s| {
        var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
        defer e.deinit();
        e.feed(s);
        const g = try Grid.init(alloc, 1, 1);
        defer g.deinit();
        try e.mirrorInto(g);
        const raw = try e.dumpPlain(alloc);
        defer alloc.free(raw);
        const want = try trimRowTails(alloc, raw);
        defer alloc.free(want);
        const got = try g.dumpPlain(alloc);
        defer alloc.free(got);
        try std.testing.expectEqualStrings(want, got);
        try std.testing.expectEqual(e.cursorPos().x, g.cursor.x);
        try std.testing.expectEqual(e.cursorPos().y, g.cursor.y);
    }
}

// The wide flag is the half of a cell that `dumpPlain` cannot grade: a spacer
// mis-tagged as narrow carries the same text and shifts every clip and every
// highlight past it by one column. So this reads ghostty's own `cell.wide`
// through the page, the way `encodeRowAt` reads it, and asks the mirrored grid
// for the same answer at every column of every row.
test "grid oracle: every cell's wide flag matches ghostty's page cell" {
    const alloc = std.testing.allocator;
    const cols: u16 = 12;
    const rows: u16 = 3;
    var e = try Engine.init(alloc, .{ .cols = cols, .rows = rows });
    defer e.deinit();
    // Row 0 puts wide glyphs at two different columns, so a rule that only
    // works at an even column fails here. Row 1 ends with a wide glyph that
    // does not fit its last column: ghostty leaves a spacer_head there and
    // wraps the glyph itself onto row 2.
    e.feed("ab\u{6f22}cd\u{1F600}z\r\n0123456789a\u{6f22}");
    const g = try Grid.init(alloc, 1, 1);
    defer g.deinit();
    try e.mirrorInto(g);

    var seen = [_]bool{false} ** 4;
    var y: u16 = 0;
    while (y < rows) : (y += 1) {
        const screen = e.term.screens.active;
        const pin = screen.pages.pin(.{ .viewport = .{ .x = 0, .y = y } }).?;
        const page = &pin.node.data;
        const cells = page.getCells(pin.rowAndCell().row);
        var x: u16 = 0;
        while (x < cols) : (x += 1) {
            const want: proto.Wide = switch (cells[x].wide) {
                .narrow => .narrow,
                .wide => .wide,
                .spacer_tail => .spacer_tail,
                .spacer_head => .spacer_head,
            };
            seen[@intFromEnum(want)] = true;
            const got = g.lines[y].cells[x].wide;
            if (got != want) {
                std.debug.print(
                    "wide flag diverged at col {d} row {d}: engine says {s}, grid says {s}\n",
                    .{ x, y, @tagName(want), @tagName(got) },
                );
                return error.WideFlagDiverged;
            }
        }
    }
    // Without this the loop above could be twelve narrow cells three times over
    // and grade nothing: the screen has to hold every shape it claims to.
    for (seen, 0..) |s, i| {
        if (!s) {
            std.debug.print("screen never held a {s} cell\n", .{@tagName(@as(proto.Wide, @enumFromInt(i)))});
            return error.ScreenMissesAWideShape;
        }
    }
}

test "binary output DEL is encoded as a visible cell and replays in snapshots" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 8, .rows = 2 });
    defer e.deinit();
    // A single DEL is enough: ghostty stores it as a cell. The old writer
    // emitted head=1, text=0x7f, and every client rejected the snapshot.
    e.feed("a\x7fb\r\nnext");
    const row = try e.encodeViewportRow(alloc, 0);
    defer alloc.free(row);
    try std.testing.expectEqualSlices(u8, &.{ 3, 0, 3, 0, 0, 1, 'a', 3, 0xef, 0xbf, 0xbd, 1, 'b' }, row);
    const snapshot = try delta.buildSnapshot(alloc, e, .{ .seq = 7, .history_rows = 0, .cols = 8, .rows = 2, .epoch = 11 });
    defer alloc.free(snapshot);
    const g = try Grid.init(alloc, 1, 1);
    defer g.deinit();
    var replica = @import("term").replica.Replica.init(alloc, g);
    try std.testing.expectEqual(@import("term").replica.Replica.Applied.painted, try replica.apply(.snapshot, snapshot));
    const dump = try g.dumpPlain(alloc);
    defer alloc.free(dump);
    try std.testing.expectEqualStrings("a\xef\xbf\xbdb\nnext", dump);
}

test "binary output bounded deterministic flood keeps every encoded viewport replayable" {
    const alloc = std.testing.allocator;
    const previous_log_level = std.testing.log_level;
    std.testing.log_level = .err;
    defer std.testing.log_level = previous_log_level;
    var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
    defer e.deinit();
    const g = try Grid.init(alloc, 80, 24);
    defer g.deinit();
    var replica = @import("term").replica.Replica.init(alloc, g);
    var rng = std.Random.DefaultPrng.init(0x1948);
    var bytes: [4096]u8 = undefined;
    rng.random().bytes(&bytes);
    for (0..8) |batch| {
        e.feed(bytes[batch * 512 ..][0..512]);
        const snapshot = try delta.buildSnapshot(alloc, e, .{ .seq = batch + 1, .history_rows = 0, .cols = 80, .rows = 24, .epoch = 11 });
        defer alloc.free(snapshot);
        try std.testing.expectEqual(@import("term").replica.Replica.Applied.painted, try replica.apply(.snapshot, snapshot));
        for (g.lines) |row| {
            for (row.text.items) |b| try std.testing.expect(b >= 0x20 and b != 0x7f);
        }
    }
}

test "erase and shorter redraw replace styled wide tails through live deltas" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 36, .rows = 6 });
    defer e.deinit();
    // Plural, off-origin rows with style and wide spacers: clearing only the
    // text, keeping an old tail, or missing a changed empty row all show up.
    e.feed("\x1b[2;5H\x1b[1;31;44mLONG 漢字 WIDE TAIL\x1b[5;8HSECOND 漢字 TAIL\x1b[0m");
    var tracker: delta.DeltaTracker = .{};
    defer tracker.deinit(alloc);
    try tracker.rebuild(alloc, e, 6, 36);
    const g = try Grid.init(alloc, 36, 6);
    defer g.deinit();
    var replica = @import("term").replica.Replica.init(alloc, g);
    const snapshot = try delta.buildSnapshot(alloc, e, .{ .seq = tracker.seq, .history_rows = 0, .cols = 36, .rows = 6, .epoch = 93 });
    defer alloc.free(snapshot);
    _ = try replica.apply(.snapshot, snapshot);
    try std.testing.expect(g.row(1).cells[4].style.flags != 0);
    try std.testing.expectEqual(proto.Wide.wide, g.row(4).cells[14].wide);

    const actions = [_][]const u8{
        "\x1b[2;5Hshort\x1b[K\x1b[5;8Htiny\x1b[K",
        "\x1b[H\x1b[2J\x1b[3J",
    };
    for (actions, 0..) |action, step| {
        const since = replica.last_seq;
        e.feed(action);
        try std.testing.expectEqual(delta.DeltaTracker.Update.advanced, try tracker.update(alloc, e));
        const payload = try tracker.buildDeltaSince(alloc, e, since);
        defer alloc.free(payload);
        try std.testing.expectEqual(@import("term").replica.Replica.Applied.painted, try replica.apply(.delta, payload));
        const dump = try g.dumpPlain(alloc);
        defer alloc.free(dump);
        if (step == 0) {
            try std.testing.expectEqualStrings("\n    short\n\n\n       tiny", dump);
        } else try std.testing.expectEqualStrings("", dump);
        for (g.lines, 0..) |row, y| {
            for (row.cells, 0..) |cell, x| {
                const retained_text = step == 0 and ((y == 1 and x >= 4 and x < 9) or (y == 4 and x >= 7 and x < 11));
                try std.testing.expect(cell.style.isDefault());
                try std.testing.expectEqual(proto.Wide.narrow, cell.wide);
                if (!retained_text) try std.testing.expect(cell.text_len == 0 or std.mem.eql(u8, row.textOf(cell), " "));
            }
        }
    }
}

test "erase preserves ISO protected text until terminal reset and deltas mirror that state" {
    const alloc = std.testing.allocator;
    var e = try Engine.init(alloc, .{ .cols = 36, .rows = 6 });
    defer e.deinit();
    var tracker: delta.DeltaTracker = .{};
    defer tracker.deinit(alloc);
    try tracker.rebuild(alloc, e, 6, 36);
    const g = try Grid.init(alloc, 36, 6);
    defer g.deinit();
    var replica = @import("term").replica.Replica.init(alloc, g);
    const snapshot = try delta.buildSnapshot(alloc, e, .{ .seq = tracker.seq, .history_rows = 0, .cols = 36, .rows = 6, .epoch = 93 });
    defer alloc.free(snapshot);
    _ = try replica.apply(.snapshot, snapshot);

    // Binary output can contain SPA (ESC V). EPA (ESC W) stops protecting
    // new characters but does not remove protection from the existing cells.
    // Their survival after ED/EL is authoritative terminal state, not stale
    // replica or framebuffer content. RIS resets that state and clears them.
    const actions = [_]struct { bytes: []const u8, expected: []const u8 }{
        .{ .bytes = "\x1b[2;5H\x1bVPROTECTED-FIRST\x1bW\x1b[5;8H\x1bVPROTECTED-SECOND\x1bW", .expected = "\n    PROTECTED-FIRST\n\n\n       PROTECTED-SECOND" },
        .{ .bytes = "\x1b[H\x1b[2J\x1b[3J", .expected = "\n    PROTECTED-FIRST\n\n\n       PROTECTED-SECOND" },
        .{ .bytes = "\x1b[2;5Hnew\x1b[K\x1b[5;8Hx\x1b[K", .expected = "\n    newTECTED-FIRST\n\n\n       xROTECTED-SECOND" },
        .{ .bytes = "\x1bc", .expected = "" },
    };
    for (actions) |action| {
        const since = replica.last_seq;
        e.feed(action.bytes);
        try std.testing.expectEqual(delta.DeltaTracker.Update.advanced, try tracker.update(alloc, e));
        const payload = try tracker.buildDeltaSince(alloc, e, since);
        defer alloc.free(payload);
        try std.testing.expectEqual(@import("term").replica.Replica.Applied.painted, try replica.apply(.delta, payload));
        const dump = try g.dumpPlain(alloc);
        defer alloc.free(dump);
        try std.testing.expectEqualStrings(action.expected, dump);
    }
    for (g.lines) |row| {
        for (row.cells) |cell| try std.testing.expect(cell.isBlank());
    }
}