Skip to main content

tear_core/
pane_grid.rs

1//! Per-pane terminal cell grid driven by a `vte` parser.
2//!
3//! Phase-2.5 scope: SGR colors (8/16/256/truecolor + bold/italic/
4//! underline/etc.), alternate screen buffer, scroll regions (DECSTBM),
5//! cursor save/restore (DECSC/DECRC), bounded scrollback, the usual
6//! cursor-motion / erase CSI subset, and basic DEC private mode
7//! toggles. Kitty graphics + sixel + hyperlinks + sync output (mode
8//! 2026) + IME bracketed paste stay in mado's terminal.rs for now —
9//! we lift incrementally.
10//!
11//! ## What this gives Phase 2 + 3
12//!
13//! `PaneGrid::feed(bytes)` parses; `PaneGrid::snapshot()` returns a
14//! `tear_types::PaneSnapshot` ready to ship over the tear-daemon ↔
15//! tear-client wire. Snapshots now carry per-cell `fg` / `bg` /
16//! `attrs` so consumers can render colored output (the Phase 3 mado
17//! `--tear-pane` viewer reads SGR-encoded cells directly).
18
19use std::collections::VecDeque;
20
21use tear_types::pane_snapshot::{CellAttrs, Color, ansi_256_color, default_ansi_palette};
22use tear_types::graphics::{Graphic, GraphicProtocol, GRAPHIC_PAYLOAD_MAX};
23use tear_types::host_role::{HostRole, TearCaps};
24use tear_types::modes::{
25    AltScreen, AutoWrap, BracketedPaste, CursorKeys, CursorVisible, FocusReporting, ModeSet,
26    MouseSgr, MouseTracking, SyncOutput,
27};
28use unicode_width::UnicodeWidthChar;
29use vte::{Params, Parser, Perform};
30
31pub use tear_types::pane_snapshot::{Cell, PaneSnapshot};
32
33/// Maximum scrollback rows kept off-screen.
34///
35/// **Default: `usize::MAX` — unlimited.** The operator-facing
36/// contract is "never lose anything"; the only ceiling is host
37/// RAM. Consumers that want bounded retention (low-RAM systems,
38/// log panes that emit billions of lines) override at construction
39/// via [`PaneGrid::with_scrollback`].
40///
41/// Pre-2026-05 default was 1,000 rows (xterm tradition); changed to
42/// match operator expectation of "I can always scroll back to
43/// anything I've seen in this pane." See
44/// `tear-config/src/lib.rs::ScrollbackConfig` for the operator-
45/// facing tunable surface and the documented opt-in to bounded mode.
46pub const DEFAULT_SCROLLBACK_ROWS: usize = usize::MAX;
47
48/// Live grid + cursor + the parser that feeds them. Owns mutable
49/// state, so callers wrap it in `Mutex` (the `InProcess` does this
50/// since multiple PTY-reader threads + the RPC dispatch thread all
51/// race for it).
52pub struct PaneGrid {
53    parser: Parser,
54    pub(crate) state: GridState,
55    /// APC re-assembly, because vte cannot do it for us.
56    ///
57    /// vte 0.15's `Perform` has `hook`/`put`/`unhook` for DCS but **no APC
58    /// method at all**: on `ESC _` it enters `State::SosPmApcString` and
59    /// consumes every byte to the terminator with no callback. So the
60    /// kitty graphics protocol — which is APC-framed — was invisible to
61    /// this parser, and an image vanished with no error and no flag.
62    ///
63    /// The fix is to lift APC out of the stream BEFORE vte sees it. That
64    /// is what mado does too; this is the same interception, moved to the
65    /// authority.
66    apc: ApcScanner,
67}
68
69/// Splits `ESC _ … ESC \` (or `BEL`) out of a byte stream.
70///
71/// A payload can be megabytes and arrives over many PTY reads, so the scan
72/// is a resumable state machine rather than a search over one buffer — an
73/// APC split across `feed()` calls must reassemble, which is exactly the
74/// chunk-boundary case the espelho conformance rows already pin for
75/// ordinary escapes.
76#[derive(Debug, Default)]
77struct ApcScanner {
78    state: ApcState,
79    buf: Vec<u8>,
80    /// Set the moment `buf` hits the cap, so the fact survives the params
81    /// being stripped later.
82    cut: bool,
83}
84
85#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
86enum ApcState {
87    /// Not in an APC, and no `ESC` pending.
88    #[default]
89    Idle,
90    /// Saw `ESC`; the next byte decides whether this is an APC.
91    Escape,
92    /// Inside an APC payload.
93    Inside,
94    /// Inside an APC and saw `ESC`; `\` terminates (ST).
95    InsideEscape,
96}
97
98impl ApcScanner {
99    /// Feed `bytes`, returning the stream with APC sequences removed plus
100    /// any payloads that completed, each with whether it was CUT.
101    ///
102    /// The cut flag is CARRIED rather than re-derived downstream. It was
103    /// briefly re-derived by comparing the final payload length against the
104    /// cap, which is wrong for a reason worth keeping: the cap applies to
105    /// the whole APC body, and kitty's params (`Ga=T,f=100;`) are stripped
106    /// before storage — so a truncated payload came back a few bytes UNDER
107    /// the cap and reported itself intact. A fact known at the boundary
108    /// must not be reconstructed from a proxy after the shape changes.
109    ///
110    /// A lone `ESC` at the end of a chunk is HELD, not emitted — emitting
111    /// it would hand vte a truncated escape and the following chunk's
112    /// bytes would be misparsed as its parameters.
113    fn split(&mut self, bytes: &[u8]) -> (Vec<u8>, Vec<(Vec<u8>, bool)>) {
114        let mut passthrough = Vec::with_capacity(bytes.len());
115        let mut done = Vec::new();
116        for &b in bytes {
117            match self.state {
118                ApcState::Idle => {
119                    if b == 0x1b {
120                        self.state = ApcState::Escape;
121                    } else {
122                        passthrough.push(b);
123                    }
124                }
125                ApcState::Escape => {
126                    if b == b'_' {
127                        // An APC opens: the ESC we withheld belongs to it.
128                        self.state = ApcState::Inside;
129                        self.buf.clear();
130                        self.cut = false;
131                    } else {
132                        // Not an APC — replay the withheld ESC, then
133                        // re-handle this byte (it may itself be an ESC,
134                        // e.g. `ESC ESC`).
135                        passthrough.push(0x1b);
136                        if b == 0x1b {
137                            self.state = ApcState::Escape;
138                        } else {
139                            passthrough.push(b);
140                            self.state = ApcState::Idle;
141                        }
142                    }
143                }
144                ApcState::Inside => match b {
145                    0x1b => self.state = ApcState::InsideEscape,
146                    // BEL terminates too — xterm accepts it for APC/OSC.
147                    0x07 => {
148                        done.push((std::mem::take(&mut self.buf), self.cut));
149                        self.state = ApcState::Idle;
150                    }
151                    _ => {
152                        if self.buf.len() < GRAPHIC_PAYLOAD_MAX {
153                            self.buf.push(b);
154                        } else {
155                            self.cut = true;
156                        }
157                    }
158                },
159                ApcState::InsideEscape => {
160                    if b == b'\\' {
161                        done.push((std::mem::take(&mut self.buf), self.cut));
162                        self.state = ApcState::Idle;
163                    } else {
164                        // An ESC inside the payload that was not ST.
165                        if self.buf.len() < GRAPHIC_PAYLOAD_MAX {
166                            self.buf.push(0x1b);
167                            self.buf.push(b);
168                        } else {
169                            self.cut = true;
170                        }
171                        self.state = ApcState::Inside;
172                    }
173                }
174            }
175        }
176        (passthrough, done)
177    }
178}
179
180/// Mutable state — separated from the parser so vte's `Perform`
181/// impl can borrow `&mut state` while the parser pushes bytes.
182pub(crate) struct GridState {
183    rows: usize,
184    cols: usize,
185    /// Primary screen cells.
186    primary: VecDeque<Vec<Cell>>,
187    /// Alternate screen cells (vim, less, htop, btop, …). Sized
188    /// identically to primary; lifecycle managed by DEC mode
189    /// 1049 / 47 / 1047.
190    alternate: Vec<Vec<Cell>>,
191    /// True when alt-screen is active.
192    alt_active: bool,
193    /// Bounded ring of scrollback rows that have rolled off the
194    /// top of the primary screen.
195    scrollback: VecDeque<Vec<Cell>>,
196    scrollback_cap: usize,
197    /// Cursor in 0-based (row, col) of the active screen.
198    cursor_row: usize,
199    cursor_col: usize,
200    /// Pen state — what colors / attrs new cells inherit.
201    pen_fg: Color,
202    pen_bg: Color,
203    pen_attrs: CellAttrs,
204    /// Saved cursor + pen for DECSC / DECRC. Lazily allocated.
205    saved: Option<SavedCursor>,
206    /// xterm "wrap_pending" — when the last print landed in the
207    /// last column, we DON'T advance the cursor immediately;
208    /// instead we set this flag. The NEXT print triggers
209    /// (cr + linefeed) before its own placement. CR/LF/cursor-move
210    /// clear the flag without effect. This matches every real
211    /// terminal — without it, `printf 'AAAAA\r\nBBBBB'` on a
212    /// 5-column grid would scroll AAAAA off when \n fires.
213    wrap_pending: bool,
214    /// DECSTBM scroll region — inclusive top, inclusive bottom. Defaults
215    /// to (0, rows-1).
216    scroll_top: usize,
217    scroll_bottom: usize,
218    /// 16-color palette for SGR 30-37 / 40-47 / 90-97 / 100-107.
219    palette: [Color; 16],
220    /// Insert/Replace mode (IRM — CSI 4 h/l). When true, print
221    /// shifts existing cells to the right before placement.
222    insert_mode: bool,
223    /// Cursor visibility (DEC mode 25 — CSI ? 25 h/l). False hides.
224    cursor_visible: bool,
225    /// DECCKM cursor-keys application mode (DEC mode 1 — CSI ? 1 h/l).
226    /// When set, host keystrokes for Up/Down/Right/Left should be
227    /// encoded as `ESC O A/B/C/D` instead of `ESC [ A/B/C/D`. Reset
228    /// on RIS (ESC c) and DECSTR (CSI ! p).
229    cursor_keys_mode: bool,
230    /// Last printed char — REP (CSI b) repeats this.
231    last_printed: Option<char>,
232    /// Who answers VT queries on this pane. `Relay` (the default) means
233    /// tear answers nothing and the attached terminal is the host — the
234    /// behaviour tear has always had.
235    role: HostRole,
236    /// DEC 7 (DECAWM) — autowrap. On by default, per xterm.
237    autowrap: bool,
238    /// DEC 1004 — focus in/out reporting.
239    focus_reporting: bool,
240    /// DEC 2004 — bracketed paste. Gates paste sanitisation downstream.
241    bracketed_paste: bool,
242    /// DEC 2026 — synchronized output.
243    sync_output: bool,
244    /// DEC 1000/1002/1003 — mouse tracking level (mutually exclusive).
245    mouse: MouseTracking,
246    /// DEC 1006 — SGR extended mouse encoding.
247    mouse_sgr: bool,
248    /// Combining-mark table — see [`PaneSnapshot::combining`]. Cells hold a
249    /// 1-based index into this; `0` means no marks.
250    combining: Vec<Vec<char>>,
251    /// Images transmitted into this pane, undecoded. See
252    /// [`tear_types::graphics`] for why the authority stores bytes rather
253    /// than pixels.
254    graphics: Vec<Graphic>,
255    /// Payload being accumulated by an in-flight DCS sixel sequence
256    /// (`hook` → `put`* → `unhook`). `None` when no DCS is open.
257    sixel_in_flight: Option<Vec<u8>>,
258    /// Reply bytes owed to the child process, drained by the runtime and
259    /// written back to the PTY.
260    ///
261    /// Always empty while `role` is `Relay`, which is what makes the
262    /// response path a no-op until the shuken flip deliberately turns it
263    /// on. A reply is data the CHILD asked for, so it goes to the PTY's
264    /// input side, never into the grid.
265    pending_response: Vec<u8>,
266    /// Window / tab title (OSC 0 / OSC 2).
267    title: Option<String>,
268    /// OSC 133 block extractor — captures prompt + command +
269    /// output + exit_code triples. Idle when the shell hasn't
270    /// emitted any OSC 133 marker yet (zero blocks; on_print is
271    /// a no-op). Once the shell's PS1 emits A, the extractor
272    /// fills as the byte stream advances.
273    pub(crate) blocks: crate::blocks::BlockExtractor,
274}
275
276#[derive(Clone, Copy)]
277struct SavedCursor {
278    row: usize,
279    col: usize,
280    fg: Color,
281    bg: Color,
282    attrs: CellAttrs,
283}
284
285impl GridState {
286    fn new(cols: usize, rows: usize, scrollback_cap: usize) -> Self {
287        Self {
288            rows,
289            cols,
290            primary: VecDeque::from(vec![vec![Cell::BLANK; cols]; rows]),
291            alternate: vec![vec![Cell::BLANK; cols]; rows],
292            alt_active: false,
293            // Allocate a modest initial capacity even when
294            // scrollback_cap is unlimited (usize::MAX). VecDeque
295            // grows on push, so the initial size is just an
296            // amortisation hint; allocating usize::MAX directly
297            // would OOM the host. 64 rows is a fine warm-up
298            // budget — the deque doubles from there on demand.
299            scrollback: VecDeque::with_capacity(64.min(scrollback_cap)),
300            scrollback_cap,
301            cursor_row: 0,
302            cursor_col: 0,
303            pen_fg: Color::WHITE,
304            pen_bg: Color::BLACK,
305            pen_attrs: CellAttrs::NONE,
306            saved: None,
307            wrap_pending: false,
308            scroll_top: 0,
309            scroll_bottom: rows.saturating_sub(1),
310            palette: default_ansi_palette(),
311            insert_mode: false,
312            cursor_visible: true,
313            cursor_keys_mode: false,
314            last_printed: None,
315            role: HostRole::default(),
316            // Autowrap is ON by default (xterm); everything else is off.
317            autowrap: true,
318            focus_reporting: false,
319            bracketed_paste: false,
320            sync_output: false,
321            mouse: MouseTracking::Off,
322            mouse_sgr: false,
323            combining: Vec::new(),
324            graphics: Vec::new(),
325            sixel_in_flight: None,
326            pending_response: Vec::new(),
327            title: None,
328            blocks: crate::blocks::BlockExtractor::default(),
329        }
330    }
331
332    /// Return a mutable reference to one cell on whichever screen
333    /// is active.
334    fn active_cell_mut(&mut self, row: usize, col: usize) -> Option<&mut Cell> {
335        if self.alt_active {
336            self.alternate.get_mut(row).and_then(|r| r.get_mut(col))
337        } else {
338            self.primary.get_mut(row).and_then(|r| r.get_mut(col))
339        }
340    }
341
342    /// Consume one complete APC payload (the bytes between `ESC _` and its
343    /// terminator, exclusive).
344    ///
345    /// Only the kitty graphics protocol is recognised — its payloads start
346    /// with `G`. Any other APC is dropped, which matches every terminal:
347    /// APC is a private-use channel and an unrecognised one carries no
348    /// meaning we could act on.
349    fn ingest_apc(&mut self, payload: &[u8], cut: bool) {
350        let Some((&b'G', rest)) = payload.split_first() else {
351            return;
352        };
353        // Kitty's framing is `G<key=value,...>;<base64 payload>`. The
354        // params are ASCII and the payload is not, so split on the FIRST
355        // `;` and never parse past it — a control key that happens to
356        // appear inside base64 must not be read as one.
357        let (params, data) = match rest.iter().position(|&b| b == b';') {
358            Some(i) => (&rest[..i], &rest[i + 1..]),
359            // No `;` at all: a control-only command (query, delete). Real,
360            // and it carries no image.
361            None => (rest, &[][..]),
362        };
363        self.push_graphic(
364            GraphicProtocol::Kitty,
365            String::from_utf8_lossy(params).into_owned(),
366            data.to_vec(),
367            cut,
368        );
369    }
370
371    /// Record a transmitted image at the current cursor position.
372    ///
373    /// The single place a graphic enters the grid, so the payload bound and
374    /// the truncation flag cannot be applied inconsistently by protocol.
375    fn push_graphic(
376        &mut self,
377        protocol: GraphicProtocol,
378        params: String,
379        mut data: Vec<u8>,
380        cut_upstream: bool,
381    ) {
382        // `cut_upstream` is the boundary's own verdict; the length check is
383        // only for producers that hand over an unbounded buffer (the DCS
384        // path bounds as it accumulates, so both agree there). Never rely on
385        // the length alone — see `ApcScanner::split`.
386        let truncated = cut_upstream || data.len() > GRAPHIC_PAYLOAD_MAX;
387        if data.len() > GRAPHIC_PAYLOAD_MAX {
388            data.truncate(GRAPHIC_PAYLOAD_MAX);
389        }
390        self.graphics.push(Graphic {
391            protocol,
392            params,
393            data,
394            at_row: self.cursor_row,
395            at_col: self.cursor_col,
396            truncated,
397        });
398    }
399
400    /// Queue a reply to the child, if and only if this pane is the host.
401    ///
402    /// The role check lives HERE, at the single chokepoint, rather than at
403    /// each call site. Every query arm calls `answer` unconditionally, so a
404    /// newly-added query cannot forget the check and start replying while
405    /// tear is still a relay — which would mean two answers on the wire.
406    fn answer(&mut self, bytes: &[u8]) {
407        if self.role.answers_queries() {
408            self.pending_response.extend_from_slice(bytes);
409        }
410    }
411
412    /// Read one cell on whichever screen is active.
413    fn active_cell_at(&self, row: usize, col: usize) -> Option<&Cell> {
414        if self.alt_active {
415            self.alternate.get(row).and_then(|r| r.get(col))
416        } else {
417            self.primary.get(row).and_then(|r| r.get(col))
418        }
419    }
420
421    fn active_row_mut(&mut self, row: usize) -> Option<&mut Vec<Cell>> {
422        if self.alt_active {
423            self.alternate.get_mut(row)
424        } else {
425            self.primary.get_mut(row)
426        }
427    }
428
429    fn active_rows(&self) -> impl Iterator<Item = &Vec<Cell>> + '_ {
430        if self.alt_active {
431            Box::new(self.alternate.iter()) as Box<dyn Iterator<Item = &Vec<Cell>>>
432        } else {
433            Box::new(self.primary.iter())
434        }
435    }
436
437    fn blank_cell(&self) -> Cell {
438        // A blank cell inherits the current background color so
439        // ED/EL fill with the pen's bg (matches xterm semantics).
440        Cell {
441            ch: ' ',
442            fg: self.pen_fg,
443            bg: self.pen_bg,
444            attrs: CellAttrs::NONE,
445            width: 1,
446            combining: 0,
447        }
448    }
449
450    /// The cell for a printed glyph. `w` is its display width: `1` normal,
451    /// `2` the lead of a double-width glyph.
452    fn current_cell_for_print(&self, ch: char, w: u8) -> Cell {
453        Cell {
454            ch,
455            fg: self.pen_fg,
456            bg: self.pen_bg,
457            attrs: self.pen_attrs,
458            width: w,
459            // A freshly printed glyph carries no marks; a following
460            // zero-width codepoint attaches them.
461            combining: 0,
462        }
463    }
464
465    /// The continuation half of a double-width glyph.
466    ///
467    /// It carries the LEAD's pen colours, not the default pen: a
468    /// default-styled spacer under a coloured lead renders as a visible seam
469    /// through the middle of the glyph.
470    fn continuation_cell(&self) -> Cell {
471        Cell {
472            ch: ' ',
473            fg: self.pen_fg,
474            bg: self.pen_bg,
475            attrs: self.pen_attrs,
476            width: 0,
477            combining: 0,
478        }
479    }
480
481
482    fn scroll_region_up(&mut self) {
483        // Scroll within [scroll_top, scroll_bottom]. Bottom row
484        // gets a blank; top row is pushed to scrollback (only when
485        // primary screen + full-screen region).
486        if self.scroll_top > self.scroll_bottom {
487            return;
488        }
489        let blank = vec![self.blank_cell(); self.cols];
490        let full_region = self.scroll_top == 0 && self.scroll_bottom == self.rows.saturating_sub(1);
491        if self.alt_active {
492            if self.scroll_top < self.alternate.len() {
493                self.alternate.remove(self.scroll_top);
494                self.alternate
495                    .insert(self.scroll_bottom.min(self.alternate.len()), blank);
496            }
497        } else {
498            if full_region {
499                if let Some(top) = self.primary.pop_front() {
500                    if self.scrollback_cap > 0 {
501                        if self.scrollback.len() >= self.scrollback_cap {
502                            self.scrollback.pop_front();
503                        }
504                        self.scrollback.push_back(top);
505                    }
506                }
507                self.primary.push_back(blank);
508            } else if self.scroll_top < self.primary.len() {
509                self.primary.remove(self.scroll_top);
510                let insert_at = (self.scroll_bottom + 1).min(self.primary.len());
511                self.primary.insert(insert_at, blank);
512            }
513        }
514    }
515
516    /// Advance past a glyph of display width `w`.
517    ///
518    /// `w` is the glyph's WIDTH, not `1`. That distinction is the whole
519    /// wide-character axis: advancing by one after a double-width glyph puts
520    /// every later cell on the row one column left of where the child process
521    /// believes it is.
522    fn advance_cursor_after_print(&mut self, w: usize) {
523        let adv = w.max(1);
524        if self.cursor_col + adv >= self.cols {
525            self.park_at_right_margin();
526        } else {
527            self.cursor_col += adv;
528        }
529    }
530
531    /// Park the cursor on the LAST column and arm the deferred wrap.
532    ///
533    /// The clamp is load-bearing and was previously invisible: with a
534    /// 1-column advance the flag could only be raised when the cursor was
535    /// already at `cols - 1`, so clamping was a no-op. At width 2 it is not —
536    /// a glyph landing flush against the margin would otherwise leave the
537    /// cursor on its own LEAD, one column left of the truth, which shows up
538    /// as every subsequent relative motion being off by one and `CSI 6n`
539    /// under-reporting the column.
540    fn park_at_right_margin(&mut self) {
541        self.cursor_col = self.cols.saturating_sub(1);
542        self.wrap_pending = true;
543    }
544
545    /// Blank any half-glyph this write is about to orphan.
546    ///
547    /// Fills with [`Cell::BLANK`] and NOT `blank_cell()`: the pen-background
548    /// blank would paint the current background into a cell the glyph never
549    /// owned, which diverges from mado on any coloured background.
550    fn clear_orphans_at(&mut self, row: usize, col: usize, w: usize) {
551        // Left edge — we are overwriting a continuation, so its lead (one to
552        // the left) loses its other half and must go.
553        if col > 0 && self.active_cell_at(row, col).is_some_and(Cell::is_continuation) {
554            if let Some(lead) = self.active_cell_mut(row, col - 1) {
555                *lead = Cell::BLANK;
556            }
557        }
558        // Right edge — the last column we occupy holds a wide LEAD, so its
559        // continuation to the right is about to be orphaned.
560        let last = col + w.saturating_sub(1);
561        if self.active_cell_at(row, last).is_some_and(|c| c.width == 2) && last + 1 < self.cols {
562            if let Some(cont) = self.active_cell_mut(row, last + 1) {
563                *cont = Cell::BLANK;
564            }
565        }
566    }
567
568    /// Attach a zero-width codepoint to the glyph that precedes the cursor.
569    ///
570    /// Three behaviours copied deliberately from mado, each of which a
571    /// "reasonable" implementation gets wrong:
572    ///
573    /// 1. **Does not set `last_printed`**, so `CSI b` (REP) repeats the
574    ///    BASE glyph rather than the mark.
575    /// 2. **Does not clear `wrap_pending`**, so a deferred wrap stays armed
576    ///    across a mark.
577    /// 3. **Walks back to the LEAD column.** When `wrap_pending` is set the
578    ///    search starts at `cols - 1`, and that cell may be the
579    ///    CONTINUATION of a margin-flush wide glyph whose lead is one to
580    ///    its left. Taking `cols - 1` verbatim attaches the mark to a
581    ///    width-0 cell, where it renders nowhere — the regression mado
582    ///    fixed on 2026-07-30. tear is born with the fix.
583    ///
584    /// A mark with no base cell (the first codepoint of a line) is dropped,
585    /// matching mado.
586    fn combine_into_previous(&mut self, c: char) {
587        let start = if self.wrap_pending {
588            self.cols.saturating_sub(1)
589        } else if self.cursor_col > 0 {
590            self.cursor_col - 1
591        } else {
592            return;
593        };
594        let row = self.cursor_row;
595        let col = self.lead_col_at(row, start);
596        if col >= self.cols || row >= self.rows {
597            return;
598        }
599        // Resolve the cell's existing table slot before taking the &mut, so
600        // the table borrow and the cell borrow never overlap.
601        let existing = self
602            .active_cell_at(row, col)
603            .map_or(0, |cell| cell.combining);
604        if existing == 0 {
605            // u16 is the index width; refuse to mint past it rather than
606            // wrapping into another cell's marks.
607            let Ok(next) = u16::try_from(self.combining.len() + 1) else {
608                return;
609            };
610            self.combining.push(vec![c]);
611            if let Some(cell) = self.active_cell_mut(row, col) {
612                cell.combining = next;
613            } else {
614                // The cell vanished between the read and the write — drop
615                // the entry rather than leaving it orphaned.
616                self.combining.pop();
617            }
618        } else if let Some(marks) = self.combining.get_mut(existing as usize - 1) {
619            marks.push(c);
620        }
621    }
622
623    /// Walk left to the lead column of whatever glyph owns `col`.
624    ///
625    /// If `col` holds a continuation cell the glyph's lead is at `col - 1`;
626    /// otherwise `col` is already the lead.
627    fn lead_col_at(&self, row: usize, col: usize) -> usize {
628        if col > 0 && self.active_cell_at(row, col).is_some_and(Cell::is_continuation) {
629            col - 1
630        } else {
631            col
632        }
633    }
634
635    /// Place one glyph of display width `w` at the cursor and advance.
636    fn put_char(&mut self, c: char, w: usize) {
637        // Honour a deferred wrap from the previous print, then place.
638        if self.wrap_pending {
639            self.wrap_pending = false;
640            self.cursor_col = 0;
641            self.linefeed();
642        }
643        // A double-width glyph that cannot fit before the right margin wraps
644        // WHOLE. Splitting it across the seam would put half a glyph in each
645        // row, which no renderer can draw correctly.
646        if w == 2 && self.cursor_col + 1 >= self.cols {
647            self.cursor_col = 0;
648            self.linefeed();
649        }
650        let row = self.cursor_row;
651        let col = self.cursor_col;
652        let cell = self.current_cell_for_print(c, w as u8);
653
654        if self.insert_mode {
655            // IRM shifts by the glyph's WIDTH, not by one column.
656            let cols = self.cols;
657            let cont = self.continuation_cell();
658            if let Some(r) = self.active_row_mut(row) {
659                if col < r.len() {
660                    r.insert(col, cell);
661                    if w == 2 && col + 1 <= r.len() {
662                        r.insert(col + 1, cont);
663                    }
664                    r.truncate(cols);
665                }
666            }
667        } else {
668            self.clear_orphans_at(row, col, w);
669            if let Some(slot) = self.active_cell_mut(row, col) {
670                *slot = cell;
671            }
672            if w == 2 && col + 1 < self.cols {
673                let cont = self.continuation_cell();
674                if let Some(slot) = self.active_cell_mut(row, col + 1) {
675                    *slot = cont;
676                }
677            }
678        }
679        self.last_printed = Some(c);
680        self.advance_cursor_after_print(w);
681    }
682
683    fn linefeed(&mut self) {
684        if self.cursor_row == self.scroll_bottom {
685            self.scroll_region_up();
686        } else if self.cursor_row + 1 < self.rows {
687            self.cursor_row += 1;
688        }
689    }
690
691    fn carriage_return(&mut self) {
692        self.cursor_col = 0;
693    }
694
695    fn backspace(&mut self) {
696        if self.cursor_col > 0 {
697            self.cursor_col -= 1;
698        }
699    }
700
701    fn tab_forward(&mut self) {
702        let next = ((self.cursor_col / 8) + 1) * 8;
703        self.cursor_col = next.min(self.cols.saturating_sub(1));
704    }
705
706    fn cursor_move_relative(&mut self, drow: isize, dcol: isize) {
707        let r = (self.cursor_row as isize + drow).max(0) as usize;
708        let c = (self.cursor_col as isize + dcol).max(0) as usize;
709        self.cursor_row = r.min(self.rows.saturating_sub(1));
710        self.cursor_col = c.min(self.cols.saturating_sub(1));
711    }
712
713    fn cursor_set(&mut self, row: usize, col: usize) {
714        self.cursor_row = row.min(self.rows.saturating_sub(1));
715        self.cursor_col = col.min(self.cols.saturating_sub(1));
716    }
717
718    fn erase_to_end_of_line(&mut self) {
719        let row = self.cursor_row;
720        let start = self.cursor_col;
721        let blank = self.blank_cell();
722        if let Some(r) = self.active_row_mut(row) {
723            for c in r.iter_mut().skip(start) {
724                *c = blank;
725            }
726        }
727    }
728
729    fn erase_from_start_of_line(&mut self) {
730        let row = self.cursor_row;
731        let stop = self.cursor_col + 1;
732        let blank = self.blank_cell();
733        if let Some(r) = self.active_row_mut(row) {
734            let stop = stop.min(r.len());
735            for c in r.iter_mut().take(stop) {
736                *c = blank;
737            }
738        }
739    }
740
741    fn erase_line(&mut self) {
742        let row = self.cursor_row;
743        let blank = self.blank_cell();
744        if let Some(r) = self.active_row_mut(row) {
745            for c in r.iter_mut() {
746                *c = blank;
747            }
748        }
749    }
750
751    fn erase_below_cursor(&mut self) {
752        // ED(0): from cursor to end of screen.
753        self.erase_to_end_of_line();
754        let start = self.cursor_row + 1;
755        let end = self.rows;
756        let blank = self.blank_cell();
757        for r in start..end {
758            if let Some(row) = self.active_row_mut(r) {
759                for c in row.iter_mut() {
760                    *c = blank;
761                }
762            }
763        }
764    }
765
766    fn erase_above_cursor(&mut self) {
767        // ED(1): from start of screen to cursor (inclusive).
768        let stop_row = self.cursor_row;
769        let blank = self.blank_cell();
770        for r in 0..stop_row {
771            if let Some(row) = self.active_row_mut(r) {
772                for c in row.iter_mut() {
773                    *c = blank;
774                }
775            }
776        }
777        self.erase_from_start_of_line();
778    }
779
780    fn erase_all(&mut self) {
781        let blank = self.blank_cell();
782        let rows = self.rows;
783        for r in 0..rows {
784            if let Some(row) = self.active_row_mut(r) {
785                for c in row.iter_mut() {
786                    *c = blank;
787                }
788            }
789        }
790    }
791
792    fn save_cursor(&mut self) {
793        self.saved = Some(SavedCursor {
794            row: self.cursor_row,
795            col: self.cursor_col,
796            fg: self.pen_fg,
797            bg: self.pen_bg,
798            attrs: self.pen_attrs,
799        });
800    }
801
802    fn restore_cursor(&mut self) {
803        if let Some(s) = self.saved {
804            self.cursor_row = s.row.min(self.rows.saturating_sub(1));
805            self.cursor_col = s.col.min(self.cols.saturating_sub(1));
806            self.pen_fg = s.fg;
807            self.pen_bg = s.bg;
808            self.pen_attrs = s.attrs;
809        }
810    }
811
812    fn enter_alt_screen(&mut self, clear: bool) {
813        if !self.alt_active {
814            self.alt_active = true;
815        }
816        if clear {
817            for row in &mut self.alternate {
818                for c in row.iter_mut() {
819                    *c = Cell::BLANK;
820                }
821            }
822            self.cursor_row = 0;
823            self.cursor_col = 0;
824        }
825    }
826
827    fn leave_alt_screen(&mut self) {
828        self.alt_active = false;
829    }
830
831    // ── SGR ────────────────────────────────────────────────────
832
833    fn apply_sgr(&mut self, params: &Params) {
834        // ── PARAMETERS AND SUB-PARAMETERS ARE NOT THE SAME THING ──────
835        //
836        // SGR has two spellings for an extended colour, and they are NOT
837        // interchangeable:
838        //
839        //   ESC[38;2;r;g;b m      five PARAMETERS      (legacy xterm)
840        //   ESC[38:2:cs:r:g:b m   one parameter with
841        //                         six SUB-PARAMETERS   (ISO 8613-6)
842        //
843        // The colon form carries a colour-space id in slot 2, which is
844        // almost always empty (`38:2::r:g:b`) and arrives as a 0.
845        //
846        // This used to `flat_map` both spellings into one stream, which
847        // erases the distinction. The colon form then read the empty
848        // colour-space slot AS THE RED CHANNEL: every channel shifted by
849        // one and the real blue component fell out the end of the colour
850        // and was executed as an SGR attribute code. Measured live in a
851        // tear pane before this change:
852        //
853        //   ESC[38;2;248;248;242m  -> fg (248,248,242)   correct
854        //   ESC[38:2::248:248:242m -> fg (0,248,248)     WRONG
855        //
856        // and when that trailing component happened to be 4, UNDERLINE
857        // latched on for the rest of the session. SGR 58/59 (underline
858        // colour) had the same shape of bug from the other direction: 58
859        // was dropped as unknown and its components then walked as
860        // attribute codes, so `ESC[58;5;4m` also stuck UNDERLINE on.
861        //
862        // So: walk PARAMETERS, and let a parameter that carries
863        // sub-parameters be self-contained.
864        let items: Vec<&[u16]> = params.iter().collect();
865        if items.is_empty() {
866            self.sgr_reset();
867            return;
868        }
869        let mut idx = 0;
870        while idx < items.len() {
871            let param = items[idx];
872            let Some(&code) = param.first() else {
873                idx += 1;
874                continue;
875            };
876
877            // Colon form: everything this directive needs is in `param`.
878            if param.len() > 1 {
879                self.apply_sgr_subparams(param);
880                idx += 1;
881                continue;
882            }
883
884            // Semicolon form: 38/48/58 consume the parameters that follow.
885            // 58/59 are underline COLOUR — tear's CellAttrs has no
886            // underline-colour field, so the value is discarded, but the
887            // parameters must still be CONSUMED or they walk as codes.
888            if matches!(code, 38 | 48 | 58) {
889                let (colour, consumed) = self.parse_extended_color_params(&items[idx..]);
890                match (code, colour) {
891                    (38, Some(c)) => self.pen_fg = c,
892                    (48, Some(c)) => self.pen_bg = c,
893                    _ => {}
894                }
895                idx += consumed;
896                continue;
897            }
898
899            self.apply_sgr_code(code);
900            idx += 1;
901        }
902    }
903
904    /// One SGR directive spelled with sub-parameters (`38:2::r:g:b`,
905    /// `38:5:n`, `4:3`, `58:2::r:g:b`). Self-contained by construction.
906    fn apply_sgr_subparams(&mut self, param: &[u16]) {
907        match param[0] {
908            // Styled underline. tear's CellAttrs carries a single boolean,
909            // so every style except `4:0` is "on"; `4:0` is the modern
910            // spelling of SGR 24.
911            4 => {
912                if param[1] == 0 {
913                    self.pen_attrs.remove(CellAttrs::UNDERLINE);
914                } else {
915                    self.pen_attrs.insert(CellAttrs::UNDERLINE);
916                }
917            }
918            code @ (38 | 48 | 58) => {
919                let colour = match param[1] {
920                    5 => param.get(2).map(|&n| ansi_256_color(n, &self.palette)),
921                    // `38:2:cs:r:g:b` has SIX slots — skip the colour-space
922                    // id. `38:2:r:g:b` (five) omits it. Choosing by length
923                    // is what keeps the channels aligned.
924                    2 => match param.len() {
925                        n if n >= 6 => {
926                            Some(Color::new(param[3] as u8, param[4] as u8, param[5] as u8))
927                        }
928                        5 => Some(Color::new(param[2] as u8, param[3] as u8, param[4] as u8)),
929                        _ => None,
930                    },
931                    _ => None,
932                };
933                match (code, colour) {
934                    (38, Some(c)) => self.pen_fg = c,
935                    (48, Some(c)) => self.pen_bg = c,
936                    // 58 = underline colour: parsed so it cannot leak,
937                    // then dropped because there is nowhere to put it.
938                    _ => {}
939                }
940            }
941            other => self.apply_sgr_code(other),
942        }
943    }
944
945    /// Semicolon-form extended colour. `rest[0]` is the 38/48/58
946    /// directive. Returns the colour (None for 58 or malformed input) and
947    /// how many PARAMETERS were consumed — always at least 1, so the
948    /// caller can never fail to advance.
949    fn parse_extended_color_params(&self, rest: &[&[u16]]) -> (Option<Color>, usize) {
950        let first = |i: usize| rest.get(i).and_then(|p| p.first().copied());
951        match first(1) {
952            Some(5) => match first(2) {
953                // `self.palette`, not the default one: a pane whose
954                // palette has been re-set by OSC 4 must resolve indexed
955                // colours against ITS palette.
956                Some(n) => (Some(ansi_256_color(n, &self.palette)), 3),
957                None => (None, 2),
958            },
959            Some(2) => match (first(2), first(3), first(4)) {
960                (Some(r), Some(g), Some(b)) => (Some(Color::new(r as u8, g as u8, b as u8)), 5),
961                _ => (None, rest.len().min(5)),
962            },
963            _ => (None, 1),
964        }
965    }
966
967    fn apply_sgr_code(&mut self, code: u16) {
968        {
969            let p = code;
970            match p {
971                0 => self.sgr_reset(),
972                1 => self.pen_attrs.insert(CellAttrs::BOLD),
973                2 => self.pen_attrs.insert(CellAttrs::DIM),
974                3 => self.pen_attrs.insert(CellAttrs::ITALIC),
975                4 => self.pen_attrs.insert(CellAttrs::UNDERLINE),
976                5 | 6 => self.pen_attrs.insert(CellAttrs::BLINK),
977                7 => self.pen_attrs.insert(CellAttrs::INVERSE),
978                8 => self.pen_attrs.insert(CellAttrs::HIDDEN),
979                9 => self.pen_attrs.insert(CellAttrs::STRIKETHROUGH),
980                21 | 22 => {
981                    self.pen_attrs.remove(CellAttrs::BOLD);
982                    self.pen_attrs.remove(CellAttrs::DIM);
983                }
984                23 => self.pen_attrs.remove(CellAttrs::ITALIC),
985                24 => self.pen_attrs.remove(CellAttrs::UNDERLINE),
986                25 => self.pen_attrs.remove(CellAttrs::BLINK),
987                27 => self.pen_attrs.remove(CellAttrs::INVERSE),
988                28 => self.pen_attrs.remove(CellAttrs::HIDDEN),
989                29 => self.pen_attrs.remove(CellAttrs::STRIKETHROUGH),
990                30..=37 => self.pen_fg = self.palette[(p - 30) as usize],
991                39 => self.pen_fg = Color::WHITE,
992                40..=47 => self.pen_bg = self.palette[(p - 40) as usize],
993                49 => self.pen_bg = Color::BLACK,
994                90..=97 => self.pen_fg = self.palette[8 + (p - 90) as usize],
995                100..=107 => self.pen_bg = self.palette[8 + (p - 100) as usize],
996                _ => {} // unknown — drop
997            }
998        }
999    }
1000
1001    fn sgr_reset(&mut self) {
1002        self.pen_fg = Color::WHITE;
1003        self.pen_bg = Color::BLACK;
1004        self.pen_attrs = CellAttrs::NONE;
1005    }
1006}
1007
1008impl Perform for GridState {
1009    fn print(&mut self, c: char) {
1010        // Pane-as-block: feed the extractor BEFORE placement so
1011        // its phase state reflects the same chronology the
1012        // grid sees. Cheap when the extractor is Idle (single
1013        // Option-is-none check).
1014        self.blocks.on_print(c);
1015        let w = UnicodeWidthChar::width(c).unwrap_or(1);
1016        if w == 0 {
1017            // A zero-width codepoint (a combining mark, a ZWJ) belongs to
1018            // the glyph before it and consumes NO column. Placing it in a
1019            // cell of its own — which this parser used to do — displaces
1020            // every later cell on the row.
1021            self.combine_into_previous(c);
1022            return;
1023        }
1024        self.put_char(c, w);
1025    }
1026
1027    /// DCS opened. `q` is sixel; everything else is ignored as before.
1028    ///
1029    /// Accumulation starts here and the payload is bounded as it grows, not
1030    /// at `unhook` — a hostile stream that never terminates would otherwise
1031    /// grow the buffer without limit while the sequence stayed open.
1032    fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, action: char) {
1033        if action == 'q' {
1034            self.sixel_in_flight = Some(Vec::new());
1035        }
1036    }
1037
1038    fn put(&mut self, byte: u8) {
1039        if let Some(buf) = self.sixel_in_flight.as_mut() {
1040            // One past the cap is enough to know it was cut; keeping more
1041            // would defeat the bound.
1042            if buf.len() < GRAPHIC_PAYLOAD_MAX {
1043                buf.push(byte);
1044            }
1045        }
1046    }
1047
1048    fn unhook(&mut self) {
1049        if let Some(data) = self.sixel_in_flight.take() {
1050            if !data.is_empty() {
1051                // The DCS path bounds as it accumulates, so a payload at
1052                // the cap is exactly the cut case.
1053                let cut = data.len() >= GRAPHIC_PAYLOAD_MAX;
1054                self.push_graphic(GraphicProtocol::Sixel, String::new(), data, cut);
1055            }
1056        }
1057    }
1058
1059    fn execute(&mut self, byte: u8) {
1060        // Any control byte cancels a pending wrap — the cursor's
1061        // about to be moved or text deferred elsewhere.
1062        self.wrap_pending = false;
1063        match byte {
1064            b'\n' => self.linefeed(),
1065            b'\r' => self.carriage_return(),
1066            b'\x08' => self.backspace(),
1067            b'\t' => self.tab_forward(),
1068            b'\x07' => {} // BEL
1069            _ => {}
1070        }
1071    }
1072
1073    fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, c: char) {
1074        // CSI sequences other than pure-SGR clear a pending wrap.
1075        if c != 'm' {
1076            self.wrap_pending = false;
1077        }
1078        let first = params
1079            .iter()
1080            .next()
1081            .and_then(|p| p.first().copied())
1082            .unwrap_or(0);
1083        let n = first.max(1) as isize;
1084        // ── Private-parameter CSI is a SEPARATE NAMESPACE ─────────────
1085        //
1086        // A prefix byte in 0x3C..=0x3F (`<` `=` `>` `?`) makes the
1087        // sequence private: it shares FINAL BYTES with the standard
1088        // sequences but means something entirely different. Dispatching
1089        // on the final byte alone therefore runs the wrong command.
1090        //
1091        // Not hypothetical. Claude Code emits `CSI > 4 ; 2 m` (xterm
1092        // XTMODKEYS / modifyOtherKeys) at startup. Read as SGR that is
1093        // params [4, 2] → UNDERLINE + DIM latched onto the pen, and
1094        // since nothing later emits SGR 0 or 24, every cell printed for
1095        // the rest of the session came out underlined — the standing
1096        // "everything is underlined in mado" artifact. Measured on a
1097        // real capture: 205/205 non-blank cells underlined before this
1098        // guard, 0 after. `CSI > … h` and `CSI ? … J/K` (DECSED/DECSEL)
1099        // are the same class one final byte over.
1100        //
1101        // So: recognise the private sequences we implement, and make
1102        // every other private sequence a no-op. Falling through to the
1103        // standard `match` is what must stay unrepresentable — adding a
1104        // new standard arm must never silently hand some private
1105        // sequence a meaning it does not have.
1106        if let Some(prefix) = intermediates
1107            .first()
1108            .copied()
1109            .filter(|b| (0x3C..=0x3F).contains(b))
1110        {
1111            if prefix == b'?' && (c == 'h' || c == 'l') {
1112                let set = c == 'h';
1113                for p in params.iter() {
1114                    if let Some(&code) = p.first() {
1115                        self.apply_dec_mode(code, set);
1116                    }
1117                }
1118            }
1119            // Secondary DA (`CSI > c`). It lives HERE and not in the
1120            // standard match below precisely because of this namespace
1121            // split: `CSI c` and `CSI > c` share a final byte and are
1122            // different queries.
1123            if prefix == b'>' && c == 'c' {
1124                self.answer(TearCaps::SECONDARY_DA);
1125            }
1126            return;
1127        }
1128        match c {
1129            // ── VT queries — answered ONLY as HostRole::Host ──────────
1130            // DSR (CSI n): 5 = "are you ok", 6 = cursor position (CPR).
1131            'n' => match first {
1132                5 => self.answer(TearCaps::STATUS_OK),
1133                6 => {
1134                    // CPR is 1-based, and it reports the cursor's CLAMPED
1135                    // column — which is why park_at_right_margin matters:
1136                    // a cursor parked on a wide glyph's lead instead of the
1137                    // last column under-reports here.
1138                    let row = self.cursor_row + 1;
1139                    let col = self.cursor_col + 1;
1140                    let mut r = Vec::new();
1141                    r.extend_from_slice(b"\x1b[");
1142                    r.extend_from_slice(row.to_string().as_bytes());
1143                    r.push(b';');
1144                    r.extend_from_slice(col.to_string().as_bytes());
1145                    r.push(b'R');
1146                    self.answer(&r);
1147                }
1148                _ => {}
1149            },
1150            // Primary DA (CSI c / CSI 0 c).
1151            'c' => self.answer(TearCaps::PRIMARY_DA),
1152            'A' => self.cursor_move_relative(-n, 0),
1153            'B' => self.cursor_move_relative(n, 0),
1154            'C' => self.cursor_move_relative(0, n),
1155            'D' => self.cursor_move_relative(0, -n),
1156            'E' => {
1157                self.carriage_return();
1158                self.cursor_move_relative(n, 0);
1159            }
1160            'F' => {
1161                self.carriage_return();
1162                self.cursor_move_relative(-n, 0);
1163            }
1164            'G' => {
1165                let col = first.max(1) as usize - 1;
1166                let row = self.cursor_row;
1167                self.cursor_set(row, col);
1168            }
1169            'H' | 'f' => {
1170                let mut it = params.iter();
1171                let row = it
1172                    .next()
1173                    .and_then(|p| p.first().copied())
1174                    .unwrap_or(1)
1175                    .max(1) as usize;
1176                let col = it
1177                    .next()
1178                    .and_then(|p| p.first().copied())
1179                    .unwrap_or(1)
1180                    .max(1) as usize;
1181                self.cursor_set(row - 1, col - 1);
1182            }
1183            'J' => match first {
1184                0 => self.erase_below_cursor(),
1185                1 => self.erase_above_cursor(),
1186                2 | 3 => self.erase_all(),
1187                _ => {}
1188            },
1189            'K' => match first {
1190                0 => self.erase_to_end_of_line(),
1191                1 => self.erase_from_start_of_line(),
1192                2 => self.erase_line(),
1193                _ => {}
1194            },
1195            'L' => {
1196                // IL — Insert Line. Inserts N blank lines at cursor;
1197                // pushes lines below down (and off the bottom of region).
1198                let blank = vec![self.blank_cell(); self.cols];
1199                let row = self.cursor_row;
1200                for _ in 0..n {
1201                    if self.alt_active {
1202                        if row < self.alternate.len() && row <= self.scroll_bottom {
1203                            self.alternate.insert(row, blank.clone());
1204                            if self.scroll_bottom + 1 < self.alternate.len() {
1205                                self.alternate.remove(self.scroll_bottom + 1);
1206                            }
1207                        }
1208                    } else if row < self.primary.len() && row <= self.scroll_bottom {
1209                        self.primary.insert(row, blank.clone());
1210                        if self.scroll_bottom + 1 < self.primary.len() {
1211                            self.primary.remove(self.scroll_bottom + 1);
1212                        }
1213                    }
1214                }
1215            }
1216            'M' => {
1217                // DL — Delete Line. Removes N lines at cursor; pulls
1218                // lines below up; pads with blanks at region bottom.
1219                let blank = vec![self.blank_cell(); self.cols];
1220                let row = self.cursor_row;
1221                for _ in 0..n {
1222                    if self.alt_active {
1223                        if row < self.alternate.len() && row <= self.scroll_bottom {
1224                            self.alternate.remove(row);
1225                            let insert_at = (self.scroll_bottom).min(self.alternate.len());
1226                            self.alternate.insert(insert_at, blank.clone());
1227                        }
1228                    } else if row < self.primary.len() && row <= self.scroll_bottom {
1229                        self.primary.remove(row);
1230                        let insert_at = (self.scroll_bottom).min(self.primary.len());
1231                        self.primary.insert(insert_at, blank.clone());
1232                    }
1233                }
1234            }
1235            '@' => {
1236                // ICH — Insert N blank cells at cursor; shifts right.
1237                let blank = self.blank_cell();
1238                let row = self.cursor_row;
1239                let col = self.cursor_col;
1240                let cols = self.cols;
1241                if let Some(r) = self.active_row_mut(row) {
1242                    for _ in 0..n {
1243                        if col < r.len() {
1244                            r.insert(col, blank);
1245                            r.truncate(cols);
1246                        }
1247                    }
1248                }
1249            }
1250            'P' => {
1251                // DCH — Delete N cells at cursor; pulls remainder of row left.
1252                let blank = self.blank_cell();
1253                let row = self.cursor_row;
1254                let col = self.cursor_col;
1255                let cols = self.cols;
1256                if let Some(r) = self.active_row_mut(row) {
1257                    for _ in 0..n {
1258                        if col < r.len() {
1259                            r.remove(col);
1260                            r.push(blank);
1261                            if r.len() > cols {
1262                                r.truncate(cols);
1263                            }
1264                        }
1265                    }
1266                }
1267            }
1268            'X' => {
1269                // ECH — Erase N cells at cursor in place (no shift).
1270                let blank = self.blank_cell();
1271                let row = self.cursor_row;
1272                let col = self.cursor_col;
1273                let n_usize = n as usize;
1274                if let Some(r) = self.active_row_mut(row) {
1275                    for i in 0..n_usize {
1276                        if col + i < r.len() {
1277                            r[col + i] = blank;
1278                        }
1279                    }
1280                }
1281            }
1282            'b' => {
1283                // REP — Repeat last printed char N times.
1284                if let Some(c) = self.last_printed {
1285                    for _ in 0..n {
1286                        Perform::print(self, c);
1287                    }
1288                }
1289            }
1290            'h' => {
1291                // SM — set mode. Currently support IRM (4).
1292                for p in params.iter() {
1293                    if p.first().copied() == Some(4) {
1294                        self.insert_mode = true;
1295                    }
1296                }
1297            }
1298            'l' => {
1299                // RM — reset mode. Currently support IRM (4).
1300                for p in params.iter() {
1301                    if p.first().copied() == Some(4) {
1302                        self.insert_mode = false;
1303                    }
1304                }
1305            }
1306            'S' => {
1307                for _ in 0..n {
1308                    self.scroll_region_up();
1309                }
1310            }
1311            'T' => {
1312                // SD — scroll down (reverse). Insert blank rows at top.
1313                for _ in 0..n {
1314                    let blank = vec![self.blank_cell(); self.cols];
1315                    if self.alt_active {
1316                        if self.scroll_top < self.alternate.len() {
1317                            self.alternate.insert(self.scroll_top, blank);
1318                            if self.scroll_bottom + 1 < self.alternate.len() {
1319                                self.alternate.remove(self.scroll_bottom + 1);
1320                            }
1321                        }
1322                    } else if self.scroll_top < self.primary.len() {
1323                        self.primary.insert(self.scroll_top, blank);
1324                        if self.scroll_bottom + 1 < self.primary.len() {
1325                            self.primary.remove(self.scroll_bottom + 1);
1326                        }
1327                    }
1328                }
1329            }
1330            'd' => {
1331                let row = first.max(1) as usize - 1;
1332                let col = self.cursor_col;
1333                self.cursor_set(row, col);
1334            }
1335            'm' => self.apply_sgr(params),
1336            'r' => {
1337                // DECSTBM — set scroll region
1338                let mut it = params.iter();
1339                let top = it
1340                    .next()
1341                    .and_then(|p| p.first().copied())
1342                    .unwrap_or(1)
1343                    .max(1) as usize
1344                    - 1;
1345                let bottom = it
1346                    .next()
1347                    .and_then(|p| p.first().copied())
1348                    .unwrap_or(self.rows as u16)
1349                    .max(1) as usize
1350                    - 1;
1351                self.scroll_top = top.min(self.rows.saturating_sub(1));
1352                self.scroll_bottom = bottom.min(self.rows.saturating_sub(1));
1353                self.cursor_set(0, 0);
1354            }
1355            's' => self.save_cursor(),
1356            'u' => self.restore_cursor(),
1357            _ => {}
1358        }
1359    }
1360
1361    fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
1362        // OSC 0 / 1 / 2 — set window/icon title. We treat them
1363        // identically: title is the second param decoded as UTF-8.
1364        let code = params.first().and_then(|p| std::str::from_utf8(p).ok());
1365        if matches!(code, Some("0") | Some("1") | Some("2")) {
1366            if let Some(t) = params.get(1).and_then(|p| std::str::from_utf8(p).ok()) {
1367                self.title = Some(t.to_owned());
1368            }
1369            return;
1370        }
1371        // OSC 7 — current working directory notification.
1372        // Most shells (zsh-vcs-info, bash with __vsc_*, ghostty's
1373        // shell-integration) emit `OSC 7 ; file://<host>/<path>`.
1374        // The block extractor stamps this onto every subsequent
1375        // block at prompt start.
1376        if matches!(code, Some("7"))
1377            && let Some(payload) = params.get(1).and_then(|p| std::str::from_utf8(p).ok())
1378        {
1379            self.blocks.set_cwd_from_osc7(payload);
1380            return;
1381        }
1382        // OSC 133 — FinalTerm prompt marks. Drive the block
1383        // extractor. The marker (A/B/C/D[;<n>]) lives in
1384        // params[1] for the standard `OSC 133;A` shape but
1385        // some shells emit `OSC 133;A;…` with extra fields
1386        // after — we pass the whole remainder to the extractor.
1387        if matches!(code, Some("133")) {
1388            // Rebuild the marker as "A" / "B;extra" / "D;0" etc.
1389            let marker: String = params
1390                .iter()
1391                .skip(1)
1392                .filter_map(|p| std::str::from_utf8(p).ok())
1393                .collect::<Vec<_>>()
1394                .join(";");
1395            self.blocks.on_osc_133(&marker);
1396        }
1397    }
1398
1399    fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, byte: u8) {
1400        match byte {
1401            b'7' => self.save_cursor(),
1402            b'8' => self.restore_cursor(),
1403            b'D' => self.linefeed(),
1404            b'E' => {
1405                self.linefeed();
1406                self.carriage_return();
1407            }
1408            b'M' => {
1409                // RI — reverse index. Move cursor up; scroll down if at top.
1410                if self.cursor_row == self.scroll_top {
1411                    // Insert a blank at top, drop bottom.
1412                    let blank = vec![self.blank_cell(); self.cols];
1413                    if self.alt_active {
1414                        if self.scroll_top < self.alternate.len() {
1415                            self.alternate.insert(self.scroll_top, blank);
1416                            if self.scroll_bottom + 1 < self.alternate.len() {
1417                                self.alternate.remove(self.scroll_bottom + 1);
1418                            }
1419                        }
1420                    } else {
1421                        self.primary.insert(self.scroll_top, blank);
1422                        if self.scroll_bottom + 1 < self.primary.len() {
1423                            self.primary.remove(self.scroll_bottom + 1);
1424                        }
1425                    }
1426                } else if self.cursor_row > 0 {
1427                    self.cursor_row -= 1;
1428                }
1429            }
1430            b'c' => {
1431                // RIS — Reset to Initial State.
1432                self.sgr_reset();
1433                self.erase_all();
1434                self.cursor_set(0, 0);
1435                self.scroll_top = 0;
1436                self.scroll_bottom = self.rows.saturating_sub(1);
1437                self.alt_active = false;
1438                self.saved = None;
1439                self.cursor_keys_mode = false;
1440                self.cursor_visible = true;
1441                self.title = None;
1442            }
1443            _ => {}
1444        }
1445    }
1446}
1447
1448impl GridState {
1449    fn apply_dec_mode(&mut self, code: u16, set: bool) {
1450        match code {
1451            // 47 / 1047 / 1049 — alternate screen variants. Differences:
1452            //   47   : enter/leave alt buffer; no cursor save.
1453            //   1047 : like 47 but clears alt buffer on enter.
1454            //   1049 : 1047 + DECSC/DECRC save+restore cursor.
1455            47 => {
1456                if set {
1457                    self.enter_alt_screen(false);
1458                } else {
1459                    self.leave_alt_screen();
1460                }
1461            }
1462            1047 => {
1463                if set {
1464                    self.enter_alt_screen(true);
1465                } else {
1466                    self.erase_all();
1467                    self.leave_alt_screen();
1468                }
1469            }
1470            1049 => {
1471                if set {
1472                    self.save_cursor();
1473                    self.enter_alt_screen(true);
1474                } else {
1475                    self.erase_all();
1476                    self.leave_alt_screen();
1477                    self.restore_cursor();
1478                }
1479            }
1480            1 => self.cursor_keys_mode = set,     // DECCKM
1481            25 => self.cursor_visible = set,      // DECTCEM
1482            7 => self.autowrap = set,             // DECAWM
1483            1004 => self.focus_reporting = set,   // focus in/out reporting
1484            2004 => self.bracketed_paste = set,   // bracketed paste
1485            2026 => self.sync_output = set,       // synchronized output
1486            // Mouse tracking levels are mutually exclusive: the LAST one
1487            // set wins, and resetting any of them turns tracking off. A
1488            // set of independent bools would let two levels be true at
1489            // once, which no terminal can mean.
1490            1000 => self.mouse = if set { MouseTracking::Click } else { MouseTracking::Off },
1491            1002 => self.mouse = if set { MouseTracking::Drag } else { MouseTracking::Off },
1492            1003 => self.mouse = if set { MouseTracking::Motion } else { MouseTracking::Off },
1493            1006 => self.mouse_sgr = set, // SGR extended mouse encoding
1494            _ => {}
1495        }
1496    }
1497}
1498
1499impl PaneGrid {
1500    // ── THE AUTHORITY SEAL ──────────────────────────────────────────
1501    //
1502    // `new` / `with_scrollback` / `feed` are `pub(crate)`, and that
1503    // visibility IS the seal described in `docs/SHUKEN.md`.
1504    //
1505    // The decision there is that `PaneGrid` is the SOLE authoritative VT
1506    // parser for a pane. A doc cannot enforce that; a consumer that can
1507    // construct its own grid and feed it bytes has a second authority, and
1508    // the two can then disagree — which is the exact defect (mado's
1509    // `terminal.rs` double-parse) the decision exists to remove.
1510    //
1511    // SHUKEN originally proposed sealing this by removing `vte` from mado's
1512    // manifest. That is necessary and NOT sufficient: mado depends on
1513    // `tear-core` directly for `InProcess`, so with `vte` gone
1514    // `tear_core::PaneGrid::new(80, 24).feed(bytes)` still compiled — a
1515    // second authoritative grid that never names `vte` at all.
1516    //
1517    // `pub(crate)` closes it at the strongest available tier: outside this
1518    // crate the constructor is not merely discouraged, it is **E0603, a
1519    // private item**. A Cargo feature was considered and rejected — mado
1520    // NEEDS `InProcess` (which owns the PTYs and drives these grids
1521    // internally), so a feature that excluded `pane_grid` from mado would
1522    // break the very runtime the decision depends on, while a feature that
1523    // included it would seal nothing.
1524    //
1525    // Reading stays public on purpose: `snapshot()` and the `PaneSnapshot`
1526    // / `Cell` types below are how a client observes the authority. The
1527    // asymmetry is the whole design — **anyone may read, only the authority
1528    // may advance.**
1529    #[must_use]
1530    pub(crate) fn new(cols: usize, rows: usize) -> Self {
1531        Self::with_scrollback(cols, rows, DEFAULT_SCROLLBACK_ROWS)
1532    }
1533
1534    #[must_use]
1535    pub(crate) fn with_scrollback(cols: usize, rows: usize, scrollback_cap: usize) -> Self {
1536        Self {
1537            parser: Parser::new(),
1538            state: GridState::new(cols, rows, scrollback_cap),
1539            apc: ApcScanner::default(),
1540        }
1541    }
1542
1543    /// Advance this pane's terminal state by `bytes`.
1544    ///
1545    /// `pub(crate)` — see the authority-seal note above. This is the only
1546    /// write verb on a pane's grid, and it is reachable only from inside
1547    /// `tear-core`, i.e. only through `InProcess`/the daemon.
1548    pub(crate) fn feed(&mut self, bytes: &[u8]) {
1549        // Lift APC out first — vte would swallow it silently (see
1550        // `ApcScanner`). Everything else reaches the parser untouched.
1551        let (passthrough, apcs) = self.apc.split(bytes);
1552        self.parser.advance(&mut self.state, &passthrough);
1553        for (payload, cut) in apcs {
1554            self.state.ingest_apc(&payload, cut);
1555        }
1556    }
1557
1558    /// Every terminal mode this pane is in, taken at ONE instant.
1559    ///
1560    /// This is how a client reads a mode under `docs/SHUKEN.md` — from the
1561    /// authority, never from a parser of its own. Today mado reads
1562    /// `bracketed_paste` from its OWN `Terminal`, which is correct only
1563    /// because mado still parses every byte; it becomes a live bug the
1564    /// instant this grid is authoritative, and it is a paste-sanitisation
1565    /// decision, not a cosmetic one.
1566    ///
1567    /// Returned as a whole `ModeSet` rather than one getter per mode so a
1568    /// client cannot mix modes from two different instants.
1569    #[must_use]
1570    pub fn modes(&self) -> ModeSet {
1571        let s = &self.state;
1572        ModeSet {
1573            bracketed_paste: BracketedPaste::new(s.bracketed_paste),
1574            cursor_keys: CursorKeys::new(s.cursor_keys_mode),
1575            focus_reporting: FocusReporting::new(s.focus_reporting),
1576            sync_output: SyncOutput::new(s.sync_output),
1577            mouse: s.mouse,
1578            mouse_sgr: MouseSgr::new(s.mouse_sgr),
1579            cursor_visible: CursorVisible::new(s.cursor_visible),
1580            autowrap: AutoWrap::new(s.autowrap),
1581            alt_screen: AltScreen::new(s.alt_active),
1582        }
1583    }
1584
1585    /// Set who answers VT queries for this pane.
1586    ///
1587    /// See [`HostRole`]. Setting [`HostRole::Host`] while a client with its
1588    /// own parser is still attached means BOTH answer, and the second reply
1589    /// lands on the PTY as if the operator had typed it.
1590    pub(crate) fn set_host_role(&mut self, role: HostRole) {
1591        self.state.role = role;
1592    }
1593
1594    /// Take the reply bytes owed to the child process.
1595    ///
1596    /// The caller writes these to the PTY's INPUT side — a reply is data
1597    /// the child asked for, not output to be rendered. Always empty while
1598    /// the pane is a [`HostRole::Relay`].
1599    #[must_use]
1600    pub(crate) fn take_response(&mut self) -> Option<Vec<u8>> {
1601        if self.state.pending_response.is_empty() {
1602            None
1603        } else {
1604            Some(std::mem::take(&mut self.state.pending_response))
1605        }
1606    }
1607
1608    #[must_use]
1609    pub fn snapshot(&self) -> PaneSnapshot {
1610        let cells: Vec<Vec<Cell>> = self.state.active_rows().cloned().collect();
1611        // Carry the rolled-off scrollback so a re-attach / session switch
1612        // restores the pane's history (the primary screen only — the
1613        // alternate screen's apps own the full viewport and have no
1614        // scrollback to restore).
1615        let scrollback: Vec<Vec<Cell>> = if self.state.alt_active {
1616            Vec::new()
1617        } else {
1618            self.state.scrollback.iter().cloned().collect()
1619        };
1620        PaneSnapshot {
1621            rows: self.state.rows,
1622            cols: self.state.cols,
1623            cells,
1624            cursor_row: self.state.cursor_row,
1625            cursor_col: self.state.cursor_col,
1626            alt_screen_active: self.state.alt_active,
1627            cursor_visible: self.state.cursor_visible,
1628            title: self.state.title.clone(),
1629            cursor_keys_mode: self.state.cursor_keys_mode,
1630            scrollback,
1631            combining: self.state.combining.clone(),
1632            modes: self.modes(),
1633            graphics: self.state.graphics.clone(),
1634        }
1635    }
1636
1637    /// Current window title (OSC 0 / 2). None until the first
1638    /// title set; cleared on RIS.
1639    #[must_use]
1640    pub fn title(&self) -> Option<&str> {
1641        self.state.title.as_deref()
1642    }
1643
1644    /// Stamp the owning pane's provenance so every block this grid
1645    /// mints records WHO ran it. **Write-once**; returns `true` if
1646    /// this call took effect.
1647    ///
1648    /// Provenance enters through the grid deliberately. Under
1649    /// shuken (`docs/SHUKEN.md`) `PaneGrid` is the sole VT
1650    /// authority — it is the one place that sees the byte stream
1651    /// and mints blocks from it — so attribution belongs at the
1652    /// same seam as the authority. A second, parallel path that
1653    /// attributed blocks anywhere else would be exactly the
1654    /// duplicated-state split shuken exists to forbid.
1655    pub fn stamp_yurai(&mut self, y: tear_types::Yurai) -> bool {
1656        self.state.blocks.stamp_yurai(y)
1657    }
1658
1659    /// Provenance every block from this grid carries.
1660    #[must_use]
1661    pub fn yurai(&self) -> &tear_types::Yurai {
1662        self.state.blocks.yurai()
1663    }
1664
1665    /// DECCKM (DEC mode 1) cursor-keys application mode.
1666    ///
1667    /// Consumers translating host keystrokes to PTY bytes (mado's
1668    /// `keybind::madori_key_to_pty_bytes`, any future tear-client
1669    /// renderer) read this to encode Up/Down/Right/Left as
1670    /// `ESC O A/B/C/D` (true) or `ESC [ A/B/C/D` (false).
1671    #[must_use]
1672    pub fn cursor_keys_mode(&self) -> bool {
1673        self.state.cursor_keys_mode
1674    }
1675
1676    /// Number of scrollback rows that have rolled off the primary
1677    /// screen. Useful for tests + UI affordances.
1678    #[must_use]
1679    pub fn scrollback_len(&self) -> usize {
1680        self.state.scrollback.len()
1681    }
1682
1683    pub fn resize(&mut self, cols: usize, rows: usize) {
1684        // Naive resize: preserve top-left, truncate / pad rest. The
1685        // full reflow algorithm (mado's grid-reflow.rs) lands when
1686        // we port the rest of the terminal state machine.
1687        let mut new_primary: VecDeque<Vec<Cell>> = VecDeque::with_capacity(rows);
1688        for r in 0..rows {
1689            let mut new_row = vec![Cell::BLANK; cols];
1690            if let Some(existing) = self.state.primary.get(r) {
1691                let n = existing.len().min(cols);
1692                new_row[..n].copy_from_slice(&existing[..n]);
1693            }
1694            new_primary.push_back(new_row);
1695        }
1696        let mut new_alt = vec![vec![Cell::BLANK; cols]; rows];
1697        for r in 0..rows.min(self.state.alternate.len()) {
1698            let existing = &self.state.alternate[r];
1699            let n = existing.len().min(cols);
1700            new_alt[r][..n].copy_from_slice(&existing[..n]);
1701        }
1702        self.state.primary = new_primary;
1703        self.state.alternate = new_alt;
1704        self.state.rows = rows;
1705        self.state.cols = cols;
1706        self.state.cursor_row = self.state.cursor_row.min(rows.saturating_sub(1));
1707        self.state.cursor_col = self.state.cursor_col.min(cols.saturating_sub(1));
1708        self.state.scroll_top = 0;
1709        self.state.scroll_bottom = rows.saturating_sub(1);
1710    }
1711}
1712
1713/// Character-width parity with mado's parser.
1714///
1715/// These are the RED GATE for the wide-character axis. They assert what a
1716/// correct VT parser does with double-width glyphs, which is what mado does
1717/// (`unicode-width`, `Cell.width` with `0 = continuation`) and what tear did
1718/// NOT: `advance_cursor_after_print` was `cursor_col += 1` unconditionally.
1719///
1720/// Why this shape and not a round-trip: a `feed → to_ansi → feed` round-trip
1721/// through tear alone is IDENTITY even while broken, because tear was
1722/// internally self-consistent at 1-advance. Self-consistency is exactly what
1723/// makes the bug invisible from inside. So these assert against true display
1724/// width — the oracle — not against tear's own agreement with itself.
1725#[cfg(test)]
1726mod width_parity {
1727    use super::*;
1728
1729    /// The founding symptom, minimal: one CJK glyph must consume TWO columns.
1730    /// Before the fix this reported `cursor_col == 1`.
1731    #[test]
1732    fn wide_glyph_advances_two_columns() {
1733        let mut g = PaneGrid::new(20, 3);
1734        g.feed("你".as_bytes());
1735        let s = g.snapshot();
1736        assert_eq!(s.cells[0][0].ch, '你', "lead cell holds the glyph");
1737        assert_eq!(s.cells[0][0].width, 2, "lead is marked double-width");
1738        assert_eq!(s.cells[0][1].width, 0, "col 1 is a continuation cell");
1739        assert_eq!(s.cursor_col, 2, "cursor advances by the glyph's WIDTH");
1740    }
1741
1742    /// The divergence compounds: every later cell on the row is displaced.
1743    /// This is the mechanism behind the column-shifted decoration that
1744    /// survived 10+ fix attempts inside mado.
1745    #[test]
1746    fn later_cells_are_not_displaced_by_wide_glyphs() {
1747        let mut g = PaneGrid::new(20, 3);
1748        g.feed("你好X".as_bytes());
1749        let s = g.snapshot();
1750        assert_eq!(s.cells[0][0].ch, '你');
1751        assert_eq!(s.cells[0][2].ch, '好', "second glyph starts at col 2, not 1");
1752        assert_eq!(s.cells[0][4].ch, 'X', "ASCII lands at col 4, not 2");
1753        assert_eq!(s.cursor_col, 5);
1754    }
1755
1756    /// A wide glyph that cannot fit before the margin wraps WHOLE — it is
1757    /// never split across the seam.
1758    #[test]
1759    fn wide_glyph_that_does_not_fit_wraps_whole() {
1760        let mut g = PaneGrid::new(20, 3);
1761        g.feed("A".repeat(19).as_bytes());
1762        g.feed("你".as_bytes());
1763        let s = g.snapshot();
1764        assert_eq!(s.cells[0][19].ch, ' ', "last col of row 0 stays blank");
1765        assert_eq!(s.cells[1][0].ch, '你', "glyph moved to the next row whole");
1766        assert_eq!(s.cells[1][1].width, 0);
1767    }
1768
1769    /// A wide glyph landing flush against the margin parks the cursor on the
1770    /// LAST column, not on its own lead. Without this clamp every subsequent
1771    /// relative motion is off by one and CSI 6n under-reports the column.
1772    #[test]
1773    fn wide_glyph_flush_to_margin_parks_at_last_column() {
1774        let mut g = PaneGrid::new(20, 3);
1775        g.feed("A".repeat(18).as_bytes());
1776        g.feed("你".as_bytes());
1777        let s = g.snapshot();
1778        assert_eq!(s.cells[0][18].ch, '你');
1779        assert_eq!(s.cells[0][19].width, 0);
1780        assert_eq!(s.cursor_col, 19, "parked at the last column, not at 18");
1781    }
1782
1783    /// Overwriting half a wide pair must not leave the other half orphaned —
1784    /// an orphan renders as half a glyph.
1785    #[test]
1786    fn overwriting_a_wide_pair_clears_its_orphan() {
1787        let mut g = PaneGrid::new(20, 3);
1788        g.feed("你".as_bytes());
1789        g.feed(b"\x1b[1;1H");
1790        g.feed(b"X");
1791        let s = g.snapshot();
1792        assert_eq!(s.cells[0][0].ch, 'X');
1793        assert_eq!(s.cells[0][0].width, 1);
1794        assert_eq!(
1795            s.cells[0][1].ch, ' ',
1796            "the orphaned continuation is cleared, not left as a half-glyph"
1797        );
1798        assert_eq!(s.cells[0][1].width, 1);
1799    }
1800
1801    /// A combining mark attaches to the preceding glyph and consumes no
1802    /// column. Placing it in its own cell (what this parser did before)
1803    /// displaces every later cell on the row.
1804    #[test]
1805    fn a_combining_mark_attaches_to_the_base_cell() {
1806        let mut g = PaneGrid::new(20, 3);
1807        g.feed("e\u{301}X".as_bytes()); // e + COMBINING ACUTE + X
1808        let s = g.snapshot();
1809        assert_eq!(s.cells[0][0].ch, 'e');
1810        assert_eq!(
1811            s.cells[0][0].marks(&s.combining),
1812            &['\u{301}'],
1813            "the mark belongs to the base cell"
1814        );
1815        assert_eq!(s.cells[0][1].ch, 'X', "X is at col 1, not col 2");
1816        assert_eq!(s.cursor_col, 2, "a mark consumes no column");
1817    }
1818
1819    /// Several marks stack onto one base cell.
1820    #[test]
1821    fn stacked_marks_accumulate_on_one_cell() {
1822        let mut g = PaneGrid::new(20, 3);
1823        g.feed("a\u{301}\u{308}".as_bytes());
1824        let s = g.snapshot();
1825        assert_eq!(s.cells[0][0].marks(&s.combining), &['\u{301}', '\u{308}']);
1826        assert_eq!(s.cursor_col, 1);
1827    }
1828
1829    /// ★ The case mado had to fix as a REGRESSION (2026-07-30), so tear is
1830    /// born with it. When a wrap is pending the search starts at the last
1831    /// column — which for a margin-flush wide glyph is its CONTINUATION.
1832    /// Attaching there puts the mark on a width-0 cell where it renders
1833    /// nowhere; it must walk back to the lead.
1834    #[test]
1835    fn a_mark_after_a_margin_flush_wide_glyph_lands_on_the_lead() {
1836        let mut g = PaneGrid::new(20, 3);
1837        g.feed("A".repeat(18).as_bytes());
1838        g.feed("你\u{301}".as_bytes());
1839        let s = g.snapshot();
1840        assert_eq!(s.cells[0][18].ch, '你', "lead at col 18");
1841        assert_eq!(
1842            s.cells[0][18].marks(&s.combining),
1843            &['\u{301}'],
1844            "the mark must attach to the LEAD, not the continuation"
1845        );
1846        assert!(
1847            s.cells[0][19].marks(&s.combining).is_empty(),
1848            "the continuation owns no marks"
1849        );
1850    }
1851
1852    /// A mark with no preceding glyph is dropped rather than creating a
1853    /// cell — matching mado.
1854    #[test]
1855    fn a_mark_at_column_zero_is_dropped() {
1856        let mut g = PaneGrid::new(20, 3);
1857        g.feed("\u{301}".as_bytes());
1858        let s = g.snapshot();
1859        assert_eq!(s.cursor_col, 0, "no column consumed");
1860        assert!(s.combining.is_empty(), "no table entry minted");
1861        assert_eq!(s.cells[0][0].ch, ' ');
1862    }
1863
1864    /// REP (`CSI b`) repeats the BASE glyph, not the mark — which is why
1865    /// `combine_into_previous` must not touch `last_printed`.
1866    #[test]
1867    fn rep_after_a_mark_repeats_the_base_glyph() {
1868        let mut g = PaneGrid::new(20, 3);
1869        g.feed("e\u{301}".as_bytes());
1870        g.feed(b"\x1b[2b");
1871        let s = g.snapshot();
1872        assert_eq!(s.cells[0][1].ch, 'e', "REP repeats the base, not the mark");
1873        assert_eq!(s.cells[0][2].ch, 'e');
1874    }
1875
1876    /// Marks must survive a replay, or a session switch silently strips
1877    /// every accent on screen.
1878    #[test]
1879    fn marks_survive_a_to_ansi_round_trip() {
1880        let mut a = PaneGrid::new(20, 3);
1881        a.feed("e\u{301}X".as_bytes());
1882        let first = a.snapshot();
1883
1884        let mut b = PaneGrid::new(20, 3);
1885        b.feed(&first.to_ansi());
1886        let second = b.snapshot();
1887
1888        assert_eq!(second.cells[0][0].ch, 'e');
1889        assert_eq!(second.cells[0][0].marks(&second.combining), &['\u{301}']);
1890        assert_eq!(second.cells[0][1].ch, 'X');
1891    }
1892
1893    /// `to_ansi` must not emit continuation cells: re-feeding its output has
1894    /// to reproduce the same grid. Emitting the spacer would push every later
1895    /// glyph one column right per wide glyph on replay.
1896    #[test]
1897    fn to_ansi_round_trips_wide_glyphs_without_drift() {
1898        let mut a = PaneGrid::new(20, 3);
1899        a.feed("你好X".as_bytes());
1900        let first = a.snapshot();
1901
1902        let mut b = PaneGrid::new(20, 3);
1903        b.feed(&first.to_ansi());
1904        let second = b.snapshot();
1905
1906        for col in 0..20 {
1907            assert_eq!(
1908                first.cells[0][col].ch, second.cells[0][col].ch,
1909                "col {col} drifted across a to_ansi round-trip"
1910            );
1911            assert_eq!(
1912                first.cells[0][col].width, second.cells[0][col].width,
1913                "col {col} width drifted across a to_ansi round-trip"
1914            );
1915        }
1916    }
1917}
1918
1919/// The Relay→Host transition (docs/SHUKEN.md; task: the DSR/DA flip blocker).
1920///
1921/// tear could not answer a VT query at all — `PaneGrid` had no response
1922/// state, which the espelho conformance header records as tear being a
1923/// RELAY whose host duty "lives one layer DOWN" in mado. After the shuken
1924/// flip mado has no parser, so nothing would answer and every program that
1925/// probes the terminal would hang.
1926///
1927/// These rows pin both halves: the machinery works as a Host, and it stays
1928/// completely inert as a Relay.
1929/// The modes a client must read from the authority.
1930///
1931/// Before this, `apply_dec_mode`'s `_ => {}` silently dropped bracketed
1932/// paste, sync output, focus reporting, autowrap and every mouse mode — so
1933/// a client had no way to learn them from tear and mado read them from its
1934/// own parser instead. That is correct only while mado still parses, and
1935/// becomes a live bug the instant this grid is authoritative.
1936/// Inline images reach the authority instead of vanishing.
1937///
1938/// The last flip blocker in `docs/SHUKEN.md`. `GridState` implemented no
1939/// DCS `hook`/`put`/`unhook`, and vte has **no APC callback at all** — it
1940/// enters `State::SosPmApcString` and consumes to the terminator — so every
1941/// sixel and every kitty image was swallowed with no error and no flag. A
1942/// renderer could not even learn that content had been dropped.
1943#[cfg(test)]
1944mod graphics_rows {
1945    use super::*;
1946
1947    #[test]
1948    fn a_sixel_payload_reaches_the_snapshot() {
1949        let mut g = PaneGrid::new(80, 24);
1950        g.feed(b"\x1bPq#0;2;0;0;0#0~~@@vv@@~~@@~~$\x1b\\");
1951        let s = g.snapshot();
1952        assert_eq!(s.graphics.len(), 1, "the sixel must not vanish");
1953        assert_eq!(s.graphics[0].protocol, GraphicProtocol::Sixel);
1954        assert!(!s.graphics[0].data.is_empty());
1955        assert!(!s.graphics[0].truncated);
1956    }
1957
1958    #[test]
1959    fn a_kitty_payload_reaches_the_snapshot_with_its_params_split_off() {
1960        let mut g = PaneGrid::new(80, 24);
1961        g.feed(b"\x1b_Ga=T,f=100,s=2,v=2;iVBORw0KGgo=\x1b\\");
1962        let s = g.snapshot();
1963        assert_eq!(s.graphics.len(), 1, "the kitty image must not vanish");
1964        let img = &s.graphics[0];
1965        assert_eq!(img.protocol, GraphicProtocol::Kitty);
1966        assert_eq!(img.params, "a=T,f=100,s=2,v=2");
1967        assert_eq!(img.data, b"iVBORw0KGgo=".to_vec());
1968    }
1969
1970    /// ★ The case that breaks a naive scanner. A PTY read boundary can
1971    /// fall anywhere, including between `ESC` and `_`, so re-assembly must
1972    /// survive across `feed()` calls — the same chunk-boundary property the
1973    /// espelho conformance rows pin for ordinary escapes.
1974    #[test]
1975    fn an_apc_split_across_feeds_reassembles() {
1976        let whole = b"\x1b_Ga=T,f=100;PAYLOAD\x1b\\";
1977        for cut in 1..whole.len() {
1978            let mut g = PaneGrid::new(80, 24);
1979            g.feed(&whole[..cut]);
1980            g.feed(&whole[cut..]);
1981            let s = g.snapshot();
1982            assert_eq!(s.graphics.len(), 1, "lost the image when cut at {cut}");
1983            assert_eq!(s.graphics[0].data, b"PAYLOAD".to_vec(), "cut at {cut}");
1984            assert!(
1985                s.to_text_rows().iter().all(|r| r.trim().is_empty()),
1986                "APC bytes leaked into the grid when cut at {cut}"
1987            );
1988        }
1989    }
1990
1991    /// A withheld `ESC` that turns out NOT to open an APC must be replayed
1992    /// to the parser, or the sequence it belonged to is silently lost.
1993    #[test]
1994    fn a_non_apc_escape_still_reaches_the_parser() {
1995        let mut g = PaneGrid::new(80, 24);
1996        // Split mid-escape so the ESC is withheld across the boundary.
1997        g.feed(b"AB\x1b");
1998        g.feed(b"[1;1HX");
1999        let s = g.snapshot();
2000        assert_eq!(
2001            s.cells[0][0].ch, 'X',
2002            "the CUP that followed a withheld ESC must still be honoured"
2003        );
2004    }
2005
2006    #[test]
2007    fn an_apc_terminated_by_bel_is_accepted() {
2008        let mut g = PaneGrid::new(80, 24);
2009        g.feed(b"\x1b_Ga=T;DATA\x07");
2010        assert_eq!(g.snapshot().graphics.len(), 1, "BEL terminates APC too");
2011    }
2012
2013    /// A control-only kitty command (query, delete) carries no `;` and no
2014    /// payload. It is real and must not be mistaken for a malformed image.
2015    #[test]
2016    fn a_kitty_control_command_without_a_payload_is_kept() {
2017        let mut g = PaneGrid::new(80, 24);
2018        g.feed(b"\x1b_Ga=d,d=A\x1b\\");
2019        let s = g.snapshot();
2020        assert_eq!(s.graphics.len(), 1);
2021        assert_eq!(s.graphics[0].params, "a=d,d=A");
2022        assert!(s.graphics[0].data.is_empty());
2023    }
2024
2025    /// A non-kitty APC is dropped — APC is a private-use channel and an
2026    /// unrecognised one carries nothing we could act on.
2027    #[test]
2028    fn an_unrecognised_apc_is_dropped_without_reaching_the_grid() {
2029        let mut g = PaneGrid::new(80, 24);
2030        g.feed(b"\x1b_Zsomething-else\x1b\\after");
2031        let s = g.snapshot();
2032        assert!(s.graphics.is_empty(), "not a kitty payload");
2033        assert_eq!(s.cells[0][0].ch, 'a', "the text after it still lands");
2034    }
2035
2036    /// A runaway payload is CUT and says so. Silently rendering a partial
2037    /// image is worse than rendering none, and an unbounded one lets a
2038    /// child drive the daemon out of memory.
2039    #[test]
2040    fn an_oversized_payload_is_bounded_and_flagged() {
2041        let mut g = PaneGrid::new(80, 24);
2042        g.feed(b"\x1b_Ga=T;");
2043        // Feed past the cap in chunks, as a real PTY would.
2044        let chunk = vec![b'x'; 1024 * 1024];
2045        for _ in 0..10 {
2046            g.feed(&chunk);
2047        }
2048        g.feed(b"\x1b\\");
2049        let s = g.snapshot();
2050        assert_eq!(s.graphics.len(), 1);
2051        assert!(s.graphics[0].truncated, "the cut must be visible");
2052        assert!(
2053            s.graphics[0].data.len() <= GRAPHIC_PAYLOAD_MAX + 1,
2054            "payload not bounded: {}",
2055            s.graphics[0].data.len()
2056        );
2057    }
2058
2059    /// Graphics must not smear into the rendered text — the residue row
2060    /// espelho's conformance test guards for queries, applied to images.
2061    #[test]
2062    fn image_bytes_leave_no_residue_in_the_grid() {
2063        let mut g = PaneGrid::new(80, 24);
2064        g.feed(b"before|");
2065        g.feed(b"\x1b_Ga=T,f=100;iVBORw0KGgo=\x1b\\");
2066        g.feed(b"\x1bPq#0;2;0;0;0#0~~$\x1b\\");
2067        g.feed(b"|after");
2068        let row0 = g.snapshot().to_text_rows().into_iter().next().unwrap();
2069        assert_eq!(row0.trim_end(), "before||after");
2070    }
2071}
2072
2073#[cfg(test)]
2074mod mode_rows {
2075    use super::*;
2076
2077    #[test]
2078    fn a_fresh_pane_reports_xterm_defaults() {
2079        let g = PaneGrid::new(80, 24);
2080        let m = g.modes();
2081        assert!(m.autowrap.enabled(), "DECAWM is ON by default per xterm");
2082        assert!(m.cursor_visible.enabled());
2083        assert!(!m.bracketed_paste.enabled());
2084        assert!(!m.sync_output.enabled());
2085        assert!(!m.mouse.is_on());
2086    }
2087
2088    /// The one that gates paste sanitisation.
2089    #[test]
2090    fn bracketed_paste_is_tracked() {
2091        let mut g = PaneGrid::new(80, 24);
2092        assert!(!g.modes().bracketed_paste.enabled());
2093        g.feed(b"\x1b[?2004h");
2094        assert!(g.modes().bracketed_paste.enabled(), "DEC 2004 set");
2095        g.feed(b"\x1b[?2004l");
2096        assert!(!g.modes().bracketed_paste.enabled(), "DEC 2004 reset");
2097    }
2098
2099    #[test]
2100    fn the_remaining_flag_modes_are_tracked() {
2101        let mut g = PaneGrid::new(80, 24);
2102        g.feed(b"\x1b[?1004h\x1b[?2026h\x1b[?1006h\x1b[?7l\x1b[?1h\x1b[?25l");
2103        let m = g.modes();
2104        assert!(m.focus_reporting.enabled(), "DEC 1004");
2105        assert!(m.sync_output.enabled(), "DEC 2026");
2106        assert!(m.mouse_sgr.enabled(), "DEC 1006");
2107        assert!(!m.autowrap.enabled(), "DEC 7 reset");
2108        assert!(m.cursor_keys.enabled(), "DEC 1 (DECCKM)");
2109        assert!(!m.cursor_visible.enabled(), "DEC 25 reset");
2110    }
2111
2112    /// Mouse levels are exclusive — the LAST one set wins. Three
2113    /// independent bools would let two be true at once, which no terminal
2114    /// can mean; the enum makes that unconstructible.
2115    #[test]
2116    fn mouse_tracking_levels_replace_rather_than_accumulate() {
2117        let mut g = PaneGrid::new(80, 24);
2118        g.feed(b"\x1b[?1000h");
2119        assert_eq!(g.modes().mouse, MouseTracking::Click);
2120        g.feed(b"\x1b[?1003h");
2121        assert_eq!(
2122            g.modes().mouse,
2123            MouseTracking::Motion,
2124            "the later level replaces the earlier one"
2125        );
2126        g.feed(b"\x1b[?1003l");
2127        assert_eq!(g.modes().mouse, MouseTracking::Off);
2128    }
2129
2130    #[test]
2131    fn alt_screen_is_reported_as_a_mode() {
2132        let mut g = PaneGrid::new(80, 24);
2133        assert!(!g.modes().alt_screen.enabled());
2134        g.feed(b"\x1b[?1049h");
2135        assert!(g.modes().alt_screen.enabled());
2136        g.feed(b"\x1b[?1049l");
2137        assert!(!g.modes().alt_screen.enabled());
2138    }
2139}
2140
2141#[cfg(test)]
2142mod host_role_rows {
2143    use super::*;
2144
2145    /// ★ THE LOAD-BEARING ROW. Landing the response path must change
2146    /// nothing today, because mado is still parsing — if tear answered now,
2147    /// the child would get TWO replies and the second lands on the PTY as
2148    /// if the operator had typed `^[[24;80R`.
2149    #[test]
2150    fn a_relay_answers_nothing_at_all() {
2151        let mut g = PaneGrid::new(80, 24);
2152        // Every query tear knows how to answer, at once.
2153        g.feed(b"\x1b[6n\x1b[5n\x1b[c\x1b[>c");
2154        assert!(
2155            g.take_response().is_none(),
2156            "a Relay must stay byte-for-byte silent — otherwise the shipped \
2157             mado+tear pair produces two answers per query"
2158        );
2159    }
2160
2161    #[test]
2162    fn a_host_answers_cursor_position_one_based() {
2163        let mut g = PaneGrid::new(80, 24);
2164        g.set_host_role(HostRole::Host);
2165        g.feed(b"hi\r\n");
2166        g.feed(b"\x1b[6n");
2167        let r = g.take_response().expect("host must answer CPR");
2168        // row 2, col 1 — 1-based, after one linefeed and a carriage return.
2169        assert_eq!(r, b"\x1b[2;1R".to_vec());
2170    }
2171
2172    /// CPR reports the CLAMPED column, which is what ties this to the
2173    /// width work: a cursor parked on a wide glyph's lead rather than the
2174    /// last column would under-report here.
2175    #[test]
2176    fn a_host_reports_the_clamped_column_after_a_margin_flush_wide_glyph() {
2177        let mut g = PaneGrid::new(20, 3);
2178        g.set_host_role(HostRole::Host);
2179        g.feed("A".repeat(18).as_bytes());
2180        g.feed("你".as_bytes());
2181        g.feed(b"\x1b[6n");
2182        let r = g.take_response().expect("host must answer CPR");
2183        assert_eq!(r, b"\x1b[1;20R".to_vec(), "column is 1-based and clamped");
2184    }
2185
2186    #[test]
2187    fn a_host_answers_device_status_and_both_device_attributes() {
2188        let mut g = PaneGrid::new(80, 24);
2189        g.set_host_role(HostRole::Host);
2190
2191        g.feed(b"\x1b[5n");
2192        assert_eq!(g.take_response().unwrap(), TearCaps::STATUS_OK.to_vec());
2193
2194        g.feed(b"\x1b[c");
2195        assert_eq!(g.take_response().unwrap(), TearCaps::PRIMARY_DA.to_vec());
2196
2197        // `CSI > c` is a DIFFERENT query sharing a final byte with `CSI c`.
2198        // Dispatching on the final byte alone would answer the wrong one.
2199        g.feed(b"\x1b[>c");
2200        assert_eq!(g.take_response().unwrap(), TearCaps::SECONDARY_DA.to_vec());
2201    }
2202
2203    /// A reply is owed to the CHILD, not painted on the screen. If a query
2204    /// smeared into the grid the operator would see `[24;80R` in their
2205    /// output — the residue row espelho's conformance test also guards.
2206    #[test]
2207    fn a_query_leaves_no_residue_in_the_rendered_grid() {
2208        for role in [HostRole::Relay, HostRole::Host] {
2209            let mut g = PaneGrid::new(80, 24);
2210            g.set_host_role(role);
2211            g.feed(b"before|");
2212            g.feed(b"\x1b[6n");
2213            g.feed(b"|after");
2214            let row0 = g.snapshot().to_text_rows().into_iter().next().unwrap();
2215            assert_eq!(
2216                row0.trim_end(),
2217                "before||after",
2218                "query bytes must never reach the grid ({role:?})"
2219            );
2220        }
2221    }
2222
2223    #[test]
2224    fn taking_a_response_drains_it() {
2225        let mut g = PaneGrid::new(80, 24);
2226        g.set_host_role(HostRole::Host);
2227        g.feed(b"\x1b[5n");
2228        assert!(g.take_response().is_some());
2229        assert!(g.take_response().is_none(), "a reply is delivered once");
2230    }
2231}
2232
2233/// Measurements, not assertions.
2234///
2235/// `#[ignore]` on purpose: these print timings and would be flaky as
2236/// gates. They exist so a perf claim about this file can be RE-MEASURED
2237/// rather than argued, per the fleet rule that perf decisions come from
2238/// profiled wall-clock and not from reading the code.
2239///
2240/// Run with:
2241/// ```text
2242/// cargo test --release -p tear-core --lib perf_measurements -- --ignored --nocapture
2243/// ```
2244///
2245/// ## Measured 2026-07-31 (aarch64-darwin, release)
2246///
2247/// | scrollback rows | `snapshot()` |
2248/// |---|---|
2249/// | 977 | 127 µs |
2250/// | 9,977 | 2.15 ms |
2251/// | 99,977 | **16.2 ms** |
2252///
2253/// Linear at roughly **0.16 µs/row**, which is the honest consequence of
2254/// `DEFAULT_SCROLLBACK_ROWS = usize::MAX`: the snapshot cost of a
2255/// long-lived pane is unbounded because its history is.
2256///
2257/// **Not currently a defect, and the reason matters.** `snapshot()` is not
2258/// on the per-frame render path — bytes reach a renderer through the
2259/// subscriber stream, and snapshots are taken on session switch, on
2260/// initial subscribe, and for MCP reads. At 100k rows a switch pays ~16 ms
2261/// once, which is perceptible but not a stall.
2262///
2263/// **The lever, if it ever is hot:** bound what a snapshot CARRIES rather
2264/// than what the grid keeps — the visible grid plus N scrollback rows —
2265/// or let `PaneSnapshot` borrow instead of own. Do not shrink the
2266/// scrollback itself; "never lose anything" is a product decision, not an
2267/// accident.
2268///
2269/// This measurement is also what settled `Cell`'s representation: with
2270/// clones this large, keeping `Cell: Copy` makes them a memcpy instead of
2271/// a per-cell branch. See `Cell::combining`.
2272#[cfg(test)]
2273mod perf_measurements {
2274    use super::*;
2275    use std::time::Instant;
2276
2277    /// `snapshot()` clones the entire scrollback, and the default cap is
2278    /// `usize::MAX`. This is the cost that decided `Cell` stays `Copy`;
2279    /// it deserves a number rather than an intuition.
2280    #[test]
2281    #[ignore = "measurement, not an assertion"]
2282    fn snapshot_cost_by_scrollback_depth() {
2283        for rows in [1_000usize, 10_000, 100_000] {
2284            let mut g = PaneGrid::new(80, 24);
2285            for i in 0..rows {
2286                g.feed(format!("line {i} with some ordinary ascii payload\r\n").as_bytes());
2287            }
2288            // Warm, then measure a small batch.
2289            let _ = g.snapshot();
2290            let t = Instant::now();
2291            const N: u32 = 10;
2292            for _ in 0..N {
2293                let s = g.snapshot();
2294                std::hint::black_box(&s);
2295            }
2296            let per = t.elapsed() / N;
2297            let sb = g.snapshot().scrollback.len();
2298            println!("scrollback {sb:>7} rows -> snapshot {per:?} each");
2299        }
2300    }
2301
2302    /// Does the combining table cost anything when nothing uses it? It is
2303    /// cloned wholesale into every snapshot.
2304    #[test]
2305    #[ignore = "measurement, not an assertion"]
2306    fn snapshot_cost_with_and_without_combining_marks() {
2307        let mut plain = PaneGrid::new(80, 24);
2308        let mut marked = PaneGrid::new(80, 24);
2309        for _ in 0..5_000 {
2310            plain.feed(b"plain ascii line here\r\n");
2311            marked.feed("ma\u{301}rked li\u{308}ne he\u{301}re\r\n".as_bytes());
2312        }
2313        for (name, g) in [("plain", &plain), ("marked", &marked)] {
2314            let _ = g.snapshot();
2315            let t = Instant::now();
2316            const N: u32 = 10;
2317            for _ in 0..N {
2318                std::hint::black_box(g.snapshot());
2319            }
2320            let s = g.snapshot();
2321            println!(
2322                "{name:>7}: snapshot {:?} each, combining table {} entries",
2323                t.elapsed() / N,
2324                s.combining.len()
2325            );
2326        }
2327    }
2328}
2329
2330#[cfg(test)]
2331mod tests {
2332    use super::*;
2333    use tear_types::pane_snapshot::{CellAttrs, Color};
2334
2335    #[test]
2336    fn print_plain_text() {
2337        let mut g = PaneGrid::new(10, 3);
2338        g.feed(b"hi");
2339        let snap = g.snapshot();
2340        assert_eq!(snap.cells[0][0].ch, 'h');
2341        assert_eq!(snap.cells[0][1].ch, 'i');
2342        assert_eq!(snap.cursor_row, 0);
2343        assert_eq!(snap.cursor_col, 2);
2344    }
2345
2346    #[test]
2347    fn newline_advances_row() {
2348        let mut g = PaneGrid::new(10, 3);
2349        g.feed(b"hi\r\nworld");
2350        let snap = g.snapshot();
2351        assert_eq!(snap.cells[0][0].ch, 'h');
2352        assert_eq!(snap.cells[1][0].ch, 'w');
2353        assert_eq!(snap.cursor_row, 1);
2354        assert_eq!(snap.cursor_col, 5);
2355    }
2356
2357    #[test]
2358    fn cursor_move_csi_cup() {
2359        let mut g = PaneGrid::new(10, 5);
2360        g.feed(b"\x1b[3;5H");
2361        let snap = g.snapshot();
2362        assert_eq!(snap.cursor_row, 2);
2363        assert_eq!(snap.cursor_col, 4);
2364    }
2365
2366    #[test]
2367    fn erase_in_display_clear_all() {
2368        let mut g = PaneGrid::new(5, 2);
2369        g.feed(b"abcde\r\nfghij");
2370        g.feed(b"\x1b[2J");
2371        let snap = g.snapshot();
2372        for row in snap.cells {
2373            for cell in row {
2374                assert_eq!(cell.ch, ' ');
2375            }
2376        }
2377    }
2378
2379    #[test]
2380    fn auto_wrap_overflows_to_next_row() {
2381        let mut g = PaneGrid::new(3, 3);
2382        g.feed(b"abcdef");
2383        let snap = g.snapshot();
2384        assert_eq!(snap.cells[0][2].ch, 'c');
2385        assert_eq!(snap.cells[1][0].ch, 'd');
2386    }
2387
2388    #[test]
2389    fn scroll_into_scrollback_on_overflow() {
2390        let mut g = PaneGrid::with_scrollback(3, 2, 100);
2391        g.feed(b"a\r\nb\r\nc");
2392        let snap = g.snapshot();
2393        // First row scrolled off; "b" is on row 0, "c" on row 1.
2394        assert_eq!(snap.cells[0][0].ch, 'b');
2395        assert_eq!(snap.cells[1][0].ch, 'c');
2396        assert!(g.scrollback_len() >= 1);
2397    }
2398
2399    #[test]
2400    fn sgr_red_foreground_sticks_through_a_word() {
2401        let mut g = PaneGrid::new(10, 1);
2402        g.feed(b"\x1b[31mRED\x1b[0m");
2403        let snap = g.snapshot();
2404        let red = tear_types::pane_snapshot::ANSI_COLORS[1];
2405        assert_eq!(snap.cells[0][0].ch, 'R');
2406        assert_eq!(snap.cells[0][0].fg, red);
2407        assert_eq!(snap.cells[0][1].fg, red);
2408        assert_eq!(snap.cells[0][2].fg, red);
2409    }
2410
2411    #[test]
2412    fn sgr_truecolor_fg() {
2413        let mut g = PaneGrid::new(10, 1);
2414        g.feed(b"\x1b[38;2;200;100;50mORANGE");
2415        let snap = g.snapshot();
2416        assert_eq!(snap.cells[0][0].fg, Color::new(200, 100, 50));
2417        assert_eq!(snap.cells[0][5].fg, Color::new(200, 100, 50));
2418    }
2419
2420    #[test]
2421    fn sgr_256_color_index() {
2422        let mut g = PaneGrid::new(10, 1);
2423        g.feed(b"\x1b[38;5;196mX");
2424        let snap = g.snapshot();
2425        // 196 in the 256-palette = bright red-ish (R idx 5 G 0 B 0)
2426        assert!(snap.cells[0][0].fg.r > 200);
2427    }
2428
2429    #[test]
2430    fn sgr_bold_attr_sticks() {
2431        let mut g = PaneGrid::new(10, 1);
2432        g.feed(b"\x1b[1mBOLD");
2433        let snap = g.snapshot();
2434        assert!(snap.cells[0][0].attrs.contains(CellAttrs::BOLD));
2435    }
2436
2437    /// No realistic SGR form may leave UNDERLINE stuck on the pen — the
2438    /// "everything is underlined" regression class. Each case writes a
2439    /// marker `X` AFTER an underline-off (or a non-underline) sequence;
2440    /// the marker must not carry UNDERLINE. Covers the legacy `24`
2441    /// reset, `0` reset, the styled `4:N` sub-param forms, `21`, the
2442    /// underline-colour pair `58`/`59`, and both the semicolon and
2443    /// COLON extended-colour spellings (the colon form flattens with an
2444    /// empty colourspace slot, so a mis-consumed component can leak into
2445    /// the SGR walk and land on an attribute code).
2446    #[test]
2447    fn no_sgr_form_leaves_underline_stuck_on_the_pen() {
2448        let cases: &[(&str, &[u8])] = &[
2449            ("4m then 24m", b"\x1b[4mU\x1b[24mX"),
2450            ("4m then 0m", b"\x1b[4mU\x1b[0mX"),
2451            ("4:3m then 4:0m", b"\x1b[4:3mU\x1b[4:0mX"),
2452            ("4:3m then 24m", b"\x1b[4:3mU\x1b[24mX"),
2453            ("21m (double-underline)", b"\x1b[21mX"),
2454            ("58:2::255:0:0 then 59m", b"\x1b[58:2::255:0:0mU\x1b[59mX"),
2455            ("fg truecolor semicolon", b"\x1b[38;2;177;185;249mX"),
2456            ("fg truecolor COLON", b"\x1b[38:2::177:185:249mX"),
2457            ("fg 256 semicolon", b"\x1b[38;5;4mX"),
2458            ("fg 256 COLON", b"\x1b[38:5:4mX"),
2459            ("bold+italic only", b"\x1b[1;3mX"),
2460        ];
2461        let mut leaked = Vec::new();
2462        for (name, bytes) in cases {
2463            let mut g = PaneGrid::new(20, 1);
2464            g.feed(bytes);
2465            let snap = g.snapshot();
2466            // The marker 'X' is the LAST printed cell on row 0.
2467            let marker = snap.cells[0]
2468                .iter()
2469                .rev()
2470                .find(|c| c.ch == 'X')
2471                .expect("marker X present");
2472            if marker.attrs.contains(CellAttrs::UNDERLINE) {
2473                leaked.push(*name);
2474            }
2475        }
2476        assert!(
2477            leaked.is_empty(),
2478            "these SGR forms leave UNDERLINE stuck on the pen: {leaked:?}"
2479        );
2480    }
2481
2482    /// Attrs of the last `X` printed by `seq` + `X`.
2483    fn marker_attrs(seq: &[u8]) -> CellAttrs {
2484        let mut g = PaneGrid::new(20, 1);
2485        let mut buf = seq.to_vec();
2486        buf.push(b'X');
2487        g.feed(&buf);
2488        g.snapshot().cells[0]
2489            .iter()
2490            .rev()
2491            .find(|c| c.ch == 'X')
2492            .expect("marker X present")
2493            .attrs
2494    }
2495
2496    /// THE regression. `CSI > 4 ; 2 m` is xterm's XTMODKEYS
2497    /// (modifyOtherKeys), not SGR — Claude Code emits it at startup.
2498    /// Dispatching on the final byte `m` alone read it as SGR 4 + SGR 2
2499    /// and latched UNDERLINE + DIM onto the pen for the whole session,
2500    /// so every subsequent cell rendered underlined.
2501    #[test]
2502    fn xtmodkeys_is_not_sgr() {
2503        let attrs = marker_attrs(b"\x1b[>4;2m");
2504        assert!(
2505            !attrs.contains(CellAttrs::UNDERLINE),
2506            "CSI >4;2m (XTMODKEYS) must not set UNDERLINE"
2507        );
2508        assert!(
2509            !attrs.contains(CellAttrs::DIM),
2510            "CSI >4;2m (XTMODKEYS) must not set DIM"
2511        );
2512        assert_eq!(attrs, CellAttrs::NONE, "XTMODKEYS must touch no attribute");
2513    }
2514
2515    /// The whole private-parameter namespace, not just the one sequence
2516    /// that bit us. A prefix in 0x3C..=0x3F shares final bytes with the
2517    /// standard sequences; none may execute the standard command. This
2518    /// guards every future `match c` arm from silently giving a private
2519    /// sequence a meaning.
2520    #[test]
2521    fn private_parameter_csi_never_runs_the_standard_command() {
2522        for seq in [
2523            &b"\x1b[>4;2m"[..],
2524            &b"\x1b[>1m"[..],
2525            &b"\x1b[?4m"[..],
2526            &b"\x1b[=4m"[..],
2527            &b"\x1b[<4m"[..],
2528        ] {
2529            assert_eq!(
2530                marker_attrs(seq),
2531                CellAttrs::NONE,
2532                "private CSI {:?} must not act as SGR",
2533                String::from_utf8_lossy(seq),
2534            );
2535        }
2536
2537        for seq in [
2538            &b"\x1b[>5A"[..],
2539            &b"\x1b[>5C"[..],
2540            &b"\x1b[?5G"[..],
2541            &b"\x1b[>2;3H"[..],
2542        ] {
2543            let mut g = PaneGrid::new(20, 3);
2544            g.feed(b"\x1b[H");
2545            g.feed(seq);
2546            let snap = g.snapshot();
2547            assert_eq!(
2548                (snap.cursor_row, snap.cursor_col),
2549                (0, 0),
2550                "private CSI {:?} must not move the cursor",
2551                String::from_utf8_lossy(seq),
2552            );
2553        }
2554
2555        let mut g = PaneGrid::new(20, 1);
2556        g.feed(b"keep\x1b[H\x1b[?2J\x1b[?0K");
2557        let row: String = g.snapshot().cells[0].iter().map(|c| c.ch).collect();
2558        assert!(
2559            row.starts_with("keep"),
2560            "private CSI ?J/?K must not erase; row was {row:?}"
2561        );
2562    }
2563
2564    fn marker_fg(seq: &[u8]) -> Color {
2565        let mut g = PaneGrid::new(20, 1);
2566        let mut buf = seq.to_vec();
2567        buf.push(b'X');
2568        g.feed(&buf);
2569        g.snapshot().cells[0]
2570            .iter()
2571            .rev()
2572            .find(|c| c.ch == 'X')
2573            .expect("marker X present")
2574            .fg
2575    }
2576
2577    /// The two spellings of an extended colour must agree, and neither
2578    /// may leak a component into the attribute walk.
2579    ///
2580    /// The colon form carries a colour-space id in slot 2 (`38:2::r:g:b`,
2581    /// usually empty). Flattening parameters and sub-parameters into one
2582    /// stream read that empty slot as RED: every channel shifted by one
2583    /// and the real blue fell out the end to be executed as an SGR code.
2584    #[test]
2585    fn semicolon_and_colon_extended_colour_agree() {
2586        let cases: &[(&[u8], &[u8], Color)] = &[
2587            (
2588                b"\x1b[38;2;248;248;242m",
2589                b"\x1b[38:2::248:248:242m",
2590                Color::new(248, 248, 242),
2591            ),
2592            (
2593                b"\x1b[38;2;177;185;249m",
2594                b"\x1b[38:2::177:185:249m",
2595                Color::new(177, 185, 249),
2596            ),
2597            // the channel value that used to latch UNDERLINE on
2598            (
2599                b"\x1b[38;2;4;4;4m",
2600                b"\x1b[38:2::4:4:4m",
2601                Color::new(4, 4, 4),
2602            ),
2603        ];
2604        for (semi, colon, want) in cases {
2605            assert_eq!(marker_fg(semi), *want, "semicolon form {semi:?}");
2606            assert_eq!(marker_fg(colon), *want, "COLON form {colon:?}");
2607        }
2608        // the 5-slot colon form (no colour-space id) is also legal
2609        assert_eq!(
2610            marker_fg(b"\x1b[38:2:10:20:30m"),
2611            Color::new(10, 20, 30),
2612            "5-slot colon truecolor"
2613        );
2614    }
2615
2616    /// No extended-colour spelling may leave an attribute behind. This is
2617    /// the generalisation of the XTMODKEYS bug: a directive whose
2618    /// parameters are not fully consumed leaks them into the attribute
2619    /// walk, and a leaked `4` is a permanently underlined session.
2620    #[test]
2621    fn extended_colour_never_leaks_an_attribute() {
2622        let mut leaked = Vec::new();
2623        for seq in [
2624            &b"\x1b[38;2;4;4;4m"[..],
2625            &b"\x1b[38:2::4:4:4m"[..],
2626            &b"\x1b[48;2;4;4;4m"[..],
2627            &b"\x1b[48:2::4:4:4m"[..],
2628            &b"\x1b[38;5;4m"[..],
2629            &b"\x1b[38:5:4m"[..],
2630            &b"\x1b[48;5;4m"[..],
2631            // SGR 58/59 — underline COLOUR. tear has nowhere to store it,
2632            // but it must still be consumed or its components walk.
2633            &b"\x1b[58;5;4m"[..],
2634            &b"\x1b[58;2;4;4;4m"[..],
2635            &b"\x1b[58:2::255:0:0m"[..],
2636            &b"\x1b[59m"[..],
2637            // truncated / malformed forms must not hang or leak
2638            &b"\x1b[38m"[..],
2639            &b"\x1b[38;2m"[..],
2640            &b"\x1b[38;5m"[..],
2641        ] {
2642            if marker_attrs(seq) != CellAttrs::NONE {
2643                leaked.push(String::from_utf8_lossy(seq).replace('\x1b', "ESC"));
2644            }
2645        }
2646        assert!(
2647            leaked.is_empty(),
2648            "these forms leaked an attribute: {leaked:?}"
2649        );
2650    }
2651
2652    /// `4:N` is the styled-underline sub-parameter form. tear stores a
2653    /// boolean, so `4:0` is off and every other style is on — but a
2654    /// flattened walk read `4:3` as SGR 4 THEN SGR 3, turning a curly
2655    /// underline into underline + italic.
2656    #[test]
2657    fn styled_underline_subparams() {
2658        assert!(marker_attrs(b"\x1b[4:3m").contains(CellAttrs::UNDERLINE));
2659        assert!(
2660            !marker_attrs(b"\x1b[4:3m").contains(CellAttrs::ITALIC),
2661            "4:3 is a curly underline, not underline + italic"
2662        );
2663        assert!(!marker_attrs(b"\x1b[4:0m").contains(CellAttrs::UNDERLINE));
2664        assert!(!marker_attrs(b"\x1b[4mU\x1b[4:0m").contains(CellAttrs::UNDERLINE));
2665    }
2666
2667    /// Plain attributes and the legacy palette codes must be untouched by
2668    /// the parameter/sub-parameter split.
2669    #[test]
2670    fn plain_sgr_still_works() {
2671        assert!(marker_attrs(b"\x1b[1m").contains(CellAttrs::BOLD));
2672        assert!(marker_attrs(b"\x1b[3m").contains(CellAttrs::ITALIC));
2673        assert!(marker_attrs(b"\x1b[1;3m").contains(CellAttrs::BOLD));
2674        assert!(marker_attrs(b"\x1b[1;3m").contains(CellAttrs::ITALIC));
2675        assert_eq!(marker_attrs(b"\x1b[1;3m\x1b[0m"), CellAttrs::NONE);
2676        assert_eq!(
2677            marker_attrs(b"\x1b[1m\x1b[m"),
2678            CellAttrs::NONE,
2679            "bare ESC[m resets"
2680        );
2681        // 31 = red from the palette; 39 = default fg
2682        assert_eq!(marker_fg(b"\x1b[31m"), default_ansi_palette()[1]);
2683        assert_eq!(marker_fg(b"\x1b[31m\x1b[39m"), Color::WHITE);
2684    }
2685
2686    /// The `?`-private modes we DO implement must keep working — the
2687    /// namespace split must not throw them out with the rest.
2688    #[test]
2689    fn dec_private_modes_still_dispatch() {
2690        let mut g = PaneGrid::new(20, 2);
2691        g.feed(b"\x1b[?25l");
2692        assert!(
2693            !g.snapshot().cursor_visible,
2694            "DECTCEM reset must hide cursor"
2695        );
2696        g.feed(b"\x1b[?25h");
2697        assert!(g.snapshot().cursor_visible, "DECTCEM set must show cursor");
2698    }
2699
2700    #[test]
2701    fn sgr_reset_returns_default_pen() {
2702        let mut g = PaneGrid::new(10, 1);
2703        g.feed(b"\x1b[31m\x1b[0mX");
2704        let snap = g.snapshot();
2705        assert_eq!(snap.cells[0][0].fg, Color::WHITE);
2706    }
2707
2708    #[test]
2709    fn alt_screen_isolates_writes_and_preserves_primary() {
2710        let mut g = PaneGrid::new(5, 2);
2711        g.feed(b"AAAAA\r\nBBBBB");
2712        // Enter alt-screen via DEC mode 1049.
2713        g.feed(b"\x1b[?1049h");
2714        // Should be on a cleared alt buffer.
2715        let alt_snap = g.snapshot();
2716        assert!(alt_snap.alt_screen_active);
2717        assert_eq!(alt_snap.cells[0][0].ch, ' ');
2718        // Write something on alt.
2719        g.feed(b"ZZZZZ");
2720        // Leave alt-screen — primary should still hold AAAAA / BBBBB.
2721        g.feed(b"\x1b[?1049l");
2722        let primary_snap = g.snapshot();
2723        assert!(!primary_snap.alt_screen_active);
2724        assert_eq!(primary_snap.cells[0][0].ch, 'A');
2725        assert_eq!(primary_snap.cells[1][0].ch, 'B');
2726    }
2727
2728    #[test]
2729    fn save_restore_cursor_via_decsc_decrc() {
2730        let mut g = PaneGrid::new(10, 5);
2731        g.feed(b"\x1b[3;5H");
2732        g.feed(b"\x1b7"); // DECSC
2733        g.feed(b"\x1b[1;1H");
2734        g.feed(b"\x1b8"); // DECRC — restore
2735        let snap = g.snapshot();
2736        assert_eq!(snap.cursor_row, 2);
2737        assert_eq!(snap.cursor_col, 4);
2738    }
2739
2740    #[test]
2741    fn snapshot_text_helpers() {
2742        let mut g = PaneGrid::new(5, 2);
2743        g.feed(b"hi\r\nbye");
2744        let snap = g.snapshot();
2745        let rows = snap.to_text_rows();
2746        assert_eq!(rows[0], "hi   ");
2747        assert_eq!(rows[1], "bye  ");
2748    }
2749
2750    #[test]
2751    fn osc_2_sets_window_title() {
2752        let mut g = PaneGrid::new(10, 1);
2753        g.feed(b"\x1b]2;hello world\x07");
2754        assert_eq!(g.title(), Some("hello world"));
2755        let snap = g.snapshot();
2756        assert_eq!(snap.title.as_deref(), Some("hello world"));
2757    }
2758
2759    #[test]
2760    fn dec_25_hides_cursor() {
2761        let mut g = PaneGrid::new(10, 1);
2762        let snap_before = g.snapshot();
2763        assert!(snap_before.cursor_visible);
2764        g.feed(b"\x1b[?25l");
2765        let snap_hidden = g.snapshot();
2766        assert!(!snap_hidden.cursor_visible);
2767        g.feed(b"\x1b[?25h");
2768        let snap_back = g.snapshot();
2769        assert!(snap_back.cursor_visible);
2770    }
2771
2772    #[test]
2773    fn ich_inserts_cells_and_shifts_right() {
2774        let mut g = PaneGrid::new(6, 1);
2775        g.feed(b"abcdef");
2776        g.feed(b"\x1b[1;1H"); // cursor to (0,0)
2777        g.feed(b"\x1b[2@"); // ICH 2 — insert 2 blanks at cursor
2778        let snap = g.snapshot();
2779        assert_eq!(snap.cells[0][0].ch, ' ');
2780        assert_eq!(snap.cells[0][1].ch, ' ');
2781        assert_eq!(snap.cells[0][2].ch, 'a');
2782        assert_eq!(snap.cells[0][3].ch, 'b');
2783    }
2784
2785    #[test]
2786    fn dch_deletes_cells_and_shifts_left() {
2787        let mut g = PaneGrid::new(6, 1);
2788        g.feed(b"abcdef");
2789        g.feed(b"\x1b[1;2H"); // cursor to (0,1) — on 'b'
2790        g.feed(b"\x1b[2P"); // DCH 2
2791        let snap = g.snapshot();
2792        assert_eq!(snap.cells[0][0].ch, 'a');
2793        assert_eq!(snap.cells[0][1].ch, 'd');
2794        assert_eq!(snap.cells[0][2].ch, 'e');
2795        assert_eq!(snap.cells[0][3].ch, 'f');
2796    }
2797
2798    #[test]
2799    fn ech_erases_in_place() {
2800        let mut g = PaneGrid::new(6, 1);
2801        g.feed(b"abcdef");
2802        g.feed(b"\x1b[1;2H");
2803        g.feed(b"\x1b[2X"); // ECH 2 — erase 2 cells in place
2804        let snap = g.snapshot();
2805        assert_eq!(snap.cells[0][0].ch, 'a');
2806        assert_eq!(snap.cells[0][1].ch, ' ');
2807        assert_eq!(snap.cells[0][2].ch, ' ');
2808        assert_eq!(snap.cells[0][3].ch, 'd');
2809    }
2810
2811    #[test]
2812    fn il_dl_insert_delete_line() {
2813        let mut g = PaneGrid::new(3, 4);
2814        g.feed(b"AAA\r\nBBB\r\nCCC\r\nDDD");
2815        g.feed(b"\x1b[2;1H"); // cursor to row 2
2816        g.feed(b"\x1b[1L"); // IL 1 — insert blank line above
2817        let snap1 = g.snapshot();
2818        // After IL: row 0 unchanged (AAA), row 1 blank, then BBB, CCC.
2819        // DDD pushed off the bottom of region.
2820        assert_eq!(snap1.cells[0][0].ch, 'A');
2821        assert_eq!(snap1.cells[1][0].ch, ' ');
2822        assert_eq!(snap1.cells[2][0].ch, 'B');
2823        // DL the inserted blank.
2824        g.feed(b"\x1b[1M"); // DL 1
2825        let snap2 = g.snapshot();
2826        assert_eq!(snap2.cells[1][0].ch, 'B');
2827    }
2828
2829    #[test]
2830    fn rep_repeats_last_printable_char() {
2831        let mut g = PaneGrid::new(10, 1);
2832        g.feed(b"X\x1b[5b"); // print X, then REP 5
2833        let snap = g.snapshot();
2834        for c in 0..6 {
2835            assert_eq!(snap.cells[0][c].ch, 'X', "col {c}");
2836        }
2837    }
2838
2839    #[test]
2840    fn irm_inserts_on_print() {
2841        let mut g = PaneGrid::new(6, 1);
2842        g.feed(b"abcdef");
2843        g.feed(b"\x1b[1;1H"); // cursor home
2844        g.feed(b"\x1b[4hZ"); // SM 4 (IRM on), then print Z
2845        let snap = g.snapshot();
2846        assert_eq!(snap.cells[0][0].ch, 'Z');
2847        assert_eq!(snap.cells[0][1].ch, 'a');
2848        assert_eq!(snap.cells[0][2].ch, 'b');
2849    }
2850
2851    #[test]
2852    fn ri_scrolls_down_at_top_of_region() {
2853        let mut g = PaneGrid::new(3, 3);
2854        g.feed(b"a\r\nb\r\nc"); // 3 lines
2855        g.feed(b"\x1b[1;1H"); // cursor to top
2856        g.feed(b"\x1bM"); // RI — should insert blank row at top
2857        let snap = g.snapshot();
2858        assert_eq!(snap.cells[0][0].ch, ' ');
2859        assert_eq!(snap.cells[1][0].ch, 'a');
2860    }
2861
2862    #[test]
2863    fn resize_preserves_top_left_content() {
2864        let mut g = PaneGrid::new(5, 3);
2865        g.feed(b"HELLO\r\nWORLD\r\nTHERE");
2866        // Shrink to 4x2 — top-left HELLO[0..4] + WORLD[0..4] survive.
2867        g.resize(4, 2);
2868        let snap = g.snapshot();
2869        assert_eq!(snap.cols, 4);
2870        assert_eq!(snap.rows, 2);
2871        assert_eq!(snap.cells[0][0].ch, 'H');
2872        assert_eq!(snap.cells[0][3].ch, 'L');
2873        assert_eq!(snap.cells[1][0].ch, 'W');
2874        // Cursor was at (2, 5) before shrink — should clamp to (1, 3).
2875        assert_eq!(snap.cursor_row, 1);
2876        assert_eq!(snap.cursor_col, 3);
2877    }
2878
2879    #[test]
2880    fn resize_grow_pads_with_blanks() {
2881        let mut g = PaneGrid::new(3, 2);
2882        g.feed(b"AB\r\nCD");
2883        g.resize(5, 4);
2884        let snap = g.snapshot();
2885        assert_eq!(snap.cols, 5);
2886        assert_eq!(snap.rows, 4);
2887        assert_eq!(snap.cells[0][0].ch, 'A');
2888        assert_eq!(snap.cells[0][3].ch, ' ');
2889        assert_eq!(snap.cells[2][0].ch, ' ');
2890    }
2891
2892    #[test]
2893    fn scrollback_caps_at_configured_size() {
2894        let mut g = PaneGrid::with_scrollback(3, 2, 3);
2895        // Push 10 lines through; scrollback should cap at 3.
2896        for i in 0..10u8 {
2897            g.feed(&[b'a' + i, b'\r', b'\n']);
2898        }
2899        assert!(g.scrollback_len() <= 3);
2900    }
2901
2902    // ── SGR / wire-format edge cases ──────────────────────────
2903
2904    #[test]
2905    fn sgr_truecolor_with_missing_params_does_not_panic() {
2906        // Only 2 of the 5 expected params for 38;2;R;G;B.
2907        let mut g = PaneGrid::new(5, 1);
2908        g.feed(b"\x1b[38;2;200mX");
2909        // Should not panic; pen unchanged or defaults.
2910        let snap = g.snapshot();
2911        assert_eq!(snap.cells[0][0].ch, 'X');
2912    }
2913
2914    #[test]
2915    fn sgr_256_with_missing_index_does_not_panic() {
2916        let mut g = PaneGrid::new(5, 1);
2917        g.feed(b"\x1b[38;5mX"); // missing index
2918        let snap = g.snapshot();
2919        assert_eq!(snap.cells[0][0].ch, 'X');
2920    }
2921
2922    #[test]
2923    fn sgr_unknown_param_is_ignored() {
2924        let mut g = PaneGrid::new(5, 1);
2925        g.feed(b"\x1b[999mX");
2926        let snap = g.snapshot();
2927        assert_eq!(snap.cells[0][0].ch, 'X');
2928        // Pen stays at default (no SGR 999 → fg unchanged).
2929        assert_eq!(snap.cells[0][0].fg, Color::WHITE);
2930    }
2931
2932    #[test]
2933    fn sgr_empty_params_resets() {
2934        let mut g = PaneGrid::new(5, 1);
2935        g.feed(b"\x1b[31m"); // set red
2936        g.feed(b"\x1b[m"); // empty params = reset
2937        g.feed(b"X");
2938        let snap = g.snapshot();
2939        assert_eq!(snap.cells[0][0].fg, Color::WHITE);
2940    }
2941
2942    #[test]
2943    fn sgr_bright_bg_100_107() {
2944        let mut g = PaneGrid::new(3, 1);
2945        g.feed(b"\x1b[104mX"); // bright blue background
2946        let snap = g.snapshot();
2947        let bright_blue = tear_types::pane_snapshot::ANSI_BRIGHT_COLORS[4];
2948        assert_eq!(snap.cells[0][0].bg, bright_blue);
2949    }
2950
2951    #[test]
2952    fn sgr_disable_attrs_21_to_29() {
2953        let mut g = PaneGrid::new(3, 1);
2954        g.feed(b"\x1b[1;4;7m"); // bold + underline + inverse
2955        g.feed(b"\x1b[22;24;27m"); // disable each
2956        g.feed(b"X");
2957        let snap = g.snapshot();
2958        assert!(snap.cells[0][0].attrs.is_empty());
2959    }
2960
2961    // ── Erase + edit edge cases ───────────────────────────────
2962
2963    #[test]
2964    fn ech_past_end_of_row_clamps() {
2965        let mut g = PaneGrid::new(3, 1);
2966        g.feed(b"abc");
2967        g.feed(b"\x1b[1;2H"); // cursor to (0, 1)
2968        g.feed(b"\x1b[100X"); // erase 100 cells — clamps to row end
2969        let snap = g.snapshot();
2970        assert_eq!(snap.cells[0][0].ch, 'a');
2971        assert_eq!(snap.cells[0][1].ch, ' ');
2972        assert_eq!(snap.cells[0][2].ch, ' ');
2973    }
2974
2975    #[test]
2976    fn ich_at_end_of_row_no_overflow() {
2977        let mut g = PaneGrid::new(3, 1);
2978        g.feed(b"abc");
2979        g.feed(b"\x1b[1;3H"); // cursor at last col
2980        g.feed(b"\x1b[5@"); // insert 5
2981        let snap = g.snapshot();
2982        // After insert: row truncated to 3 cells; the original 'c'
2983        // was at col 2 and gets pushed off.
2984        assert_eq!(snap.cells[0][0].ch, 'a');
2985        assert_eq!(snap.cells[0][1].ch, 'b');
2986        assert_eq!(snap.cells[0][2].ch, ' ');
2987    }
2988
2989    #[test]
2990    fn dch_more_than_row_clamps() {
2991        let mut g = PaneGrid::new(3, 1);
2992        g.feed(b"abc");
2993        g.feed(b"\x1b[1;1H");
2994        g.feed(b"\x1b[100P"); // delete 100 cells
2995        let snap = g.snapshot();
2996        for c in 0..3 {
2997            assert_eq!(snap.cells[0][c].ch, ' ', "col {c}");
2998        }
2999    }
3000
3001    // ── OSC + title edge cases ────────────────────────────────
3002
3003    #[test]
3004    fn osc_with_no_params_is_dropped() {
3005        let mut g = PaneGrid::new(3, 1);
3006        g.feed(b"\x1b]\x07"); // empty OSC
3007        let snap = g.snapshot();
3008        assert!(snap.title.is_none());
3009    }
3010
3011    #[test]
3012    fn osc_very_long_title_works() {
3013        let mut g = PaneGrid::new(3, 1);
3014        let long_title: String = "x".repeat(1000);
3015        let payload = format!("\x1b]2;{}\x07", long_title);
3016        g.feed(payload.as_bytes());
3017        assert_eq!(g.title().map(str::len), Some(1000));
3018    }
3019
3020    // ── DEC mode interactions ─────────────────────────────────
3021
3022    #[test]
3023    fn dec_1049_save_and_restore_cursor_around_alt_screen() {
3024        let mut g = PaneGrid::new(10, 3);
3025        g.feed(b"AAA\r\nBBB");
3026        // cursor at (1, 3)
3027        g.feed(b"\x1b[?1049h"); // enter alt + save cursor
3028        g.feed(b"\x1b[5;5H"); // move cursor in alt
3029        let alt = g.snapshot();
3030        assert!(alt.alt_screen_active);
3031        // Leave alt — cursor restored to (1, 3).
3032        g.feed(b"\x1b[?1049l");
3033        let back = g.snapshot();
3034        assert!(!back.alt_screen_active);
3035        assert_eq!(back.cursor_row, 1);
3036        assert_eq!(back.cursor_col, 3);
3037        // Primary preserved.
3038        assert_eq!(back.cells[0][0].ch, 'A');
3039        assert_eq!(back.cells[1][0].ch, 'B');
3040    }
3041
3042    #[test]
3043    fn dec_25_cursor_visibility_round_trip() {
3044        let mut g = PaneGrid::new(3, 1);
3045        g.feed(b"\x1b[?25l"); // hide
3046        assert!(!g.snapshot().cursor_visible);
3047        g.feed(b"\x1b[?25h"); // show
3048        assert!(g.snapshot().cursor_visible);
3049        g.feed(b"\x1b[?25l"); // hide again
3050        assert!(!g.snapshot().cursor_visible);
3051    }
3052
3053    // ── Misc robustness ───────────────────────────────────────
3054
3055    #[test]
3056    fn bel_does_not_crash_or_consume_cell() {
3057        let mut g = PaneGrid::new(3, 1);
3058        g.feed(b"A\x07B"); // BEL between two chars
3059        let snap = g.snapshot();
3060        assert_eq!(snap.cells[0][0].ch, 'A');
3061        assert_eq!(snap.cells[0][1].ch, 'B');
3062    }
3063
3064    #[test]
3065    fn tab_aligns_to_next_multiple_of_8() {
3066        let mut g = PaneGrid::new(20, 1);
3067        g.feed(b"\tX"); // tab from col 0 → col 8
3068        let snap = g.snapshot();
3069        assert_eq!(snap.cells[0][8].ch, 'X');
3070    }
3071
3072    #[test]
3073    fn resize_to_zero_clamps_safely() {
3074        let mut g = PaneGrid::new(5, 3);
3075        g.feed(b"hello");
3076        // PaneGrid documents "max(1)" — but the constructor accepts
3077        // 0 cols/rows in theory. Resize to 0 should not panic.
3078        g.resize(0, 0);
3079        let snap = g.snapshot();
3080        // Cursor clamped to 0,0 since rows.saturating_sub(1) = 0.
3081        assert_eq!(snap.cursor_row, 0);
3082        assert_eq!(snap.cursor_col, 0);
3083    }
3084
3085    #[test]
3086    fn ris_resets_pen_and_clears_screen() {
3087        let mut g = PaneGrid::new(5, 2);
3088        g.feed(b"\x1b[31m"); // red pen
3089        g.feed(b"AB\r\nCD");
3090        g.feed(b"\x1bc"); // RIS
3091        let snap = g.snapshot();
3092        for row in snap.cells {
3093            for cell in row {
3094                assert_eq!(cell.ch, ' ');
3095                assert_eq!(cell.fg, Color::WHITE);
3096            }
3097        }
3098        assert_eq!(snap.cursor_row, 0);
3099        assert_eq!(snap.cursor_col, 0);
3100    }
3101
3102    // ── DECCKM (DEC mode 1) ─────────────────────────────────────
3103    //
3104    // Pins the cursor-keys application mode tracking that mado's
3105    // embedded-tear input path reads to encode arrow-key bytes
3106    // correctly. Vim / less / htop / btop / etc. all toggle this
3107    // on alt-screen entry; without correct tracking the editor
3108    // sees the wrong cursor-key escape sequence and arrow-key
3109    // navigation breaks.
3110
3111    #[test]
3112    fn cursor_keys_mode_defaults_to_false() {
3113        let g = PaneGrid::new(5, 1);
3114        assert!(!g.cursor_keys_mode());
3115        assert!(!g.snapshot().cursor_keys_mode);
3116    }
3117
3118    #[test]
3119    fn decckm_set_via_csi_question_1_h() {
3120        let mut g = PaneGrid::new(5, 1);
3121        g.feed(b"\x1b[?1h"); // DECCKM set
3122        assert!(g.cursor_keys_mode());
3123        assert!(g.snapshot().cursor_keys_mode);
3124    }
3125
3126    #[test]
3127    fn decckm_reset_via_csi_question_1_l() {
3128        let mut g = PaneGrid::new(5, 1);
3129        g.feed(b"\x1b[?1h"); // set
3130        g.feed(b"\x1b[?1l"); // reset
3131        assert!(!g.cursor_keys_mode());
3132        assert!(!g.snapshot().cursor_keys_mode);
3133    }
3134
3135    #[test]
3136    fn decckm_survives_unrelated_modes() {
3137        let mut g = PaneGrid::new(5, 1);
3138        g.feed(b"\x1b[?1h"); // DECCKM set
3139        g.feed(b"\x1b[?25l"); // hide cursor (mode 25)
3140        g.feed(b"\x1b[?1049h"); // enter alt-screen (mode 1049)
3141        assert!(
3142            g.cursor_keys_mode(),
3143            "DECCKM must persist across cursor-visibility + alt-screen toggles"
3144        );
3145    }
3146
3147    #[test]
3148    fn ris_resets_cursor_keys_mode() {
3149        let mut g = PaneGrid::new(5, 1);
3150        g.feed(b"\x1b[?1h"); // DECCKM set
3151        assert!(g.cursor_keys_mode());
3152        g.feed(b"\x1bc"); // RIS
3153        assert!(
3154            !g.cursor_keys_mode(),
3155            "RIS must reset DECCKM to normal mode"
3156        );
3157    }
3158
3159    #[test]
3160    fn decckm_multi_param_csi() {
3161        // Some shells set multiple modes in one CSI: `CSI ? 1 ; 25 h`.
3162        // Both must apply.
3163        let mut g = PaneGrid::new(5, 1);
3164        g.feed(b"\x1b[?25l"); // hide first to verify mode 25 is in fact off
3165        g.feed(b"\x1b[?1;25h"); // set DECCKM + DECTCEM
3166        assert!(g.cursor_keys_mode());
3167        assert!(g.snapshot().cursor_visible);
3168    }
3169}
3170
3171#[cfg(test)]
3172mod proptests {
3173    use super::*;
3174    use proptest::prelude::*;
3175
3176    proptest! {
3177        /// No matter what bytes we feed (printable, control, malformed
3178        /// escapes, anything), PaneGrid never panics + the cursor stays
3179        /// inside the grid + the snapshot has the right dimensions.
3180        #[test]
3181        fn random_bytes_never_panic_and_cursor_stays_in_bounds(
3182            cols in 1usize..=80,
3183            rows in 1usize..=24,
3184            bytes in proptest::collection::vec(any::<u8>(), 0..2048),
3185        ) {
3186            let mut g = PaneGrid::new(cols, rows);
3187            g.feed(&bytes);
3188            let snap = g.snapshot();
3189            prop_assert_eq!(snap.cols, cols);
3190            prop_assert_eq!(snap.rows, rows);
3191            prop_assert_eq!(snap.cells.len(), rows);
3192            for row in &snap.cells {
3193                prop_assert_eq!(row.len(), cols);
3194            }
3195            prop_assert!(snap.cursor_row < rows.max(1));
3196            prop_assert!(snap.cursor_col < cols.max(1));
3197        }
3198
3199        /// Plain ASCII printable runs advance the cursor by exactly
3200        /// `min(len, capacity)` cells, accounting for wrap.
3201        #[test]
3202        fn printable_ascii_runs_fill_cells_in_order(
3203            text in r"[A-Za-z0-9 ]{1,40}",
3204        ) {
3205            let mut g = PaneGrid::new(40, 3);
3206            g.feed(text.as_bytes());
3207            let snap = g.snapshot();
3208            for (i, c) in text.chars().enumerate() {
3209                if i < snap.cols {
3210                    prop_assert_eq!(snap.cells[0][i].ch, c);
3211                }
3212            }
3213        }
3214
3215        /// Snapshot text dimensions always match snapshot.cols × rows.
3216        #[test]
3217        fn snapshot_text_dimensions_match(
3218            cols in 1usize..=120,
3219            rows in 1usize..=40,
3220            bytes in proptest::collection::vec(any::<u8>(), 0..1024),
3221        ) {
3222            let mut g = PaneGrid::new(cols, rows);
3223            g.feed(&bytes);
3224            let snap = g.snapshot();
3225            let text_rows = snap.to_text_rows();
3226            prop_assert_eq!(text_rows.len(), rows);
3227            for row in &text_rows {
3228                // chars().count() because some control codes (BEL etc.)
3229                // never reach print so the rows stay exactly cols wide.
3230                prop_assert_eq!(row.chars().count(), cols);
3231            }
3232        }
3233
3234        /// Resize never panics + cursor is in bounds afterwards.
3235        #[test]
3236        fn resize_keeps_cursor_in_bounds(
3237            cols1 in 1usize..=60,
3238            rows1 in 1usize..=20,
3239            cols2 in 1usize..=60,
3240            rows2 in 1usize..=20,
3241            bytes in proptest::collection::vec(any::<u8>(), 0..512),
3242        ) {
3243            let mut g = PaneGrid::new(cols1, rows1);
3244            g.feed(&bytes);
3245            g.resize(cols2, rows2);
3246            let snap = g.snapshot();
3247            prop_assert_eq!(snap.cols, cols2);
3248            prop_assert_eq!(snap.rows, rows2);
3249            prop_assert!(snap.cursor_row < rows2.max(1));
3250            prop_assert!(snap.cursor_col < cols2.max(1));
3251        }
3252    }
3253}