crates/signaltty-term/src/headless.rs

//! Headless terminal surface: `vt100` screen model plus a rendered-line
//! ring with a monotonic content sequence. One per pane, owned by the server.
//!
//! The ring is built from the RENDERED grid, never from raw `\n` splitting:
//! lines that scroll off the top of the vt100 screen move into history, the
//! current screen rows follow, and in-place repaints (CR overwrites,
//! cursor-up redraws, erase-line, alt-screen updates) only refresh the text
//! of the rows they touch. Rewriting one spinner line a thousand times still
//! leaves one ring line behind.
//!
//! Alt-screen note: while a full-screen TUI owns the alternate grid there
//! is no scrollback (engine limit, documented in docs/05, not silent) —
//! reads return the current alt grid, repaints bump the touched rows'
//! sequences so a polling orchestrator sees new content without duplicates,
//! and nothing is appended to history on repaint or on alt enter/exit.

use std::collections::{HashMap, VecDeque};

use crate::backend::{TermOptions, TerminalBackend};
use crate::sanitize::strip_ansi;

/// Rendered-line capacity per pane (history + current screen rows).
pub const DEFAULT_SCROLLBACK_LINES: usize = 5000;

/// One rendered line with its position in the pane's content sequence.
#[derive(Clone, Debug)]
struct RingLine {
    seq: u64,
    text: String,
}

/// Cursor-based read result (`pane.read` rendered mode, contracts/ipc.md).
#[derive(Clone, Debug, Default)]
pub struct RenderedRead {
    pub text: String,
    pub seq: u64,
    pub next_seq: u64,
    pub dropped: bool,
    pub truncated: bool,
}

/// How far one batch could have moved the grid.
///
/// `linefeeds` counts motions that advance a row (LF/VT/FF/NEL, CSI `S`/`T`,
/// and printable wraps). `bound` is that count plus one slack, and plus a
/// large allowance for ED (`CSI J`), which blanks the screen without moving
/// the cursor. Unevidenced scroll matches may use only `linefeeds` from the
/// pre-batch cursor; the slack and an erase do not invent a scroll.
#[derive(Clone, Copy)]
struct ScrollBudget {
    bound: usize,
    linefeeds: usize,
}

fn scroll_budget(batch: &[u8], cols: u16) -> ScrollBudget {
    let mut linefeeds = 0usize;
    let mut clear = 0usize;
    let mut printable = 0usize;
    let mut i = 0;
    while i < batch.len() {
        let b = batch[i];
        match b {
            b'\n' | b'\x0b' | b'\x0c' | b'\x84' => linefeeds += 1,
            b'\x1b' => {
                let mut j = i + 1;
                if j < batch.len() && batch[j] == b'E' {
                    linefeeds += 1; // NEL
                    i = j;
                } else if j < batch.len() && batch[j] == b'[' {
                    j += 1;
                    let mut val = 0usize;
                    let mut digits = false;
                    while j < batch.len() && batch[j].is_ascii_digit() {
                        val = val
                            .saturating_mul(10)
                            .saturating_add((batch[j] - b'0') as usize);
                        digits = true;
                        j += 1;
                    }
                    if j < batch.len() && (batch[j] == b'S' || batch[j] == b'T') {
                        linefeeds += if digits { val.max(1) } else { 1 };
                        i = j;
                    } else if j < batch.len() && batch[j] == b'J' {
                        // ED can blank every row. Not a cursor linefeed.
                        clear = 4096;
                        i = j;
                    }
                }
            }
            0x20..=0x7e | 0x80..=0xff => printable += 1,
            _ => {}
        }
        i += 1;
    }
    linefeeds += printable / (cols.max(1) as usize);
    ScrollBudget {
        bound: linefeeds.saturating_add(1).saturating_add(clear),
        linefeeds,
    }
}

/// Fast byte: printable ASCII or a line-advancing control that vt100 maps
/// to plain cursor motion with no mode dependence (CR resets col;
/// LF/VT/FF all funnel into the same `lf()`).
fn is_fast_byte(b: u8) -> bool {
    matches!(b, 0x20..=0x7e | b'\r' | b'\n' | 0x0b | 0x0c)
}

/// A legacy batch is fast-eligible iff every byte is fast. (Ground state,
/// clean UTF-8 and sticky modes are checked separately per batch.)
fn batch_is_plain(batch: &[u8]) -> bool {
    batch.iter().all(|&b| is_fast_byte(b))
}

/// Build a ring line from an ASCII row buffer, trimming trailing padding.
/// Equivalent to `str::trim_end` here: the buffer holds ASCII printables
/// and space padding only (tabs never lodge in grid cells as content).
/// Returns None on non-ASCII content — the caller aborts to the slow path.
fn ascii_line(buf: &[u8]) -> Option<String> {
    let mut end = buf.len();
    while end > 0 && buf[end - 1] == b' ' {
        end -= 1;
    }
    String::from_utf8(buf[..end].to_vec()).ok()
}

/// vte ground-state model for fast-run eligibility. Mirrors the vte 0.11.1
/// state table for the bytes that can appear in a feed: ESC aborts any
/// state (Anywhere rule), CAN/SUB abort, C0 controls preserve state, C1
/// bytes never change state (except 0x9c out of DCS/SOS), CSI completes on
/// 0x40–0x7e, OSC completes on BEL, DCS/SOS complete on 0x9c or ST.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
enum ScanState {
    #[default]
    Ground,
    Esc,
    EscInter,
    Csi,
    Osc,
    Dcs,
}

/// utf8parse 0.2.2 model (exact positional validity, not just a counter: a
/// wrong-range continuation is an error back to Ground, which a counter
/// would mistrack as still-pending).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
enum Utf8State {
    #[default]
    Ground,
    Tail1,
    Tail2,
    Tail3,
    NeedE0,
    NeedED,
    NeedF0,
    NeedF4,
}

fn step_utf8(st: Utf8State, b: u8) -> Utf8State {
    match st {
        Utf8State::Ground | Utf8State::Tail1 => Utf8State::Ground,
        Utf8State::Tail2 => {
            if (0x80..=0xbf).contains(&b) {
                Utf8State::Tail1
            } else {
                Utf8State::Ground
            }
        }
        Utf8State::Tail3 => {
            if (0x80..=0xbf).contains(&b) {
                Utf8State::Tail2
            } else {
                Utf8State::Ground
            }
        }
        Utf8State::NeedE0 => {
            if (0xa0..=0xbf).contains(&b) {
                Utf8State::Tail1
            } else {
                Utf8State::Ground
            }
        }
        Utf8State::NeedED => {
            if (0x80..=0x9f).contains(&b) {
                Utf8State::Tail1
            } else {
                Utf8State::Ground
            }
        }
        Utf8State::NeedF0 => {
            if (0x90..=0xbf).contains(&b) {
                Utf8State::Tail2
            } else {
                Utf8State::Ground
            }
        }
        Utf8State::NeedF4 => {
            if (0x80..=0x8f).contains(&b) {
                Utf8State::Tail2
            } else {
                Utf8State::Ground
            }
        }
    }
}

/// Partial CSI being accumulated for sticky-mode detection. Bounded state
/// (saturating numbers, two stored params): carries cleanly across feeds.
/// Disagreements with vte's param parsing on exotic shapes (leading colons
/// and the like) always fall slow-ward, never fast.
#[derive(Clone, Copy, Debug, Default)]
struct CsiAccum {
    private: bool,
    inter: bool,
    p0: u32,
    p1: u32,
    nparams: u8,
    cur: u32,
    has_cur: bool,
    subs: u8,
    first_sub: u32,
    seen_6: bool,
}

impl CsiAccum {
    fn digit(&mut self, d: u8) {
        self.cur = self
            .cur
            .saturating_mul(10)
            .saturating_add(d as u32)
            .min(9999);
        self.has_cur = true;
    }

    /// Close the current subparam (`:` separator, `;` separator, or the
    /// final byte). Slots the first sub of each of the first two params
    /// (vt100's DECSTBM canonicalization reads exactly those).
    fn sub_close(&mut self) {
        let val = if self.has_cur { self.cur } else { 0 };
        if self.subs == 0 {
            self.first_sub = val;
            if self.nparams < 2 {
                if self.nparams == 0 {
                    self.p0 = val;
                } else {
                    self.p1 = val;
                }
            }
        }
        if self.has_cur {
            self.subs = self.subs.saturating_add(1);
        }
        self.cur = 0;
        self.has_cur = false;
    }

    /// Close the current `;`-separated param, recording an exact `[6]`
    /// (mirrors decset's `&[6]` arm).
    fn param_close(&mut self) {
        self.sub_close();
        if self.subs == 1 && self.first_sub == 6 {
            self.seen_6 = true;
        }
        self.subs = 0;
        self.first_sub = 0;
        if self.nparams < 2 {
            self.nparams += 1;
        }
    }
}

/// Carry from one feed to the next: everything the scanner needs to seed
/// its left-to-right walk. All bounded, all Copy.
#[derive(Clone, Copy, Debug, Default)]
struct ParseCarry {
    state: ScanState,
    csi: CsiAccum,
    utf8: Utf8State,
}

/// A fast run's pending commit, recorded purely then applied after the
/// cursor check passes.
#[allow(clippy::enum_variant_names)]
enum FastOp {
    Refresh { row: usize, text: String },
    Scroll { line: String },
}

/// Advance the scan model by one slow byte. Fast bytes never reach here
/// (they are state-preserving by construction).
#[allow(clippy::too_many_arguments)]
fn step_scan_byte(
    b: u8,
    rows: u16,
    st: &mut ScanState,
    csi: &mut CsiAccum,
    utf8: &mut Utf8State,
    margins: &mut bool,
    origin: &mut bool,
) {
    // CAN/SUB abort any sequence (vte Anywhere rule).
    if b == 0x18 || b == 0x1a {
        *st = ScanState::Ground;
        *utf8 = Utf8State::Ground;
        return;
    }
    // UTF-8 layer first: vte checks Utf8 state before anything else, and a
    // non-continuation byte is consumed as U+FFFD back to Ground (this
    // swallows even ESC, so groundness must not be assumed after it).
    if *utf8 != Utf8State::Ground {
        *utf8 = step_utf8(*utf8, b);
        return;
    }
    // ESC aborts any state (vte Anywhere rule).
    if b == 0x1b {
        *st = ScanState::Esc;
        return;
    }
    match *st {
        ScanState::Ground => match b {
            0xc2..=0xdf => *utf8 = Utf8State::Tail1,
            0xe0 => *utf8 = Utf8State::NeedE0,
            0xe1..=0xec => *utf8 = Utf8State::Tail2,
            0xed => *utf8 = Utf8State::NeedED,
            0xee..=0xef => *utf8 = Utf8State::Tail2,
            0xf0 => *utf8 = Utf8State::NeedF0,
            0xf1..=0xf3 => *utf8 = Utf8State::Tail3,
            0xf4 => *utf8 = Utf8State::NeedF4,
            // Printables, C0 controls and C1 bytes: Print/Execute/ignore,
            // all state-preserving.
            _ => {}
        },
        ScanState::Esc => match b {
            0x00..=0x17 | 0x19 | 0x1c..=0x1f | 0x7f | 0x80..=0x9f => {}
            0x20..=0x2f => *st = ScanState::EscInter,
            0x5b => {
                *st = ScanState::Csi;
                *csi = CsiAccum::default();
            }
            0x5d => *st = ScanState::Osc,
            0x50 | 0x58 | 0x5e | 0x5f => *st = ScanState::Dcs,
            // RIS rebuilds vt100's screen: sticky modes are gone.
            0x63 => {
                *st = ScanState::Ground;
                *margins = false;
                *origin = false;
            }
            _ => *st = ScanState::Ground,
        },
        ScanState::EscInter => {
            if (0x30..=0x7e).contains(&b) {
                *st = ScanState::Ground;
            }
        }
        ScanState::Csi => match b {
            0x30..=0x39 => csi.digit(b - b'0'),
            0x3a => csi.sub_close(),
            0x3b => csi.param_close(),
            0x3c..=0x3f => csi.private = true,
            0x20..=0x2f => csi.inter = true,
            0x40..=0x7e => {
                csi.param_close();
                csi_dispatch_accum(csi, b, rows, margins, origin);
                *st = ScanState::Ground;
            }
            // C0 controls execute without disturbing CSI; DEL and C1 bytes
            // are ignored in CSI states (vte tables have no entries).
            _ => {}
        },
        // BEL terminates OSC (vte OscString table). 0x9c notably does
        // NOT (no table entry: consumed as data).
        ScanState::Osc => {
            if b == 0x07 {
                *st = ScanState::Ground;
            }
        }
        ScanState::Dcs => {
            if b == 0x9c {
                *st = ScanState::Ground;
            }
        }
    }
}

/// Apply a completed CSI to the sticky flags.
fn csi_dispatch_accum(
    csi: &CsiAccum,
    final_byte: u8,
    rows: u16,
    margins: &mut bool,
    origin: &mut bool,
) {
    // vt100 ignores any dispatch with intermediates (`Some(i)` arm).
    if csi.inter {
        return;
    }
    match final_byte {
        b'r' if !csi.private => {
            // DECSTBM. Mirrors grid::set_scroll_region via decstbm: params
            // default to full screen, and an inverted range resets to full.
            let top = if csi.p0 == 0 { 1 } else { csi.p0 };
            let bottom = if csi.p1 == 0 { rows as u32 } else { csi.p1 };
            let rows_u = rows as u32;
            // Full-screen params (or an inverted range, which vt100
            // resets to full) deactivate the region; anything else sticks.
            let full = top == 1 && bottom == rows_u;
            let inverted = top.saturating_sub(1) >= bottom.saturating_sub(1);
            *margins = !(full || inverted);
        }
        b'h' | b'l' if csi.private => {
            // DECSET/DECRST `?6` (origin mode). Deliberately never cleared
            // except by RIS: DECRC can restore a saved origin mode without
            // a visible `6`, so clearing on `?6l` would be unsound.
            if csi.seen_6 {
                *origin = true;
            }
        }
        _ => {}
    }
}

struct Surface {
    parser: vt100::Parser,
    cols: u16,
    rows: u16,
    /// Lines scrolled off the top (oldest→newest, seqs ascending and below
    /// every screen seq).
    history: VecDeque<RingLine>,
    /// Current screen, one entry per grid row (positional order).
    screen: Vec<RingLine>,
    in_alt: bool,
    /// Last assigned sequence number; reported as `next_seq`. Runtime-only:
    /// reset on restart, so pre-restart cursors read as dropped.
    head: u64,
    /// Set by `restore_surface` to the restored head: any nonzero cursor at
    /// or below it is pre-restart and reads as dropped.
    dropped_floor: u64,
    max_lines: usize,
    /// Fast-path parser model. `feed()` carves plain-ASCII runs out of the
    /// byte stream and captures their lines from the input side with zero
    /// grid extraction; everything else goes through the legacy grid
    /// reconcile below. The model mirrors the `vte` state machine (ground
    /// detection) plus the two pieces of sticky `vt100` mode that affect
    /// plain-text layout (scroll region, origin mode). See `feed`.
    carry: ParseCarry,
    /// True once any feed left the parser outside ground state or set a
    /// sticky mode: while set, plain runs take the slow path. Cleared only
    /// by RIS (`ESC c`, which rebuilds vt100's screen from scratch).
    margins_sticky: bool,
    origin_sticky: bool,
    /// Set by `resize`: the grid was truncated/padded in place, so cached
    /// row texts may be stale. Forces one slow reconcile (which resyncs via
    /// `fit_len` + `refresh_in_place`); cleared there.
    resized: bool,
}

impl Surface {
    fn new(cols: u16, rows: u16) -> Surface {
        Surface {
            parser: vt100::Parser::new(rows, cols, 0),
            cols,
            rows,
            history: VecDeque::new(),
            // Lazily sized to the grid on the first feed.
            screen: Vec::new(),
            in_alt: false,
            head: 0,
            dropped_floor: 0,
            max_lines: DEFAULT_SCROLLBACK_LINES,
            carry: ParseCarry::default(),
            margins_sticky: false,
            origin_sticky: false,
            resized: false,
        }
    }

    fn feed(&mut self, data: &[u8]) {
        if data.is_empty() {
            return;
        }
        // Degenerate sizes and single-row screens take the legacy path:
        // the fast row model below needs at least two rows (a scroll must
        // leave the completed row behind on the grid).
        if self.rows < 2 || self.cols < 1 {
            self.feed_slow(data);
            #[cfg(debug_assertions)]
            self.debug_assert_grid_sync();
            return;
        }
        if self.screen.is_empty() {
            // Factory-fresh parser: blank grid, cursor at (0,0). Size the
            // screen vec blindly; simulation and reconciles fill it in.
            for _ in 0..self.rows {
                self.screen.push(RingLine {
                    seq: 0,
                    text: String::new(),
                });
            }
        }
        // Walk the feed on the legacy cut points (CR/LF + byte budget),
        // classifying each batch: plain batches in Ground state fuse into
        // pending fast spans (zero grid reads), anything else flushes the
        // span and reconciles exactly as the legacy loop would. Slow batches
        // are never split or fused, so every legacy reconcile observes the
        // same grid state at the same byte offset as before.
        let max_bytes = (self.cols as usize).max(64);
        let mut st = self.carry.state;
        let mut csi = self.carry.csi;
        let mut utf8 = self.carry.utf8;
        let mut margins = self.margins_sticky;
        let mut origin = self.origin_sticky;
        // Pending fast span [span_start..i+1): consecutive fast-eligible
        // legacy batches, not yet fed to the parser.
        let mut span_start: Option<usize> = None;
        let mut start = 0usize;
        for (i, &b) in data.iter().enumerate() {
            let cut = b == b'\n' || b == b'\r';
            let bytes = i + 1 - start;
            if !(cut || bytes >= max_bytes || i + 1 == data.len()) {
                continue;
            }
            let batch = &data[start..i + 1];
            let eligible = st == ScanState::Ground
                && utf8 == Utf8State::Ground
                && !margins
                && !origin
                && batch_is_plain(batch);
            if eligible {
                // Fuse: fast bytes preserve all scan state, so stepping is
                // skipped (and the span stays eligible by induction).
                if span_start.is_none() {
                    span_start = Some(start);
                }
            } else {
                if let Some(ss) = span_start.take() {
                    if !self.try_fast(&data[ss..start]) {
                        self.feed_slow(&data[ss..start]);
                    }
                }
                let budget = scroll_budget(batch, self.cols);
                let pre_row = self.parser.screen().cursor_position().0 as usize;
                self.parser.process(batch);
                self.reconcile(budget, pre_row, margins);
                for &sb in batch {
                    step_scan_byte(
                        sb,
                        self.rows,
                        &mut st,
                        &mut csi,
                        &mut utf8,
                        &mut margins,
                        &mut origin,
                    );
                }
            }
            start = i + 1;
        }
        if let Some(ss) = span_start.take() {
            if !self.try_fast(&data[ss..]) {
                self.feed_slow(&data[ss..]);
            }
        }
        self.carry = ParseCarry {
            state: st,
            csi,
            utf8,
        };
        self.margins_sticky = margins;
        self.origin_sticky = origin;
        #[cfg(debug_assertions)]
        self.debug_assert_grid_sync();
    }

    /// Legacy path, verbatim: reconcile the rendered ring in small batches
    /// so a bulk scroll that lands in one PTY read is still captured line
    /// by line. Batches cut after every CR/LF: printing a line and
    /// scrolling it off are then reconciled as the two separate steps the
    /// terminal applied, which keeps strict scroll matching exact (a
    /// print+scroll in one step would hide the new line mid-grid and
    /// defeat the matcher).
    /// Splitting on CR/LF is UTF-8 safe (neither byte appears inside a
    /// multibyte sequence) and harmless to the parser, which is a byte
    /// state machine that resumes across `process` calls — even mid-OSC.
    /// The byte budget (~one visual row) bounds how much new content a
    /// single reconcile can carry, so wrap-driven scrolls stay inside
    /// the matcher's tail allowance.
    fn feed_slow(&mut self, data: &[u8]) {
        let max_bytes = (self.cols as usize).max(64);
        let mut start = 0usize;
        for (i, &b) in data.iter().enumerate() {
            let cut = b == b'\n' || b == b'\r';
            let bytes = i + 1 - start;
            if cut || bytes >= max_bytes || i + 1 == data.len() {
                let batch = &data[start..i + 1];
                let budget = scroll_budget(batch, self.cols);
                let pre_row = self.parser.screen().cursor_position().0 as usize;
                self.parser.process(batch);
                start = i + 1;
                self.reconcile(budget, pre_row, self.margins_sticky);
            }
        }
    }

    // ---------------- fast path: input-side line capture ----------------
    //
    // Grid extraction (`grid_text`) costs ~42µs per 24-row screen; at ~26k
    // CR/LF batches per MiB that dominated `feed()` (~1.1s/MiB vs ~13ms of
    // raw vt100 parsing). The fast path carves plain-ASCII runs (printable
    // ASCII + CR/LF/VT/FF, nothing else) out of the feed and captures their
    // lines from the input side with zero grid reads. Everything else —
    // escape sequences, controls, UTF-8, alt-screen, sticky modes — takes
    // the legacy grid reconcile, verbatim.
    //
    // Soundness argument (all verified against vt100 0.15.2 + vte 0.11.1 +
    // utf8parse 0.2.2 sources):
    // - A fast run starts only in vte Ground state with no partial UTF-8
    //   (the feed scan tracks both exactly, including carry across feeds;
    //   ESC aborts any state, CAN/SUB abort, C0 controls preserve state,
    //   C1 bytes never change state except ST-after-DCS/SOS, UTF-8 errors
    //   consume one byte back to Ground). Fast bytes themselves are
    //   state-preserving (Print/Execute), so groundness survives the run.
    // - vt100 ignores the modes that would otherwise complicate plain-text
    //   layout: no LNM (`lf` never resets col), no IRM (`sm`/`rm` are
    //   no-ops), no wrap suppression (`?7` unhandled), no charset shifts
    //   (SO/SI are no-ops). The two modes it honors and that affect plain
    //   feeds — scroll region (`CSI r`) and origin mode (`CSI ? 6 h`) — are
    //   tracked as sticky flags that force the slow path (cleared only by
    //   RIS, which rebuilds the screen). Alt-screen is checked per run.
    // - The row simulation mirrors vt100 exactly for ASCII: width-1 cells,
    //   wrap when col == cols before a printable (same `col_wrap` rule; the
    //   last-cell wrap flag only affects `contents()` joining, never rows),
    //   CR resets col, LF/VT/FF increment the row with a scroll at the
    //   bottom margin. Touched rows must hold ASCII (byte columns); any
    //   other row aborts the run to the slow path.
    // - Belt and braces: the simulated end cursor must equal the parser's
    //   real cursor, or the run reconciles from grid truth once (never
    //   re-feeds: the bytes are already consumed, so a second `process`
    //   would duplicate them). Debug builds additionally assert full grid
    //   sync after every fast feed.
    //
    // Intended behavior changes vs the legacy matcher (both unpinned by any
    // test, both fixes): repetitive streams no longer duplicate lines (the
    // matcher's bound admitted a spurious k=2 scroll: 30×"y" became 41
    // lines), and blank scrolled lines are captured as empty entries instead
    // of vanishing through the blank-scroll quirk.

    /// Capture one plain-ASCII run from the input side.
    ///
    /// Exactly-once contract: every byte is processed by the parser
    /// exactly once. All bail-out checks that don't need post-state run
    /// BEFORE `process` and return false (the parser is untouched, so the
    /// caller may run the legacy path over the same bytes). Once `process`
    /// runs this function never returns false: a cursor mismatch reconciles
    /// from the grid once instead of re-feeding, so a second `process` of
    /// the same bytes is impossible (a prior version processed first and
    /// returned false afterwards, double-feeding the parser whenever a
    /// touched row held non-ASCII text or the cursors diverged). The screen
    /// vec is untouched until the run is committed or reconciled.
    fn try_fast(&mut self, run: &[u8]) -> bool {
        if run.is_empty() {
            return true;
        }
        if self.screen.len() != self.rows as usize || self.resized {
            return false;
        }
        if self.in_alt || self.margins_sticky || self.origin_sticky {
            return false;
        }
        let cols = self.cols as usize;
        let rows = self.rows as usize;
        let (start_row, start_col) = self.parser.screen().cursor_position();
        let (mut row, mut col) = (start_row as usize, start_col as usize);
        if row >= rows || col > cols {
            return false;
        }
        // Pure simulation over the cached screen (grid-synced by induction:
        // every prior segment committed or reconciled exactly). Runs BEFORE
        // `process` so every ASCII precondition bails with the parser
        // untouched and the caller can safely take the legacy path.
        if !self.screen[row].text.is_ascii() {
            return false;
        }
        let mut buf: Vec<u8> = self.screen[row].text.clone().into_bytes();
        let mut ops: Vec<FastOp> = Vec::new();
        macro_rules! complete_row {
            () => {{
                let line = match ascii_line(buf.as_slice()) {
                    Some(line) => line,
                    None => return false,
                };
                if row + 1 >= rows {
                    ops.push(FastOp::Scroll { line });
                    row = rows - 1;
                    // The scrolled-in row is blank by terminal semantics
                    // (vt100 inserts a fresh row); it must NOT be reloaded
                    // from the screen vec, which still holds the pre-shift
                    // bottom row until ops apply.
                    buf = Vec::new();
                } else {
                    ops.push(FastOp::Refresh { row, text: line });
                    row += 1;
                    // No shift happened, so the cached row is valid.
                    if !self.screen[row].text.is_ascii() {
                        return false;
                    }
                    buf = self.screen[row].text.clone().into_bytes();
                }
                // col is intentionally preserved: vt100 has no LNM, so
                // LF/VT/FF never touch the column (staircase is correct).
            }};
        }
        for &b in run {
            match b {
                b'\r' => col = 0,
                b'\n' | 0x0b | 0x0c => complete_row!(),
                _ => {
                    debug_assert!((0x20..=0x7e).contains(&b), "fast run holds printables only");
                    if col >= cols {
                        // vt100 `col_wrap`: wrap before writing when the
                        // cursor sits past the last column.
                        complete_row!();
                        col = 0;
                    }
                    if col < buf.len() {
                        // ASCII-only buffer: byte index == column.
                        buf[col] = b;
                    } else {
                        while buf.len() < col {
                            buf.push(b' ');
                        }
                        buf.push(b);
                    }
                    col += 1;
                }
            }
        }
        let final_text = match ascii_line(buf.as_slice()) {
            Some(text) => text,
            None => return false,
        };
        // Single parse of these bytes: the parser owns ground truth now.
        self.parser.process(run);
        let (end_row, end_col) = self.parser.screen().cursor_position();
        if row != end_row as usize || col != end_col as usize {
            // Model divergence (untracked mode or vt100 behavior change):
            // sync the ring from grid truth once. Never re-feed: the bytes
            // are already consumed, and a second `process` would duplicate
            // them in the grid. History is untouched so far.
            let budget = scroll_budget(run, self.cols);
            self.reconcile(budget, start_row as usize, self.margins_sticky);
            return true;
        }
        ops.push(FastOp::Refresh {
            row,
            text: final_text,
        });
        for op in ops {
            match op {
                FastOp::Refresh { row, text } => {
                    let slot = &mut self.screen[row];
                    if slot.text != text {
                        self.head += 1;
                        slot.seq = self.head;
                        slot.text = text;
                    }
                }
                FastOp::Scroll { line } => {
                    let top = self.screen.remove(0);
                    // The completed line stays visible one row up; sync its
                    // text, bumping only on change (same rule as refresh).
                    let staying = &mut self.screen[rows - 2];
                    if staying.text != line {
                        self.head += 1;
                        staying.seq = self.head;
                        staying.text = line;
                    }
                    self.history.push_back(top);
                    // A scroll always inserts a blank row; the legacy path
                    // mints it a fresh sequence unconditionally.
                    self.head += 1;
                    self.screen.push(RingLine {
                        seq: self.head,
                        text: String::new(),
                    });
                    self.evict();
                }
            }
        }
        true
    }

    /// Debug-only: the screen vec must mirror the grid exactly.
    #[cfg(debug_assertions)]
    fn debug_assert_grid_sync(&self) {
        let grid = self.grid_text();
        assert_eq!(
            grid.len(),
            self.screen.len(),
            "screen vec length drifted from grid"
        );
        for (i, (g, s)) in grid.iter().zip(self.screen.iter()).enumerate() {
            assert_eq!(g, &s.text, "screen vec row {i} drifted from grid");
        }
    }

    /// Current grid rows as plain text, trailing padding trimmed.
    fn grid_text(&self) -> Vec<String> {
        let width = self.cols.max(1);
        self.parser
            .screen()
            .rows(0, width)
            .map(|r| r.trim_end().to_string())
            .collect()
    }

    fn reconcile(&mut self, budget: ScrollBudget, pre_row: usize, in_region: bool) {
        let alt = self.parser.screen().alternate_screen();
        let grid = self.grid_text();
        let transitioned = alt != self.in_alt;
        self.in_alt = alt;
        self.resized = false;
        if grid.len() != self.screen.len() {
            // First feed, or a resize: fit lengths, then reconcile in
            // place (never guess a scroll across a size change).
            self.fit_len(&grid);
            self.refresh_in_place(&grid);
            return;
        }
        // On alt enter/exit the grids are unrelated buffers: refresh in
        // place so alt rows never leak into main-screen history.
        if !transitioned && !alt {
            if let Some(k) = self.scroll_by(&grid, budget, pre_row, in_region) {
                self.apply_scroll(k, &grid);
                return;
            }
        }
        self.refresh_in_place(&grid);
    }

    /// Scroll-up `k` (1..=n) consistent with the new grid and the batch's
    /// scroll bound, or None. Batches cut after every CR/LF, so a genuine
    /// scroll advances one line per step.
    ///
    /// Only the leading `n-2k` rows must match: a print that scrolls in the
    /// same step (long wrapped line at the bottom) lands its new rows at the
    /// tail of the overlap, so the tail `k` overlap rows are excused. This
    /// biases toward preservation — a coincidental match archives lines to
    /// history rather than dropping them. Candidates beyond the batch's
    /// scroll bound are ignored: repetitive content can match any shift,
    /// but the batch itself caps how far the grid could have moved. A
    /// partial scroll still needs a
    /// non-blank line on either side of the fold so an idle blank screen
    /// never churns history; a fully blanked grid over non-blank rows
    /// counts as a full scroll-off only when the batch's bound can explain
    /// moving every row (an explicit clear — `scroll_budget` reports ED as
    /// a large bound). A short overwrite that leaves the grid blank is an
    /// in-place edit, not a scroll. An identical grid never scrolls
    /// (covers no-op feeds on uniform screens, which would otherwise flood
    /// history). `pre_row` is the cursor row before the batch: on the full
    /// screen a linefeed scrolls only from the bottom row. Inside a scroll
    /// region the bottom is not the screen bottom, so that cap is skipped.
    fn scroll_by(
        &self,
        grid: &[String],
        budget: ScrollBudget,
        pre_row: usize,
        in_region: bool,
    ) -> Option<usize> {
        let n = grid.len();
        let bound = budget.bound;
        if n == 0 || self.screen.len() != n {
            return None;
        }
        if self
            .screen
            .iter()
            .map(|l| l.text.as_str())
            .eq(grid.iter().map(String::as_str))
        {
            return None;
        }
        let mut smallest: Option<usize> = None;
        let mut evidenced: Option<usize> = None;
        for k in 1..n {
            // Leading rows must match exactly; trailing `k` overlap rows
            // may hold freshly printed content. Needs at least one
            // verified row, and the batch must be able to explain the
            // shift (bounds repetitive-content matches).
            if n < 2 * k + 1 || k > bound {
                continue;
            }
            let moved = &self.screen[..k];
            if !(moved.iter().any(|l| !l.text.is_empty())
                || grid[n - k..].iter().any(|l| !l.is_empty()))
            {
                continue;
            }
            let kept = &self.screen[k..n - k];
            if !kept
                .iter()
                .map(|l| l.text.as_str())
                .eq(grid[..n - 2 * k].iter().map(String::as_str))
            {
                continue;
            }
            if smallest.is_none() {
                smallest = Some(k);
            }
            // Non-blank evidence proves the shift: blanks align with
            // anything, so the largest evidenced `k` is the exact scroll.
            if grid[..n - 2 * k].iter().any(|l| !l.is_empty()) {
                evidenced = Some(k);
            }
        }
        // A non-blank kept prefix proves the shift. Without that evidence,
        // blanks match every `k`. Trust an unevidenced shift only when the
        // cursor, plus this batch's linefeeds, could have reached the
        // bottom and scrolled that far. An erase followed by LF from a
        // higher row blanks the line in place.
        let max_scroll = if in_region {
            bound
        } else {
            pre_row
                .saturating_add(budget.linefeeds)
                .saturating_sub(n.saturating_sub(1))
        };
        if let Some(k) = evidenced.or(smallest.filter(|&k| k <= max_scroll && k < bound)) {
            return Some(k);
        }
        if bound >= n
            && grid.iter().all(|l| l.is_empty())
            && self.screen.iter().any(|l| !l.text.is_empty())
        {
            return Some(n);
        }
        None
    }

    fn apply_scroll(&mut self, k: usize, grid: &[String]) {
        // A full-screen clear matches `k == n` with an empty grid. Archive
        // the non-blank lines only — blank padding must not flood history.
        if k >= self.screen.len() && grid.iter().all(|l| l.is_empty()) {
            for line in self.screen.drain(..) {
                if !line.text.is_empty() {
                    self.history.push_back(line);
                }
            }
            self.fit_len(grid);
            self.evict();
            return;
        }
        let k = k.min(self.screen.len());
        for line in self.screen.drain(..k) {
            self.history.push_back(line);
        }
        for text in grid.iter().skip(grid.len().saturating_sub(k)) {
            self.head += 1;
            self.screen.push(RingLine {
                seq: self.head,
                text: text.clone(),
            });
        }
        self.evict();
    }

    /// Grow or shrink the screen vec to the grid length. Shrinking moves
    /// non-blank top rows into history; growing takes fresh seqs.
    fn fit_len(&mut self, grid: &[String]) {
        while self.screen.len() < grid.len() {
            let text = grid[self.screen.len()].clone();
            self.head += 1;
            self.screen.push(RingLine {
                seq: self.head,
                text,
            });
        }
        if self.screen.len() > grid.len() {
            let drop = self.screen.len() - grid.len();
            for line in self.screen.drain(..drop) {
                if !line.text.is_empty() {
                    self.history.push_back(line);
                }
            }
            self.evict();
        }
    }

    /// Rows whose text changed keep their position but take a fresh seq, so
    /// a polling cursor observes the repaint exactly once and the ring never
    /// grows on repaint alone.
    fn refresh_in_place(&mut self, grid: &[String]) {
        for (row, text) in self.screen.iter_mut().zip(grid.iter()) {
            if row.text != *text {
                row.text = text.clone();
                self.head += 1;
                row.seq = self.head;
            }
        }
    }

    fn evict(&mut self) {
        // Capacity counts content lines; trailing blank grid padding is not
        // content and must not eat the scrollback budget.
        while self.history.len() + self.screen_content_len() > self.max_lines {
            if self.history.pop_front().is_none() {
                break;
            }
        }
    }

    /// Screen rows through the last non-blank one: trailing blank grid rows
    /// are padding, not content (matches `contents()` trimming newlines).
    fn screen_content_len(&self) -> usize {
        let mut n = self.screen.len();
        while n > 0 && self.screen[n - 1].text.is_empty() {
            n -= 1;
        }
        n
    }

    fn oldest_retained(&self) -> u64 {
        if let Some(first) = self.history.front() {
            return first.seq;
        }
        if let Some(min) = self.screen.iter().map(|l| l.seq).min() {
            return min;
        }
        self.head + 1
    }

    /// Content lines (history + live screen rows) as (seq, text) in
    /// positional order.
    fn content(&self) -> impl Iterator<Item = &RingLine> {
        let n = self.screen_content_len();
        self.history.iter().chain(self.screen.iter().take(n))
    }

    fn rendered(&self, after_seq: u64, max_lines: usize) -> RenderedRead {
        let picked: Vec<&RingLine> = self.content().filter(|l| l.seq > after_seq).collect();
        let total = picked.len();
        let take = max_lines.min(total);
        // Newest `take` by position; still cursor-continuous.
        let lines = &picked[total - take..];
        let text = lines
            .iter()
            .map(|l| l.text.as_str())
            .collect::<Vec<_>>()
            .join("\n");
        RenderedRead {
            seq: lines.first().map(|l| l.seq).unwrap_or(self.head),
            next_seq: self.head,
            dropped: after_seq != 0
                && (after_seq <= self.dropped_floor
                    || after_seq < self.oldest_retained()
                    || after_seq > self.head),
            truncated: total > take,
            text,
        }
    }

    fn tail_vec(&self, n: usize, strip: bool) -> Vec<String> {
        let total = self.history.len() + self.screen_content_len();
        let skip = total.saturating_sub(n);
        let mut out: Vec<String> = self.content().skip(skip).map(|l| l.text.clone()).collect();
        if strip {
            out = out.iter().map(|l| strip_ansi(l)).collect();
        }
        out
    }

    fn ring_len(&self) -> usize {
        self.history.len() + self.screen_content_len()
    }

    fn resize(&mut self, cols: u16, rows: u16) {
        if self.cols == cols && self.rows == rows {
            return;
        }
        // No reflow: vt100 truncates/pads rows in place, so the visible
        // screen survives (TUIs redraw on SIGWINCH anyway). Length fixup
        // happens on the next feed.
        self.parser.set_size(rows, cols);
        self.cols = cols;
        self.rows = rows;
        self.resized = true;
    }
}

pub struct HeadlessBackend {
    surfaces: HashMap<String, Surface>,
}

impl HeadlessBackend {
    pub fn new() -> HeadlessBackend {
        HeadlessBackend {
            surfaces: HashMap::new(),
        }
    }

    /// Rendered lines after `after_seq` (oldest→newest), per contracts/ipc.md.
    pub fn rendered(&self, id: &str, after_seq: u64, max_lines: usize) -> Option<RenderedRead> {
        self.surfaces
            .get(id)
            .map(|s| s.rendered(after_seq, max_lines))
    }

    /// Last `n` content lines (oldest→newest), optionally ANSI-stripped.
    /// Rebased on the rendered ring: same shape as before, cursor-correct.
    pub fn tail(&self, id: &str, n: usize, strip: bool) -> Option<Vec<String>> {
        self.surfaces.get(id).map(|s| s.tail_vec(n, strip))
    }

    /// Retained content lines (history + live screen rows).
    pub fn ring_len(&self, id: &str) -> usize {
        self.surfaces.get(id).map(|s| s.ring_len()).unwrap_or(0)
    }

    /// Create the surface unless it exists (resume keeps restored tail).
    pub fn ensure_surface(&mut self, id: &str, cols: u16, rows: u16) {
        if let Some(s) = self.surfaces.get_mut(id) {
            s.resize(cols, rows);
        } else {
            self.surfaces
                .insert(id.to_string(), Surface::new(cols, rows));
        }
    }

    /// Recreate a surface pre-filled with persisted tail lines (restore).
    /// Sequences restart at 1, so every pre-restart cursor reads as dropped
    /// (see `dropped_floor`); content stays available from scratch.
    pub fn restore_surface(&mut self, id: &str, cols: u16, rows: u16, lines: Vec<String>) {
        let mut s = Surface::new(cols, rows);
        let skip = lines.len().saturating_sub(s.max_lines);
        for line in lines.into_iter().skip(skip) {
            s.head += 1;
            s.history.push_back(RingLine {
                seq: s.head,
                text: strip_ansi(&line),
            });
        }
        s.dropped_floor = s.head;
        self.surfaces.insert(id.to_string(), s);
    }
}

impl Default for HeadlessBackend {
    fn default() -> Self {
        Self::new()
    }
}

impl TerminalBackend for HeadlessBackend {
    fn create_surface(&mut self, id: &str, cols: u16, rows: u16) {
        self.surfaces
            .insert(id.to_string(), Surface::new(cols, rows));
    }

    fn feed_output(&mut self, id: &str, data: &[u8]) {
        if let Some(s) = self.surfaces.get_mut(id) {
            s.feed(data);
        }
    }

    fn send_input(&mut self, _id: &str, _data: &[u8]) {
        // Server-side backend: input goes straight to the PTY master,
        // not through here. Client backends forward to IPC.
    }

    fn resize(&mut self, id: &str, cols: u16, rows: u16) {
        if let Some(s) = self.surfaces.get_mut(id) {
            s.resize(cols, rows);
        }
    }

    fn snapshot(&self, id: &str) -> String {
        self.surfaces
            .get(id)
            .map(|s| s.parser.screen().contents())
            .unwrap_or_default()
    }

    fn screen_state(&self, id: &str) -> Vec<u8> {
        self.surfaces
            .get(id)
            .map(|s| s.parser.screen().state_formatted())
            .unwrap_or_default()
    }

    fn configure(&mut self, _opts: &TermOptions) {}

    fn destroy(&mut self, id: &str) {
        self.surfaces.remove(id);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn screen_and_tail() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"hello\r\nworld\r\n");
        assert!(b.snapshot("p").contains("hello"));
        let tail = b.tail("p", 10, false).unwrap();
        assert_eq!(tail, vec!["hello".to_string(), "world".to_string()]);
    }

    #[test]
    fn tail_keeps_utf8_split_across_reads() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"caf\xc3");
        b.feed_output("p", b"\xa9\r\n");
        assert_eq!(
            b.tail("p", 1, false).unwrap(),
            vec!["caf\u{e9}".to_string()]
        );
    }

    #[test]
    fn screen_state_replays_colours_and_line_starts() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"\x1b[31mred\x1b[0m\r\nnext\r\n");
        let state = b.screen_state("p");
        // Replaying into a fresh screen reproduces it exactly.
        let mut replay = vt100::Parser::new(24, 80, 0);
        replay.process(&state);
        assert_eq!(replay.screen().contents(), b.snapshot("p"));
        assert_eq!(
            replay.screen().cell(0, 0).unwrap().fgcolor(),
            vt100::Color::Idx(1)
        );
        assert_eq!(replay.screen().cell(1, 0).unwrap().contents(), "n");
    }

    #[test]
    fn resize_keeps_the_visible_screen() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"still here\r\n");
        b.resize("p", 120, 40);
        assert!(b.snapshot("p").contains("still here"));
        b.resize("p", 40, 10);
        assert!(b.snapshot("p").contains("still here"));
    }

    #[test]
    fn tail_strips_ansi() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"\x1b[31mred\x1b[0m\r\n");
        assert_eq!(b.tail("p", 5, true).unwrap(), vec!["red".to_string()]);
    }

    #[test]
    fn scrollback_is_bounded() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        for i in 0..(DEFAULT_SCROLLBACK_LINES + 100) {
            b.feed_output("p", format!("line {i}\r\n").as_bytes());
        }
        assert_eq!(b.ring_len("p"), DEFAULT_SCROLLBACK_LINES);
    }

    // ---- US3 rendered ring (T008): TUI repaint fixtures ----

    fn rendered_text(b: &HeadlessBackend, id: &str) -> String {
        b.rendered(id, 0, 5000).unwrap().text
    }

    #[test]
    fn rendered_cr_overwrite_resolves_to_final_text() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"Status: Starting\rStatus: Ready   \r\n");
        assert_eq!(rendered_text(&b, "p"), "Status: Ready");
        assert_eq!(
            b.tail("p", 10, true).unwrap(),
            vec!["Status: Ready".to_string()]
        );
    }

    #[test]
    fn rendered_cursor_up_redraw_has_no_duplicates() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"=== hdr ===\r\nline A\r\n\x1b[1A\x1b[2Kline B\r\n");
        let text = rendered_text(&b, "p");
        assert_eq!(text, "=== hdr ===\nline B");
        // tail is rebased on the same ring: identical text.
        let tail = b.tail("p", 200, true).unwrap().join("\n");
        assert_eq!(tail, text);
    }

    #[test]
    fn rendered_erase_line_then_rewrite() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"hello\x1b[2K\x1b[Gbye\r\n");
        assert_eq!(rendered_text(&b, "p"), "bye");
    }

    #[test]
    fn rendered_spinner_rewritten_50_times_stays_one_line() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        for i in 0..50 {
            b.feed_output("p", format!("spin {i}\r").as_bytes());
        }
        let r = b.rendered("p", 0, 5000).unwrap();
        assert_eq!(r.text, "spin 49");
        assert!(!r.dropped);
        assert!(!r.truncated);
    }

    #[test]
    fn rendered_second_read_is_delta_only() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"alpha\r\n");
        let r1 = b.rendered("p", 0, 200).unwrap();
        assert!(r1.text.contains("alpha"));
        b.feed_output("p", b"beta\r\n");
        let r2 = b.rendered("p", r1.next_seq, 200).unwrap();
        assert_eq!(r2.text, "beta");
        assert!(!r2.dropped);
        // Unchanged pane: empty text, unchanged head.
        let r3 = b.rendered("p", r2.next_seq, 200).unwrap();
        assert_eq!(r3.text, "");
        assert_eq!(r3.next_seq, r2.next_seq);
        assert_eq!(r3.seq, r2.next_seq);
    }

    #[test]
    fn rendered_bulk_scroll_burst_keeps_order() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        let mut bulk = String::new();
        for i in 0..10 {
            bulk.push_str(&format!("l{i}\r\n"));
        }
        b.feed_output("p", bulk.as_bytes());
        let text = rendered_text(&b, "p");
        let expect: Vec<String> = (0..10).map(|i| format!("l{i}")).collect();
        assert_eq!(text, expect.join("\n"));
    }

    #[test]
    fn rendered_eviction_reports_dropped() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        for chunk in 0..52 {
            let mut bulk = String::new();
            for i in 0..100 {
                bulk.push_str(&format!("line {}\r\n", chunk * 100 + i));
            }
            b.feed_output("p", bulk.as_bytes());
        }
        assert_eq!(b.ring_len("p"), DEFAULT_SCROLLBACK_LINES);
        let dropped = b.rendered("p", 1, 5000).unwrap();
        assert!(dropped.dropped);
        let full = b.rendered("p", 0, 5000).unwrap();
        assert!(!full.dropped);
        assert!(!full.truncated);
        assert!(full.text.starts_with("line 200\n"));
        assert!(full.text.ends_with("line 5199"));
    }

    #[test]
    fn rendered_truncated_returns_newest_lines() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        for i in 0..10 {
            b.feed_output("p", format!("l{i}\r\n").as_bytes());
        }
        let r = b.rendered("p", 0, 3).unwrap();
        assert!(r.truncated);
        assert_eq!(r.text, "l7\nl8\nl9");
    }

    #[test]
    fn rendered_alt_screen_returns_grid_without_history_pollution() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"main line\r\n");
        let before = rendered_text(&b, "p");
        assert_eq!(before, "main line");
        // Enter alt screen: main content hidden, alt grid shown.
        b.feed_output("p", b"\x1b[?1049h");
        b.feed_output("p", b"alt content\r\n");
        let r1 = b.rendered("p", 0, 5000).unwrap();
        assert!(r1.text.contains("alt content"));
        assert!(!r1.text.contains("main line"));
        // Repaint: polling with the cursor yields only the changed row.
        b.feed_output("p", b"\x1b[Halt content v2");
        let r2 = b.rendered("p", r1.next_seq, 5000).unwrap();
        assert!(r2.text.contains("v2"));
        assert!(!r2.dropped);
        // Exit: main grid restored, alt lines never entered history.
        b.feed_output("p", b"\x1b[?1049l");
        let after = rendered_text(&b, "p");
        assert_eq!(after, before);
        assert!(!after.contains("alt content"));
    }

    #[test]
    fn rendered_clear_does_not_flood_history() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"keep\r\n");
        let len_before = b.ring_len("p");
        b.feed_output("p", b"\x1b[2J\x1b[H");
        assert_eq!(b.ring_len("p"), len_before);
    }

    #[test]
    fn restore_marks_pre_restart_cursors_dropped() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"before\r\n");
        let old_next = b.rendered("p", 0, 200).unwrap().next_seq;
        assert!(old_next > 0);
        // Simulate restart recovery: content restored, sequence reset.
        b.restore_surface("p", 80, 24, vec!["before".to_string()]);
        let r = b.rendered("p", old_next, 200).unwrap();
        assert!(r.dropped);
        let fresh = b.rendered("p", 0, 200).unwrap();
        assert!(!fresh.dropped);
        assert_eq!(fresh.text, "before");
    }

    #[test]
    fn resize_keeps_scrollback() {
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"keepme\r\n");
        b.resize("p", 100, 30);
        assert_eq!(b.tail("p", 5, true).unwrap(), vec!["keepme".to_string()]);
    }

    // ---- Fast path (input-side capture): behavior pins ----

    #[test]
    fn fast_repetitive_lines_captured_exactly() {
        // The legacy matcher's scroll bound admitted a spurious k=2 shift
        // on repetitive content (30x"y" became 41 lines). The fast path
        // captures exactly.
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        for _ in 0..30 {
            b.feed_output("p", b"y\r\n");
        }
        let r = b.rendered("p", 0, 5000).unwrap();
        assert_eq!(r.text.lines().count(), 30);
        assert!(r.text.lines().all(|l| l == "y"));
    }

    #[test]
    fn fast_blank_scroll_preserves_scrolled_line() {
        // A blank LF on a full screen scrolls exactly one row: the top
        // line must move to history, not vanish through the legacy
        // matcher's blank-scroll quirk (which dropped it).
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        for i in 0..30 {
            b.feed_output("p", format!("line{i}\r\n").as_bytes());
        }
        b.feed_output("p", b"\r\n");
        let text = rendered_text(&b, "p");
        assert!(
            text.contains("line6"),
            "scrolled-off top line kept: {text:?}"
        );
        assert_eq!(text.lines().count(), 30);
    }

    #[test]
    fn fast_long_wrapped_line_bit_for_bit() {
        // 500 ASCII chars = 6 full rows + 20 cells, no scroll on 24 rows.
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        let line = "A".repeat(500);
        b.feed_output("p", format!("{line}\r\n").as_bytes());
        let tail = b.tail("p", 8, false).unwrap();
        assert_eq!(tail.len(), 7);
        for row in &tail[..6] {
            assert_eq!(row, &"A".repeat(80));
        }
        assert_eq!(tail[6], "A".repeat(20));
    }

    #[test]
    fn fast_staircase_n_without_r() {
        // Bare LF never resets the column (vt100 has no LNM): cooked PTYs
        // send CRLF, but raw printf output staircases like a real terminal.
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"ab\ncd\n");
        assert_eq!(
            b.tail("p", 2, false).unwrap(),
            vec!["ab".to_string(), "  cd".to_string()]
        );
    }

    #[test]
    fn fast_split_sequence_stays_sound() {
        // An SGR split across feeds must not leak param bytes into content:
        // the second feed's leading bytes complete the CSI, then "B" prints.
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"A\x1b[31");
        b.feed_output("p", b"mB\r\n");
        assert_eq!(rendered_text(&b, "p"), "AB");
    }

    #[test]
    fn fast_split_csi_across_feeds_recovers_ground() {
        // ED split across feeds: completion dispatches, ground returns, and
        // the following plain text takes the fast path again.
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"keep\r\n\x1b[2");
        b.feed_output("p", b"Jafter\r\n");
        let text = rendered_text(&b, "p");
        assert!(text.contains("after"), "{text:?}");
        assert!(
            !text.contains('J'),
            "CSI final must dispatch, not print: {text:?}"
        );
    }

    #[test]
    fn fast_sticky_scroll_region_stays_correct() {
        // A scroll region forces plain runs onto the slow path (grid
        // truth). DECSTBM homes the cursor, so "b" overwrites "a": that
        // overwrite proves the sticky flag engaged (the fast path would
        // have placed "b" at row 1). RIS clears the flag again.
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"a\r\n");
        b.feed_output("p", b"\x1b[1;5r");
        b.feed_output("p", b"b\r\n");
        let text = rendered_text(&b, "p");
        assert_eq!(text, "b", "{text:?}");
        b.feed_output("p", b"\x1bc");
        b.feed_output("p", b"c\r\n");
        assert!(rendered_text(&b, "p").contains('c'));
    }

    #[test]
    fn fast_sticky_origin_mode_stays_correct() {
        // Origin-set homes the cursor, so "y" overwrites "x": same proof
        // as above, the fast path would have read "x\\ny".
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"x\r\n");
        b.feed_output("p", b"\x1b[?6h");
        b.feed_output("p", b"y\r\n");
        let text = rendered_text(&b, "p");
        assert_eq!(text, "y", "{text:?}");
    }

    #[test]
    fn fast_chunked_feeds_capture_exactly() {
        // PTY reads split lines arbitrarily (odd chunk sizes realign digit
        // widths mid-stream). Every line must land exactly once, in order,
        // regardless of fragmentation. Regression test: a stale row-buffer
        // reload after an in-span scroll once corrupted these.
        for chunk in [2usize, 3, 7, 101] {
            let mut data = Vec::new();
            for i in 1..=300 {
                data.extend_from_slice(format!("line{i}\r\n").as_bytes());
            }
            let mut b = HeadlessBackend::new();
            b.create_surface("p", 80, 24);
            for piece in data.chunks(chunk) {
                b.feed_output("p", piece);
            }
            let r = b.rendered("p", 0, 100_000).unwrap();
            let expect: Vec<String> = (1..=300).map(|i| format!("line{i}")).collect();
            assert_eq!(
                r.text.lines().collect::<Vec<_>>(),
                expect.iter().map(String::as_str).collect::<Vec<_>>(),
                "chunk={chunk}"
            );
        }
    }

    // ---- Exactly-once oracle: independent single vt100 parse ----

    /// Fresh vt100 parser fed the FULL input stream exactly once (a single
    /// `process` call). Never shares state with the Surface under test, so
    /// any double-feed in the ring (parser or ring duplication) diverges
    /// from it. The pre-existing `debug_assert_grid_sync` compares the ring
    /// to the Surface's own (possibly double-fed) parser, so it cannot see
    /// this class of bug; these helpers can.
    fn oracle_parse(full: &[u8], cols: u16, rows: u16) -> vt100::Parser {
        let mut p = vt100::Parser::new(rows, cols, 0);
        p.process(full);
        p
    }

    fn oracle_grid(full: &[u8], cols: u16, rows: u16) -> Vec<String> {
        let p = oracle_parse(full, cols, rows);
        let width = cols.max(1);
        p.screen()
            .rows(0, width)
            .map(|r| r.trim_end().to_string())
            .collect()
    }

    fn oracle_contents(full: &[u8], cols: u16, rows: u16) -> String {
        oracle_parse(full, cols, rows).screen().contents()
    }

    /// Oracle ring text for inputs that never scroll: the oracle grid rows
    /// with per-row trailing padding trimmed and trailing blank rows
    /// dropped — the same normalization the ring applies (`grid_text` +
    /// `screen_content_len`). (`vt100::Screen::contents` keeps trailing
    /// padding spaces, so it cannot be compared to ring text directly.)
    fn oracle_text(full: &[u8], cols: u16, rows: u16) -> String {
        let grid = oracle_grid(full, cols, rows);
        let mut n = grid.len();
        while n > 0 && grid[n - 1].is_empty() {
            n -= 1;
        }
        grid[..n].join("\n")
    }

    fn surface_screen_texts(b: &HeadlessBackend, id: &str) -> Vec<String> {
        b.surfaces
            .get(id)
            .unwrap()
            .screen
            .iter()
            .map(|l| l.text.clone())
            .collect()
    }

    #[test]
    fn fast_utf8_then_ascii_never_double_feeds() {
        // Exact repro from review: "café" rides the slow path (non-ASCII)
        // and leaves the cursor on a non-ASCII row; the following
        // pure-ASCII run used to be processed by `try_fast` and then
        // re-fed by `feed_slow`, duplicating the reads in both grid and
        // ring ("café OK\nnext\n OK\nnext").
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", "café".as_bytes());
        b.feed_output("p", " OK\r\nnext\r\n".as_bytes());
        let full = "café OK\r\nnext\r\n";
        assert_eq!(rendered_text(&b, "p"), "café OK\nnext");
        assert_eq!(b.snapshot("p"), oracle_contents(full.as_bytes(), 80, 24));
        assert_eq!(
            surface_screen_texts(&b, "p"),
            oracle_grid(full.as_bytes(), 80, 24)
        );
        // Same bytes in a single feed must agree too.
        let mut one = HeadlessBackend::new();
        one.create_surface("p", 80, 24);
        one.feed_output("p", full.as_bytes());
        assert_eq!(rendered_text(&one, "p"), "café OK\nnext");
        assert_eq!(one.snapshot("p"), oracle_contents(full.as_bytes(), 80, 24));
        assert_eq!(
            surface_screen_texts(&one, "p"),
            oracle_grid(full.as_bytes(), 80, 24)
        );
    }

    #[test]
    fn fast_ascii_crossing_utf8_row_never_double_feeds() {
        // Later-row bail: cursor is on an ASCII row, the next row is
        // non-ASCII. A plain run that line-feeds onto it used to be
        // parsed, rejected, and parsed again.
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"hello\r\ncaf\xc3\xa9\r\n");
        b.feed_output("p", b"\x1b[H");
        b.feed_output("p", b"Z\r\nMORE\r\n");
        let full: &[u8] = b"hello\r\ncaf\xc3\xa9\r\n\x1b[HZ\r\nMORE\r\n";
        assert_eq!(rendered_text(&b, "p"), "Zello\nMORE");
        assert_eq!(b.snapshot("p"), oracle_contents(full, 80, 24));
        assert_eq!(surface_screen_texts(&b, "p"), oracle_grid(full, 80, 24));
    }

    #[test]
    fn fast_utf8_then_scrolled_ascii_appears_once() {
        // Double-feeding a screenful of unique lines can leave the live
        // grid looking like one pass. The ring must still hold each line
        // once, and the live grid must match one vt100 parse.
        let mut rest = Vec::new();
        for i in 0..40 {
            rest.extend(format!("row{i:02}\r\n").into_bytes());
        }
        let mut full = b"caf\xc3\xa9\r\n".to_vec();
        full.extend_from_slice(&rest);
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        b.feed_output("p", b"caf\xc3\xa9\r\n");
        b.feed_output("p", &rest);
        let tail = b.tail("p", 5000, false).unwrap();
        assert_eq!(tail.iter().filter(|l| *l == "café").count(), 1);
        for i in 0..40 {
            let line = format!("row{i:02}");
            assert_eq!(tail.iter().filter(|l| *l == &line).count(), 1, "{line}");
        }
        assert_eq!(b.snapshot("p"), oracle_contents(&full, 80, 24));
        assert_eq!(surface_screen_texts(&b, "p"), oracle_grid(&full, 80, 24));
    }

    /// Deterministic PRNG (no external crate): the property test below must
    /// be reproducible seed by seed.
    struct FuzzRng(u64);
    impl FuzzRng {
        fn next(&mut self) -> u64 {
            self.0 = self
                .0
                .wrapping_mul(6364136223846793005)
                .wrapping_add(1442695040888963407);
            self.0 >> 33
        }
        fn below(&mut self, n: u64) -> u64 {
            self.next() % n
        }
    }

    /// One mixed stream that never wraps or scrolls, so the ring's full
    /// text is exactly the independent parser's visible grid. Cursor
    /// tracking mirrors vt100 for the bytes this emits (CR resets the
    /// column, LF/VT/FF do not, wide characters take their display width).
    fn mixed_utf8_stream(rng: &mut FuzzRng) -> Vec<u8> {
        const COLS: usize = 80;
        // Leave two rows unused so a line-feed never scrolls.
        const MAX_ROW: usize = 22;
        const ASCII_TOKENS: [&str; 8] = ["a", "ok", "Hi!", "line", "xyz", "123", "  ", "-x-"];
        const UTF8_TOKENS: [(&str, usize); 6] = [
            ("é", 1),
            ("✓", 1),
            ("你", 2),
            ("🎉", 2),
            ("café", 4),
            ("über", 4),
        ];
        const ESC_TOKENS: [&str; 7] = [
            "\x1b[31m", "\x1b[32m", "\x1b[1m", "\x1b[0m", "\x1b[K", "\x1b[2K", "\x07",
        ];
        let mut full = Vec::with_capacity(256);
        let mut row = 0usize;
        let mut col = 0usize;
        let down = |full: &mut Vec<u8>, row: &mut usize, col: &mut usize, bare: bool| {
            if *row + 1 >= MAX_ROW {
                full.push(b'\r');
                *col = 0;
                return;
            }
            if bare {
                full.push(b'\n');
            } else {
                full.extend_from_slice(b"\r\n");
                *col = 0;
            }
            *row += 1;
        };
        for _ in 0..32 {
            match rng.below(10) {
                0..=3 => {
                    let t = ASCII_TOKENS[rng.below(ASCII_TOKENS.len() as u64) as usize];
                    if col + t.len() > COLS {
                        down(&mut full, &mut row, &mut col, false);
                    }
                    full.extend_from_slice(t.as_bytes());
                    col += t.len();
                }
                4..=5 => {
                    let (t, w) = UTF8_TOKENS[rng.below(UTF8_TOKENS.len() as u64) as usize];
                    if col + w > COLS {
                        down(&mut full, &mut row, &mut col, false);
                    }
                    full.extend_from_slice(t.as_bytes());
                    col += w;
                }
                6..=8 => match rng.below(6) {
                    0..=2 => down(&mut full, &mut row, &mut col, false),
                    3 => down(&mut full, &mut row, &mut col, true),
                    4 => {
                        // VT and FF are fast bytes and share vt100's `lf()`.
                        if row + 1 >= MAX_ROW {
                            full.push(b'\r');
                            col = 0;
                        } else {
                            full.push(if rng.below(2) == 0 { 0x0b } else { 0x0c });
                            row += 1;
                        }
                    }
                    _ => {
                        full.push(b'\r');
                        col = 0;
                    }
                },
                _ => {
                    let t = ESC_TOKENS[rng.below(ESC_TOKENS.len() as u64) as usize];
                    full.extend_from_slice(t.as_bytes());
                }
            }
        }
        full
    }

    #[test]
    fn fast_ring_matches_independent_oracle_on_mixed_utf8_feeds() {
        // Property-style: a few thousand deterministic streams of ASCII /
        // UTF-8 (é, ✓, CJK, emoji) / CR / LF / VT / FF / ESC (SGR, EL, BEL),
        // each split at random chunk boundaries. Nothing wraps or scrolls,
        // so the ring's full text and the live screen must equal one
        // independent vt100 parse of the concatenated bytes. `snapshot`
        // is the surface parser: a double-feed diverges from the oracle
        // even when the ring was reconciled against that double-feed.
        const CASES: u64 = 3072;
        for case in 0..CASES {
            let mut rng = FuzzRng(case.wrapping_mul(0x9e3779b97f4a7c15).wrapping_add(0xD1CE));
            let full = mixed_utf8_stream(&mut rng);
            let mut b = HeadlessBackend::new();
            b.create_surface("p", 80, 24);
            let mut pos = 0;
            while pos < full.len() {
                let n = 1 + rng.below(12) as usize;
                let end = (pos + n).min(full.len());
                b.feed_output("p", &full[pos..end]);
                pos = end;
            }
            let expect_grid = oracle_grid(&full, 80, 24);
            assert_eq!(
                b.snapshot("p"),
                oracle_contents(&full, 80, 24),
                "parser diverged from single-parse oracle (case={case} input={full:?})"
            );
            assert_eq!(
                b.tail("p", 5000, false).unwrap().join("\n"),
                oracle_text(&full, 80, 24),
                "ring text diverged from single-parse oracle (case={case} input={full:?})"
            );
            assert_eq!(
                surface_screen_texts(&b, "p"),
                expect_grid,
                "screen vec diverged from single-parse oracle (case={case} input={full:?})"
            );
        }
    }

    /// Release-mode perf gate for the rendered ring. Ignored by default
    /// (timing-sensitive); run with
    /// `cargo test --release -p signaltty-term -- --ignored fast_ring_perf`.
    /// Bounds are generous (~20x above measured) so this only fires on a
    /// real algorithmic regression back toward per-batch reconciliation.
    #[test]
    #[ignore = "release-only perf gate"]
    fn fast_ring_perf_regression() {
        use std::time::{Duration, Instant};
        let budget = if cfg!(debug_assertions) {
            Duration::from_secs(30)
        } else {
            Duration::from_secs(1)
        };
        let mut bulk = Vec::with_capacity(1024 * 1024);
        let mut i = 0u32;
        while bulk.len() < 1024 * 1024 {
            bulk.extend_from_slice(format!("bulk line {i:08} {:*<60}\r\n", "").as_bytes());
            i += 1;
        }
        bulk.truncate(1024 * 1024);
        let mut b = HeadlessBackend::new();
        b.create_surface("p", 80, 24);
        let t = Instant::now();
        b.feed_output("p", &bulk);
        assert!(
            t.elapsed() < budget,
            "1MiB bulk feed took {:?} (budget {budget:?})",
            t.elapsed()
        );
        assert!(b.ring_len("p") > 0);
    }
}