Skip to main content

justerm_core/
term.rs

1//! The terminal state model: a `vte::Perform` that maps parsed VT actions onto
2//! the grid, cursor, and pen. This is where the "hidden VT state" lives —
3//! pending-wrap, the wide-char spacer, and the pen (BCE seam).
4
5use std::collections::VecDeque;
6
7use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
8use vte::{Params, Perform};
9
10use crate::cell::{Cell, CellFlags};
11use crate::color::Color;
12use crate::cursor::{Cursor, CursorShape, Pen};
13use crate::damage::{LineBounds, LineDamage, ScrollOp, TermDamage};
14use crate::event::TermEvent;
15use crate::grid::{ExtAttrs, Grid, Row};
16use crate::input::{
17    KeyEvent, MouseEncoding, MouseEvent, MouseProtocol, encode_focus, encode_key, encode_mouse,
18    encode_paste,
19};
20use crate::search::Match;
21use crate::selection::{BufferPoint, Selection};
22use crate::serialize::{Frame, FrameKind, MAX_SCROLL_COUNT, MarkerId, MarkerKind, Overlay, Span};
23
24/// Buffer-walk primitives shared by every read surface (#585). A child module, so
25/// it reaches `Term`'s private fields directly — no field is widened for it.
26mod walk;
27
28/// The search query surface (#586) — finding matches, and the highlight set the
29/// consumer pushes back. Stands on `walk`, whose `pub(super)` reaches a sibling
30/// module because both are descendants of `term`.
31mod search;
32
33/// The selection surface (#587) — gestures, the anchor fixups the write path drives,
34/// and text extraction. Stands on `walk` like its siblings.
35mod selection;
36
37/// The decoration-marker surface (#588) — marks anchored to absolute buffer lines, the
38/// OSC 133 command queries over them, and the anchor fixups the write path drives.
39mod markers;
40
41/// Viewport logical lines (#601) — the soft-wrap-joined text a consumer needs for URL
42/// detection and the a11y mirror. The last read surface to leave this file under #584.
43mod logical;
44
45/// Tracked points (#691) — absolute positions the engine keeps on their content for a
46/// holder that lives outside it, and the anchor fixups the write path drives.
47mod tracked;
48
49/// Owns the authoritative screen state and applies VT actions to it.
50pub struct Term {
51    grid: Grid,
52    /// The inactive screen. Swapped with `grid` on alt-screen enter/leave; holds
53    /// whichever of primary/alternate is not currently shown. The alt screen has
54    /// no scrollback (#3 only rings the primary).
55    alt_grid: Grid,
56    cursor: Cursor,
57    /// Cursor saved on alt-screen enter (DEC 1049), restored on leave.
58    saved_cursor: Cursor,
59    /// Whether the alternate screen is currently active. Guards enter/leave so a
60    /// double-enter or double-leave is a no-op.
61    on_alt: bool,
62    /// One flag per column: is there a tab stop here? Explicit per-column state
63    /// (HTS sets, TBC clears), not a fixed modulo. Default = every 8th column.
64    tabs: Vec<bool>,
65    /// Which characters end a word for Word (semantic) selection — **consumer policy**,
66    /// not engine state (ADR-0017). Defaults to [`DEFAULT_WORD_SEPARATORS`]; replaced
67    /// through `set_word_separators`, which is also where the `' '` floor is enforced.
68    ///
69    /// Being *policy* is what puts it on the short list `full_reset` carries across RIS,
70    /// beside `replies` and `events`: `ESC c` resets the terminal's state, and a
71    /// consumer's configuration is not that. All three references survive RIS here by
72    /// construction rather than by remembering to — alacritty's `reset_state` never
73    /// touches `self.config`, xterm.js holds it in `OptionsService`, and ghostty passes
74    /// the set in per call.
75    word_separators: String,
76    /// Origin mode (DECOM ?6): when set, cursor addressing is relative to the
77    /// scroll region's top margin (and clamped to it).
78    origin_mode: bool,
79    /// Autowrap (DECAWM ?7): default on. When off, a glyph past the right margin
80    /// pins the cursor to the last column and overwrites in place instead of
81    /// wrapping to the next line (matches xterm.js) (#63).
82    autowrap: bool,
83    /// Insert mode (IRM, the non-private SM/RM mode 4): default off (replace).
84    /// When on, a printed glyph shifts the row's tail right first (#64).
85    insert_mode: bool,
86    /// New-line mode (LNM, the non-private SM/RM mode 20): default off. When on,
87    /// a line feed also carriage-returns (`convertEol`). Output-only — the Enter
88    /// key still encodes CR, matching xterm.js (#71).
89    newline_mode: bool,
90    /// Reverse wraparound (DEC ?45): default off. When on, a *backspace* at
91    /// column 0 of a soft-wrapped row moves back to the end of the previous row
92    /// (BS only, soft wraps only — matches xterm.js) (#80).
93    reverse_wraparound: bool,
94    /// Bracketed-paste mode (DEC ?2004). The engine owns the flag; the input
95    /// encoder (#11) reads it to decide whether to wrap pasted text in markers.
96    bracketed_paste: bool,
97    /// Synchronized output (DEC ?2026): the app brackets a frame of output so the
98    /// renderer can paint it atomically. The engine only *tracks* the flag — the
99    /// consumer owns the paint-hold and the spec-mandated timeout (#73).
100    synchronized_output: bool,
101    /// Color-scheme-update notifications (DEC ?2031): the app asked to be told
102    /// when the light/dark scheme changes. The engine is theme-agnostic — it only
103    /// tracks the flag; the consumer (which knows the scheme) drives the ?997
104    /// notification via `report_color_scheme` (#85).
105    color_scheme_updates: bool,
106    /// Grapheme-cluster mode (DEC ?2027, default OFF): the app opted into UAX #29 grapheme-cluster
107    /// width — a ZWJ / skin-tone / flag / emoji+VS16 sequence is clustered into ONE cell instead of
108    /// one cell per scalar (#295). OFF keeps the per-char (wcwidth-compatible) behaviour so the
109    /// cursor stays in sync with wcwidth apps — clustering is opt-in for exactly that reason (#301).
110    grapheme_clustering: bool,
111    /// win32-input-mode (DEC ?9001): the app asked for keys as raw Windows
112    /// key-records. The engine only *tracks* the flag — the raw record encoding
113    /// (`CSI Vk;Sc;Uc;Kd;Cs;Rc _`) is a non-goal (raw passthrough, no semantic
114    /// conversion), left to the ConPTY consumer; `encode_key` is unchanged (#86).
115    win32_input_mode: bool,
116    /// Application cursor keys (DECCKM ?1): when set, cursor keys / Home / End
117    /// encode as SS3 rather than CSI (see `input.rs`).
118    app_cursor_keys: bool,
119    /// Application keypad mode (DECNKM ?66 / DECKPAM `ESC =` / DECKPNM `ESC >`):
120    /// tracked for protocol completeness + DECRQM, but NOT yet acted on in key
121    /// encoding — xterm.js tracks it the same way and never reads it (#74).
122    application_keypad: bool,
123    /// VT52 compatibility mode (DECANM ?2 *reset*): when set, `esc_dispatch` is
124    /// re-routed into the pre-ANSI VT52 dialect (`ESC A`-style sequences) instead
125    /// of the ANSI meaning. `ESC <` clears it. Default off (ANSI). (#84)
126    vt52_mode: bool,
127    /// VT52 `ESC Y row col` direct-addressing state (#84). vte tokenizes `ESC Y`
128    /// as a final and returns to ground, so the two coordinate bytes arrive as
129    /// `print()` calls — not part of the escape sequence. This counts them down
130    /// (2 → 1 → 0; 0 = not addressing) and `vt52_y_row` parks the first (row)
131    /// until the second (col) lands. Each byte decodes as `value - 0x20`.
132    vt52_y_pending: u8,
133    vt52_y_row: usize,
134    /// Mouse tracking mode — what events the app asked to be reported
135    /// (?1000/?1002/?1003). `Off` by default.
136    mouse_protocol: MouseProtocol,
137    /// Mouse coordinate encoding (default X10 vs ?1006 SGR).
138    mouse_encoding: MouseEncoding,
139    /// Focus in/out reporting (?1004): emit `CSI I`/`CSI O` on focus change.
140    focus_events: bool,
141    /// Kitty keyboard-protocol progressive-enhancement flags currently in effect
142    /// (bit0 disambiguate, bit1 report-events, bit2 alt-keys, bit3 all-as-escape,
143    /// bit4 associated-text). 0 = legacy. `encode_key` consults these (#23).
144    kitty_flags: u8,
145    /// Saved `kitty_flags` for the protocol's push/pop stack (`CSI > u` pushes,
146    /// `CSI < u` pops). Capped depth — overflow drops the oldest entry.
147    kitty_stack: Vec<u8>,
148    /// Consumer events (title / bell / cwd) accumulated since the last
149    /// `drain_events` (#12). Pull, not push — see `event.rs`.
150    events: Vec<TermEvent>,
151    /// Outbound reply bytes (DA/DSR/DECRQM query answers, #27) accumulated
152    /// during `feed` for the consumer to write back to the PTY. Raw bytes →
153    /// PTY, kept separate from typed `events` → UI.
154    replies: Vec<u8>,
155    /// The hyperlink currently open (OSC 8 with a URI), stamped onto every glyph
156    /// written until closed (OSC 8 with empty URI). Ambient pen-like state — not
157    /// part of the pen/SGR, and *not* cleared by an SGR reset.
158    ///
159    /// The URI itself, not a pool index — there is no pool (#628). The lead paragraph
160    /// here used to describe one (*"Hyperlink side-table … referenced by `Cell.link`
161    /// (1-based). Append-only (#26)"*), left behind when its field was deleted.
162    current_link: Option<std::sync::Arc<str>>,
163    /// Live OSC 8 `id=` groups: `"id;;uri"` → the allocation that key already named,
164    /// held **weakly** (#635).
165    ///
166    /// `Weak`, not `Arc`, is the whole lifetime story. A strong entry here would make
167    /// every id'd link immortal for the life of the `Term` — precisely the leak #628
168    /// deleted, re-entering through the door grouping opens. A dangling key is the
169    /// correct answer rather than a hole: the link it named has left the buffer, so a
170    /// later open of the same id is genuinely a new link. xterm.js expresses the same
171    /// lifetime by *deleting* its `_entriesWithId` entry when the last line marker
172    /// referencing it is disposed (`OscLinkService.ts:98-100`); justerm has no disposal
173    /// hook by design, and `Weak` is that lifetime without one.
174    link_ids: std::collections::HashMap<String, std::sync::Weak<str>>,
175    /// Map length at which [`Self::link_ids`] is swept for dangling keys, doubling each
176    /// time so the sweep is amortised O(1) per open and dead keys stay O(live).
177    ///
178    /// A sweep is affordable here for the reason #628's rejected option (c) was not:
179    /// staleness is observable in O(1) (`Weak::strong_count`), where (c) had to decide
180    /// "is the pool oversized" by counting live references — the O(buffer) walk it was
181    /// trying to avoid.
182    link_ids_sweep_at: usize,
183    /// Scroll region top/bottom margins (DECSTBM), 0-based inclusive. A
184    /// line-feed at `scroll_bottom` scrolls only rows `[scroll_top..=scroll_bottom]`.
185    /// Default = the full screen.
186    scroll_top: usize,
187    scroll_bottom: usize,
188    /// Lines that have scrolled off the top of the primary screen, oldest at the
189    /// front. Accrues only on a top-anchored, primary-screen scroll.
190    scrollback: VecDeque<Row>,
191    /// How many lines the viewport is scrolled up from the bottom. 0 = following
192    /// the live screen; clamped to `[0, scrollback.len()]`.
193    display_offset: usize,
194    /// Maximum scrollback lines retained; the oldest are evicted past this.
195    scrollback_limit: usize,
196    /// A spare row buffer recycled across full-screen scrolls: the cap-evicted
197    /// oldest line is parked here and reused as the next scroll's blank bottom,
198    /// so a steady-state flood allocates nothing (ADR-0009).
199    recycled_row: Option<Row>,
200    /// Per-line damage bounds since the last `reset_damage` (ack), one per row.
201    line_damage: Vec<LineBounds>,
202    /// A first-class scroll recorded since the last `reset_damage`.
203    scroll: Option<ScrollOp>,
204    /// The whole screen changed (alt switch / clear / later resize+flood) — the
205    /// renderer must redraw everything.
206    full_damage: bool,
207    /// The cursor `(row, col)` at the last `reset_damage` (ack) — where the
208    /// consumer last saw the caret. A pure cursor move records no content
209    /// damage, so `damage()` folds this *old* cell plus the current one into the
210    /// frame; without it a cell-invert caret ghosts at the old spot (mirrors
211    /// Alacritty's `last_cursor`). #38.
212    prev_cursor: (usize, usize),
213    /// The live selection, in absolute buffer coordinates. `None` when nothing
214    /// is selected. See `selection.rs`.
215    selection: Option<Selection>,
216    /// The search highlights the consumer asked to paint (#108). Search
217    /// matches are consumer-owned (it drives next/prev), so the engine holds only
218    /// the set handed back via `set_search_highlights`, and `frame()` projects it
219    /// onto the viewport — the same anchoring path as the selection.
220    search_highlights: Vec<Match>,
221    /// The *active* (current) search match (#428), stored as its absolute span
222    /// (#436) — designated by the consumer (next/prev is its policy) either as
223    /// an index into `search_highlights` (resolved to the span at call time) or
224    /// directly by span, which a capping backend uses for a past-cap match
225    /// (xterm creates its active decoration from the found result, OUTSIDE the
226    /// capped highlight list). A span is NOT structurally tied to the set, so
227    /// every path that voids the set must void this too: `set_search_highlights`
228    /// (hand-over reset, #428) and `invalidate_search_highlights` (the single
229    /// funnel for eviction / every region scroll incl. the accrual sub-region
230    /// branch (#449) / reflow / both alt swaps) — a stale span would otherwise
231    /// keep painting coordinates that now hold other text.
232    ///
233    /// **That list is the *motion* funnel, and it is complete only for motion.** An
234    /// in-place erase or overwrite stales this set too, and deliberately does not
235    /// funnel — read `invalidate_search_highlights`, which owns that decision and its
236    /// grounds. Stated here because this comment enumerating the callers reads as the
237    /// whole rule, and a reader who stops at it concludes the erase verbs were
238    /// forgotten (#750).
239    active_search_highlight: Option<Match>,
240    /// Engine-owned decoration markers (#118), split per buffer like xterm's
241    /// `BufferSet` (#177 S0): each a stable id bound to an absolute buffer line
242    /// that re-anchors through eviction/scroll/reflow like a selection anchor. The
243    /// active buffer's list is selected by `on_alt` — `markers`/`markers_mut`.
244    /// `alt_markers` stays empty while the alt guards (#158/#164) are in place; it
245    /// is disposed on alt-leave (xterm `clearAllMarkers`). `next_marker_id` hands
246    /// out monotonic ids across both buffers so ids never alias.
247    normal_markers: VecDeque<Marker>,
248    alt_markers: VecDeque<Marker>,
249    next_marker_id: u32,
250    /// The basis that keeps a *pulled* marker index valid without re-pulling
251    /// (#490). Both are reported by [`Term::marker_index`] and — from the wire
252    /// slice on — by the frame header, so a consumer can compare what it holds
253    /// against what is current.
254    ///
255    /// `evicted_total` counts lines popped off the front of scrollback since
256    /// startup or RIS. Eviction shifts **every** live marker by the same −1, so
257    /// that whole class of movement is one number rather than M facts, and a
258    /// consumer rebases a held line by the delta.
259    ///
260    /// `marker_epoch` covers everything the delta cannot express: a mutation
261    /// after which a held line is wrong for a reason no single offset repairs.
262    /// It says *"what you pulled no longer describes this buffer"* — not *"a verb
263    /// ran"*, which is why the movers bump it only when a surviving marker's line
264    /// actually moved. Disposal is deliberately **not** a bump: a consumer learns
265    /// of that through `TermEvent::MarkerDisposed` and can drop the entry without
266    /// asking for the rest again.
267    evicted_total: u64,
268    marker_epoch: u32,
269    /// Positions the engine keeps on their content for a holder that lives
270    /// *outside* it (#691). Split per buffer and re-anchored by the same fixups as
271    /// the markers beside them; the difference is that nothing here reaches a
272    /// frame — a tracked point is answered on request, never projected.
273    normal_tracked: Vec<TrackedPoint>,
274    alt_tracked: Vec<TrackedPoint>,
275    next_tracked_id: u32,
276    /// Cursor state saved by DECSC (ESC 7), restored by DECRC (ESC 8). A slot
277    /// separate from `saved_cursor` (which is the alt-screen save). Defaults to
278    /// home/default so a DECRC with no prior DECSC restores a sane state.
279    decsc: SavedCursor,
280    /// SCS-designated character sets G0..G3 (#62). `gl` indexes the active (GL)
281    /// set, switched by SI (→G0) / SO (→G1). First cut uses G0/G1.
282    charsets: [Charset; 4],
283    gl: usize,
284}
285
286/// A character set designated by SCS (#62). First cut: ASCII (default), DEC
287/// Special Graphics (line-drawing), and UK. G2/G3 and the GR half are later.
288#[derive(Clone, Copy, PartialEq, Eq, Default)]
289enum Charset {
290    #[default]
291    Ascii,
292    DecSpecialGraphics,
293    Uk,
294}
295
296impl Charset {
297    /// Map one GL byte (a `char` in the 7-bit range) through this set. ASCII and
298    /// any out-of-range char pass through; UK swaps `#`→£; DEC Special Graphics
299    /// translates `_`..`~` to the line-drawing / symbol glyphs.
300    fn map(self, c: char) -> char {
301        match self {
302            Charset::Ascii => c,
303            Charset::Uk if c == '#' => '£',
304            Charset::Uk => c,
305            Charset::DecSpecialGraphics => dec_special_graphics(c),
306        }
307    }
308}
309
310/// The VT100 DEC Special Graphics set: bytes `_`..`~` (0x5F..0x7E) map to the
311/// box-drawing and symbol glyphs. Matches xterm/alacritty; anything outside the
312/// range passes through unchanged.
313fn dec_special_graphics(c: char) -> char {
314    // Keys ``..`~` only — `_` (0x5F) is deliberately absent, matching xterm.js /
315    // alacritty (it passes through as a literal underscore), not the strict-DEC
316    // "0x5F = blank" reading.
317    match c {
318        '`' => '◆',
319        'a' => '▒',
320        'b' => '␉',
321        'c' => '␌',
322        'd' => '␍',
323        'e' => '␊',
324        'f' => '°',
325        'g' => '±',
326        'h' => '␤',
327        'i' => '␋',
328        'j' => '┘',
329        'k' => '┐',
330        'l' => '┌',
331        'm' => '└',
332        'n' => '┼',
333        'o' => '⎺',
334        'p' => '⎻',
335        'q' => '─',
336        'r' => '⎼',
337        's' => '⎽',
338        't' => '├',
339        'u' => '┤',
340        'v' => '┴',
341        'w' => '┬',
342        'x' => '│',
343        'y' => '≤',
344        'z' => '≥',
345        '{' => 'π',
346        '|' => '≠',
347        '}' => '£',
348        '~' => '·',
349        other => other,
350    }
351}
352
353/// Default scrollback retention when not specified.
354const DEFAULT_SCROLLBACK: usize = 10_000;
355
356/// The narrowest screen the engine represents: **two columns**.
357///
358/// A width-2 glyph occupies a `WIDE_CHAR` lead *and* the `WIDE_CHAR_SPACER` that
359/// stands for its second half, so one column cannot hold one — and a pair with only
360/// one half written is the malformed state every repair path in this crate keys off
361/// (ADR-0025 D4). `Term::with_scrollback` and [`Term::resize`] clamp `cols` up to
362/// this, which is what makes D4 (*both halves of a pair move together*)
363/// unconditionally satisfiable rather than true only above some unstated width.
364///
365/// Both references that a terminal *engine* can be compared to forbid one column for
366/// exactly this reason — alacritty's `MIN_COLUMNS = 2` and xterm.js's
367/// `MINIMUM_COLS = 2` — and the third (ghostty) permits it only by destroying the
368/// glyph.
369///
370/// The clamp is **silent and pull-only**: a `resize(1, rows)` during a pane drag is
371/// widened rather than rejected, and no event reports it. Both references instead
372/// make the clamped size the one that travels outward — alacritty derives its
373/// `WindowSize` from the clamped `SizeInfo`, xterm.js fires `onResize` with the
374/// clamped pair — so a justerm consumer must do that correlation itself: read the
375/// width back from [`Term::grid`] / the frame header and size the PTY from *that*,
376/// never from the value it requested. Sizing a PTY to one column leaves the
377/// application rendering for a width the buffer does not have (#547).
378pub const MIN_COLUMNS: usize = 2;
379
380/// The built-in word-boundary set for Word (semantic) selection — the default value of
381/// [`Term::set_word_separators`], and **policy the consumer may replace** (ADR-0017:
382/// mechanism in core, policy injected).
383///
384/// It is alacritty's `SEMANTIC_ESCAPE_CHARS` (`alacritty_terminal/src/term/mod.rs:45`
385/// @ `852e971`) verbatim — space, tab and a punctuation set that deliberately omits
386/// `.`, `/` and `-` so a path or URL stays one word — **plus U+3000 IDEOGRAPHIC
387/// SPACE**, which is justerm's one divergence from every reference default.
388///
389/// Two properties of this list are load-bearing and neither is obvious:
390///
391/// - **It is a literal set, not the Unicode `White_Space` property.** The predicate was
392///   `char::is_whitespace()` until #545, so every space-like codepoint ended a word —
393///   including the four `Line_Break=GL` (glue) ones U+00A0, U+2007, U+202F and U+205F,
394///   whose whole purpose is "do not break here". A locale-formatted `1<NNBSP>234`
395///   double-clicked as `1`. All three references are literal sets for the same reason.
396/// - **U+3000 is in it, and no reference's default has it.** It is the only
397///   East-Asian-Wide codepoint `White_Space` accepts (measured over U+0000–U+10FFFF),
398///   so on alacritty and xterm.js ` abc` is one word while justerm gives the useful
399///   answer. Keeping it was argued in #535 *on the grounds that core had no injection
400///   point*; that ground is gone, so it survives here as a **default**, and a consumer
401///   who wants reference-exact behaviour removes it.
402///
403/// [`Term::set_word_separators`] additionally forces `' '` into whatever it is given —
404/// see there for why that floor is not optional.
405pub const DEFAULT_WORD_SEPARATORS: &str = ",│`|:\"' ()[]{}<>\t\u{3000}";
406
407/// A declared OSC 8 hyperlink, as handed to a consumer.
408///
409/// **Owned, not borrowed**, and that is the point: the URI lives in a row's side map, so
410/// a `&str` into it would be tied to `&Engine` and a caller could not hold the link
411/// across the next `feed()` — which is precisely what a hover handler does. Measured on
412/// the alternative: reading a borrow costs 0.75 ns, but keeping it *does not compile*, so
413/// the caller copies the string instead at 62.6 ns. Handing back this handle is 17.9 ns
414/// — cheaper than the workaround it removes, on a call made once per hover.
415///
416/// Cloning is a refcount bump; the allocation is shared with every cell of the same OSC 8
417/// open and released when the last row holding it dies (#628).
418///
419/// **A struct rather than a bare `Arc<str>`** for two reasons: it keeps `Arc` out of the
420/// published signature, and OSC 8's `id=` parameter (#635) lands here as a field without
421/// changing the return type again. Same shape as alacritty's `Hyperlink`, for the same
422/// reasons (`alacritty_terminal/src/term/cell.rs`).
423///
424/// **Link *identity* is deliberately not exposed yet.** Two OSC 8 opens of an identical
425/// URI are two links here, so `uri() == uri()` cannot answer "is this cell part of the
426/// same link as that one?" — an `Arc::ptr_eq` accessor would. It is left out because no
427/// consumer asks it today (nothing outside this crate's tests calls `link_at` at all),
428/// and unlike this type's *shape*, adding a method later is not a breaking change. The
429/// asymmetry decides it: shipping an accessor nobody uses is hard to undo, adding one
430/// when a caller appears is free.
431#[derive(Clone, PartialEq, Eq, Debug)]
432pub struct Hyperlink {
433    uri: std::sync::Arc<str>,
434}
435
436impl Hyperlink {
437    pub(crate) fn new(uri: std::sync::Arc<str>) -> Self {
438        Hyperlink { uri }
439    }
440
441    /// The link target, exactly as the application declared it — never validated,
442    /// never resolved. Whether it is a URL a consumer is willing to open is that
443    /// consumer's policy (ADR-0017), the same way colour resolution is.
444    pub fn uri(&self) -> &str {
445        &self.uri
446    }
447}
448
449/// Length at which the `id=` group map is first swept for dangling keys, doubling from
450/// there. Small enough that a session declaring a handful of ids never pays a sweep,
451/// large enough that the sweep is not the common path.
452const LINK_IDS_FIRST_SWEEP: usize = 16;
453
454/// The `id=` value out of an OSC 8 `params` field, or `None` when it is absent or empty.
455///
456/// Three rules, each taken from xterm.js's `_createHyperlink` verbatim rather than from
457/// the spec prose, because each is a place a reasonable reading goes wrong
458/// (`src/common/InputHandler.ts:3128-3131` at the pinned SHA `699f5537b023`):
459///
460/// - **`:`-separated**, not `;` — `params` is one OSC argument holding a key=value list
461///   (`id=xyz123:foo=bar:baz=quux`), so the split is on colons (`params.split(':')`).
462/// - **`id` may sit anywhere in it** (`findIndex(e => e.startsWith('id='))`), so matching
463///   only a leading `id=` is the wrong parse and passes a single-parameter test.
464/// - **an empty value is not an id** (`slice(3) || undefined`). This is the one with teeth:
465///   an empty key would group every `id=`-with-no-value link in a session into one link
466///   across unrelated URIs, and it is a wrong answer that grows with uptime.
467///
468/// Only the first `id=` is consulted, matching `findIndex` — an empty first one yields
469/// `None` rather than searching on for a non-empty sibling.
470fn osc8_link_id(params: &[u8]) -> Option<&[u8]> {
471    params
472        .split(|b| *b == b':')
473        .find_map(|kv| kv.strip_prefix(b"id="))
474        .filter(|value| !value.is_empty())
475}
476
477/// The widest grid the engine will hold, and the mirror of [`MIN_COLUMNS`] — but derived
478/// from a different kind of constraint, which is why the two are not symmetric.
479///
480/// The floor is **semantic**: a width-2 glyph needs two cells, so one column is a screen
481/// no correct grid can be. The ceiling is **representational**: the frame header stores
482/// `cols` and `rows` as `u16` each, so a grid wider than `u16::MAX` cannot be *described*
483/// to a consumer even though the engine could hold it. Without the clamp that mismatch was
484/// silent — measured, `Engine::new(70_000, 2)` built a 70 000-column grid whose frame
485/// declared `cols = 4464` and decoded `Ok`, so a consumer laid out 4464 columns of a
486/// 70 000-column screen with nothing reporting the difference (#621).
487///
488/// No reference bounds a grid this way, and that is expected rather than a divergence:
489/// none of them serializes a grid, so none has a header field to overflow. This is the
490/// one axis where justerm's own wire is the only authority.
491///
492/// **A backstop, not a policy.** A 4K display at a very small font is roughly 550 columns;
493/// this is two orders of magnitude past any real terminal, so it should never be reached
494/// by a consumer that is not already doing something wrong. The clamp is silent and
495/// pull-only on the same terms as [`MIN_COLUMNS`] — read the size back from
496/// [`Term::grid`] rather than trusting the value you passed in.
497pub const MAX_COLUMNS: usize = u16::MAX as usize;
498
499/// The tallest grid the engine will hold. The row half of [`MAX_COLUMNS`] — same
500/// `u16` header field, same reasoning, same silent-clamp contract.
501pub const MAX_ROWS: usize = u16::MAX as usize;
502
503/// The most live markers one buffer will hold (#721).
504///
505/// Derived from the wire the same way [`MAX_COLUMNS`] is: the marker group's count
506/// are `u16`, so a population past `u16::MAX` encodes a wrapped count while writing
507/// every record, and `decode` then reads the next group's count out of the middle of
508/// a marker record and returns `Ok`. The field bounds the value; this constant only
509/// writes that bound down where the value is produced, because `encode` returns
510/// `Vec<u8>` and has no channel to refuse.
511///
512/// **Why a bound is needed at all**, rather than a wider field: markers are allocated
513/// by the *stream*. `add_command_mark` appends per OSC 133 sequence, several marks can
514/// share one line, and scrollback eviction only drops a marker when its line reaches
515/// absolute 0 — so a stream that never emits a newline accumulates marks in a 24-row
516/// buffer without bound (measured: 70 000, #721). [`crate::Engine::feed`] is an untrusted
517/// entry point (ADR-0007), and unbounded allocation behind it is a defect class that
518/// record exists to catch.
519///
520/// **A backstop, not a policy**, on the same terms as [`MAX_COLUMNS`]. Ordinary shell
521/// integration emits at most four marks per command and a command occupies at least one
522/// line, so a default-scrollback session tops out near 40 000 — this is not reached by a
523/// consumer that is not already being fed something hostile. Overflow disposes the
524/// *oldest* marker and announces it through `TermEvent::MarkerDisposed`, which is the
525/// channel scrollback eviction already uses for the same event.
526pub const MAX_MARKERS: usize = u16::MAX as usize;
527
528/// The longest command text an OSC-133 `OutputStart` mark will freeze, in `char`s
529/// (#750). A longer command is captured truncated to this many characters.
530///
531/// **Why a bound at all** is [`MAX_MARKERS`]'s argument one field over: the *stream*
532/// decides the size. The text spans `[B, C)`, and nothing bounds how far apart those
533/// two sequences are — a stream that emits `B`, dumps a full screen and then `C` names
534/// a command as long as the buffer. Re-extracting on demand made that a transient
535/// allocation; freezing it at `C` makes it resident, for as long as the mark lives, and
536/// [`crate::Engine::feed`] is an untrusted entry point (ADR-0007).
537///
538/// **A display bound, not a semantic one.** [`CommandLine::command`]'s consumer
539/// announces it and lists it; a prefix is a usable answer and an absent one is not, so
540/// overflow truncates rather than declining to capture. The truncation is at a `char`
541/// boundary, so the answer is always valid text. No ordinary command reaches it — this
542/// is not a limit a shell user can type into.
543pub const MAX_COMMAND_TEXT: usize = 4096;
544
545/// The state DECSC (ESC 7) saves and DECRC (ESC 8) restores: position, pen/SGR,
546/// pending-wrap, and origin mode (per ADR-0004 — DECRC restores origin mode,
547/// which Alacritty omits). Cursor *visibility* is deliberately not part of this
548/// (DECTCEM is separate from DECSC).
549#[derive(Clone, Copy, Default)]
550struct SavedCursor {
551    row: usize,
552    col: usize,
553    pen: Pen,
554    pending_wrap: bool,
555    origin_mode: bool,
556    /// SCS charset state at save time — DECSC/DECRC round-trip the designated
557    /// sets and the active GL shift (#62).
558    charsets: [Charset; 4],
559    gl: usize,
560}
561
562/// An engine-owned decoration marker (#118): a stable id bound to an absolute
563/// buffer line. The line shifts in lockstep with eviction/region scroll/reflow
564/// (the same coordinate moves the selection anchor tracks); the marker is
565/// dropped when its line leaves the buffer — **or when `ED` blanks the whole row
566/// it stands on** (#750), which is the one death that is not the buffer moving.
567struct Marker {
568    id: MarkerId,
569    line: usize,
570    /// The cursor column at emit time (#166). Meaningful for OSC-133 command
571    /// marks — CommandStart(B)/OutputStart(C) columns bound the *typed command*
572    /// (excluding the prompt), like VSCode's `commandStartX`/`commandExecutedX`.
573    /// Plain `add_marker` decorations are row-granular and carry `col = 0`.
574    ///
575    /// **Domain is `[0, cols]`, not `[0, cols - 1]` (#562)** — a bound, not a cell.
576    /// A command that exactly fills its row ends *one past* the last column, and
577    /// that value is what `extract_lines` wants: it clips `[b_col, c_col)`, so the
578    /// exclusive end absorbs it through `.min(cells.len())`. Storing `cursor.col`
579    /// alone (the cursor is held at `cols - 1` with `pending_wrap`) cost such a
580    /// command its last character with no resize involved. The **inclusive** side
581    /// cannot absorb it, so `extract_lines` steps a `from` of `cells.len()` to the
582    /// next line rather than selecting an empty run and flushing a `\n`.
583    col: usize,
584    /// Plain for a `add_marker` decoration; a command-boundary role for an
585    /// OSC 133 mark (#158). All kinds share the anchor/eviction machinery.
586    kind: MarkerKind,
587    /// What an `OutputStart` mark froze about the command it closes (#750) —
588    /// `None` on every other kind, and the reason this is one boxed pointer rather
589    /// than two inline fields: three marks in four never carry it, and the
590    /// population is bounded at [`MAX_MARKERS`].
591    ///
592    /// It dies with the marker, which is the point: a side table keyed by
593    /// [`MarkerId`] would need its own purge at every disposal site, i.e. exactly
594    /// the missing-destruction-funnel defect this issue is about (ADR-0025 D1 —
595    /// a fact lives with its owner).
596    command: Option<Box<CommandRecord>>,
597}
598
599/// The part of a command that is **not** in the buffer, frozen on its `OutputStart`
600/// mark (#750).
601///
602/// Both fields are recorded at the instant they are first true, and neither can be
603/// recovered afterwards:
604///
605/// - `text` is complete and on screen exactly when `C` arrives. Re-reading it later
606///   through the recorded `[b_col, c_col)` clip names whatever now occupies those
607///   cells — measured for a plain overwrite, ICH, DCH and an erase, and only the last
608///   of those is a verb any mark-lifetime rule could reach.
609/// - `exit` arrives with `D`, one mark later, and lives in no cell at all. Resolving it
610///   at query time meant pairing over *survivors* (`out.last_mut()`), which re-parented
611///   a code onto the previous command as soon as a disposal broke the run. Written here
612///   when `D` is parsed, a disposal can only drop an answer, never move one.
613struct CommandRecord {
614    text: Box<str>,
615    exit: Option<i32>,
616}
617
618/// A stable handle to a tracked buffer position (#691), handed out by
619/// [`Term::track_point`].
620///
621/// It is deliberately **not** a [`MarkerId`]: a marker is a decoration anchor and
622/// rides two frame groups, so every marker a consumer registers is something the
623/// renderer paints. A tracked point is private to whoever asked for it.
624#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
625pub struct TrackedId(pub(crate) u32);
626
627/// One tracked position: an absolute buffer `(line, col)` the write path keeps on
628/// its content (#691). The element type of `normal_tracked` / `alt_tracked`.
629struct TrackedPoint {
630    id: TrackedId,
631    line: usize,
632    col: usize,
633}
634
635/// One live marker, as the pull query reports it (#490): its stable id, its
636/// **absolute** `[scrollback ++ screen]` line, and the static facts a consumer
637/// would otherwise have to re-learn from every frame.
638///
639/// `kind` and `exit` ride here rather than on the frame because they never change
640/// after `push_marker` — re-sending them per frame is the same class of waste as
641/// re-sending the line.
642#[derive(Debug, Clone, PartialEq, Eq)]
643pub struct MarkerEntry {
644    pub id: MarkerId,
645    pub line: u32,
646    pub kind: MarkerKind,
647}
648
649/// The answer to [`Term::marker_index`] (#490) — every live marker of the *active*
650/// buffer, plus the basis that says how long the answer stays usable.
651///
652/// The consumer keeps this and rebases per frame:
653/// `current = line - (evicted_total_now - evicted_total)`, valid for exactly as long
654/// as `epoch` is unchanged. When the epoch moves, the held lines are wrong in a way
655/// no offset repairs and the consumer asks again.
656///
657/// It reports the active buffer because an absolute index means a different thing on
658/// each screen — the same reason `markers`/`markers_mut` route by `on_alt`. An
659/// alt-screen switch therefore bumps the epoch even though no line moved: what the
660/// answer *describes* changed.
661#[derive(Debug, Clone, PartialEq, Eq)]
662pub struct MarkerIndex {
663    pub markers: Vec<MarkerEntry>,
664    pub evicted_total: u64,
665    pub epoch: u32,
666}
667
668/// One executed shell command recovered from OSC-133 marks (#166), for
669/// screen-reader command navigation. The consumer jumps prompt-to-prompt over
670/// these and announces `command` + a success/fail signal from `exit`.
671#[derive(Debug, Clone, PartialEq, Eq)]
672pub struct CommandLine {
673    /// The command's jump anchor as a *document* line — the logical-line index of
674    /// the CommandStart(B) mark within [`Term::accessible_text`], so the consumer
675    /// reveals the right row of the accessible view (soft-wrapped rows collapse to
676    /// one logical line). This is core's analog of VSCode's
677    /// `bufferToEditorLineMapping`; the frame-mode web side has no wrap info to
678    /// map it itself.
679    ///
680    /// **It is an index into a document, so it is only meaningful together with the
681    /// document it indexes** — the one [`Term::accessible_text`] returns *at the same
682    /// instant, on the primary screen*. Neither half is expressible as a number on this
683    /// struct (#743), and they are the two that recur; they are not a proof of
684    /// sufficiency. The hedge was earned: a mark whose row is erased in place also
685    /// answers about content that is gone — on the primary screen, at one instant, and
686    /// a re-ask reproduces it, so neither half below reaches it. That was **#750**, a
687    /// defect in mark *lifetime* rather than in dating, and it is fixed at the
688    /// lifetime: `ED` now retires the marks on each whole row it blanks, and the
689    /// command's text and exit are frozen when the stream reveals them rather than
690    /// re-read from cells (see [`Term::command_lines`]). One residue is deliberate and
691    /// belongs to this field — `EL`/`ECH` retire nothing, so a mark can still name a
692    /// row they blanked, and this line then resolves onto it:
693    ///
694    /// - **the instant.** No scalar this engine publishes dates a document line, and
695    ///   the reason is not one axis but two. Eviction moves it by the number of evicted
696    ///   *line-ends*, which equals the row count except when an evicted row soft-wraps
697    ///   into the next — measured, one eviction took the absolute lines
698    ///   `[12, 12, 12, 13]` → `[11, 11, 11, 12]` while this line stayed at `11`, and the
699    ///   very next eviction moved both. And flipping a row's wrap bit, which ordinary
700    ///   output does, moves this line while the absolute lines, `evicted_total` and
701    ///   `marker_epoch` all stay put — a motion the absolute space does not have.
702    ///   Carrying the instant is therefore *buildable but expensive*: a line-end counter
703    ///   **and** a generation of its own. ADR-0029 defers it (alternative D) and takes
704    ///   the re-ask discharge, which D3 grants this surface on its own merits;
705    /// - **the screen.** The document is `[scrollback ++ primary]`, always. While the
706    ///   alt screen is up [`Term::accessible_text`] returns the *alt* document, and this
707    ///   line indexes the other one. When the alt screen is the taller of the two the
708    ///   index still **resolves**, onto unrelated content — so this is not a bounds
709    ///   problem a caller can check its way out of.
710    ///
711    /// So: ask for both together, keep them together, and re-ask rather than rebase.
712    pub line: usize,
713    /// The typed command text, prompt- and output-excluded (B→C columns).
714    ///
715    /// **Frozen at the `133;C` that closed the command (#750)**, not re-read from the
716    /// cells when you ask. Those cells are not reserved for it: a plain overwrite,
717    /// `ICH`, `DCH` and an erase were each measured making the recorded column range
718    /// name somebody else's content, and only the last of the four is a verb any
719    /// mark-lifetime rule could reach. Bounded at [`MAX_COMMAND_TEXT`] `char`s.
720    pub command: String,
721    /// The CommandFinished(D) exit code, if the shell reported one and the
722    /// command has finished.
723    ///
724    /// **Recorded when `133;D` is parsed (#750)**, onto the mark that closed the
725    /// command — not paired here at query time. It lives in no cell, so nothing on
726    /// screen can reconstruct it, and pairing over *survivors* re-parented a code onto
727    /// the previous command as soon as a disposal broke the run.
728    pub exit: Option<i32>,
729}
730
731/// Collect per-line damage bounds into damaged `LineDamage` spans (undamaged
732/// lines dropped). Shared by `damage` (content-only) and `frame_damage`
733/// (content + cursor cells).
734fn bounds_to_lines(bounds: &[LineBounds]) -> Vec<LineDamage> {
735    bounds
736        .iter()
737        .enumerate()
738        .filter(|(_, b)| b.is_damaged())
739        .map(|(line, b)| {
740            let (left, right) = b.span();
741            LineDamage { line, left, right }
742        })
743        .collect()
744}
745
746impl Term {
747    pub fn new(cols: usize, rows: usize) -> Self {
748        Self::with_scrollback(cols, rows, DEFAULT_SCROLLBACK)
749    }
750
751    pub fn with_scrollback(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
752        // Both clamps mirror `resize` exactly, so a screen cannot be born at a size a
753        // resize would refuse. They are not the same *kind* of rule, though: the width
754        // floor is a published contract (#547 — one column was supported and no longer
755        // is), while the row floor is `resize`'s own long-standing "a terminal is never
756        // 0-tall" that this constructor merely failed to enforce while carrying the
757        // same `scroll_bottom: rows - 1` below. That gap was a subtract-overflow panic
758        // on `rows == 0`, not a degenerate screen.
759        // …and the ceiling is the header's, not the glyph's: `frame.cols`/`rows` are u16,
760        // so a wider grid would be built and then misdescribed on the wire (#621).
761        let cols = cols.clamp(MIN_COLUMNS, MAX_COLUMNS);
762        let rows = rows.clamp(1, MAX_ROWS);
763        Term {
764            grid: Grid::new(cols, rows),
765            alt_grid: Grid::new(cols, rows),
766            cursor: Cursor::default(),
767            saved_cursor: Cursor::default(),
768            on_alt: false,
769            origin_mode: false,
770            autowrap: true,
771            insert_mode: false,
772            newline_mode: false,
773            reverse_wraparound: false,
774            bracketed_paste: false,
775            synchronized_output: false,
776            color_scheme_updates: false,
777            grapheme_clustering: false,
778            win32_input_mode: false,
779            app_cursor_keys: false,
780            application_keypad: false,
781            vt52_mode: false,
782            vt52_y_pending: 0,
783            vt52_y_row: 0,
784            mouse_protocol: MouseProtocol::Off,
785            mouse_encoding: MouseEncoding::Default,
786            focus_events: false,
787            kitty_flags: 0,
788            kitty_stack: Vec::new(),
789            events: Vec::new(),
790            replies: Vec::new(),
791            current_link: None,
792            link_ids: std::collections::HashMap::new(),
793            link_ids_sweep_at: LINK_IDS_FIRST_SWEEP,
794            tabs: default_tabs(cols),
795            word_separators: DEFAULT_WORD_SEPARATORS.to_owned(),
796            scroll_top: 0,
797            scroll_bottom: rows - 1,
798            scrollback: VecDeque::new(),
799            display_offset: 0,
800            scrollback_limit,
801            recycled_row: None,
802            line_damage: vec![LineBounds::undamaged(cols); rows],
803            scroll: None,
804            full_damage: false,
805            prev_cursor: (0, 0), // matches the default cursor's home position
806            selection: None,
807            search_highlights: Vec::new(),
808            active_search_highlight: None,
809            normal_markers: VecDeque::new(),
810            alt_markers: VecDeque::new(),
811            next_marker_id: 0,
812            evicted_total: 0,
813            marker_epoch: 0,
814            normal_tracked: Vec::new(),
815            alt_tracked: Vec::new(),
816            next_tracked_id: 0,
817            decsc: SavedCursor::default(),
818            charsets: [Charset::Ascii; 4],
819            gl: 0,
820        }
821    }
822
823    /// What changed since the last `reset_damage()` — line ranges, each with a
824    /// changed column span. See ADR-0003.
825    pub fn damage(&self) -> TermDamage {
826        if self.full_damage {
827            return TermDamage::Full;
828        }
829        // Scrolled up under follow-bottom "stay": the viewport is frozen, so
830        // screen changes below it are not visible — report nothing. (A user
831        // scroll that moves the viewport sets full_damage above.)
832        if self.display_offset > 0 {
833            return TermDamage::Partial(Vec::new());
834        }
835        TermDamage::Partial(bounds_to_lines(&self.line_damage))
836    }
837
838    /// Render damage: content damage plus the cursor cells, for [`Term::frame`].
839    ///
840    /// A pure cursor move changes no cell *content*, so [`Term::damage`] (which
841    /// stays content-only, the cadence/flow-control primitive) would miss it —
842    /// yet a cell-invert caret must clear its old spot and ink the new one. So
843    /// the frame producer folds the old (last-acked) + current cursor cells in,
844    /// but only when the cursor actually moved: a still cursor needs no redraw,
845    /// keeping an idle frame empty. Mirrors Alacritty's `last_cursor`. #38.
846    fn frame_damage(&self) -> TermDamage {
847        if self.full_damage {
848            return TermDamage::Full;
849        }
850        if self.display_offset > 0 {
851            return TermDamage::Partial(Vec::new());
852        }
853        let cur = self.cursor.point();
854        if cur == self.prev_cursor {
855            return TermDamage::Partial(bounds_to_lines(&self.line_damage));
856        }
857        let mut bounds = self.line_damage.clone();
858        bounds[cur.0].expand(cur.1, cur.1);
859        let pr = self.prev_cursor.0.min(self.grid.rows() - 1);
860        let pc = self.prev_cursor.1.min(self.grid.cols() - 1);
861        bounds[pr].expand(pc, pc);
862        TermDamage::Partial(bounds_to_lines(&bounds))
863    }
864
865    /// Clear accumulated damage. The consumer calls this after applying a frame
866    /// (the ack); the next `damage()` reflects only changes since.
867    pub fn reset_damage(&mut self) {
868        for b in &mut self.line_damage {
869            b.reset();
870        }
871        self.scroll = None;
872        self.full_damage = false;
873        // The consumer has now seen the caret at the current position; the next
874        // frame's cursor-move damage is measured from here (#38).
875        self.prev_cursor = self.cursor.point();
876    }
877
878    /// Mark the whole screen damaged (alt switch / clear / flood, and a consumer
879    /// reattach that needs a full re-sync — see [`crate::Engine::mark_fully_damaged`]).
880    pub fn mark_fully_damaged(&mut self) {
881        self.full_damage = true;
882    }
883
884    /// Record that columns `[left, right]` of `row` changed.
885    ///
886    /// Both columns are **clamped to the last column, and asserted in debug** (#536).
887    ///
888    /// Ten of the fourteen call sites derive their bound from a cursor column or from `cols`.
889    /// **Four derive it from a wide pair's width**, and that is the shape worth centralising:
890    /// `write_glyph`'s `col + width - 1` (which had no guard — this issue), `promote_cluster_to_wide`'s
891    /// `col + 1` (guarded by its own `col + 1 >= cols` early return), `demote_cluster_to_narrow`'s
892    /// `(col + 1).min(cols - 1)` (self-clamped), and `relocate_cluster_wide`'s literal `(0, 1)`
893    /// (valid only because `MIN_COLUMNS = 2`, #547). Three carried a private guard and one did not.
894    ///
895    /// No reference has this shape to port a clamp from: alacritty computes damage ranges too
896    /// (`term/mod.rs:1406`, `:1649` @ `852e971`) but always from a column or `columns()` — its print
897    /// path records no damage at all, relying on the previous and current cursor *points* to bracket
898    /// the line — while xterm.js tracks whole rows (`markDirty(y)`) and ghostty a per-row
899    /// `dirty: bool`. alacritty's `LineDamageBounds::expand`, which this one is a copy of, is equally
900    /// unguarded.
901    ///
902    /// The two halves do different jobs:
903    ///
904    /// - the **`debug_assert` is the detector**. An out-of-range bound is stored silently and
905    ///   detonates later, when `frame()` slices the row, so the stack trace accuses the reader
906    ///   rather than the writer. That delay is what #536 was filed about, and the assert collapses
907    ///   it — a bad caller dies here, at the site that recorded it (measured: an injected off-by-one
908    ///   moved the panic from `frame()`'s slice to this line).
909    /// - the **clamp is the release backstop**, and it clamps *toward a false positive*. justerm is
910    ///   a library, so a panic crosses into the consumer's process; over-damaging repaints a cell
911    ///   that did not change, which costs nothing a consumer can see. ghostty states the asymmetry
912    ///   as a rule: *"Dirty tracking may have false positives but should never have false negatives.
913    ///   A false negative would result in a visual artifact on the screen."* (`page.zig:1993-1995`).
914    ///
915    /// **`left` is guarded for that reason, and it is the axis that can actually lose a cell.**
916    /// Clamping `right` cannot under-report — columns past the last do not exist. But `LineBounds`
917    /// marks a line undamaged with `left = cols, right = 0` and `is_damaged()` is `left <= right`,
918    /// so a single `expand` with `left > right` on an otherwise-clean line leaves the line reading
919    /// as undamaged and **drops its whole span silently**. Unreachable from the ten column-derived
920    /// sites today; guarded because that is precisely the failure ghostty's rule forbids.
921    ///
922    /// `row` is deliberately left to panic on the index, and that is **not** in tension with
923    /// `frame_damage` clamping a row fifty lines below (`prev_cursor.0.min(rows - 1)`). The two
924    /// rows are different kinds of thing under the same rule: `prev_cursor` is a *stale remembered*
925    /// coordinate that a shrinking resize may have put out of range, so clamping it repaints the
926    /// nearest surviving cell — a false positive. `row` here is a *live computed* index for the
927    /// mutation just made, so clamping it would damage a different line than the one that changed:
928    /// a false negative on the real line, which is the outcome the rule forbids.
929    fn damage_span(&mut self, row: usize, left: usize, right: usize) {
930        let last = self.grid.cols().saturating_sub(1);
931        debug_assert!(
932            left <= right && right <= last,
933            "damage_span({row}, {left}, {right}) is not a span inside [0, {last}]"
934        );
935        self.line_damage[row].expand(left.min(last), right.min(last));
936    }
937
938    /// The first-class scroll recorded since the last `reset_damage`, if any.
939    /// Suppressed while scrolled up — a content scroll must not shift the frozen
940    /// viewport.
941    ///
942    /// **The count is capped at the region's own height (#661).** Shifting a region
943    /// by more than its height moves every source row outside it, so the surplus
944    /// names nothing a consumer can act on — while it does overflow the wire's
945    /// `i16` and turn an up-scroll into a down-scroll: measured, a single
946    /// 32 770-byte `feed()` of newlines, no slow consumer required. Both references
947    /// that state a quantity at their own scroll sites clamp it to the same bound
948    /// (alacritty `term/mod.rs:773`, ghostty `Terminal.zig:2703`).
949    ///
950    /// The cap is here, on the **read**, and not on the accumulator in
951    /// `record_scroll`: a region that scrolls far and comes back then still reports
952    /// its true small net, instead of one walked down from a saturated value.
953    ///
954    /// A second, crate-internal bound backs it up, and it is representational rather
955    /// than semantic: [`MAX_ROWS`] is `u16::MAX` while the wire field is `i16`, so a
956    /// region can legally be taller than any count that field can hold. In that
957    /// corner the magnitude truncates. What it never does is wrap — a wrapped count
958    /// arrives with the opposite sign and the consumer shifts the wrong way, which is
959    /// the whole of #661.
960    pub fn scroll_delta(&self) -> Option<ScrollOp> {
961        if self.display_offset > 0 {
962            return None;
963        }
964        self.scroll.map(cap_scroll)
965    }
966
967    /// Build a serializable [`Frame`] from the current damage + grid + grapheme
968    /// pool (#6). `Full` ships every row; `Partial` ships the damaged spans. The
969    /// global side-table is remapped to **frame-local** indices — the engine pool
970    /// is append-only and leaky, so a frame carries only the clusters its cells
971    /// reference, renumbered, with each cell's `extra` rewritten to the local id.
972    pub fn frame(&self) -> Frame {
973        let cols = self.grid.cols();
974        let rows = self.grid.rows();
975        let (kind, line_spans): (FrameKind, Vec<(usize, usize, usize)>) = match self.frame_damage()
976        {
977            TermDamage::Full => (
978                FrameKind::Full,
979                (0..rows).map(|l| (l, 0, cols - 1)).collect(),
980            ),
981            TermDamage::Partial(lines) => (
982                FrameKind::Partial,
983                lines
984                    .into_iter()
985                    .map(|d| (d.line, d.left, d.right))
986                    .collect(),
987            ),
988        };
989
990        // Frame-local numbering for the hyperlink table (#26). Keyed by the URI's
991        // *identity* — the `Arc` pointer — so two cells sharing one open share one entry
992        // and a distinct open gets its own, which is exactly the semantics the pool
993        // index used to carry.
994        //
995        // Sized by this frame, not by session history (#628). It was
996        // `vec![0u32; hyperlink_pool.len() + 1]`, allocated and zeroed on **every**
997        // frame against every OSC 8 the session had ever seen — measured at 10 µs per
998        // frame with 100 000 opens retained. With the pool gone there is nothing left to
999        // size it by, and the cost disappears rather than being reduced.
1000        let mut link_table: Vec<String> = Vec::new();
1001        let mut link_remap: std::collections::HashMap<*const u8, u32> =
1002            std::collections::HashMap::new();
1003        // Cells come from the viewport at `display_offset`, not the live grid:
1004        // viewport row `line` is absolute buffer line `top + line` (scrollback
1005        // when scrolled up, the live grid when `display_offset == 0`, where
1006        // `top == scrollback.len()` and this is identical to reading the grid).
1007        // Without this, a wire consumer — cells reach it only through `frame()` —
1008        // could never display scrollback (#48).
1009        let top = self.scrollback.len() - self.display_offset;
1010        let mut spans = Vec::with_capacity(line_spans.len());
1011        for (line, left, right) in line_spans {
1012            let mut cells = Vec::with_capacity(right - left + 1);
1013            let mut combining = std::collections::BTreeMap::new();
1014            let mut links = std::collections::BTreeMap::new();
1015            let mut ucolors = std::collections::BTreeMap::new();
1016            let row = self.abs_row(top + line);
1017            let last_col = row.len().saturating_sub(1);
1018            for col in left..=right {
1019                let mut cell = row[col];
1020                // Soft-wrap is a row property (#538), but the wire has no per-row slot — so it is
1021                // *derived* back onto the last cell's WRAPLINE bit here, which keeps the format
1022                // byte-identical and is why moving the storage needed no VERSION bump. The bit is
1023                // therefore wire-only: on a live grid it is never set, and `Row::is_wrapped` is
1024                // the question to ask.
1025                if col == last_col && row.is_wrapped() {
1026                    cell.insert_flags(CellFlags::WRAPLINE);
1027                }
1028                // Combining clusters and hyperlinks live in the row's maps; each
1029                // tagged cell contributes its reference to the frame, recorded on
1030                // the span by span-relative column (the cell holds only the bit).
1031                if let Some(marks) = row.combining_at(col) {
1032                    // The cluster itself, at its column — no side table and no index
1033                    // since v14 (#621). Nothing interned these (this push was
1034                    // unconditional), so the index only ever bought indirection.
1035                    combining.insert(col - left, marks.to_vec());
1036                }
1037                if let Some(uri) = row.link_at(col) {
1038                    // Number each distinct open once per frame (only referenced URIs
1039                    // ship). The wire keeps its interning — #621 measured inlining a URI
1040                    // per linked cell at +171…403% — so this stays an index into
1041                    // `link_table`; only the *engine* side stopped being a table.
1042                    let key = std::sync::Arc::as_ptr(uri) as *const u8;
1043                    let next = link_table.len() as u32 + 1;
1044                    let fidx = *link_remap.entry(key).or_insert_with(|| {
1045                        link_table.push(uri.to_string());
1046                        next
1047                    });
1048                    let fidx = core::num::NonZeroU32::new(fidx)
1049                        .expect("frame-local link indices are 1-based");
1050                    links.insert(col - left, fidx);
1051                }
1052                // Underline colour (SGR 58, #520): a colour reference, not a
1053                // side-table index, so it rides the span inline. `ucolor_at` is
1054                // flag-gated + already Default-filtered (the stamp only fires on an
1055                // underlined cell), so a present entry is a real non-default colour.
1056                if let Some(color) = row.ucolor_at(col) {
1057                    ucolors.insert(col - left, color);
1058                }
1059                cells.push(cell);
1060            }
1061            spans.push(Span {
1062                line: line as u16,
1063                left: left as u16,
1064                right: right as u16,
1065                cells,
1066                combining,
1067                links,
1068                ucolors,
1069            });
1070        }
1071
1072        Frame {
1073            cols: cols as u16,
1074            rows: rows as u16,
1075            kind,
1076            // The live cursor: position in screen coords + DECTCEM visibility.
1077            // Reported, not drawn — the consumer renders the caret (#38).
1078            cursor_row: self.cursor.row as u16,
1079            cursor_col: self.cursor.col as u16,
1080            // Hidden while scrolled up: the live cursor is off the frozen
1081            // viewport, and a cell-invert caret would otherwise ink over
1082            // scrollback. Consistent with the frozen-damage policy (no cursor
1083            // damage is emitted while scrolled) and with xterm.js / alacritty,
1084            // which hide the caret when it falls outside the visible rows (#48).
1085            cursor_visible: self.cursor.visible && self.display_offset == 0,
1086            cursor_shape: self.cursor.shape,
1087            cursor_blink: self.cursor.blink,
1088            // Viewport scroll position for the consumer's scrollbar (ADR-0013).
1089            display_offset: self.display_offset as u32,
1090            scrollback_len: self.scrollback.len() as u32,
1091            evicted_total: self.evicted_total,
1092            marker_epoch: self.marker_epoch,
1093            // The active buffer's population, which is what `marker_index` reports and
1094            // therefore what a consumer's held index is compared against (#490).
1095            marker_count: self.markers().len() as u32,
1096            // The mouse tracking mode as a routing mask (#129): which mouse events
1097            // the app wants, derived from the protocol by the single source
1098            // `encode_mouse` shares. The consumer routes app-vs-local on it.
1099            mouse_events: self.mouse_protocol.wanted_events(),
1100            // Alt-screen flag (#149): buffer-global state the consumer can't
1101            // derive from viewport damage; the a11y announce policy gates on it.
1102            alt_screen: self.on_alt,
1103            scroll: self.scroll_delta(),
1104            spans,
1105            link_table,
1106            // Interaction overlays projected onto this viewport (#108): the
1107            // engine-owned selection and the consumer-supplied search highlights,
1108            // each re-projected here so the scroll offset is applied once, by the
1109            // same authority that projects the cells.
1110            overlay: Overlay {
1111                selection: self.selection_range(),
1112                matches: self
1113                    .search_highlights
1114                    .iter()
1115                    .flat_map(|m| self.match_spans(m))
1116                    .collect(),
1117                // The consumer-designated active match (#428), projected through
1118                // the same `match_spans` math — usually also present in `matches`
1119                // above (the renderer's ranking resolves the overlap, #424), but
1120                // a span designation may sit OUTSIDE a capped hand-over (#436).
1121                active_match: self
1122                    .active_search_highlight
1123                    .as_ref()
1124                    .map(|m| self.match_spans(m))
1125                    .unwrap_or_default(),
1126                markers: self.marker_positions(),
1127            },
1128        }
1129    }
1130
1131    /// Record a scroll of rows `[top, bottom]` by `count` (positive = up).
1132    ///
1133    /// Damage is indexed by row position, so it must follow the content the
1134    /// scroll just moved: rotate the bounds the same way and mark the newly
1135    /// exposed line fully damaged (it is new blank content for the consumer).
1136    fn record_scroll(&mut self, top: usize, bottom: usize, count: isize) {
1137        let cols = self.grid.cols();
1138        match count {
1139            1 => {
1140                self.line_damage[top..=bottom].rotate_left(1);
1141                self.line_damage[bottom] = LineBounds::fully_damaged(cols);
1142            }
1143            -1 => {
1144                self.line_damage[top..=bottom].rotate_right(1);
1145                self.line_damage[top] = LineBounds::fully_damaged(cols);
1146            }
1147            _ => {}
1148        }
1149        // Accumulate repeated scrolls of the same region into one op (flow
1150        // control). A *different* region cannot be expressed as one op, so
1151        // degrade to full rather than silently dropping the earlier scroll.
1152        match self.scroll {
1153            Some(op) if op.top == top && op.bottom == bottom => {
1154                self.scroll = Some(ScrollOp {
1155                    top,
1156                    bottom,
1157                    count: op.count + count,
1158                });
1159            }
1160            None => self.scroll = Some(ScrollOp { top, bottom, count }),
1161            Some(_) => {
1162                self.scroll = None;
1163                self.mark_fully_damaged();
1164            }
1165        }
1166    }
1167
1168    /// Number of lines currently held in scrollback history.
1169    /// Replace the word-boundary set used by Word (semantic) selection — the policy half
1170    /// of `selection_begin(.., SelectionType::Word)`, injected per ADR-0017 (core owns
1171    /// the buffer walk, the consumer owns which characters separate words). The default
1172    /// is [`DEFAULT_WORD_SEPARATORS`].
1173    ///
1174    /// **`' '` is forced into whatever you pass**, and that is not a convenience. A
1175    /// blank cell packs `' '`, so the space terminates the walk at the end of a row's
1176    /// written text *and* backstops the wide-pair rule: without it, double-clicking next
1177    /// to a wide separator starts the highlight on that separator's trailing spacer,
1178    /// bisecting the glyph, and the walk then runs to the row's end through the padding.
1179    /// Enforcing it here rather than in the walk is ghostty's shape — it prepends its own
1180    /// blank codepoint to every parsed set at the config intake (`config/Config.zig`,
1181    /// *"Always include null as first boundary"*), so `selectWord` never has to.
1182    ///
1183    /// A consequence worth knowing before you narrow the set: this predicate is the only
1184    /// thing bounding the walk, so a set that omits the separators actually present in
1185    /// the buffer makes one double-click walk the whole soft-wrap run (see #206).
1186    pub fn set_word_separators(&mut self, separators: &str) {
1187        let mut set: String = separators.to_owned();
1188        if !set.contains(' ') {
1189            set.push(' ');
1190        }
1191        self.word_separators = set;
1192    }
1193
1194    /// The word-boundary set currently in force — what was passed to
1195    /// [`Term::set_word_separators`] plus the forced `' '`, or
1196    /// [`DEFAULT_WORD_SEPARATORS`] if it was never called.
1197    pub fn word_separators(&self) -> &str {
1198        &self.word_separators
1199    }
1200
1201    pub fn scrollback_len(&self) -> usize {
1202        self.scrollback.len()
1203    }
1204
1205    /// Whether the app has an open synchronized-output block (DEC ?2026, #73).
1206    pub fn synchronized_output(&self) -> bool {
1207        self.synchronized_output
1208    }
1209
1210    /// Whether the app enabled color-scheme-update notifications (DEC ?2031, #85).
1211    pub fn color_scheme_updates(&self) -> bool {
1212        self.color_scheme_updates
1213    }
1214
1215    /// Whether the app enabled grapheme-cluster mode (DEC ?2027, #295): emoji ZWJ / skin-tone /
1216    /// flag / VS16 sequences are clustered into one cell. OFF (default) is per-char, wcwidth-compat.
1217    pub fn grapheme_clustering(&self) -> bool {
1218        self.grapheme_clustering
1219    }
1220
1221    /// Whether the app enabled win32-input-mode (DEC ?9001, #86). The engine does
1222    /// not encode the raw key-records itself (a non-goal); a ConPTY consumer reads
1223    /// this to decide whether to emit them.
1224    pub fn win32_input_mode(&self) -> bool {
1225        self.win32_input_mode
1226    }
1227
1228    /// Queue a color-scheme report (`CSI ? 997 ; 1 n` dark / `; 2 n` light) on the
1229    /// reply channel. The consumer calls this to answer a `ColorSchemeQuery` event
1230    /// or, when its scheme changes and `color_scheme_updates()` is set, to send the
1231    /// unsolicited notification. The engine never stores or interprets the scheme
1232    /// (#85).
1233    pub fn report_color_scheme(&mut self, dark: bool) {
1234        let ps = if dark { 1 } else { 2 };
1235        self.replies
1236            .extend_from_slice(format!("\x1b[?997;{ps}n").as_bytes());
1237    }
1238
1239    /// OSC 10/11 set/query the default fg/bg, stacking the `;`-separated specs
1240    /// across the `[foreground, background]` slots — xterm's
1241    /// `_setOrReportSpecialColor` offset loop (#137). OSC 10 starts at slot 0
1242    /// (fg → bg), OSC 11 at slot 1 (bg). A `?` spec is a query. xterm's 3rd slot
1243    /// (cursor / OSC 12) is out of scope, so the stack caps at two slots — extra
1244    /// specs are dropped.
1245    fn special_color(&mut self, params: &[&[u8]], start: usize) {
1246        for (i, &spec) in params[1..].iter().enumerate() {
1247            let event = match start + i {
1248                0 if spec == b"?" => TermEvent::QueryForeground,
1249                0 => TermEvent::SetForeground(String::from_utf8_lossy(spec).into_owned()),
1250                1 if spec == b"?" => TermEvent::QueryBackground,
1251                1 => TermEvent::SetBackground(String::from_utf8_lossy(spec).into_owned()),
1252                _ => break, // past [fg, bg] — cursor (OSC 12) unsupported
1253            };
1254            self.events.push(event);
1255        }
1256    }
1257
1258    /// Answer an OSC 4 palette query (#122): wrap the consumer-supplied spec for
1259    /// `index` in the OSC 4 reply envelope, ST-terminated.
1260    pub fn report_palette_color(&mut self, index: u8, spec: &str) {
1261        self.replies
1262            .extend_from_slice(format!("\x1b]4;{index};{spec}\x1b\\").as_bytes());
1263    }
1264
1265    /// Answer an OSC 10 foreground query (#122): wrap the consumer-supplied spec
1266    /// in the OSC 10 reply envelope, ST-terminated.
1267    pub fn report_foreground(&mut self, spec: &str) {
1268        self.replies
1269            .extend_from_slice(format!("\x1b]10;{spec}\x1b\\").as_bytes());
1270    }
1271
1272    /// Answer an OSC 11 background query (#122): wrap the consumer-supplied spec
1273    /// (it knows its palette) in the OSC 11 reply envelope, ST-terminated. The
1274    /// engine formats the envelope only — it never knows the colour.
1275    pub fn report_background(&mut self, spec: &str) {
1276        self.replies
1277            .extend_from_slice(format!("\x1b]11;{spec}\x1b\\").as_bytes());
1278    }
1279
1280    /// The cells of visible row `i` (0..rows) at the current scroll position.
1281    /// The viewport windows into `[history.. ; screen..]`: rows above
1282    /// `scrollback.len()` come from history, the rest from the live screen.
1283    pub fn viewport_line(&self, i: usize) -> &[Cell] {
1284        let top = self.scrollback.len() - self.display_offset;
1285        let idx = top + i;
1286        if idx < self.scrollback.len() {
1287            &self.scrollback[idx]
1288        } else {
1289            self.grid.row(idx - self.scrollback.len())
1290        }
1291    }
1292
1293    /// Scroll the viewport up by `n` lines into history (clamped to the oldest).
1294    pub fn scroll_up(&mut self, n: usize) {
1295        let target = (self.display_offset + n).min(self.scrollback.len());
1296        self.set_display_offset(target);
1297    }
1298
1299    /// Scroll the viewport down by `n` lines toward the live screen.
1300    pub fn scroll_down(&mut self, n: usize) {
1301        let target = self.display_offset.saturating_sub(n);
1302        self.set_display_offset(target);
1303    }
1304
1305    /// Jump the viewport back to the live screen (follow the bottom).
1306    pub fn scroll_to_bottom(&mut self) {
1307        self.set_display_offset(0);
1308    }
1309
1310    /// Move the viewport. A user scroll changes which lines are visible, so the
1311    /// whole viewport is repainted (full damage) when the offset actually moves.
1312    fn set_display_offset(&mut self, offset: usize) {
1313        // The alt screen has no scrollback to view; scroll intents are no-ops.
1314        if self.on_alt {
1315            return;
1316        }
1317        if offset != self.display_offset {
1318            self.display_offset = offset;
1319            self.mark_fully_damaged();
1320        }
1321    }
1322
1323    // ---- selection -----------------------------------------------------------
1324
1325    /// Map a viewport cell `(row, col)` to an absolute buffer point. The top
1326    /// visible row is `scrollback.len() - display_offset`, so viewport row `i`
1327    /// is that plus `i`.
1328    ///
1329    /// **`row` is clamped to the last visible row, and that is the anchor's whole defence
1330    /// against a caller that hands it one past the end (#660).** The row arrives from a
1331    /// pointer position, so it is off the end whenever a drag leaves the grid — including
1332    /// the ordinary case where the container is a sub-cell remainder taller than
1333    /// `rows × cell_height` and a click lands in that strip. Stored unclamped it does not
1334    /// fail here: it detonates later, in whichever read walks the selection
1335    /// (`selection_range`, `selection_text`, the word extents), so the stack trace accuses
1336    /// the reader rather than the caller — the delay `damage_span`'s doc describes and
1337    /// #536 was filed about.
1338    ///
1339    /// **Clamped, and deliberately *not* `debug_assert`ed** — which is where this differs
1340    /// from `damage_span`, whose split it otherwise mirrors. That function is engine-
1341    /// internal, so an out-of-range span there is a justerm bug and the assert names its
1342    /// producer. This one is reached from `Engine::selection_begin` / `selection_extend`,
1343    /// whose documented input is *"what a mouse event carries"* — and a pointer leaves the
1344    /// grid whenever a drag does, so a row past the end is **ordinary input, not a defect**.
1345    /// Asserting on it would panic a consumer's debug build for a legal gesture.
1346    ///
1347    /// Clamping is also what every producer already wants: a drag past the bottom edge
1348    /// selects to the edge. alacritty clamps at the same boundary (`Point::grid_clamp`);
1349    /// ghostty's pins cannot express an out-of-range row at all.
1350    ///
1351    /// **This is a backstop, and since #667 nothing in the family relies on it.** The
1352    /// sentence here used to read that justerm-web's selection converter did *not* clamp,
1353    /// which was what made an unclamped row reachable in the shipped stack rather than
1354    /// only in theory; that converter now bounds both axes at its own seam, as all three
1355    /// references do at theirs. The claim is retracted rather than deleted because it was
1356    /// the record of why this clamp was worth adding.
1357    ///
1358    /// **`col` is bounded the same way, and against the grid rather than the line
1359    /// (#671).** #660 reasoned about the row alone and this function passed the column
1360    /// through, which was not a smaller version of the same gap — it was a *different*
1361    /// one, because the two axes are consumed differently downstream. A column reaches
1362    /// `resolve`, where the `Side` decides whether it gets a `+ 1`, and the two readers
1363    /// then bound only one end each: `selection_range`'s Linear arm clips `right_excl`
1364    /// and not `left`, `selection_text`'s Block arm clips `hi` and not `from`. So
1365    /// `Side::Right` was already safe by accident (its `+ 1` lands past the end and the
1366    /// clip catches it) while **`Side::Left` had no `+ 1` to clip**, and the raw column
1367    /// survived into `left` — silently deleting the anchor's own row from both the
1368    /// projection and the copy. `usize::MAX` was the one value that panicked instead,
1369    /// on the `+ 1`.
1370    ///
1371    /// Bounding here rather than in `resolve` keeps one site answering "what does a
1372    /// viewport coordinate mean", and makes both axes total for the same reason; the
1373    /// alternative — clamping at each `+ 1` — is five sites for one rule. alacritty
1374    /// bounds both endpoints' columns in `Selection::to_range` *before* its own side
1375    /// arithmetic and pairs the `+ 1` with an explicit *"column == columns → wrap to the
1376    /// next line"*; justerm reaches that same outcome through the reader's
1377    /// `right_excl > left`, which is why an **in-range** `Side::Right` on the last column
1378    /// still starts the selection on the following row and is pinned as unchanged.
1379    ///
1380    /// The grid, not `abs_line(..).len()`, is the bound: `SelectionType::Line` already
1381    /// resolves `to` as `grid.cols()`, so the whole type works in grid coordinates and a
1382    /// short line must not shrink a selection that reaches past it.
1383    ///
1384    /// **`resolve`'s five `+ 1`s stay unguarded, and that is sound only while every stored
1385    /// anchor arrives through here.** The completeness pass enumerated the writers: the
1386    /// three coordinate fixups move `.line` or write columns that are in range by
1387    /// construction, `resize`'s primary branch re-clamps the reflowed points (#562) and its
1388    /// alt branch drops the selection outright (#660), and `Term::resize` is the only writer
1389    /// of `grid.cols()`. So no path strands a column that was clamped here. The condition is
1390    /// **a fourth writer of `self.selection`** — one that builds an `Anchor` without this
1391    /// function would put `resolve` back in reach of its own arithmetic.
1392    fn viewport_to_abs(&self, row: usize, col: usize) -> BufferPoint {
1393        let top = self.scrollback.len() - self.display_offset;
1394        let last = self.grid.rows().saturating_sub(1);
1395        BufferPoint {
1396            line: top + row.min(last),
1397            col: col.min(self.grid.cols().saturating_sub(1)),
1398        }
1399    }
1400
1401    /// The hyperlink **URI** at **screen** `(row, col)` (the live grid), or `None` —
1402    /// flag-gated through the row's link map. Since #628 the map holds the URI itself,
1403    /// so there is no index and no second call to resolve one.
1404    /// Mirrors `grid().cell(row, col)`.
1405    pub(crate) fn screen_link_at(&self, row: usize, col: usize) -> Option<Hyperlink> {
1406        self.grid
1407            .row_ref(row)
1408            .link_at(col)
1409            .cloned()
1410            .map(Hyperlink::new)
1411    }
1412
1413    /// The underline colour (SGR 58, #520) at screen `(row, col)`, as a theme-agnostic
1414    /// reference. `Color::Default` means "follow the fg" — the common case, and what an
1415    /// unset cell returns. Mirror of [`Term::screen_link_at`].
1416    pub(crate) fn screen_underline_color_at(&self, row: usize, col: usize) -> Color {
1417        self.grid.row_ref(row).ucolor_at(col).unwrap_or_default()
1418    }
1419
1420    /// The hyperlink URI at **viewport** `(row, col)` (visible window, history
1421    /// included at the current scroll), or `None`. Mirrors `viewport_line(row)`.
1422    pub(crate) fn viewport_link_at(&self, row: usize, col: usize) -> Option<Hyperlink> {
1423        let idx = self.scrollback.len() - self.display_offset + row;
1424        self.abs_row(idx).link_at(col).cloned().map(Hyperlink::new)
1425    }
1426
1427    /// Resize the screen to `cols` x `rows`. Rows dropped off the top (on shrink)
1428    /// enter scrollback. Column reflow of soft-wrapped lines is layered on top
1429    /// separately (#7). The whole screen is damaged.
1430    ///
1431    /// `cols` is widened to [`MIN_COLUMNS`] — a narrower screen cannot hold a
1432    /// width-2 glyph, so it is clamped rather than represented (#547).
1433    pub fn resize(&mut self, cols: usize, rows: usize) {
1434        // A terminal is never 0-tall; clamp so the math below (rows - 1) can't
1435        // underflow. Columns clamp to MIN_COLUMNS, not 1: chunking by cols needs a
1436        // non-zero width, but a *wide glyph* needs two (#547). The ceiling is the frame
1437        // header's u16, clamped here as well as in the constructor — a ceiling that held
1438        // only until the first resize is the gap `new` had against this function's own row
1439        // floor before #547 (#621).
1440        let cols = cols.clamp(MIN_COLUMNS, MAX_COLUMNS);
1441        let rows = rows.clamp(1, MAX_ROWS);
1442        let old_cols = self.grid.cols();
1443        let old_rows = self.grid.rows();
1444        let limit = self.scrollback_limit;
1445
1446        // A reflow rewrites marker lines outright, at three separate sites below and in
1447        // two different frames of reference — so a held index goes stale in a way no
1448        // offset repairs (#490). Bumped **here**, once, rather than beside each rewrite:
1449        // this function is the only way any of them runs, and a per-site obligation is
1450        // the shape `docs/map/territory/marker.md` already records as a known hole for
1451        // the alt guard. Gated on a *dimension change* as well as on there being a marker:
1452        // `resize` has no early return for unchanged geometry, and `justerm-web`'s fit
1453        // loop re-asserts the size every frame (`ResizePort` states no idempotency
1454        // guarantee), so an ungated bump is a full re-pull per frame — measured at 100
1455        // bumps for 100 no-op resizes.
1456        if (cols != old_cols || rows != old_rows)
1457            && (!self.normal_markers.is_empty() || !self.alt_markers.is_empty())
1458        {
1459            self.bump_marker_epoch();
1460        }
1461
1462        // A reflow moves match coordinates (and can change the match set), so the
1463        // query-derived highlights are invalidated; the consumer re-searches at
1464        // the new width. The selection re-anchors below — it is user-authored.
1465        self.invalidate_search_highlights();
1466
1467        // ...except on the alt screen, where a *shrink* drops the selection (#660). The
1468        // primary pane carries user-authored points through `reflow_pane` and gets them back
1469        // mapped to the new geometry; on alt the selection is dropped instead.
1470        //
1471        // **This is a policy choice, not a capability limit, and the difference matters
1472        // because the first version of this comment got it wrong.** It claimed the alt pane
1473        // has "nothing to re-anchor through" — measurably false: the alt branch below makes
1474        // its own `reflow_pane` call with tracked points and already uses the returned
1475        // `extras` / `evicted` to rotate and dispose alt *markers*. `reflow: false` disables
1476        // the column re-split, not the point tracking. Installing a false "cannot" as the
1477        // justification for fixing a false "cannot" is precisely the failure #660 is.
1478        //
1479        // What actually makes rotation the wrong trade here is that a marker and a selection
1480        // are different shapes. A marker is one point with a binary fate — survive, or be
1481        // disposed with an event. A selection is two *ordered* endpoints, so when a shrink
1482        // destroys the row under one of them and not the other, "dispose" has no meaning and
1483        // the correct behaviour is the clamp-and-overtake policy `selection_rotate_region`
1484        // implements for scrolls. Reusing the marker path would give a selection whose ends
1485        // moved by different rules; writing the second policy is a feature, not this fix.
1486        // And on a column shrink an alt anchor would additionally need the `.min(cols - 1)`
1487        // the primary branch applies, which alt markers deliberately do *not* take.
1488        //
1489        // Both references drop rather than rotate on the axis they consider unsafe —
1490        // alacritty on a width change (`term/mod.rs:680-682`), xterm.js on a height change,
1491        // its comment naming this bug class outright (`SelectionService.ts:156-160`).
1492        //
1493        // **Any geometry change, not just a shrink — and the `!=` is load-bearing in the
1494        // direction that looks wasteful.** The obvious refinement is to keep the selection on
1495        // a *grow*, since the alt pane pads rather than moving content, so no anchor looks
1496        // invalidated. That was tried and a randomised sweep refuted it in one run: an alt
1497        // resize also reflows the **primary** pane below, and on the alt screen `scrollback`
1498        // *is* that primary history — so `scrollback.len()` moves under an anchor whose
1499        // absolute line was measured from the old base, even when the alt grid itself does not
1500        // move at all. `selection_text` then walks off the end. The branch below already knows
1501        // this and converts alt *markers* through `old_base` → new base for exactly that
1502        // reason ("the primary scrollback below may rewrap and change length even when the alt
1503        // grid does not move"); the selection has no such conversion, so the geometry change
1504        // is the honest trigger.
1505        //
1506        // Rebasing instead of dropping is the better answer and is not done here: a grow has
1507        // no destroyed row, so both endpoints could shift by the base delta unambiguously —
1508        // but a *shrink* still needs the two-endpoint policy below, and shipping half of it
1509        // would leave the two axes behaving differently for no stated reason.
1510        //
1511        // The exact no-op is still not a resize: nothing reflows when neither axis moves, so
1512        // the base cannot shift, and a consumer that re-asserts its size every frame (a
1513        // `fit()` loop) must not make selecting on the alt screen impossible.
1514        if self.on_alt && (cols != old_cols || rows != old_rows) {
1515            self.selection = None;
1516        }
1517
1518        // Both screens are resized. Scrollback pairs with the PRIMARY screen
1519        // (whichever is active) — the alt screen has no history of its own.
1520        // `reflow: true` is the *primary* pane's setting and is deliberately a constant, **not**
1521        // `self.autowrap`. ghostty gates its equivalent on DECAWM — `.reflow =
1522        // self.modes.get(.wraparound)` (`terminal/Terminal.zig` `resize`) — and the reading that
1523        // makes that coherent is the one this file just accepted for the alt screen: an application
1524        // that turns autowrap off is placing lines itself, so its content is a layout rather than a
1525        // flow, and re-wrapping it changes what it drew.
1526        //
1527        // Not followed here, for three reasons, and they are recorded rather than filed because
1528        // nothing observable is known to break either way (measured: with DECAWM off, a full
1529        // 6-column row still re-splits into two rows at width 3 exactly as it does with DECAWM on;
1530        // the difference against ghostty is that ghostty truncates that row instead).
1531        //
1532        // - **The wrap flag is not a lie.** `Row::is_wrapped` means "this row continues into the
1533        //   next", which after a re-split is simply true. DECAWM governs the **write** path — where
1534        //   a glyph goes when the cursor is at the last column — not how stored content is laid out
1535        //   again later. Dropping the flag would make `"abcdef"` extract as `"abc\ndef"`.
1536        // - **The mode is global and momentary; the buffer is neither.** DECAWM is read at resize
1537        //   time and would decide the fate of history written under the opposite setting. A TUI that
1538        //   turns it off while drawing would, on a resize landing in that window, leave every
1539        //   properly wrapped line in scrollback un-reflowed.
1540        // - **It costs content.** Not reflowing truncates each row to the new width, so the tail of
1541        //   a long line leaves the grid. justerm keeps it.
1542        //
1543        // There is no per-row signal to be finer with: a row written under DECAWM off and a row that
1544        // merely ended early are both simply unwrapped. "Do not re-split an unwrapped logical line"
1545        // would break ordinary reflow, since a line that exactly fills its width carries no wrap
1546        // flag either.
1547        let dims = ReflowDims {
1548            old_cols,
1549            cols,
1550            rows,
1551            limit,
1552            reflow: true,
1553        };
1554        let scrollback = std::mem::take(&mut self.scrollback);
1555        if self.on_alt {
1556            // Active = alt (cursor, no scrollback); inactive = primary. No selection anchors
1557            // to track here — but *because the geometry change above dropped them* (#660),
1558            // not because there cannot be any. This comment used to read "selection is
1559            // primary-only and cleared on alt enter": the clearing is real
1560            // (`enter_alt_screen` / `leave_alt_screen`, "a selection cannot survive a screen
1561            // swap") and says nothing about a selection made *while* the alt screen is up,
1562            // which is the ordinary act of copying out of vim. A premise that held at one
1563            // instant was read as an invariant holding for the screen's lifetime.
1564            // Alt markers still ride this pane, but **not because it reflows** — since #567 it does
1565            // not, so a marker's content no longer moves under it and the old reason here ("justerm
1566            // column-reflows the alt grid, so a marker must follow its content") is retracted. What
1567            // they ride it for is the row *fit*: a shrink still drops rows off the top, and a marker
1568            // on one of those has left a screen with no history to hold it. Their stored line is
1569            // `base + alt_row` (base = primary scrollback len), so convert to alt-local rows here
1570            // and re-anchor on the new base afterward — the primary scrollback below may rewrap and
1571            // change length even when the alt grid does not move at all.
1572            let old_base = scrollback.len();
1573            let mut alt_pts: Vec<(usize, usize)> = self
1574                .alt_markers
1575                .iter()
1576                .map(|m| (m.line - old_base, m.col))
1577                .collect();
1578            // Alt-scoped tracked points are stored on the same `base + alt_row`
1579            // frame as the alt markers, so they convert and come back the same way
1580            // (#691).
1581            let alt_tracked_off = alt_pts.len();
1582            alt_pts.extend(
1583                self.alt_tracked
1584                    .iter()
1585                    .map(|p| (p.line.saturating_sub(old_base), p.col)),
1586            );
1587            let alt = self.grid.take_lines();
1588            let r_alt = reflow_pane(
1589                alt,
1590                VecDeque::new(),
1591                self.cursor.point(),
1592                &alt_pts,
1593                ReflowDims {
1594                    limit: 0,
1595                    reflow: false,
1596                    ..dims
1597                },
1598            );
1599            self.grid.set_screen(r_alt.screen, cols, rows);
1600            self.cursor.set_point(r_alt.cursor, rows, cols);
1601
1602            // Primary is inactive here, but markers anchor *primary* content, so
1603            // they reflow with it. There is no *primary* selection to carry alongside them —
1604            // `switch_to_alt` nulls it before the alt screen exists, so one cannot coexist
1605            // with `on_alt` — which is a different statement from the "cleared on alt enter"
1606            // this comment used to make, and the difference is #660: that clearing says
1607            // nothing about the *alt* selection the branch above now drops.
1608            // `(line, col)`, not `(line, 0)`: the column is what bounds OSC-133 command-text
1609            // extraction (#166), and discarding it here truncated the recorded command for any
1610            // resize taken while a full-screen app was up. The primary branch below has always
1611            // passed and restored both; this one is the sibling that did not.
1612            let mut marker_pts: Vec<(usize, usize)> = self
1613                .normal_markers
1614                .iter()
1615                .map(|m| (m.line, m.col))
1616                .collect();
1617            // Primary-scoped tracked points anchor primary content too, so they
1618            // reflow with this pane even though the alt screen is the active one
1619            // (#691) — the same reason the markers above do.
1620            let tracked_off = marker_pts.len();
1621            marker_pts.extend(self.normal_tracked.iter().map(|p| (p.line, p.col)));
1622            let primary = self.alt_grid.take_lines();
1623            let r = reflow_pane(
1624                primary,
1625                scrollback,
1626                self.saved_cursor.point(),
1627                &marker_pts,
1628                dims,
1629            );
1630            self.alt_grid.set_screen(r.screen, cols, rows);
1631            self.scrollback = r.scrollback;
1632            self.saved_cursor.set_point(r.cursor, rows, cols);
1633            for (i, m) in self.normal_markers.iter_mut().enumerate() {
1634                m.line = r.extras[i].0.saturating_sub(r.evicted);
1635                m.col = r.extras[i].1;
1636            }
1637            // Released rather than clamped when the reflow evicted its line — see
1638            // the primary-active branch for why the two loops differ (#691).
1639            let mut ti = 0;
1640            let evicted = r.evicted;
1641            let extras = &r.extras;
1642            self.normal_tracked.retain_mut(|p| {
1643                let (line, col) = extras[tracked_off + ti];
1644                ti += 1;
1645                match line.checked_sub(evicted) {
1646                    Some(line) => {
1647                        p.line = line;
1648                        p.col = col;
1649                        true
1650                    }
1651                    None => false,
1652                }
1653            });
1654            // The alt half lives in a different frame from the primary one above, and adding the
1655            // two was the defect: `extras` count from the top of the alt pane's own history, and
1656            // the alt screen **has** no history — every row the shrink pushed off the top is gone,
1657            // not archived. Passing the primary's scrollback limit made `reflow_pane` keep them,
1658            // so a rows-only resize (no reflow at all) reported a marker four lines past the end of
1659            // the buffer. The limit is `0` here because that is what an alt screen's history is.
1660            //
1661            // A marker whose row went with it is **disposed**, matching what the alt screen already
1662            // does when a row leaves by scrolling (`markers_rotate_region` fires `MarkerDisposed`
1663            // for the marker on the departing edge). Silently relocating it to row 0 would put a
1664            // decoration on content it was never attached to.
1665            let new_base = self.scrollback.len();
1666            let mut alt_disposed = Vec::new();
1667            let mut i = 0;
1668            self.alt_markers.retain_mut(|m| {
1669                let (line, col) = r_alt.extras[i];
1670                i += 1;
1671                match line.checked_sub(r_alt.evicted) {
1672                    Some(row) if row < rows => {
1673                        m.line = new_base + row;
1674                        // The column rides along for the same reason as the primary half, but
1675                        // **unpinned**: `add_marker` always passes column 0, and I could not get an
1676                        // OSC-133 mark (the only column-bearing kind) to appear in `alt_markers` at
1677                        // all. That is a gap in my knowledge, not evidence the column is
1678                        // structurally zero — `push_marker` takes a column and `markers_mut` routes
1679                        // by active buffer, so the field is reachable in principle. Carrying it
1680                        // keeps the two halves stating one invariant; if the alt path really is
1681                        // marker-column-free, this line is a no-op.
1682                        m.col = col;
1683                        true
1684                    }
1685                    _ => {
1686                        alt_disposed.push(m.id);
1687                        false
1688                    }
1689                }
1690            });
1691            for id in alt_disposed {
1692                self.events.push(TermEvent::MarkerDisposed(id));
1693            }
1694            // The alt half's tracked points, on the alt marker's rule: a row the
1695            // shrink pushed off an unarchived screen is gone, so the point is
1696            // released rather than relocated (#691).
1697            //
1698            // The `row < rows` half of that guard is **unproven, deliberately kept**.
1699            // A mutation dropping it stays green, and a sweep of 324 alt resizes
1700            // (rows 1..6 x cols {4,10,30}, both directions, a point on every alt row
1701            // at columns 0 / 1 / cols-1 / cols plus one past the pane) never reached
1702            // it — the alt fit runs with `reflow: false`, so a surviving row always
1703            // maps below the new row count. That is a measured *validity condition*,
1704            // not a proof of unreachability, and the marker loop above carries the
1705            // same bound: parity is the reason it stays.
1706            let mut ai = 0;
1707            let alt_extras = &r_alt.extras;
1708            let alt_evicted = r_alt.evicted;
1709            self.alt_tracked.retain_mut(|p| {
1710                let (line, col) = alt_extras[alt_tracked_off + ai];
1711                ai += 1;
1712                match line.checked_sub(alt_evicted) {
1713                    Some(row) if row < rows => {
1714                        p.line = new_base + row;
1715                        p.col = col;
1716                        true
1717                    }
1718                    _ => false,
1719                }
1720            });
1721        } else {
1722            // Active = primary (cursor, scrollback); inactive = alt. The selection
1723            // anchors (absolute) reflow alongside the cursor so they keep their
1724            // content across a column change.
1725            let sel_pts: Vec<(usize, usize)> = self
1726                .selection
1727                .as_ref()
1728                .map(|s| {
1729                    vec![
1730                        (s.anchor.point.line, s.anchor.point.col),
1731                        (s.focus.point.line, s.focus.point.col),
1732                    ]
1733                })
1734                .unwrap_or_default();
1735            // Markers reflow on the same pane by (line, col) — the column matters
1736            // for OSC-133 command marks, whose B/C columns bound the extracted
1737            // command text (#166). They ride after the selection points so each
1738            // reads its own reflowed slot back from `extras` (#118).
1739            let mut pts = sel_pts.clone();
1740            pts.extend(self.normal_markers.iter().map(|m| (m.line, m.col)));
1741            // Tracked points ride after the markers, reading their own slots back
1742            // by the same offset idiom (#691).
1743            let tracked_off = pts.len();
1744            pts.extend(self.normal_tracked.iter().map(|p| (p.line, p.col)));
1745
1746            let primary = self.grid.take_lines();
1747            let r = reflow_pane(primary, scrollback, self.cursor.point(), &pts, dims);
1748            self.grid.set_screen(r.screen, cols, rows);
1749            self.scrollback = r.scrollback;
1750            self.cursor.set_point(r.cursor, rows, cols);
1751            if let Some(sel) = &mut self.selection {
1752                // A selection endpoint is **UI** state, so its reading of `col == cols` (#562) is
1753                // neither the cursor's nor a mark's: it is clamped into the grid. UI state may not
1754                // move the application's content to make room for itself — the criterion that
1755                // decided this, and the one ghostty encodes by clamping every non-cursor pin before
1756                // it can widen a row (`terminal/PageList.zig:1576-1585` @ `e6e26e1`) while leaving
1757                // the cursor pin unclamped (`:1602-1606`).
1758                sel.anchor.point = BufferPoint {
1759                    line: r.extras[0].0.saturating_sub(r.evicted),
1760                    col: r.extras[0].1.min(cols - 1),
1761                };
1762                sel.focus.point = BufferPoint {
1763                    line: r.extras[1].0.saturating_sub(r.evicted),
1764                    col: r.extras[1].1.min(cols - 1),
1765                };
1766            }
1767            let marker_off = sel_pts.len();
1768            for (i, m) in self.normal_markers.iter_mut().enumerate() {
1769                m.line = r.extras[marker_off + i].0.saturating_sub(r.evicted);
1770                m.col = r.extras[marker_off + i].1;
1771            }
1772            // A point whose reflowed line fell inside the evicted prefix has left
1773            // the buffer, so it is released rather than clamped to line 0 (#691) —
1774            // deliberately unlike the marker loop above, which saturates. A marker
1775            // that lands on the wrong line still paints something the consumer can
1776            // see and correct; a tracked point is *asked* for a position, and
1777            // answering with content the caller never anchored to is the exact
1778            // failure this whole module exists to remove.
1779            let mut i = 0;
1780            let evicted = r.evicted;
1781            let extras = &r.extras;
1782            self.normal_tracked.retain_mut(|p| {
1783                let (line, col) = extras[tracked_off + i];
1784                i += 1;
1785                match line.checked_sub(evicted) {
1786                    Some(line) => {
1787                        p.line = line;
1788                        p.col = col;
1789                        true
1790                    }
1791                    None => false,
1792                }
1793            });
1794
1795            let alt = self.alt_grid.take_lines();
1796            let r = reflow_pane(
1797                alt,
1798                VecDeque::new(),
1799                (0, 0),
1800                &[],
1801                ReflowDims {
1802                    limit: 0,
1803                    reflow: false,
1804                    ..dims
1805                },
1806            );
1807            self.alt_grid.set_screen(r.screen, cols, rows);
1808        }
1809
1810        // Carry the deferred wrap across the resize — it is *cursor* state, and it used to be
1811        // reset here alongside the margins and tab stops, which are screen configuration and do
1812        // legitimately reset. Losing it meant the next byte overwrote the last glyph instead of
1813        // wrapping past it, on a column resize *and* on a rows-only one where no reflow runs at
1814        // all.
1815        //
1816        // The flag means "the cursor is logically one past the column it sits on". Where the
1817        // reflow leaves it somewhere other than the last column that logical position **is**
1818        // representable, so the flag is cleared and the cursor takes it instead — ghostty's rule,
1819        // stated in its own words for the saved cursor: *"If we had pending wrap set and we're no
1820        // longer at the end of the line, we unset the pending wrap and move the cursor to reflect
1821        // the correct next position"* (`terminal/Screen.zig:2092-2098` @ `e6e26e1`). alacritty
1822        // reaches the same place from the other side, lifting the cursor outside the grid before
1823        // reflowing and clamping it back afterwards (`grid/resize.rs:113-116`, `:248-251`,
1824        // `:173-177` @ `852e971`); xterm.js needs no rule because `x === cols` is representable.
1825        //
1826        // `col + 1` cannot overflow the row: the branch requires `col != cols - 1`, and `col` is
1827        // already clamped below `cols` by `Cursor::set_point`.
1828        if self.cursor.pending_wrap && self.cursor.col != cols - 1 {
1829            self.cursor.pending_wrap = false;
1830            self.cursor.col += 1;
1831        }
1832        self.scroll_top = 0;
1833        self.scroll_bottom = rows - 1;
1834        self.tabs = default_tabs(cols);
1835        self.display_offset = self.display_offset.min(self.scrollback.len());
1836
1837        // Damage tracking is sized to the screen; a resize repaints everything,
1838        // so drop any pending scroll op (it points at the old rows).
1839        self.line_damage = vec![LineBounds::undamaged(cols); rows];
1840        self.scroll = None;
1841        self.mark_fully_damaged();
1842    }
1843
1844    pub fn grid(&self) -> &Grid {
1845        &self.grid
1846    }
1847
1848    pub fn cursor(&self) -> &Cursor {
1849        &self.cursor
1850    }
1851
1852    /// Whether bracketed-paste mode (DEC ?2004) is enabled. The input encoder
1853    /// (#11) reads this to decide whether to wrap pasted text in markers.
1854    pub fn bracketed_paste(&self) -> bool {
1855        self.bracketed_paste
1856    }
1857
1858    // ---- input encoding (#11) ------------------------------------------------
1859
1860    /// Encode a key event to bytes using the active cursor-key mode (DECCKM)
1861    /// and the kitty keyboard-protocol flags (`encode_key` consults both).
1862    pub fn encode_key(&self, ev: KeyEvent) -> Option<Vec<u8>> {
1863        encode_key(
1864            &ev,
1865            self.app_cursor_keys,
1866            self.application_keypad,
1867            self.kitty_flags,
1868        )
1869    }
1870
1871    /// Encode a mouse event using the active tracking mode + encoding. `None`
1872    /// when reporting is off or the event is filtered by the mode.
1873    pub fn encode_mouse(&self, ev: MouseEvent) -> Option<Vec<u8>> {
1874        encode_mouse(&ev, self.mouse_protocol, self.mouse_encoding)
1875    }
1876
1877    /// Encode pasted text, wrapping it in bracketed-paste markers when ?2004 is
1878    /// on.
1879    pub fn encode_paste(&self, text: &str) -> Vec<u8> {
1880        encode_paste(text, self.bracketed_paste)
1881    }
1882
1883    /// Encode a focus change (`CSI I`/`CSI O`), or `None` when focus reporting
1884    /// (?1004) is off.
1885    pub fn encode_focus(&self, focused: bool) -> Option<Vec<u8>> {
1886        encode_focus(focused, self.focus_events)
1887    }
1888
1889    /// Take the consumer events queued since the last drain, emptying the queue.
1890    pub fn drain_events(&mut self) -> Vec<TermEvent> {
1891        std::mem::take(&mut self.events)
1892    }
1893
1894    /// Take the reply bytes queued since the last drain (DA/DSR/DECRQM answers),
1895    /// emptying the buffer. The consumer writes them back to the PTY.
1896    pub fn drain_replies(&mut self) -> Vec<u8> {
1897        std::mem::take(&mut self.replies)
1898    }
1899
1900    /// Device Status Report (CSI Ps n): 6 = cursor position, 5 = operating
1901    /// status. Queues the reply for `drain_replies` (#27).
1902    fn device_status_report(&mut self, param: u16) {
1903        match param {
1904            6 => {
1905                // CSI row;col R, 1-based — region-relative under origin mode
1906                // (the coordinate system the app is addressing in).
1907                let row = if self.origin_mode {
1908                    self.cursor.row.saturating_sub(self.scroll_top)
1909                } else {
1910                    self.cursor.row
1911                } + 1;
1912                let col = self.cursor.col + 1;
1913                self.replies
1914                    .extend_from_slice(format!("\x1b[{row};{col}R").as_bytes());
1915            }
1916            5 => self.replies.extend_from_slice(b"\x1b[0n"), // status: OK
1917            _ => {}
1918        }
1919    }
1920
1921    /// Kitty keyboard-protocol negotiation (#23). `lead` is the leading CSI
1922    /// intermediate: `?` query, `>` push, `=` set, `<` pop.
1923    fn kitty_dispatch(&mut self, lead: u8, params: &Params) {
1924        match lead {
1925            // Query → report the current flags as `CSI ? flags u` (#27 channel).
1926            b'?' => self
1927                .replies
1928                .extend_from_slice(format!("\x1b[?{}u", self.kitty_flags).as_bytes()),
1929            // Push: save the current flags, then set the new ones (default 0).
1930            b'>' => {
1931                const KITTY_STACK_CAP: usize = 16;
1932                if self.kitty_stack.len() >= KITTY_STACK_CAP {
1933                    self.kitty_stack.remove(0); // drop the oldest on overflow
1934                }
1935                self.kitty_stack.push(self.kitty_flags);
1936                self.kitty_flags = param_or(params, 0, 0) as u8;
1937            }
1938            // Pop `n` (default 1): restore from the stack, 0 once empty.
1939            b'<' => {
1940                for _ in 0..param_or(params, 0, 1) {
1941                    self.kitty_flags = self.kitty_stack.pop().unwrap_or(0);
1942                }
1943            }
1944            // Set in place (no push): mode 1 replace, 2 or-in, 3 and-not.
1945            b'=' => {
1946                let flags = param_or(params, 0, 0) as u8;
1947                self.kitty_flags = match param_or(params, 1, 1) {
1948                    1 => flags,
1949                    2 => self.kitty_flags | flags,
1950                    3 => self.kitty_flags & !flags,
1951                    _ => self.kitty_flags,
1952                };
1953            }
1954            _ => {}
1955        }
1956    }
1957
1958    /// DECRQM (CSI ? Ps $ p): report whether DEC private mode `Ps` is set —
1959    /// `CSI ? Ps ; val $ y` with val 1=set, 2=reset, 0=not recognized (#27).
1960    fn decrqm(&mut self, mode: u16) {
1961        let state = match mode {
1962            1 => Some(self.app_cursor_keys),
1963            // DECANM (#84): set = ANSI mode (the normal state), reset = VT52.
1964            2 => Some(!self.vt52_mode),
1965            6 => Some(self.origin_mode),
1966            // DECCOLM: derived from the actual width, never a tracked flag — a
1967            // flag would lie if the consumer ignored the resize request (#82).
1968            3 => Some(self.grid.cols() == 132),
1969            7 => Some(self.autowrap),
1970            45 => Some(self.reverse_wraparound),
1971            9 => Some(self.mouse_protocol == MouseProtocol::X10),
1972            66 => Some(self.application_keypad),
1973            12 => Some(self.cursor.blink),
1974            25 => Some(self.cursor.visible),
1975            // Mouse tracking is a single-state enum (the levels are mutually
1976            // exclusive — an app enables one), so querying ?1000 while ?1002 is
1977            // active reports "reset". Faithful to that model.
1978            1000 => Some(self.mouse_protocol == MouseProtocol::Normal),
1979            1002 => Some(self.mouse_protocol == MouseProtocol::ButtonEvent),
1980            1003 => Some(self.mouse_protocol == MouseProtocol::AnyEvent),
1981            1004 => Some(self.focus_events),
1982            1006 => Some(self.mouse_encoding == MouseEncoding::Sgr),
1983            1015 => Some(self.mouse_encoding == MouseEncoding::Urxvt),
1984            1005 => Some(self.mouse_encoding == MouseEncoding::Utf8),
1985            1016 => Some(self.mouse_encoding == MouseEncoding::SgrPixels),
1986            47 | 1047 | 1049 => Some(self.on_alt),
1987            2004 => Some(self.bracketed_paste),
1988            2026 => Some(self.synchronized_output),
1989            2027 => Some(self.grapheme_clustering),
1990            2031 => Some(self.color_scheme_updates),
1991            9001 => Some(self.win32_input_mode),
1992            _ => None,
1993        };
1994        let val = match state {
1995            Some(true) => 1,
1996            Some(false) => 2,
1997            None => 0,
1998        };
1999        self.replies
2000            .extend_from_slice(format!("\x1b[?{mode};{val}$y").as_bytes());
2001    }
2002
2003    // ---- cursor / scroll primitives ------------------------------------------
2004
2005    /// Move down one line. At the bottom margin, scroll the region instead;
2006    /// below the region, just descend (no scroll). Column is unchanged (raw LF;
2007    /// CR is what returns to column 0).
2008    /// An ordinary line feed — `LF`/`VT`/`FF`, `IND` and `NEL`. None of them serves a wrap.
2009    fn linefeed(&mut self) {
2010        self.linefeed_inner(false);
2011    }
2012
2013    /// A line feed, carrying the one fact the shift itself cannot see: whether the auto-wrap asked
2014    /// for it.
2015    ///
2016    /// `serves_wrap` is the **bottom** seam's exemption in `shift_region`, the mirror of
2017    /// `evicts_to_scrollback` for the top one. When `wrapline` drives this, the blank that lands at
2018    /// the region's bottom *is* where the wrapped text is about to go — so the row that #540 would
2019    /// call "the one that lost its continuation to the blank" is in fact the row whose continuation
2020    /// that blank **is** (#557).
2021    ///
2022    /// xterm.js threads the identical fact through the identical seam, in the opposite direction:
2023    /// `BufferService.scroll(eraseAttr, isWrapped)` stamps the *destination* row
2024    /// (`common/services/BufferService.ts:68`/`:77` @ `699f553`), and exactly one of its four
2025    /// non-test callers passes `true` — the auto-wrap branch of `_print` (`InputHandler.ts:588`),
2026    /// not `lineFeed`, `index` or the ED-2 loop.
2027    fn linefeed_inner(&mut self, serves_wrap: bool) {
2028        // New-line mode (LNM ?20): a line feed also returns to column 0 (#71).
2029        if self.newline_mode {
2030            self.carriage_return();
2031        }
2032        if self.cursor.row == self.scroll_bottom {
2033            // A top-anchored primary-screen scroll pushes the evicted top line
2034            // into scrollback history.
2035            if self.scroll_top == 0 && !self.on_alt {
2036                // Scrollback accrues whenever the scroll is top-anchored on the
2037                // primary screen (`scroll_top == 0`) — but the O(1) ring handshake
2038                // only applies to a *full-screen* scroll (`scroll_bottom` at the
2039                // last row). A top-anchored *sub-region* (`[0..k]`, k < rows-1)
2040                // still accrues, yet must scroll only its region, so it keeps the
2041                // copy + region scroll. These are distinct predicates (ADR-0009).
2042                let evicted = if self.scroll_bottom == self.grid.rows() - 1 {
2043                    // Full-screen hot path: move the evicted top row out, install
2044                    // a recycled blank as the new bottom (zero-alloc steady state).
2045                    let blank = self
2046                        .recycled_row
2047                        .take()
2048                        .unwrap_or_else(|| Row::from_cells(Vec::with_capacity(self.grid.cols())));
2049                    let evicted = self.grid.scroll_up_recycle(blank);
2050                    // The one row-shifting path that does not route through `shift_region` (it
2051                    // needs the primitive that *returns* the evicted row), so it records its own
2052                    // scroll op — and owes no seam clear, which is now an argument rather than a
2053                    // coincidence: the top seam is exempt because this evicts into scrollback
2054                    // (adjacency is preserved one row back), and the bottom seam is exempt because
2055                    // this branch only runs at `scroll_bottom == rows - 1`, where a wrap on the
2056                    // last row is the state the scroll exists to serve.
2057                    self.record_scroll(self.scroll_top, self.scroll_bottom, 1);
2058                    evicted
2059                } else {
2060                    // Top-anchored sub-region: copy row 0, then region-scroll
2061                    // `[0..=scroll_bottom]` (rows below stay fixed).
2062                    //
2063                    // #449: the fixed rows keep their GRID position while
2064                    // scrollback grows, so their content's concatenated absolute
2065                    // index shifts +1 — re-anchor the content-tracking anchors
2066                    // (selection, markers; alacritty's swap-back of the fixed
2067                    // bottom lines is the screen-relative equivalent of this)
2068                    // and invalidate the query-derived highlights
2069                    // (drop-not-re-anchor policy, #108). In-region and
2070                    // scrollback content keeps stable indices — untouched.
2071                    let below = self.scrollback.len() + self.scroll_bottom + 1;
2072                    self.selection_shift_below_margin(below);
2073                    self.markers_shift_below_margin(below);
2074                    self.tracked_shift_below_margin(below);
2075                    self.invalidate_search_highlights();
2076                    let evicted = self.grid.row_owned(0);
2077                    self.shift_region(
2078                        self.scroll_top,
2079                        self.scroll_bottom,
2080                        false,
2081                        true,
2082                        serves_wrap,
2083                    );
2084                    evicted
2085                };
2086                self.scrollback.push_back(evicted);
2087                // Follow-bottom = stay: if the user is scrolled up, bump the
2088                // offset so the same lines stay in view instead of being yanked
2089                // to the bottom.
2090                if self.display_offset > 0 {
2091                    self.display_offset = (self.display_offset + 1).min(self.scrollback.len());
2092                }
2093                // Cap: evict the oldest line past the limit. The view is anchored
2094                // to history, so dropping the front shifts the offset down too
2095                // (xterm.js trims ybase and ydisp together) — also keeps the
2096                // offset within `[0, len]`. The evicted row is parked for reuse.
2097                if self.scrollback.len() > self.scrollback_limit {
2098                    self.recycled_row = self.scrollback.pop_front();
2099                    // Every absolute index below just shifted by exactly one, which is
2100                    // what makes this class of movement expressible as a scalar (#490).
2101                    // Counted here rather than in `markers_evict_oldest` because the fact
2102                    // is about the *buffer*, not about markers.
2103                    //
2104                    // **Scope, because the name over-promises.** This counts the
2105                    // scrollback *cap* evicting one line. Reflow also drops lines off the
2106                    // front (`PaneReflow::evicted`, installed by replacing the deque) and
2107                    // is deliberately not counted: it moves the survivors non-uniformly,
2108                    // so no delta repairs them and `marker_epoch` signals it instead. A
2109                    // holder rebasing off this number *without* also watching the epoch
2110                    // gets a wrong answer across every resize.
2111                    self.evicted_total += 1;
2112                    // Every absolute index just shifted down by one; move the
2113                    // selection with it so its anchors keep their content.
2114                    self.selection_evict_oldest();
2115                    // Query-derived highlights can't survive the index shift (see
2116                    // the method doc); selection re-anchors, highlights invalidate.
2117                    self.invalidate_search_highlights();
2118                    // Markers are persistent anchors: shift them down with the
2119                    // index, disposing any whose line was the evicted one (#118).
2120                    self.markers_evict_oldest();
2121                    // Same obligation for a position held outside the engine — the
2122                    // one holder in this space that had no fixup at all (#691).
2123                    self.tracked_evict_oldest();
2124                    if self.display_offset > 0 {
2125                        // Scrolled up: evicting the oldest line advanced the
2126                        // viewport, so it must be repainted (the "frozen while
2127                        // scrolled" rule does not apply when the view itself moved).
2128                        self.display_offset -= 1;
2129                        self.mark_fully_damaged();
2130                    }
2131                }
2132            } else {
2133                // Region (top margin > 0) or alt-screen scroll: the evicted line
2134                // does NOT enter scrollback, so content moves *within* the screen
2135                // and absolute indices in the region shift. Rotate the selection
2136                // up so it follows; an endpoint on the dropped line clears it.
2137                let base = self.scrollback.len();
2138                self.selection_rotate_region(
2139                    base + self.scroll_top,
2140                    base + self.scroll_bottom,
2141                    true,
2142                );
2143                // Rotate the active buffer's markers with the content (#187):
2144                // per-buffer storage (#186) scopes them, so an alt scroll rotates
2145                // *alt* marks and leaves the frozen primary list untouched — no
2146                // guard needed. `markers_rotate_region` routes via `markers_mut`.
2147                self.markers_rotate_region(base + self.scroll_top, base + self.scroll_bottom, true);
2148                self.tracked_rotate_region(base + self.scroll_top, base + self.scroll_bottom, true);
2149                self.invalidate_search_highlights();
2150                self.shift_region(
2151                    self.scroll_top,
2152                    self.scroll_bottom,
2153                    false,
2154                    false,
2155                    serves_wrap,
2156                );
2157            }
2158        } else if self.cursor.row + 1 < self.grid.rows() {
2159            self.cursor.row += 1;
2160        }
2161    }
2162
2163    /// DECSTBM (CSI r): set the top/bottom scroll margins (1-based inclusive).
2164    /// An invalid region (top ≥ bottom) is ignored.
2165    fn set_scroll_region(&mut self, top: usize, bottom: usize) {
2166        let bottom = bottom.min(self.grid.rows());
2167        if top >= bottom {
2168            return;
2169        }
2170        self.scroll_top = top - 1;
2171        self.scroll_bottom = bottom - 1;
2172        self.goto(0, 0); // DECSTBM homes the cursor (absolute)
2173    }
2174
2175    // ---- alt screen (DEC 1049) -----------------------------------------------
2176
2177    /// Enter the alternate screen: save the cursor, swap in the other grid, and
2178    /// clear it.
2179    /// Save the cursor into the alt-screen slot — `?1048` set, and the first
2180    /// half of `?1049` enter (#72).
2181    fn save_alt_cursor(&mut self) {
2182        self.saved_cursor = self.cursor;
2183    }
2184
2185    /// Restore the cursor from the alt-screen slot — `?1048` reset, and the
2186    /// second half of `?1049` leave. DECTCEM visibility is a standalone mode, not
2187    /// part of the save, so preserve it across the restore (#38/#72).
2188    fn restore_alt_cursor(&mut self) {
2189        let visible = self.cursor.visible;
2190        self.cursor = self.saved_cursor;
2191        self.cursor.visible = visible;
2192    }
2193
2194    /// Switch to the (cleared) alternate buffer without touching the cursor —
2195    /// `?47`/`?1047` set, and the second half of `?1049` enter (#72).
2196    fn switch_to_alt(&mut self) {
2197        if self.on_alt {
2198            return;
2199        }
2200        // The pulled index reports the ACTIVE buffer, so a swap changes what the
2201        // consumer's held answer even describes — with no line having moved (#490).
2202        // Gated on a marker existing on either side, since an empty index is already
2203        // correct for both buffers.
2204        if !self.normal_markers.is_empty() || !self.alt_markers.is_empty() {
2205            self.bump_marker_epoch();
2206        }
2207        std::mem::swap(&mut self.grid, &mut self.alt_grid);
2208        self.grid.clear();
2209        self.on_alt = true;
2210        self.display_offset = 0; // the alt screen has no scrollback to view
2211        self.selection = None; // a selection cannot survive a screen swap
2212        self.invalidate_search_highlights(); // matches index the primary buffer
2213        self.mark_fully_damaged();
2214    }
2215
2216    /// Switch back to the primary buffer without touching the cursor —
2217    /// `?47`/`?1047` reset, and the first half of `?1049` leave (#72).
2218    fn switch_to_primary(&mut self) {
2219        if !self.on_alt {
2220            return;
2221        }
2222        // Dispose the alt buffer's markers on leave — xterm `activateNormalBuffer`
2223        // → `clearAllMarkers` (#177 S0). Empty while the alt guards stand, so this
2224        // fires nothing today; it's the seam the alt-marker slices (#187) build on.
2225        for m in self.alt_markers.drain(..) {
2226            self.events.push(TermEvent::MarkerDisposed(m.id));
2227        }
2228        // Same fate for alt-scoped tracked points, and for the same reason: the alt
2229        // buffer is not archived, so leaving it destroys what they named (#691).
2230        // No announcement — `tracked_point` answers `None` on the next ask.
2231        // The pulled index reports the ACTIVE buffer, so a swap changes what the
2232        // consumer's held answer even describes — with no line having moved (#490).
2233        // Only `normal_markers` is asked: the drain above just emptied `alt_markers`, so
2234        // an `|| !alt_markers.is_empty()` disjunct would be dead code carrying a comment
2235        // that claims it reads "either side". What was alt-scoped left through
2236        // `MarkerDisposed`; what can still be stale is the primary population.
2237        if !self.normal_markers.is_empty() {
2238            self.bump_marker_epoch();
2239        }
2240        self.alt_tracked.clear();
2241        std::mem::swap(&mut self.grid, &mut self.alt_grid);
2242        self.on_alt = false;
2243        self.display_offset = 0; // return to the primary at its bottom
2244        self.selection = None; // a selection cannot survive a screen swap
2245        self.invalidate_search_highlights(); // matches index the swapped-out buffer
2246        self.mark_fully_damaged();
2247    }
2248
2249    fn enter_alt_screen(&mut self) {
2250        if self.on_alt {
2251            return;
2252        }
2253        self.save_alt_cursor();
2254        self.switch_to_alt();
2255    }
2256
2257    /// Leave the alternate screen: swap the primary grid back in and restore the
2258    /// saved cursor.
2259    fn leave_alt_screen(&mut self) {
2260        if !self.on_alt {
2261            return;
2262        }
2263        self.switch_to_primary();
2264        self.restore_alt_cursor();
2265    }
2266
2267    /// RI (ESC M): move up one line. At the top margin, scroll the region down
2268    /// instead.
2269    fn reverse_index(&mut self) {
2270        if self.cursor.row == self.scroll_top {
2271            // RI never enters scrollback; the region scrolls down within the
2272            // screen, so absolute indices in it shift down. Rotate the selection.
2273            let base = self.scrollback.len();
2274            self.selection_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2275            // Rotate the active buffer's markers (#187) — alt-scoped on the alt
2276            // screen, so no guard (see `linefeed`).
2277            self.markers_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2278            self.tracked_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2279            self.invalidate_search_highlights();
2280            self.shift_region(self.scroll_top, self.scroll_bottom, true, false, false);
2281        } else if self.cursor.row > 0 {
2282            self.cursor.row -= 1;
2283        }
2284    }
2285
2286    // ---- cursor save/restore (DECSC / DECRC) ---------------------------------
2287
2288    /// DECSC (ESC 7): save the cursor position, pen, pending-wrap, and origin
2289    /// mode. Visibility is not saved (DECTCEM is separate).
2290    fn save_cursor(&mut self) {
2291        self.decsc = SavedCursor {
2292            row: self.cursor.row,
2293            col: self.cursor.col,
2294            pen: self.cursor.pen,
2295            pending_wrap: self.cursor.pending_wrap,
2296            origin_mode: self.origin_mode,
2297            charsets: self.charsets,
2298            gl: self.gl,
2299        };
2300    }
2301
2302    /// DECRC (ESC 8): restore what DECSC saved. Origin mode is restored (per
2303    /// ADR-0004); visibility is left as-is. The position is clamped to the
2304    /// current screen in case it shrank since the save.
2305    fn restore_cursor(&mut self) {
2306        let s = self.decsc;
2307        self.cursor.row = s.row.min(self.grid.rows() - 1);
2308        self.cursor.col = s.col.min(self.grid.cols() - 1);
2309        self.cursor.pen = s.pen;
2310        self.cursor.pending_wrap = s.pending_wrap;
2311        self.origin_mode = s.origin_mode;
2312        self.charsets = s.charsets;
2313        self.gl = s.gl;
2314    }
2315
2316    /// RIS (ESC c) — full reset to the power-on state (#53). Reconstruct every
2317    /// screen/mode field to its construction default (preserving only the
2318    /// dimensions and the scrollback cap), but keep the consumer-bound output
2319    /// queues (`replies`/`events`) that accrued earlier in this `feed`, and
2320    /// signal a full repaint. The vte parser lives outside `Term`, so replacing
2321    /// `self` does not disturb in-progress parsing. Mirrors xterm.js fullReset.
2322    fn full_reset(&mut self) {
2323        let replies = std::mem::take(&mut self.replies);
2324        let mut events = std::mem::take(&mut self.events);
2325        // RIS wipes the buffer, so every marker's line is gone — announce each
2326        // disposal so the consumer drops its decorations (and isn't confused when
2327        // the reset id counter reissues the same ids). The events survive the
2328        // reset below (#118).
2329        events.extend(
2330            self.normal_markers
2331                .iter()
2332                .chain(&self.alt_markers)
2333                .map(|m| TermEvent::MarkerDisposed(m.id)),
2334        );
2335        let (cols, rows) = (self.grid.cols(), self.grid.rows());
2336        // The word-boundary set is consumer *policy* (ADR-0017), not terminal state, so
2337        // RIS does not own it — an application printing `reset` must not silently revert
2338        // a setting the embedder chose. It rides across with `replies`/`events` because
2339        // this reset rebuilds `Term` wholesale; the references never face the question,
2340        // holding the equivalent outside the object RIS clears (#545).
2341        let word_separators = std::mem::take(&mut self.word_separators);
2342        // Tracked points die here with everything else, but their *id counter*
2343        // rides across (#691). They have no disposal event — a holder learns its
2344        // point is gone by being told `None` — so a reissued id would answer a
2345        // stale ask with a *different* point's position, silently. The markers
2346        // above take the other route and announce; the counter is what a pull-only
2347        // handle has instead.
2348        let next_tracked_id = self.next_tracked_id;
2349        // The marker epoch rides across too, and then moves — for the reason one
2350        // paragraph up, now that a marker index is *pulled* as well as announced (#490).
2351        // A consumer re-pulls when the epoch differs; resetting it to 0 leaves it equal
2352        // to the value a quiet session already holds, so the one signal it watches would
2353        // not fire for the mutation that invalidates everything. `evicted_total`
2354        // legitimately restarts — the buffer it counted is gone — and the epoch change is
2355        // what stops the consumer rebasing against the old basis.
2356        let marker_epoch = self.marker_epoch;
2357        // Marker ids ride across for the *same* reason as `next_tracked_id`, which this
2358        // slice makes true of markers: a pulled handle outlives the announcement that
2359        // killed it, so a reissued id lets a stale `MarkerDisposed(7)` drop the live
2360        // post-RIS marker 7.
2361        let next_marker_id = self.next_marker_id;
2362        *self = Term::with_scrollback(cols, rows, self.scrollback_limit);
2363        self.replies = replies;
2364        self.events = events;
2365        self.word_separators = word_separators;
2366        self.next_tracked_id = next_tracked_id;
2367        self.next_marker_id = next_marker_id;
2368        self.marker_epoch = marker_epoch;
2369        self.bump_marker_epoch();
2370        self.mark_fully_damaged();
2371    }
2372
2373    /// DECSTR (CSI ! p) — soft reset (#53). Resets a defined subset of modes to
2374    /// their defaults *without* destroying screen content or scrollback, moving
2375    /// the active cursor, or touching the mouse/focus reporting subsystem. Per
2376    /// xterm.js softReset, autowrap returns to ON (the xterm default), not off.
2377    fn soft_reset(&mut self) {
2378        self.cursor.visible = true;
2379        self.cursor.pen = Pen::default();
2380        self.scroll_top = 0;
2381        self.scroll_bottom = self.grid.rows() - 1;
2382        self.origin_mode = false;
2383        self.app_cursor_keys = false;
2384        self.bracketed_paste = false;
2385        self.grapheme_clustering = false; // ?2027 back to the wcwidth-compat default (#295)
2386        self.autowrap = true; // xterm default is ON (not the VT100 "off")
2387        self.insert_mode = false;
2388        self.charsets = [Charset::Ascii; 4];
2389        self.gl = 0;
2390        self.decsc = SavedCursor::default();
2391    }
2392
2393    fn carriage_return(&mut self) {
2394        self.cursor.col = 0;
2395        self.cursor.pending_wrap = false;
2396    }
2397
2398    /// DECSCUSR (CSI Ps SP q): set the caret shape + blink (#89). 0/2 = steady
2399    /// block, 1 = blinking block; 3/4 = blinking/steady underline; 5/6 =
2400    /// blinking/steady bar (odd = blink). 0 resets to the default (steady block).
2401    /// An unknown param leaves the style unchanged. Mirrors xterm.js.
2402    fn set_cursor_style(&mut self, param: u16) {
2403        let (shape, blink) = match param {
2404            0 | 2 => (CursorShape::Block, false),
2405            1 => (CursorShape::Block, true),
2406            3 => (CursorShape::Underline, true),
2407            4 => (CursorShape::Underline, false),
2408            5 => (CursorShape::Bar, true),
2409            6 => (CursorShape::Bar, false),
2410            _ => return,
2411        };
2412        self.cursor.shape = shape;
2413        self.cursor.blink = blink;
2414    }
2415
2416    /// Backspace (BS, 0x08): move the cursor one column left. With reverse
2417    /// wraparound (?45) a backspace at column 0 of a *soft-wrapped* row moves
2418    /// back to the last column of the previous row — undoing one autowrap. Only
2419    /// soft wraps reverse (the previous row carries `WRAPLINE`); a hard CR/LF
2420    /// line does not. BS only (not cursor-left), matching xterm.js (#80).
2421    fn backspace(&mut self) {
2422        self.cursor.pending_wrap = false;
2423        if self.cursor.col > 0 {
2424            self.cursor.col -= 1;
2425            return;
2426        }
2427        if self.reverse_wraparound
2428            && self.cursor.row > self.scroll_top
2429            && self.cursor.row <= self.scroll_bottom
2430        {
2431            let prev = self.cursor.row - 1;
2432            let last = self.grid.cols() - 1;
2433            if self.grid.row_ref(prev).is_wrapped() {
2434                self.grid.row_mut(prev).set_wrapped(false);
2435                self.cursor.row = prev;
2436                self.cursor.col = last;
2437            }
2438        }
2439    }
2440
2441    /// Auto-wrap at end of line: line-feed then return to column 0.
2442    fn wrapline(&mut self) {
2443        self.linefeed_inner(true);
2444        self.cursor.col = 0;
2445        self.cursor.pending_wrap = false;
2446    }
2447
2448    // ---- tab stops (HT / HTS / TBC) ------------------------------------------
2449
2450    /// HT: advance to the next set tab stop, or the last column if none remain
2451    /// (no wrap).
2452    fn put_tab(&mut self) {
2453        let cols = self.grid.cols();
2454        let mut col = self.cursor.col;
2455        while col + 1 < cols {
2456            col += 1;
2457            if self.tabs[col] {
2458                break;
2459            }
2460        }
2461        self.cursor.col = col;
2462        self.cursor.pending_wrap = false;
2463    }
2464
2465    /// HTS (ESC H): set a tab stop at the cursor column.
2466    fn set_tab_stop(&mut self) {
2467        let col = self.cursor.col;
2468        self.tabs[col] = true;
2469    }
2470
2471    /// TBC (CSI g): clear the tab stop at the cursor (mode 0) or all stops
2472    /// (mode 3).
2473    fn clear_tab_stop(&mut self, mode: u16) {
2474        match mode {
2475            0 => {
2476                let col = self.cursor.col;
2477                self.tabs[col] = false;
2478            }
2479            3 => self.tabs.iter_mut().for_each(|t| *t = false),
2480            _ => {}
2481        }
2482    }
2483
2484    // ---- printing ------------------------------------------------------------
2485
2486    /// The extended attributes the pen currently stamps onto a cell it writes: the open OSC 8
2487    /// hyperlink (#26/#46) and a non-default underline colour (SGR 58, #520).
2488    ///
2489    /// The colour is gated on the UNDERLINE attribute — an underline colour is meaningless on a
2490    /// cell that draws no underline, and xterm likewise does not persist it there
2491    /// (`AttributeData isEmpty()` ignores the colour; `InputHandler.test.ts:2084`). That keeps
2492    /// it off the wire for cells that never draw it (ADR-0020: no inert per-cell payload). SGR 58
2493    /// is the *underline* colour, so STRIKETHROUGH alone does not arm it.
2494    ///
2495    /// One place, because there are three sites that write a pen-built cell (the glyph, its wide
2496    /// spacer, and the vacated wrap column) — mirroring the pen half of `Row::ext_attrs_at`, so a
2497    /// later rider is added here rather than at each of them (#521/#528).
2498    fn pen_ext_attrs(&self) -> ExtAttrs {
2499        let ucolor = self.cursor.pen.underline_color;
2500        let armed =
2501            ucolor != Color::Default && self.cursor.pen.flags.contains(CellFlags::UNDERLINE);
2502        ExtAttrs::from_pen(self.current_link.clone(), armed.then_some(ucolor))
2503    }
2504
2505    /// Free a cell that has stopped being part of a glyph — the *structural repair* every
2506    /// overwrite, erase and row-shift owes the no-orphan invariant when it destroys one half of
2507    /// a width-2 glyph, plus the spacer a mode-2027 demotion no longer needs.
2508    ///
2509    /// This is **not** an erase. The app asked for something at a *different* column; freeing
2510    /// this one is the engine keeping its own invariant. But it is still a mutation, so it
2511    /// **damages** — and that is the half every site used to forget, because each function
2512    /// damaged its own range and the repaired cell lies outside it by construction (that is what
2513    /// makes it a repair). A frame-mode consumer therefore kept painting the destroyed glyph.
2514    /// Bundling the reset with its damage is the point of this helper: a repair site added later
2515    /// cannot forget the half that has no compiler behind it (#530).
2516    ///
2517    /// The cell it leaves is a **blank carrying the current background** — the same rule
2518    /// `clear_cells` already applies to a BCE erase, extended to the repair, so one sentence
2519    /// covers both: *a blank cell carries the current background.* A bare `Cell::default()`
2520    /// would punch an uncoloured notch into a coloured run, which no reference implementation
2521    /// does.
2522    ///
2523    /// Deliberately the pen's **background only** — not its full attributes, and this is not a
2524    /// compromise between references: it is byte-for-byte xterm.js's `_eraseAttrData()`
2525    /// (`DEFAULT_ATTR_DATA` + `curAttr.bg & ~0xFC000000`, i.e. default everything plus the pen's
2526    /// background colour), which is what its `replaceCells` / `insertCells` / `deleteCells`
2527    /// repairs are handed — eight of the twelve sites here. Only xterm's *print* path uses the
2528    /// whole pen. Taking the whole pen
2529    /// (xterm.js `setCellFromCodepoint(x, 0, 1, curAttr)`) would plant the pen's hyperlink and,
2530    /// worse, its DECSCA protection onto a cell the app never wrote — a cell no later erase could
2531    /// clear. Taking the *cell's own* attributes (alacritty `clear_wide`, which keeps `extra`)
2532    /// would leave the destroyed glyph's hyperlink alive and clickable, the defect #529 is filed
2533    /// against. Both were considered and rejected; the maintainer chose this on 2026-07-24 and it
2534    /// is theirs to reverse (see #530 for what they were shown).
2535    ///
2536    /// What used to be recorded here as a known limitation is **resolved** (#538, ADR-0025 D1):
2537    /// `reset()` still clears the whole content word, but the soft-wrap link is no longer part of
2538    /// it. The live flag is on the `Row`, so freeing the last column — here or on the erase path —
2539    /// cannot break a wrap; `CellFlags::WRAPLINE` is wire-only, derived onto the last cell at
2540    /// encode time and never read back (`cell.rs`). Ending a wrap is now an explicit per-verb call
2541    /// (`end_wrap`), which is the shape both references already had and the reason the move was
2542    /// made: ghostty and xterm.js hold the flag on the row/line, and xterm.js takes `clearWrap` as
2543    /// an explicit argument on its erase helper (`_eraseInBufferLine`, `InputHandler.ts:1175`)
2544    /// rather than letting a cell clear decide it.
2545    ///
2546    /// Known cost, accepted rather than overlooked: with DECSCA the freed cell loses its
2547    /// protection. ghostty has the same hole and flags it in its own source; justerm does not
2548    /// implement DECSCA today, so revisit if it lands.
2549    fn free_cell(&mut self, row: usize, col: usize) {
2550        let bg = self.cursor.pen.bg;
2551        let cell = self.grid.cell_mut(row, col);
2552        cell.reset();
2553        cell.set_bg(bg);
2554        // As in `clear_cells`: the bits are gone, so release what they gated (#628).
2555        self.grid.row_mut(row).purge_side_maps(col..col + 1);
2556        self.damage_span(row, col, col);
2557    }
2558
2559    /// Will the next `wrapline()` actually reach another row?
2560    ///
2561    /// `wrapline` → `linefeed` advances in exactly two cases: the cursor sits at the scroll
2562    /// region's bottom (so the region scrolls under it), or it has a row below it on screen.
2563    /// Parked *below* a DECSTBM region on the last row it does neither — it silently stays put.
2564    ///
2565    /// Both wide-at-boundary paths must ask before they commit anything, because both destroy
2566    /// content on the assumption that the row is about to change: `write_glyph` blanks the column
2567    /// it is leaving, and `relocate_cluster_wide` writes its cluster to `(cursor.row, 0..=1)`
2568    /// *after* the wrap — which is the same row when nothing advanced, so it lands on live cells.
2569    /// Reasoning only about the vacated source column misses that second case entirely.
2570    ///
2571    /// This mirrors `linefeed`'s own condition; the two must be read together.
2572    fn wrapline_advances(&self) -> bool {
2573        self.cursor.row == self.scroll_bottom || self.cursor.row + 1 < self.grid.rows()
2574    }
2575
2576    /// Blank the last column as the soft-wrap artefact it is, when a width-2 glyph could not fit
2577    /// there (#528). Shared by the two paths that reach this state: `write_glyph`'s wide-at-boundary
2578    /// wrap and `relocate_cluster_wide`'s promoted cluster.
2579    ///
2580    /// The column is **written**, not merely flagged: a blank built from the current pen, exactly
2581    /// as every reference does it — xterm.js `setCellFromCodepoint(col, 0, 1, curAttr)`
2582    /// (`InputHandler.ts:609-611`; `BufferLine.ts:244-251` takes the pen's fg/bg *and* its
2583    /// `extended` link/colour), ghostty `printCell(0, .spacer_head)` (`Terminal.zig:1410-1412`,
2584    /// whose `printCell` stamps the cursor's hyperlink), alacritty `write_at_cursor(' ')` under a
2585    /// `LEADING_WIDE_CHAR_SPACER` template (`mod.rs:1108-1113`, assigning `extra` from it).
2586    ///
2587    /// Flagging in place instead left the previous occupant's glyph, hyperlink and underline colour
2588    /// alive in a cell every text reader skips — so a renderer drew a character that could not be
2589    /// copied, searched or announced (#528). Building from `Pen::cell` also clears the presence
2590    /// bits, so no stale side-map entry can be read back through the new cell.
2591    ///
2592    /// WRAPLINE marks the row a continuation rather than a hard line-end (search, logical lines
2593    /// #113 and reflow #7 all read it); the leading-spacer marker makes the text extractors skip
2594    /// the blank instead of joining `"ab한"` → `"ab 한"`.
2595    ///
2596    /// The marker is **alacritty's** `LEADING_WIDE_CHAR_SPACER` (`term/cell.rs`) — ghostty calls
2597    /// the same thing `.spacer_head`. It is *not* xterm's: xterm.js has no marker at all, writing
2598    /// a bare null cell and re-inferring the artefact at reflow time from "ends in null and the
2599    /// following line starts with a wide char". That difference has a consequence here — in
2600    /// xterm.js a lost marker degrades to an empty cell that trimming drops anyway, whereas
2601    /// justerm writes `' '`, so the marker is the *only* thing keeping this column out of the
2602    /// extracted text.
2603    fn vacate_for_wrap(&mut self, row: usize, col: usize) {
2604        // Writing this column makes the vacate an overwrite like any other, so it inherits the
2605        // no-orphan obligation every other overwrite site carries (`write_glyph`,
2606        // `promote_cluster_to_wide`, `insert_chars`, `delete_chars`, the erase path): if the
2607        // column was the *spacer* of a wide glyph, blanking it destroys the spacer marker and
2608        // strands the lead. That is unrecoverable rather than merely untidy — every repair path
2609        // keys off `is_wide_spacer()`, so once the marker is gone no later write, ECH, EL, ICH
2610        // or DCH can ever clear the orphan.
2611        if col > 0 && self.grid.cell(row, col).is_wide_spacer() {
2612            self.free_cell(row, col - 1);
2613        }
2614        let mut vacated = self.cursor.pen.cell(' ');
2615        vacated.set_leading_spacer();
2616        *self.grid.cell_mut(row, col) = vacated;
2617        self.begin_wrap(row);
2618        let ext = self.pen_ext_attrs();
2619        self.grid.row_mut(row).set_ext_attrs(col, ext);
2620        // The cell's contents changed, so a frame-mode consumer must be told or it keeps painting
2621        // the old glyph (ADR-0003: every mutation site records damage). The *repaired* lead above
2622        // is damaged by `free_cell`, which owns that pairing for all twelve repair sites — this
2623        // site used to hand-roll it, and keeping both left neither able to discriminate.
2624        self.damage_span(row, col, col);
2625    }
2626
2627    /// Write one glyph at the cursor, handling deferred wrap and the wide-char
2628    /// spacer, then advance the cursor (deferring the wrap if it hits the edge).
2629    fn write_glyph(&mut self, c: char, width: usize) {
2630        // Every wide branch below is gated on `width == 2`, and the four unguarded uses
2631        // (`insert_chars`, `col + width - 1` twice, the cursor advance) assume the same bound.
2632        // The caller coerces (#595); this states the assumption at the site that holds it, so a
2633        // future second caller fails a test rather than writing an unmarked run of blanks.
2634        // Ghostty pairs its own source-side clamp with the same assertion for the same reason
2635        // (`Terminal.zig`, *"it is possible to have a width of 3 … assert(width <= 2)"*).
2636        debug_assert!(
2637            width <= 2,
2638            "write_glyph({c:?}, {width}) — the cell model represents at most a pair"
2639        );
2640        let cols = self.grid.cols();
2641
2642        // Resolve a deferred last-column wrap before placing the next glyph.
2643        // The row being left soft-wrapped: mark its last cell so reflow (#7) can
2644        // tell it from a hard CR/LF line-end.
2645        if self.cursor.pending_wrap {
2646            let row = self.cursor.row;
2647            // Claim the wrap only if there will *be* a next row to continue into. Parked below a
2648            // DECSTBM region on the last row, `wrapline` → `linefeed` advances nothing and the
2649            // glyph overwrites this same row from column 0 — so the wrap never happened, and a
2650            // flag set here is permanently false: the cursor never leaves, nothing clears it, and
2651            // it survives into `backspace`'s reverse-wraparound, reflow, and every text reader.
2652            //
2653            // The predicate is not new and neither is its rationale: `wrapline_advances` was
2654            // written for exactly this state and is already asked by both wide-at-boundary paths.
2655            // This narrow path was the one caller that committed without asking. (Surfaced by the
2656            // #540 completeness pass, which found a row-shift verb inheriting the bogus flag and
2657            // merging two unrelated logical lines.)
2658            if self.wrapline_advances() {
2659                self.begin_wrap(row);
2660            }
2661            self.wrapline();
2662        }
2663
2664        // A width-2 glyph that cannot fit in the last column wraps first — unless
2665        // autowrap is off, in which case it is dropped (xterm.js `continue`), not
2666        // squeezed or wrapped.
2667        if width == 2 && self.cursor.col + 1 >= cols {
2668            if !self.autowrap {
2669                return;
2670            }
2671            // …but only if the wrap actually happens: vacating for a wrap that never occurs
2672            // blanks a column holding a live glyph.
2673            if self.wrapline_advances() {
2674                self.vacate_for_wrap(self.cursor.row, cols - 1);
2675            }
2676            self.wrapline();
2677        }
2678
2679        // Insert mode (IRM): open a `width`-wide gap at the cursor first, shifting
2680        // the row's tail right (off-edge cells discarded, wide halves repaired),
2681        // then write into the gap — mirrors xterm.js's insertCells (#64).
2682        if self.insert_mode {
2683            self.insert_chars(width);
2684        }
2685
2686        let (row, col) = (self.cursor.row, self.cursor.col);
2687
2688        // Overwriting either half of a pair that wrapped from the row above ends that pair, so the
2689        // row above's artefact record is void (#534). The one exception is the in-place same-width
2690        // overwrite — a wide lead replaced by another wide lead at the same column — which is
2691        // ghostty's `if (cell.wide != wide)` escape and the reason this is asked *before* the
2692        // write rather than after it. Note IRM has already run its own check inside `insert_chars`
2693        // by the time this would fire, on the pre-shift state, which is the correct one.
2694        if col <= 1 && self.wrapped_pair_at_row_start(row) && !(col == 0 && width == 2) {
2695            self.void_wrap_artefact_above(row);
2696        }
2697
2698        // Overwriting one half of an existing wide glyph orphans the other —
2699        // clear it so no stray lead/spacer is left behind.
2700        let last = col + width - 1;
2701        if col > 0 && self.grid.cell(row, col).is_wide_spacer() {
2702            self.free_cell(row, col - 1);
2703        }
2704        if last + 1 < cols && self.grid.cell(row, last).is_wide() {
2705            self.free_cell(row, last + 1);
2706        }
2707
2708        let mut cell = self.cursor.pen.cell(c);
2709        if width == 2 {
2710            cell.insert_flags(CellFlags::WIDE_CHAR);
2711        }
2712        *self.grid.cell_mut(row, col) = cell;
2713        // Stamp the pen's extended attrs — the open hyperlink (#26/#46) and a non-default
2714        // underline colour (#520) — into the row's side maps.
2715        let ext = self.pen_ext_attrs();
2716        // `.clone()`: `ExtAttrs` stopped being `Copy` at #628 (the link rider is a shared
2717        // `Arc<str>`), and the spacer below stamps the same value — a refcount bump, not
2718        // a second string.
2719        self.grid.row_mut(row).set_ext_attrs(col, ext.clone());
2720
2721        // The trailing column of a wide glyph carries a distinct spacer marker —
2722        // and the same link + underline colour, so a hover/selection/underline over
2723        // either half agrees.
2724        if width == 2 && col + 1 < cols {
2725            let mut spacer = self.cursor.pen.cell(' ');
2726            spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
2727            *self.grid.cell_mut(row, col + 1) = spacer;
2728            self.grid.row_mut(row).set_ext_attrs(col + 1, ext);
2729        }
2730
2731        // Record damage for the cell(s) just written.
2732        self.damage_span(row, col, col + width - 1);
2733
2734        // Advance. Reaching/passing the last column sets pending-wrap instead of
2735        // wrapping eagerly — the cursor parks on the last column.
2736        let new_col = col + width;
2737        if new_col >= cols {
2738            self.cursor.col = cols - 1;
2739            // With autowrap off (DECAWM ?7l) the cursor pins to the last column
2740            // and the next glyph overwrites in place — no deferred wrap (#63).
2741            self.cursor.pending_wrap = self.autowrap;
2742        } else {
2743            self.cursor.col = new_col;
2744        }
2745    }
2746
2747    /// Attach a combining mark (width-0 code point) to the grapheme it modifies —
2748    /// the cell the cursor just left. With pending-wrap the cursor still sits on
2749    /// the just-written last-column glyph, so attach in place (no back-up, no
2750    /// deferred wrap); otherwise step back one column, and once more over a
2751    /// wide-char spacer to reach its lead. Stored in the grapheme side-table.
2752    fn push_combining(&mut self, c: char) {
2753        let row = self.cursor.row;
2754        let mut col = if self.cursor.pending_wrap {
2755            self.cursor.col
2756        } else {
2757            self.cursor.col.saturating_sub(1)
2758        };
2759        if self.grid.cell(row, col).is_wide_spacer() {
2760            col = col.saturating_sub(1);
2761        }
2762        // Append the mark to the row's combining map at this column (setting the
2763        // cell's combining bit). No global pool — the cluster rides the row.
2764        self.grid.row_mut(row).push_combining(col, c);
2765        self.damage_span(row, col, col);
2766    }
2767
2768    /// Mode 2027 (#295): if `c` **extends** the previous cell's grapheme cluster (UAX #29), append
2769    /// it to that cell's side-table — no new cell, no cursor advance — and return `true`. Otherwise
2770    /// return `false` so `print` takes the normal per-scalar path (a break starts a new cell).
2771    ///
2772    /// The break state is reconstructed fresh from the previous cell's stored cluster (base scalar +
2773    /// side-table marks) rather than persisted across calls, so cursor moves / CR-LF can't corrupt
2774    /// it (mirrors ghostty). Width promotion for a narrow base (a flag's second RI, a text-base +
2775    /// VS16) is handled by the caller in a later step; here the base's existing width holds.
2776    fn try_grapheme_join(&mut self, c: char) -> bool {
2777        let row = self.cursor.row;
2778        // Locate the previous cluster's base cell, exactly as `push_combining`: with pending-wrap
2779        // the cursor still sits on the last glyph; else step back one, and over a wide spacer.
2780        let col = if self.cursor.pending_wrap {
2781            self.cursor.col
2782        } else if self.cursor.col == 0 {
2783            return false; // nothing precedes on this row
2784        } else {
2785            self.cursor.col - 1
2786        };
2787        let col = if self.grid.cell(row, col).is_wide_spacer() {
2788            col.saturating_sub(1)
2789        } else {
2790            col
2791        };
2792        // Reconstruct the previous cluster's text: base scalar + any already-joined scalars.
2793        let mut prev = String::new();
2794        prev.push(self.grid.cell(row, col).c());
2795        if let Some(marks) = self.grid.row_ref(row).combining_at(col) {
2796            prev.extend(marks.iter().copied());
2797        }
2798        if !crate::grapheme::grapheme_extends(&prev, c) {
2799            return false;
2800        }
2801        // Join: ride the side-table (no new cell).
2802        self.grid.row_mut(row).push_combining(col, c);
2803        // Width promotion: a flag's second regional indicator, or a text-base + VS16, grows the
2804        // cluster to width 2. `UnicodeWidthStr` gives the cluster width (RI-pair → 2, VS16 → 2). If
2805        // the base cell is still narrow, widen it in place.
2806        let cluster_w = {
2807            prev.push(c);
2808            UnicodeWidthStr::width(prev.as_str())
2809        };
2810        if cluster_w == 2 && !self.grid.cell(row, col).is_wide() {
2811            self.promote_cluster_to_wide(row, col);
2812        } else if cluster_w == 1 && self.grid.cell(row, col).is_wide() {
2813            // The mirror case: a default-wide emoji + VS15 (text selector) shrinks to width 1.
2814            self.demote_cluster_to_narrow(row, col);
2815        }
2816        self.damage_span(row, col, col);
2817        true
2818    }
2819
2820    /// Shrink a wide cluster cell back to a single-width cell (#295): a default-wide emoji joined by
2821    /// VS15 (U+FE0E, the text selector) requests text presentation → width 1. Remove `WIDE_CHAR`,
2822    /// free the spacer, and back the cursor up over it (the inverse of `promote_cluster_to_wide`).
2823    fn demote_cluster_to_narrow(&mut self, row: usize, col: usize) {
2824        let cols = self.grid.cols();
2825        self.grid
2826            .cell_mut(row, col)
2827            .remove_flags(CellFlags::WIDE_CHAR);
2828        if col + 1 < cols {
2829            self.free_cell(row, col + 1); // free the now-unused spacer
2830        }
2831        // The cluster shrank 2→1: the cursor sat just past the wide cell (col+2, or pending-wrap on
2832        // the last column); it now sits just past the single-width cell at col+1.
2833        self.cursor.pending_wrap = false;
2834        self.cursor.col = (col + 1).min(cols - 1);
2835        self.damage_span(row, col, (col + 1).min(cols - 1));
2836    }
2837
2838    /// Widen a narrow base cell to a double-width cluster in place (#295): set `WIDE_CHAR`, write
2839    /// its spacer, and step the cursor over it. Only reached when a joining scalar (flag's 2nd RI,
2840    /// VS16) promotes the cluster to width 2. A base pinned at the last column has no room for a
2841    /// spacer — relocation is a later step; until then it stays narrow (rare, renders single-width).
2842    fn promote_cluster_to_wide(&mut self, row: usize, col: usize) {
2843        let cols = self.grid.cols();
2844        if col + 1 >= cols {
2845            // No spacer room at the last column: relocate the whole cluster to the next line as a
2846            // wide cell (the row soft-wraps), mirroring write_glyph's wide-at-boundary wrap (#303).
2847            self.relocate_cluster_wide(row, col);
2848            return;
2849        }
2850        // Overwriting col+1 with the spacer can orphan the far half of a WIDE glyph standing there
2851        // (the cursor may have been repositioned before the joining scalar arrived). Reset that
2852        // orphan, exactly as write_glyph does (2462-2470), so no dangling spacer survives.
2853        if self.grid.cell(row, col + 1).is_wide() && col + 2 < cols {
2854            self.free_cell(row, col + 2);
2855        }
2856        self.grid
2857            .cell_mut(row, col)
2858            .insert_flags(CellFlags::WIDE_CHAR);
2859        // The spacer is the lead's second half, so it takes the LEAD's extended attrs — the
2860        // hyperlink and underline colour riding the row's side maps — exactly as write_glyph
2861        // stamps both halves of a wide write. `pen.cell(' ')` carries neither (and the pen may
2862        // have moved on since the base was printed), so they are re-attached here; a base with
2863        // none clears whatever the overwritten column held (#521).
2864        let ext = self.grid.row_ref(row).ext_attrs_at(col);
2865        let mut spacer = self.cursor.pen.cell(' ');
2866        spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
2867        *self.grid.cell_mut(row, col + 1) = spacer;
2868        self.grid.row_mut(row).set_ext_attrs(col + 1, ext);
2869        // The cursor sat at col+1 (just past the narrow base); move it over the new spacer, applying
2870        // the same last-column pending-wrap rule as a wide write.
2871        let new_col = col + 2;
2872        if new_col >= cols {
2873            self.cursor.col = cols - 1;
2874            self.cursor.pending_wrap = self.autowrap;
2875        } else {
2876            self.cursor.col = new_col;
2877        }
2878        self.damage_span(row, col, col + 1);
2879    }
2880
2881    /// Relocate a last-column narrow cluster to the next line as a wide cell (#303): its base +
2882    /// side-table marks move to `(next_row, 0..=1)` and the vacated last column becomes a soft-wrap
2883    /// (WRAPLINE + leading spacer), exactly as `write_glyph` wraps a wide glyph that can't fit. With
2884    /// autowrap off it stays narrow.
2885    ///
2886    /// The destination is an **overwrite**, so it owes the no-orphan repair every other overwrite
2887    /// site owes (#529, ADR-0025 D4) — see the comment at that site for why justerm restates it
2888    /// once per wide-writing path where the references get it structurally.
2889    ///
2890    /// The `cols < 2` arm is **unreachable since #547** —
2891    /// `MIN_COLUMNS = 2` is the floor on every path that sets a width — and is kept only as a
2892    /// bounds guard for the `col + 1` writes below, not as a described behaviour.
2893    fn relocate_cluster_wide(&mut self, row: usize, col: usize) {
2894        let cols = self.grid.cols();
2895        if cols < 2 || !self.autowrap || !self.wrapline_advances() {
2896            // Nowhere to place a wide cell — leave it narrow. `!wrapline_advances()` joins the
2897            // other two for the same reason: with no next row, the relocation would write the
2898            // cluster over columns 0-1 of the *current* row and destroy whatever is there.
2899            return;
2900        }
2901        // Capture the base cell (glyph + attrs), its marks, and its extended attrs before
2902        // vacating. The extended attrs (hyperlink, underline colour) must be read HERE and not
2903        // after the move: they live in the *source row's* side maps, and `wrapline()` below may
2904        // scroll — after which that row is a different (or recycled) `Row` (#521).
2905        let base = *self.grid.cell(row, col);
2906        let marks: Vec<char> = self
2907            .combining_at(row, col)
2908            .map(<[char]>::to_vec)
2909            .unwrap_or_default();
2910        let ext = self.grid.row_ref(row).ext_attrs_at(col);
2911        // Vacate the last column as a soft-wrap artefact — the same step `write_glyph` takes for a
2912        // wide glyph that cannot fit, and now literally the same code, so the two cannot drift
2913        // apart again (#528; they held opposite behaviours until then).
2914        self.vacate_for_wrap(row, col);
2915        // Advance to the next line (scrolls if at the bottom); cursor lands at col 0.
2916        self.wrapline();
2917        let nr = self.cursor.row;
2918        // The destination is an overwrite like any other, so it owes the same no-orphan repair
2919        // `write_glyph` performs for its own trailing column (#529, D4): the spacer about to land
2920        // on `(nr, 1)` half-destroys a wide glyph standing there, stranding its far half at
2921        // `(nr, 2)` — a `WIDE_CHAR_SPACER` with no lead to its left, still carrying the destroyed
2922        // glyph's hyperlink and underline colour. Asked *before* the writes, on the pre-write
2923        // state, exactly as `write_glyph`'s `last + 1` check is.
2924        //
2925        // Two of the three references have this exact site, and both repair it without a rule of
2926        // their own, because they write a pair as two *separate* cell writes and the repair lives
2927        // in the write:
2928        //   - xterm.js names the case outright — *"Combining character widens 1 column to 2. Move
2929        //     old character to next line."* (`InputHandler.ts:583-611` @ 699f553,
2930        //     `copyCellsFrom(oldRow, oldCol, 0, oldWidth, false)` at `:605-607`). The relocation
2931        //     leaves `x == 2`, so its once-per-run right-edge repair (`:668-669`) lands on exactly
2932        //     the orphaned column.
2933        //   - ghostty relocates in `Terminal.zig:1188-1252` @ e6e26e1 and reaches the repair
2934        //     through `cursorRight(1); printCell(0, .spacer_tail)` (`:1251-1252`) — that second
2935        //     `printCell` runs the `cell.wide != wide` switch (`:1484`) whose `.wide` arm clears
2936        //     the neighbouring lead's tail (`:1489-1499`).
2937        //   - alacritty has **no** counterpart: a width-0 codepoint returns early through
2938        //     `push_zerowidth` (`term/mod.rs:1069-1085` @ 852e971), so a cluster never changes
2939        //     width and nothing is ever relocated. Its orphan repair (`:994-1008`) is still the
2940        //     mechanism reference, reached the same way — one repair per `write_at_cursor`.
2941        // justerm writes both halves in one step, so the repair is not structural here and each
2942        // wide-writing path restates it — this is the third (`write_glyph`,
2943        // `promote_cluster_to_wide`, and now the relocation).
2944        //
2945        // What justerm does **not** copy is ghostty's reach-back at this site: its `.wide` arm
2946        // also clears the previous row's `.spacer_head` (`:1504-1506`, gated `cursor.y > 0 and
2947        // cursor.x <= 1`) — the very marker this relocation set seven statements earlier
2948        // (`:1200`). Derived from source, not executed. Suppressing it here is #534's rule
2949        // verbatim: a repair keyed on a state predicate must not fire while that state is
2950        // mid-construction.
2951        //
2952        // The other two obligations `write_glyph` carries are N/A here, recorded because an
2953        // unexplained omission is what gets re-litigated:
2954        //   - the *left*-orphan repair asks `col > 0`, and the lead lands at column 0.
2955        //   - `void_wrap_artefact_above(nr)` would clear a record that `vacate_for_wrap` **just
2956        //     set**, in both the advance case (`nr == row + 1`, so its target `nr - 1` is `row`)
2957        //     and the scroll case (`nr == row`, the source rotated up to `row - 1`). Firing it
2958        //     would be self-clobbering, not merely redundant — the same shape as #534's
2959        //     mid-construction rule. Measured after a repairing relocation: `is_row_wrapped(0)`
2960        //     and `(0, cols-1).is_leading_spacer()` both hold.
2961        //
2962        // `2 < cols` is a live bound, not defence in depth. The print paths cannot leave a
2963        // `WIDE_CHAR` lead in the last column — `write_glyph` wraps rather than write one there
2964        // and `promote_cluster_to_wide` relocates rather than promote in place — but `Row::resize`
2965        // can: the alt screen resizes without reflowing (#567), so truncating a row through a pair
2966        // strands its lead in the final column. The relocation then meets `is_wide() == true` at
2967        // `cols == 2`, and without the bound reads `(nr, 2)` on a two-column grid — an
2968        // out-of-bounds panic in a library, inside a consumer's process, reachable by shrinking a
2969        // window over a CJK glyph. Pinned by `min_columns.rs::
2970        // a_relocation_beside_a_truncated_wide_lead_does_not_index_past_the_row`.
2971        if 2 < cols && self.grid.cell(nr, 1).is_wide() {
2972            self.free_cell(nr, 2);
2973        }
2974        // Re-place the base as a wide lead + spacer, re-attaching the marks fresh (drop the combining
2975        // bit so push_combining starts a clean cluster at the new column).
2976        let mut lead = base;
2977        lead.set_combined(false);
2978        lead.insert_flags(CellFlags::WIDE_CHAR);
2979        *self.grid.cell_mut(nr, 0) = lead;
2980        for m in marks {
2981            self.grid.row_mut(nr).push_combining(0, m);
2982        }
2983        // Re-attach the extended attrs to BOTH halves at the new row. `lead` copied the base's
2984        // presence bits but not its map entries, so without this the bit is set with nothing
2985        // behind it — the read is gated and silently returns the default, and the frame stops
2986        // round-tripping (the cell encodes as linked with no index).
2987        self.grid.row_mut(nr).set_ext_attrs(0, ext.clone());
2988        let mut spacer = self.cursor.pen.cell(' ');
2989        spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
2990        *self.grid.cell_mut(nr, 1) = spacer;
2991        self.grid.row_mut(nr).set_ext_attrs(1, ext);
2992        // Cursor just past the wide cell (pending-wrap if it fills a 2-column row).
2993        if cols <= 2 {
2994            self.cursor.col = cols - 1;
2995            self.cursor.pending_wrap = self.autowrap;
2996        } else {
2997            self.cursor.col = 2;
2998            self.cursor.pending_wrap = false;
2999        }
3000        self.damage_span(nr, 0, 1);
3001    }
3002
3003    // ---- cursor movement (CSI A/B/C/D/G/d/H/f) -------------------------------
3004
3005    fn move_up(&mut self, n: usize) {
3006        self.cursor.row = self.cursor.row.saturating_sub(n);
3007        self.cursor.pending_wrap = false;
3008    }
3009
3010    fn move_down(&mut self, n: usize) {
3011        self.cursor.row = (self.cursor.row + n).min(self.grid.rows() - 1);
3012        self.cursor.pending_wrap = false;
3013    }
3014
3015    fn move_forward(&mut self, n: usize) {
3016        self.cursor.col = (self.cursor.col + n).min(self.grid.cols() - 1);
3017        self.cursor.pending_wrap = false;
3018    }
3019
3020    fn move_back(&mut self, n: usize) {
3021        self.cursor.col = self.cursor.col.saturating_sub(n);
3022        self.cursor.pending_wrap = false;
3023    }
3024
3025    fn set_col(&mut self, col: usize) {
3026        self.cursor.col = col.min(self.grid.cols() - 1);
3027        self.cursor.pending_wrap = false;
3028    }
3029
3030    fn set_row(&mut self, row: usize) {
3031        self.cursor.row = row.min(self.grid.rows() - 1);
3032        self.cursor.pending_wrap = false;
3033    }
3034
3035    fn goto(&mut self, row: usize, col: usize) {
3036        // Origin mode addresses rows relative to the scroll region's top margin
3037        // and clamps to its bottom; otherwise rows are absolute to the screen.
3038        let (offset, max_row) = if self.origin_mode {
3039            (self.scroll_top, self.scroll_bottom)
3040        } else {
3041            (0, self.grid.rows() - 1)
3042        };
3043        self.cursor.row = (row + offset).min(max_row);
3044        self.cursor.col = col.min(self.grid.cols() - 1);
3045        self.cursor.pending_wrap = false;
3046    }
3047
3048    // ---- erase (CSI J / K) ---------------------------------------------------
3049
3050    /// Clear cells `from..to` on `row`.
3051    ///
3052    /// Background Color Erase (BCE): erased cells carry the current SGR
3053    /// background only — fg and text attributes reset to default (matches
3054    /// xterm/alacritty, where the fill is `cursor.template.bg.into()`).
3055    ///
3056    /// **Cleared concern, with its validity condition — an empty range would break the pair
3057    /// invariant.** With `from == to` the first guard below still frees the lead at `from - 1`
3058    /// while the second is skipped (`to > from` is false) and the fill loop does nothing, so the
3059    /// spacer at `from` would survive its lead — an ADR-0025 D4 break, and the exact lead-less
3060    /// orphan the word walk must then treat as opaque. This is unreachable **as long as every
3061    /// caller passes a non-empty range**, which holds today: `ECH` clamps to
3062    /// `(col + n).min(cols)` with `n >= 1` (both `CSI X` and `CSI 0 X` erase one cell), and every
3063    /// `EL`/`ED` site passes `0..cols` or `0..=cursor`. A future caller that can pass an empty
3064    /// range must guard here first.
3065    fn clear_cells(&mut self, row: usize, from: usize, to: usize) {
3066        let cols = self.grid.cols();
3067        // Erasing either half of a pair that wrapped from the row above ends it, so that row's
3068        // artefact record is void (#534). `from <= 1` rather than `from == 0` because erasing from
3069        // column 1 destroys the spacer and the no-orphan repair below then frees the lead. ghostty
3070        // reaches the same row from its erase path — `Screen.splitCellBoundary`'s `x == 0 or x ==
3071        // 1` branch (`Screen.zig:1873` @ `e6e26e1`), called from `eraseChars` (`Terminal.zig:3159`).
3072        if from <= 1 && to > from && self.wrapped_pair_at_row_start(row) {
3073            self.void_wrap_artefact_above(row);
3074        }
3075        // Don't orphan a wide char straddling the erase boundary.
3076        if from > 0 && self.grid.cell(row, from).is_wide_spacer() {
3077            self.free_cell(row, from - 1);
3078        }
3079        if to > from && to < cols && self.grid.cell(row, to - 1).is_wide() {
3080            self.free_cell(row, to);
3081        }
3082
3083        let bg = self.cursor.pen.bg;
3084        for col in from..to {
3085            let cell = self.grid.cell_mut(row, col);
3086            cell.reset();
3087            cell.set_bg(bg);
3088        }
3089        // `reset` cleared the presence bits; this releases what they gated (#628).
3090        self.grid.row_mut(row).purge_side_maps(from..to);
3091        if to > from {
3092            self.damage_span(row, from, to - 1);
3093        }
3094    }
3095
3096    /// End `row`'s soft wrap, because something just destroyed the content that was continuing
3097    /// onto the next row.
3098    ///
3099    /// Which verbs owe this is **not** derivable from the erased range — it is a per-verb rule,
3100    /// and both references spell it out call site by call site rather than inferring it:
3101    ///
3102    /// | verb | ends the wrap? | xterm | ghostty |
3103    /// |---|---|---|---|
3104    /// | `EL 0` (erase right) | **yes**, at any column | `ClearRight` → `LineClrWrapped` unconditionally (`util.c:1871`) | `cursorResetWrap()` in `eraseLine(.right)` |
3105    /// | `ECH` | **yes**, at any column | same `ClearRight` (`util.c:1961`) | `cursorResetWrap()` in `eraseChars` |
3106    /// | `DCH` | **yes** | `screen.c` | `cursorResetWrap()` — *"Our row's soft-wrap is always reset"* |
3107    /// | `EL 1` (erase left) | no | `ClearLeft`, no clear | no |
3108    /// | `ICH` | no | no | no |
3109    ///
3110    /// The shape behind the three that do: each destroys content **from the cursor rightward**, so
3111    /// "this row continues past its last column" can no longer be asserted. Erasing leftward or
3112    /// inserting blanks leaves the tail — and whatever it flowed into — intact.
3113    ///
3114    /// **`EL 2` is a deliberate divergence.** justerm ends the wrap; xterm does not (`ClearLine`,
3115    /// `util.c:1905`, has no `LineClrWrapped`) and ghostty copies that with a comment naming it —
3116    /// *"it seems like complete should reset the soft-wrap state of the line but in xterm it does
3117    /// not."* justerm differs because it *joins* logical lines for `accessible_text` / `search` /
3118    /// selection text, so a blanked-but-still-wrapped row visibly merges two lines in copy — a
3119    /// consequence xterm does not carry. Recorded rather than silently matched or silently
3120    /// Mark `row` as soft-wrapping into the next one — and damage the cell the bit rides on.
3121    ///
3122    /// The exact mirror of [`Term::end_wrap`], and it exists for the mirror of that function's
3123    /// reason. The flag lives on the `Row` (#538) and reaches a consumer only as the last cell's
3124    /// `WRAPLINE`, derived at encode time. Every other cell-carried fact changes when that cell is
3125    /// written, so damage covers it for free; this one does not, and a `Partial` frame would never
3126    /// ship the bit — a frame-mode consumer rebuilding logical lines from cells then keeps the two
3127    /// rows *split* forever, the exact dual of the "joined forever" that `end_wrap` guards.
3128    ///
3129    /// `end_wrap` took that obligation in #540; the set side never did. It stayed invisible because
3130    /// a wrap normally moves the cursor to the next row, and `frame_damage` tops the frame up with
3131    /// the old cursor cell. When a **scroll serves the wrap** the cursor keeps its row index, so
3132    /// nothing tops it up — which is how #557 surfaced it.
3133    ///
3134    /// Damaging here rather than at each caller is what keeps this true for set sites added later,
3135    /// the same argument `end_wrap`'s comment makes.
3136    fn begin_wrap(&mut self, row: usize) {
3137        self.grid.row_mut(row).set_wrapped(true);
3138        let last = self.grid.cols() - 1;
3139        self.damage_span(row, last, last);
3140    }
3141
3142    /// diverged; see #538.
3143    fn end_wrap(&mut self, row: usize) {
3144        self.grid.row_mut(row).set_wrapped(false);
3145        // The flag is stored on the `Row` but rides the wire on the row's **last cell**, derived
3146        // at encode time. Every other cell-carried fact changes only when that cell is written,
3147        // so damage covers it for free; this one does not, and a `Partial` frame would never
3148        // re-ship the bit — leaving a frame-mode consumer with two rows joined forever. Damaging
3149        // here rather than at each caller is what keeps that true for call sites added later.
3150        let last = self.grid.cols() - 1;
3151        self.damage_span(row, last, last);
3152        // The wrap artefact goes with the wrap. The marker's claim is "the last column is the
3153        // blank a width-2 glyph vacated **because this row continues onto the next**", so a row
3154        // that stops continuing cannot hold one (ADR-0025 D3 — position is part of the test, and
3155        // so is the wrap it is positioned in). Coupling the two here is what makes the row-shift
3156        // seams and every wrap-ending erase a single rule instead of a clear per verb: ghostty
3157        // couples them in one function the same way — `Screen.cursorResetWrap`
3158        // (`terminal/Screen.zig:1524` @ `e6e26e1`, spacer-head clear at `:1539-1545`), reached from
3159        // `deleteChars` / `eraseChars` / `eraseLine`. It early-returns on `if (!page_row.wrap)`;
3160        // this one clears unconditionally, which is strictly safer.
3161        //
3162        // Most callers erase through this column anyway, so the clear is redundant for them; the
3163        // ones it is *not* redundant for are the row-shift seams (#540's `shift_region`, which
3164        // ends a wrap without touching a cell) and `delete_chars`, whose marker rides the shift.
3165        // The leftward erases are the mirror case — they blank this column while the wrap
3166        // legitimately survives — and go through `drop_artefact_if_erased` instead.
3167        //
3168        // One wrap-ending path deliberately does *not* reach here: `shift_region`'s `top == 0`
3169        // seam, whose row is in scrollback rather than the grid. It couples the same two clears
3170        // inline; see the comment there.
3171        self.grid.cell_mut(row, last).clear_leading_spacer();
3172    }
3173
3174    /// The pair that wrapped into `row` is about to be destroyed or moved, so the artefact record
3175    /// on the row **above** it is void — drop it. **Call before the mutation.**
3176    ///
3177    /// The marker makes a claim with two clauses: this row soft-wraps (owned by `end_wrap`), and
3178    /// its last column is the blank *that specific pair* vacated. This is the second clause, and
3179    /// the rule behind every call site is one sentence: **the record survives only an in-place
3180    /// same-width overwrite.** Anything else that reaches columns 0/1 of the continuation — a
3181    /// narrow write, an erase, a shift in either direction — ends the pair the record was about,
3182    /// and a wide lead that arrives afterwards by some other route did not *wrap* from anywhere.
3183    ///
3184    /// Both references gate on that, and both gate on the state **before** the write rather than
3185    /// after it:
3186    ///
3187    /// - ghostty `Terminal.zig:1484` @ `e6e26e1` — the whole wide-repair `switch` sits under
3188    ///   `if (cell.wide != wide)`, so a wide glyph overwritten by another wide glyph skips it; the
3189    ///   reach-back stanza then appears in the `.wide` (`:1501-1506`) and `.spacer_tail`
3190    ///   (`:1529-1532`) arms only.
3191    /// - alacritty `term/mod.rs:994` @ `852e971` — the reach-back at `:1004-1008` is inside
3192    ///   `if cursor_cell.flags.intersects(WIDE_CHAR | WIDE_CHAR_SPACER)`, but with no
3193    ///   width-unchanged escape, so it drops a record that is still true. Alacritty is the outlier
3194    ///   of the two and justerm follows ghostty.
3195    ///
3196    /// Asking *after* the mutation instead looks equivalent and is not: it answers "is some wide
3197    /// lead standing at column 0", which a `DCH` that pulls the *next* wide glyph left also
3198    /// satisfies, and which a two-step placement (a narrow base promoted to wide by VS16 under
3199    /// mode 2027, or IRM's insert-then-write) satisfies only at the end. Both were measured
3200    /// disagreeing with the rule above before this took its current form.
3201    ///
3202    /// The erase and intra-row-shift call sites are **ported, not derived**: ghostty's
3203    /// `Screen.splitCellBoundary` (`Screen.zig:1831`, the `x == 0 or x == 1` branch at `:1873`)
3204    /// reaches up one row and clears the previous row's spacer head, and it is called from
3205    /// `deleteChars` (`Terminal.zig:3107-3109`) and `eraseChars` (`:3159-3160`). Only justerm's
3206    /// `ICH` site has no counterpart — ghostty's `insertBlanks` (`:2988`) calls it nowhere.
3207    ///
3208    /// `row == 0` does not mean "no row above": on the primary screen the text readers walk
3209    /// `[scrollback ++ grid]` as one buffer (`abs_floor() == 0`), so the row above grid row 0 is
3210    /// the last **scrollback** row and it can carry the marker. Alacritty reaches the same row for
3211    /// the same reason — its `topmost_line()` is `Line(-history_size)` (`grid/mod.rs:504`), so
3212    /// `point.line - 1` indexes into history; ghostty is the one that stops at the viewport
3213    /// (`cursor.y > 0`). On the alt screen `abs_floor()` is the screen top, so no join crosses the
3214    /// boundary and there is nothing to repair.
3215    ///
3216    /// No damage is owed by either branch, and for a stronger reason than #540's: the marker is a
3217    /// `content` bit outside `CONTENT_MARKER_MASK`, so `Cell::flags()` never sees it and it does
3218    /// not cross the wire at all. The `damage_span` below is defensive, not load-bearing.
3219    fn void_wrap_artefact_above(&mut self, row: usize) {
3220        if row > 0 {
3221            let last = self.grid.cols() - 1;
3222            if self.grid.cell(row - 1, last).is_leading_spacer() {
3223                self.grid.cell_mut(row - 1, last).clear_leading_spacer();
3224                self.damage_span(row - 1, last, last);
3225            }
3226        } else if !self.on_alt
3227            && let Some(cell) = self.scrollback.back_mut().and_then(|r| r.last_mut())
3228        {
3229            cell.clear_leading_spacer();
3230        }
3231    }
3232
3233    /// Is a wide pair standing at columns 0..=1 of `row` — i.e. is there a record for
3234    /// `void_wrap_artefact_above` to void? A cheap pre-mutation test the four call sites share, so
3235    /// the rule lives in one place rather than being re-derived per verb (ADR-0025 D2).
3236    fn wrapped_pair_at_row_start(&self, row: usize) -> bool {
3237        self.grid.cell(row, 0).is_wide()
3238    }
3239
3240    /// Drop a wide-wrap artefact marker that has outlived the wrap it belonged to, without
3241    /// touching the wrap itself.
3242    ///
3243    /// The mirror of the marker clean-up inside `end_wrap`, for the verbs that erase *leftward*:
3244    /// `EL 1` and `ED 1` correctly leave the wrap alone (the row's tail still flows onward), but
3245    /// they can still clear the last column, and then the artefact's blank turns into visible
3246    /// text that a reflow bakes in permanently. Only the marker goes; the wrap is the caller's
3247    /// business.
3248    fn drop_artefact_if_erased(&mut self, row: usize, from: usize, to: usize) {
3249        let last = self.grid.cols() - 1;
3250        if from <= last && to > last {
3251            self.grid.cell_mut(row, last).clear_leading_spacer();
3252        }
3253    }
3254
3255    /// Shift `[top..=bottom]` by one line — up unless `down` — and end the wraps the shift
3256    /// falsified. Every row-shifting verb (IL/DL/SU/SD and the region paths in LF/RI) goes
3257    /// through here so the repair cannot be forgotten at a call site (ADR-0025 D2).
3258    ///
3259    /// The wrap flag claims "this row continues into the **next** row", so it is a statement about
3260    /// *adjacency*, and rotating whole `Row`s keeps it true for free: both halves of a pair inside
3261    /// the region move by the same line, so the claim still describes the same neighbour. Only the
3262    /// two seams falsify it, where a row's next neighbour changed underneath it:
3263    ///
3264    /// - **`top - 1`**, just outside the region. Its continuation rotated away (up-shift) or was
3265    ///   pushed down (down-shift), so whatever now sits at `top` is a stranger. This is the seam
3266    ///   that merges two unrelated logical lines in copy/search/accessible text (#540's repro).
3267    /// - **the row that lost its continuation to the blank** — `bottom - 1` after an up-shift (the
3268    ///   blank lands at `bottom`), `bottom` after a down-shift (its continuation rotated up to
3269    ///   `top` and was blanked there). The down-shift form is the one that reaches *outside* the
3270    ///   region: the stale claim points at `bottom + 1`, a row the verb never touched.
3271    ///
3272    /// Damaging matters as much as clearing, and `end_wrap` does both: `top - 1` is outside the
3273    /// region, so the scroll op the caller records does not cover it and a `Partial` frame would
3274    /// never re-ship the derived `WRAPLINE` bit.
3275    ///
3276    /// **Each seam has exactly one exemption, and both are facts about the caller that this
3277    /// function cannot see** — which is why they are parameters rather than tests:
3278    ///
3279    /// - `evicts_to_scrollback` exempts the **top** seam: a linefeed pushes row 0 into scrollback,
3280    ///   so the readers' `[scrollback ++ grid]` walk finds the continuation one row further back
3281    ///   and adjacency survives.
3282    /// - `serves_wrap` exempts the **bottom** seam: the shift was asked for by `wrapline`, so the
3283    ///   blank it exposes at `bottom` is not a stranger that displaced a continuation — it *is*
3284    ///   the continuation, about to be written into (#557).
3285    ///
3286    /// Both are one-sided on purpose. A wrap-serving scroll still falsifies the top seam, and a
3287    /// scrollback-evicting linefeed still falsifies the bottom one when no wrap asked for it.
3288    ///
3289    /// **No reference implements this rule**, so it is derived rather than ported — ADR-0004, the
3290    /// spec is the authority for VT semantics, above any implementation:
3291    ///
3292    /// - **ghostty** clears the wrap on *every* row a full-width IL/DL touches
3293    ///   (`terminal/Terminal.zig:2746-2752`, `:2906-2912` @ `e6e26e1`). The clear runs *before* the
3294    ///   row swap at `:2936-2939`, so both ends stay false: an interior pair is split, not
3295    ///   preserved. It still never reaches the row above the shifted range.
3296    /// - **alacritty** has no `WRAPLINE` clear on any scroll path (@ `852e971`).
3297    /// - **xterm.js** splices whole line objects and never touches `isWrapped`
3298    ///   (`common/InputHandler.ts:1345-1402` @ `699f553`). Its opposite polarity — "I continue the
3299    ///   *previous* row" (`common/buffer/Buffer.ts:566-570`) — moves the exposure to the mirrored
3300    ///   seam rather than removing it: a spliced-in line keeps a continuation claim about a
3301    ///   predecessor it never met.
3302    ///
3303    /// The seam row's wide-wrap *marker* is the same shift's other half, and it now rides along:
3304    /// `end_wrap` clears both (#534), and the `top == 0` branch below — the one seam whose row is
3305    /// not a grid row — couples them inline for the same reason.
3306    ///
3307    /// **Validity condition for clearing at the seams rather than everywhere.** ghostty clears the
3308    /// wrap and the spacer head on *every* row a full-width IL/DL touches, and its own comment
3309    /// gives two reasons: it splits interior pairs, **and** it supports left/right margins
3310    /// (DECSLRM), where a partial-row shift can break an interior pair without moving its
3311    /// neighbour. justerm rotates whole `Row`s and implements no DECSLRM, so an interior pair and
3312    /// its continuation always move together and seam-only is sound. If left/right margins ever
3313    /// land, this rule and #534's marker rule break at the same time — neither is safe under a
3314    /// shift that moves part of a row.
3315    fn shift_region(
3316        &mut self,
3317        top: usize,
3318        bottom: usize,
3319        down: bool,
3320        evicts_to_scrollback: bool,
3321        serves_wrap: bool,
3322    ) {
3323        if down {
3324            self.grid.scroll_down_region(top, bottom);
3325        } else {
3326            self.grid.scroll_up_region(top, bottom);
3327        }
3328        // Recording the scroll op is part of shifting, not a step a caller adds after: damage is
3329        // indexed by row position, so `record_scroll` rotates `line_damage` with the content. A
3330        // seam clear damaged *before* that rotation is carried to the wrong row — and on a
3331        // down-shift it lands on `top`, which `record_scroll` immediately overwrites with
3332        // `fully_damaged`. The clear then never reaches the wire at all: the model splits the
3333        // rows, a `Partial` frame does not say so, and the consumer keeps them joined forever.
3334        // Ordering it here is what makes that unrepeatable at a sixth call site.
3335        self.record_scroll(top, bottom, if down { -1 } else { 1 });
3336        if top > 0 {
3337            self.end_wrap(top - 1);
3338        } else if !evicts_to_scrollback && !self.on_alt {
3339            // `top == 0` does not mean "no row above": on the primary the text readers walk
3340            // `[scrollback ++ grid]` as one buffer (`abs_floor() == 0`), so the row above grid row
3341            // 0 is the last *scrollback* row and it can wrap into the screen. A full-screen SU /
3342            // DL / RI therefore leaves this issue's defect one row higher, outside the grid.
3343            //
3344            // `evicts_to_scrollback` is what keeps `linefeed` out: it pushes grid row 0 into
3345            // scrollback, so the continuation is re-attached one row further back and the claim
3346            // stays true — clearing there would split a line the scroll preserved. On the alt
3347            // screen `abs_floor()` is the screen top, so no join crosses the boundary at all.
3348            //
3349            // No damage is owed with the clear, unlike `end_wrap`'s grid form: a scrollback row
3350            // only reaches the wire while `display_offset > 0`, and there `damage()` returns an
3351            // empty `Partial` (`term.rs`, the frozen-viewport short-circuit) while any scroll that
3352            // *moves* the viewport marks full damage. Valid as long as that short-circuit holds.
3353            //
3354            // The artefact marker goes with the wrap here exactly as it does in `end_wrap`, and
3355            // this branch is the reason that coupling cannot simply live in `end_wrap`: it is the
3356            // one wrap-ending path whose row is not a grid row, so it does not call it. Leaving it
3357            // out left #534's defect alive one row above the grid — reachable from every
3358            // `scroll_region_lines` verb, since all of them pass `evicts_to_scrollback: false`,
3359            // and visible as a word selection one cell too wide plus a reflow that bakes the
3360            // stranded marker mid-row.
3361            if let Some(row) = self.scrollback.back_mut() {
3362                row.set_wrapped(false);
3363                if let Some(cell) = row.last_mut() {
3364                    cell.clear_leading_spacer();
3365                }
3366            }
3367        }
3368        // The blank lands at `bottom` going up and at `top` going down, so the row that lost its
3369        // continuation is the one just above it. Going down that is `top - 1`, already cleared
3370        // above; going up it is `bottom - 1`, which for a one-row region is that same row.
3371        //
3372        // The up-shift form needs the `bottom + 1` guard, and it is not defensive — without it the
3373        // clear destroys a **live** wrap. A row at the screen's bottom edge that wraps is the
3374        // ordinary soft-wrap-at-the-last-row state: `wrapline` sets the flag and the linefeed
3375        // scrolls precisely so the continuation has somewhere to land, which is the *next* row
3376        // after this shift. Its claim is about a row that does not exist yet, so the shift makes it
3377        // true rather than false.
3378        //
3379        // **The rest of that guard's original rationale was too narrow, and #557 is what it cost.**
3380        // It read: *"the link is only broken when there is a stationary row below the region
3381        // (`bottom + 1 < rows`): then the continuation stayed put while its lead moved up."* A
3382        // stationary row below is **necessary but not sufficient**. At a *region's* bottom the same
3383        // wrapline-asked-for scroll happens with `bottom + 1 < rows` perfectly true, and the clear
3384        // then split the logical line the scroll existed to continue. The geometry was never the
3385        // discriminator; **why the shift is happening** is — which is what `serves_wrap` carries.
3386        //
3387        // The guard stays anyway: it is the screen-bottom case of the same fact, and it also holds
3388        // for a *non*-wrap-serving linefeed at the screen edge.
3389        //
3390        // One invariant is still worth naming, because it was not true when this guard was first
3391        // written: **a row only claims a wrap if a next row will exist for it**. A row parked below
3392        // a DECSTBM region kept a permanent false claim, and this guard preserved it — the #540
3393        // completeness pass merged two unrelated logical lines through exactly that hole. The claim
3394        // is now gated at its set site (`write_glyph` asks `wrapline_advances`), so the guard's
3395        // premise holds. Valid as long as that gate stays.
3396        let orphaned = if serves_wrap {
3397            // The blank this shift just exposed is the continuation the wrap is waiting for, so
3398            // there is nothing to falsify — see the `serves_wrap` note on `linefeed_inner` (#557).
3399            None
3400        } else if down {
3401            Some(bottom)
3402        } else if bottom + 1 < self.grid.rows() {
3403            bottom.checked_sub(1)
3404        } else {
3405            None
3406        };
3407        if let Some(row) = orphaned {
3408            self.end_wrap(row);
3409        }
3410    }
3411
3412    fn erase_display(&mut self, mode: u16) {
3413        let (cols, rows) = (self.grid.cols(), self.grid.rows());
3414        let (cr, cc) = (self.cursor.row, self.cursor.col);
3415        match mode {
3416            0 => {
3417                // Erases this row's tail and every row below, so nothing can continue from here
3418                // — and the rows below cannot continue either.
3419                self.clear_cells(cr, cc, cols);
3420                self.end_wrap(cr);
3421                for row in (cr + 1)..rows {
3422                    self.clear_cells(row, 0, cols);
3423                    self.end_wrap(row);
3424                    self.dispose_markers_on_row(row);
3425                }
3426            }
3427            1 => {
3428                // Leftward: this row's tail survives, so its own wrap does. The rows *above* are
3429                // gone entirely.
3430                for row in 0..cr {
3431                    self.clear_cells(row, 0, cols);
3432                    self.end_wrap(row);
3433                    self.dispose_markers_on_row(row);
3434                }
3435                self.clear_cells(cr, 0, cc + 1);
3436                self.drop_artefact_if_erased(cr, 0, cc + 1);
3437                // Covering the whole row means nothing continues from it. xterm.js has a
3438                // dedicated arm for exactly this case, in its own words: *"Deleted entire
3439                // previous line. This next line can no longer be wrapped."*
3440                // (`InputHandler.ts:1248-1252` — under its continuation polarity that assignment
3441                // is this engine's `end_wrap(cr)`.) `EL 1` has no such arm there, and none here.
3442                if cc + 1 == cols {
3443                    self.end_wrap(cr);
3444                }
3445            }
3446            2 => {
3447                for row in 0..rows {
3448                    self.clear_cells(row, 0, cols);
3449                    self.end_wrap(row);
3450                    self.dispose_markers_on_row(row);
3451                }
3452            }
3453            // Notably **`3` (ED 3, erase scrollback) is not implemented** and falls through
3454            // here as a no-op. Recorded rather than filed, because an unimplemented verb is
3455            // not a defect — but whoever implements it inherits an obligation that is invisible
3456            // from this site (#660's completeness pass): it would be the first verb that
3457            // shortens the buffer *from the front* by N lines, so it needs both an anchor
3458            // fixup (selection, markers **and** tracked points are absolute-from-oldest — three
3459            // holders since #691, and the third has no frame to make its drift visible; the
3460            // *fourth* is search highlights, which every list of this obligation has so far
3461            // omitted — see the destruction-funnel invariant note) and a `display_offset`
3462            // clamp. Without the second, `selection_range`'s `scrollback.len() - display_offset`
3463            // underflows — and so do the same expressions in `viewport_line`,
3464            // `viewport_link_at` and `match_spans`. alacritty's `ClearMode::Saved` arm does
3465            // both (`term/mod.rs:1806-1811`).
3466            //
3467            // **A real `clear` emits it** — measured on the VM, `ESC[H ESC[2J ESC[3J`
3468            // (`tests/fixtures/osc133_clear.raw`) — so this no-op is reached in ordinary
3469            // traffic and not only by a test. Its visible consequence since #750: `ED 2`
3470            // now retires the command marks on the screen while the ones that already
3471            // scrolled off survive a `clear` a real terminal would have erased them with
3472            // (`command_lines_capture.rs` pins that, so implementing this verb will move
3473            // a test rather than silently changing an answer).
3474            _ => {}
3475        }
3476    }
3477
3478    /// Erase in line (EL): 0 = cursor→end, 1 = start→cursor, 2 = whole line.
3479    fn erase_line(&mut self, mode: u16) {
3480        let cols = self.grid.cols();
3481        let (cr, cc) = (self.cursor.row, self.cursor.col);
3482        match mode {
3483            // Erase right — ends the wrap at any column (xterm's `ClearRight`).
3484            0 => {
3485                self.clear_cells(cr, cc, cols);
3486                self.end_wrap(cr);
3487            }
3488            // Erase left — the tail survives, so the wrap does. The artefact marker does not:
3489            // if the erase reached the last column it just blanked the cell the marker described.
3490            1 => {
3491                self.clear_cells(cr, 0, cc + 1);
3492                self.drop_artefact_if_erased(cr, 0, cc + 1);
3493            }
3494            // Erase the whole line — see `end_wrap`: a deliberate divergence from xterm.
3495            2 => {
3496                self.clear_cells(cr, 0, cols);
3497                self.end_wrap(cr);
3498            }
3499            _ => {}
3500        }
3501    }
3502
3503    // ---- intra-line editing (ICH / DCH / ECH) --------------------------------
3504
3505    /// ECH (CSI Pn X): erase `n` cells in place from the cursor — no shift.
3506    /// BCE-filled (via `clear_cells`); pending-wrap is left untouched.
3507    fn erase_chars(&mut self, n: usize) {
3508        let cols = self.grid.cols();
3509        let (row, col) = (self.cursor.row, self.cursor.col);
3510        let to = (col + n).min(cols);
3511        self.clear_cells(row, col, to);
3512        // Destroys content from the cursor rightward, so the row can no longer be continuing —
3513        // unconditionally, at any column and for any `n`. Both references do exactly this (see
3514        // `end_wrap`): xterm routes ECH through the same `ClearRight` as `EL 0`, ghostty calls
3515        // `cursorResetWrap()` in `eraseChars`.
3516        self.end_wrap(row);
3517    }
3518
3519    /// ICH (CSI Pn @): insert `n` blanks at the cursor, shifting the rest of the
3520    /// line right; cells pushed past the right edge are lost. The opened gap is
3521    /// BCE-filled; pending-wrap is left untouched.
3522    fn insert_chars(&mut self, n: usize) {
3523        let cols = self.grid.cols();
3524        let (r, col) = (self.cursor.row, self.cursor.col);
3525        let n = n.min(cols - col);
3526        if n == 0 {
3527            return;
3528        }
3529        // Shifting a wrapped pair out of columns 0/1 ends it, so the row above's artefact record
3530        // is void (#534). Asked **before** the shift, which is what keeps IRM correct: `write_glyph`
3531        // routes its wide-at-boundary insert through here *after* `vacate_for_wrap` has just set
3532        // the marker on the row above, and a post-shift test would see the freshly blanked gap and
3533        // clear the marker inside its own SET site's critical section. Pre-shift the question is
3534        // about the pair that was actually there, which is the one the record is about.
3535        if col <= 1 && self.wrapped_pair_at_row_start(r) {
3536            self.void_wrap_artefact_above(r);
3537        }
3538        let bg = self.cursor.pen.bg;
3539        let row = self.grid.row_mut(r);
3540        // Shift [col .. cols-n) right by n; the tail falls off the edge. The
3541        // combining map follows the moved cells (the bit travels with the raw
3542        // copy, the cluster data must too).
3543        row.copy_within(col..cols - n, col + n);
3544        row.move_maps(col..cols - n, col + n);
3545        for cell in &mut row[col..col + n] {
3546            cell.reset();
3547            cell.set_bg(bg);
3548        }
3549        // Repair wide-char halves split at the seams (no-orphan invariant):
3550        // a lead just before the gap lost its spacer; the first shifted cell may
3551        // be a spacer whose lead did not move.
3552        if col > 0 && self.grid.cell(r, col - 1).is_wide() {
3553            self.free_cell(r, col - 1);
3554        }
3555        if col + n < cols && self.grid.cell(r, col + n).is_wide_spacer() {
3556            self.free_cell(r, col + n);
3557        }
3558        // A lead shifted to the last column lost its spacer off the edge.
3559        if self.grid.cell(r, cols - 1).is_wide() {
3560            self.free_cell(r, cols - 1);
3561        }
3562        // Note ICH needs no repair to *this* row's marker: a right shift always pushes the last
3563        // column off the edge, so it discards a marker rather than carrying one inward —
3564        // measured, and pinned by `ich_discards_the_marker_off_the_edge`.
3565        self.damage_span(r, col, cols - 1);
3566    }
3567
3568    /// DCH (CSI Pn P): delete `n` cells at the cursor, shifting the tail left; the
3569    /// vacated cells at the right are BCE-blanked. Pending-wrap is left untouched.
3570    fn delete_chars(&mut self, n: usize) {
3571        let cols = self.grid.cols();
3572        let (r, col) = (self.cursor.row, self.cursor.col);
3573        let n = n.min(cols - col);
3574        if n == 0 {
3575            return;
3576        }
3577        // The shift pulls the tail left and blanks the far end, so the row stops continuing —
3578        // ghostty says it outright (*"Our row's soft-wrap is always reset"* in `deleteChars`,
3579        // `Terminal.zig:3133` @ `e6e26e1`).
3580        //
3581        // **Before the shift, not after** (#534): `end_wrap` clears the artefact marker at the
3582        // *last* column, and the marker is a cell bit that the shift carries inward with every
3583        // other cell. Ending the wrap afterwards would clear a column the marker has already left,
3584        // stranding it mid-row where it describes nothing (ADR-0025 D3) and silently swallows the
3585        // blank between two runs in copy, search and accessible text. Same shape as #540's
3586        // `record_scroll` ordering: the clear has to happen where the state still is.
3587        self.end_wrap(r);
3588        // Deleting a wrapped pair out of columns 0/1 ends it, so the row above's artefact record
3589        // is void — and this is where the "ask before, not after" rule earns its keep twice over:
3590        // a `DCH` can pull the *next* wide glyph left into column 0, which a post-shift "is a wide
3591        // lead standing here?" test happily accepts even though the pair the record was about has
3592        // been deleted. ghostty asks the same question at the same point:
3593        // `Screen.splitCellBoundary(cursor.x)` from `deleteChars` (`Terminal.zig:3107` @ `e6e26e1`),
3594        // whose `x == 0 or x == 1` branch reaches up a row and clears the spacer head.
3595        if col <= 1 && self.wrapped_pair_at_row_start(r) {
3596            self.void_wrap_artefact_above(r);
3597        }
3598        let bg = self.cursor.pen.bg;
3599        let row = self.grid.row_mut(r);
3600        // Shift [col+n .. cols) left to [col ..); BCE-fill the vacated tail. The
3601        // combining map follows the moved cells.
3602        row.copy_within(col + n..cols, col);
3603        row.move_maps(col + n..cols, col);
3604        for cell in &mut row[cols - n..cols] {
3605            cell.reset();
3606            cell.set_bg(bg);
3607        }
3608        // Repair wide-char halves split by the deletion (no-orphan invariant):
3609        // a lead just before the cut lost its spacer; the cell now at the cursor
3610        // may be a spacer whose lead was deleted.
3611        if col > 0 && self.grid.cell(r, col - 1).is_wide() {
3612            self.free_cell(r, col - 1);
3613        }
3614        if self.grid.cell(r, col).is_wide_spacer() {
3615            self.free_cell(r, col);
3616        }
3617        self.damage_span(r, col, cols - 1);
3618    }
3619
3620    // ---- line/region editing (IL / DL / SU / SD) -----------------------------
3621
3622    /// Scroll rows `[top..=bottom]` by `n` lines, BCE-filling the exposed lines.
3623    /// `down` inserts blanks at the top (content moves down); otherwise content
3624    /// moves up and blanks appear at the bottom. Reuses the one-line region scroll
3625    /// primitives (so damage + scroll-op accumulation come for free), then fills
3626    /// the exposed lines with the current SGR background.
3627    fn scroll_region_lines(&mut self, top: usize, bottom: usize, n: usize, down: bool) {
3628        let height = bottom - top + 1;
3629        let n = n.min(height);
3630        if n == 0 {
3631            return;
3632        }
3633        // Anchors (selection #3, markers #118/#158) live at absolute buffer lines;
3634        // SU/SD/IL/DL don't accrue scrollback, so `base` is stable across the loop.
3635        let base = self.scrollback.len();
3636        for _ in 0..n {
3637            self.shift_region(top, bottom, down, false, false);
3638            // Rotate anchors with the content, like `linefeed`/`reverse_index`
3639            // (#162). `up` = content moved up = the non-`down` case. Markers rotate
3640            // with the active buffer (#187) — alt-scoped on the alt screen, so no
3641            // guard. **The selection is unguarded here for the same reason, not because
3642            // "it is cleared on alt enter"** — that was this comment's claim until #660 and
3643            // it is false: a selection made while the alt screen is up is ordinary, it does
3644            // reach this line, and rotating it is correct, because the content really did
3645            // move under it. The code was right; only its stated reason was wrong.
3646            self.selection_rotate_region(base + top, base + bottom, !down);
3647            self.markers_rotate_region(base + top, base + bottom, !down);
3648            self.tracked_rotate_region(base + top, base + bottom, !down);
3649        }
3650        self.invalidate_search_highlights();
3651        // BCE-fill the n exposed lines (the primitives blank to default).
3652        let bg = self.cursor.pen.bg;
3653        let (fill_top, fill_end) = if down {
3654            (top, top + n)
3655        } else {
3656            (bottom + 1 - n, bottom + 1)
3657        };
3658        let cols = self.grid.cols();
3659        for r in fill_top..fill_end {
3660            for c in 0..cols {
3661                let cell = self.grid.cell_mut(r, c);
3662                cell.reset();
3663                cell.set_bg(bg);
3664            }
3665        }
3666    }
3667
3668    /// SU (CSI Pn S): scroll the scroll region up by `n`.
3669    fn scroll_up_lines(&mut self, n: usize) {
3670        self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, false);
3671    }
3672
3673    /// SD (CSI Pn T): scroll the scroll region down by `n`.
3674    fn scroll_down_lines(&mut self, n: usize) {
3675        self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, true);
3676    }
3677
3678    /// IL (CSI Pn L): insert `n` blank lines at the cursor, scrolling
3679    /// `[cursor..=scroll_bottom]` down. A no-op when the cursor is outside the
3680    /// scroll region.
3681    fn insert_lines(&mut self, n: usize) {
3682        let cur = self.cursor.row;
3683        if cur < self.scroll_top || cur > self.scroll_bottom {
3684            return;
3685        }
3686        self.scroll_region_lines(cur, self.scroll_bottom, n, true);
3687    }
3688
3689    /// DL (CSI Pn M): delete `n` lines at the cursor, scrolling
3690    /// `[cursor..=scroll_bottom]` up. A no-op when the cursor is outside the
3691    /// scroll region.
3692    fn delete_lines(&mut self, n: usize) {
3693        let cur = self.cursor.row;
3694        if cur < self.scroll_top || cur > self.scroll_bottom {
3695            return;
3696        }
3697        self.scroll_region_lines(cur, self.scroll_bottom, n, false);
3698    }
3699
3700    // ---- SGR (CSI m) ---------------------------------------------------------
3701
3702    fn sgr(&mut self, params: &Params) {
3703        let pen = &mut self.cursor.pen;
3704        let mut iter = params.iter();
3705        while let Some(param) = iter.next() {
3706            let code = param.first().copied().unwrap_or(0);
3707            match code {
3708                0 => pen.reset(),
3709                1 => pen.flags.insert(CellFlags::BOLD),
3710                2 => pen.flags.insert(CellFlags::DIM),
3711                3 => pen.flags.insert(CellFlags::ITALIC),
3712                4 => pen.flags.insert(CellFlags::UNDERLINE),
3713                5 => pen.flags.insert(CellFlags::BLINK),
3714                7 => pen.flags.insert(CellFlags::INVERSE),
3715                8 => pen.flags.insert(CellFlags::HIDDEN),
3716                9 => pen.flags.insert(CellFlags::STRIKETHROUGH),
3717                22 => pen.flags.remove(CellFlags::BOLD | CellFlags::DIM),
3718                23 => pen.flags.remove(CellFlags::ITALIC),
3719                24 => pen.flags.remove(CellFlags::UNDERLINE),
3720                25 => pen.flags.remove(CellFlags::BLINK),
3721                27 => pen.flags.remove(CellFlags::INVERSE),
3722                28 => pen.flags.remove(CellFlags::HIDDEN),
3723                29 => pen.flags.remove(CellFlags::STRIKETHROUGH),
3724                30..=37 => pen.fg = Color::Indexed((code - 30) as u8),
3725                38 => {
3726                    if let Some(c) = parse_extended_color(param, &mut iter) {
3727                        pen.fg = c;
3728                    }
3729                }
3730                39 => pen.fg = Color::Default,
3731                40..=47 => pen.bg = Color::Indexed((code - 40) as u8),
3732                48 => {
3733                    if let Some(c) = parse_extended_color(param, &mut iter) {
3734                        pen.bg = c;
3735                    }
3736                }
3737                49 => pen.bg = Color::Default,
3738                // Underline colour (SGR 58 / 59, #520) — same extended-colour grammar
3739                // as 38/48 (colon `58:2:r:g:b` / `58:5:n`, or legacy semicolon), so it
3740                // reuses `parse_extended_color` verbatim. 59 returns to "follow the fg".
3741                58 => {
3742                    if let Some(c) = parse_extended_color(param, &mut iter) {
3743                        pen.underline_color = c;
3744                    }
3745                }
3746                59 => pen.underline_color = Color::Default,
3747                // bright foreground/background (aixterm) → palette 8..=15.
3748                90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),
3749                100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),
3750                _ => {}
3751            }
3752        }
3753    }
3754}
3755
3756/// Cap a recorded scroll to what a consumer can act on **and** what the wire can
3757/// carry (#661) — two bounds for two different reasons, see [`Term::scroll_delta`].
3758///
3759/// A free function so the second bound is provable without building the grid that
3760/// reaches it: a region taller than `i16::MAX` means a screen taller than 32 767
3761/// rows, and every scroll of it rotates a `line_damage` of that length, so driving
3762/// the engine to that corner costs ~10⁹ element moves (measured: 16 s in a debug
3763/// build, for one assertion). The engine-level tests in `tests/damage.rs` prove
3764/// `scroll_delta` applies this at ordinary sizes; the wire-level one in
3765/// `tests/serialize.rs` proves a count at the bound survives `encode`.
3766fn cap_scroll(op: ScrollOp) -> ScrollOp {
3767    let height = op.bottom.saturating_sub(op.top).saturating_add(1) as isize;
3768    let bound = height.min(MAX_SCROLL_COUNT);
3769    ScrollOp {
3770        count: op.count.clamp(-bound, bound),
3771        ..op
3772    }
3773}
3774
3775/// Parse `38`/`48`/`58` extended colour (foreground / background / underline colour, #520), in
3776/// either form:
3777/// - sub-parameter (colon) form inline in `param`: `38:5:n`, `38:2:r:g:b`
3778///   (optionally `38:2:cs:r:g:b` with a colorspace id), or
3779/// - legacy (semicolon) form: pull the following top-level params from `iter`.
3780///
3781/// The colon RGB form is **count-based** (`off = if param.len() >= 6 { 3 } else { 2 }`): a 5-param
3782/// `38:2:r:g:b` (no colorspace slot) reads RGB(r,g,b) directly, while a 6-param `38:2:cs:r:g:b` — or
3783/// `38:2::r:g:b` with an *empty* cs, the form kitty/nvim actually emit — skips the colorspace slot.
3784/// The short 5-param form is **non-conformant to T.416 / ISO-8613-6** (the de-jure standard always
3785/// carries a colorspace field), but tolerating it is the **ecosystem-dominant** behaviour, verified
3786/// against real source (2026-07, #520): VTE (`src/sgr.hh`, branches on `n > 4`), foot (`csi.c`,
3787/// `sub.idx >= 5`) and alacritty (`ansi.rs`, `params.len() > 4`) all count the sub-parameters and
3788/// decode the short form as RGB(r,g,b), exactly as here. VTE's own comment calls it a "common
3789/// misinterpretation of the standard" (foot: "bastard version") that it supports anyway; **only
3790/// xterm.js is strict** (always consumes a colorspace slot, so it misreads the short form). So a
3791/// difference from xterm here is deliberate leniency shared with the non-xterm ecosystem, not a
3792/// defect — the ADR-0004 spec-faithfulness is about not *omitting* behaviour, not about rejecting a
3793/// widely-emitted non-standard input.
3794fn parse_extended_color<'a, I>(param: &[u16], iter: &mut I) -> Option<Color>
3795where
3796    I: Iterator<Item = &'a [u16]>,
3797{
3798    if param.len() > 1 {
3799        // Colon sub-parameter form: kind is param[1].
3800        match param[1] {
3801            2 => {
3802                // 38:2:r:g:b (len 5) or 38:2:cs:r:g:b (len 6, colorspace skipped).
3803                let off = if param.len() >= 6 { 3 } else { 2 };
3804                let r = *param.get(off)? as u8;
3805                let g = *param.get(off + 1)? as u8;
3806                let b = *param.get(off + 2)? as u8;
3807                Some(Color::Rgb(r, g, b))
3808            }
3809            5 => Some(Color::Indexed(*param.get(2)? as u8)),
3810            _ => None,
3811        }
3812    } else {
3813        // Legacy semicolon form: kind, then its operands, are separate params.
3814        match iter.next()?.first().copied()? {
3815            2 => {
3816                let r = iter.next()?.first().copied()? as u8;
3817                let g = iter.next()?.first().copied()? as u8;
3818                let b = iter.next()?.first().copied()? as u8;
3819                Some(Color::Rgb(r, g, b))
3820            }
3821            5 => Some(Color::Indexed(iter.next()?.first().copied()? as u8)),
3822            _ => None,
3823        }
3824    }
3825}
3826
3827/// Reflow one screen (joined with its `scrollback`) to `cols` x `rows`, tracking
3828/// `point` (a cursor in screen coordinates). Returns the new screen rows, the new
3829/// scrollback (capped to `limit`), and the new point. The alt screen passes an
3830/// empty scrollback and discards the returned one.
3831/// The fixed dimensions a resize reflows toward.
3832#[derive(Clone, Copy)]
3833struct ReflowDims {
3834    old_cols: usize,
3835    cols: usize,
3836    rows: usize,
3837    limit: usize,
3838    /// Whether a column change may **re-split** this pane's content, or only re-fit its rows.
3839    ///
3840    /// False for the alt screen (#567). Reflow re-splits a long line so history stays readable at
3841    /// the new width — it assumes the content is text that *flows*. The alt screen has no history,
3842    /// its content is a **layout** rather than a paragraph (re-wrapping htop's columns means
3843    /// nothing), and the application already knows the new size and repaints. All three references
3844    /// take the same position with the same shape — one flag on the same resize function:
3845    /// ghostty `alt.resize(.{ .reflow = false })`, alacritty `grid.resize(!is_alt, …)`, xterm.js
3846    /// gating on `_hasScrollback` with the alt buffer built as `new Buffer(false, …)`.
3847    ///
3848    /// It is not merely wasted work: measured on a real `htop` recording taken across a live
3849    /// `SIGWINCH`, re-splitting leaves debris in the cells htop does not overwrite, because htop
3850    /// repaints **without** clearing. `vim` hides it by erasing first.
3851    reflow: bool,
3852}
3853
3854/// The result of reflowing one pane.
3855struct PaneReflow {
3856    screen: Vec<Row>,
3857    scrollback: VecDeque<Row>,
3858    /// The cursor's new screen-relative position.
3859    cursor: (usize, usize),
3860    /// Each tracked extra point's new position **in this pane's own `[history ++ screen]` frame**,
3861    /// index-aligned with the `extra_abs` argument — *before* any history the caller discards.
3862    ///
3863    /// Reported raw, with `evicted` beside it, because the two callers translate differently and
3864    /// doing it here silently picked the primary's answer for both: the primary keeps its history,
3865    /// so an extra's absolute line only moves by what the cap threw away, while the alt pane has no
3866    /// history at all and everything above the screen is *gone*. Adding the alt result to the
3867    /// primary's scrollback length then produced a line the buffer does not have — reachable
3868    /// without any reflow, on a rows-only resize.
3869    extras: Vec<(usize, usize)>,
3870    /// Rows that left the buffer entirely off the front of this pane's history. For the primary
3871    /// that is the scrollback cap's eviction; for the alt pane, whose limit is `0` because it has
3872    /// no history, it is every row the shrink pushed off the top. An extra whose raw line is below
3873    /// this **is not in the buffer any more** — the caller decides what that means for its kind.
3874    evicted: usize,
3875}
3876
3877/// Reflow one pane (its `scrollback` joined with `screen`) to `dims`, tracking
3878/// the screen-relative cursor `point` plus any `extra_abs` points given in
3879/// **absolute** `[scrollback ++ screen]` coordinates (selection anchors).
3880fn reflow_pane(
3881    screen: Vec<Row>,
3882    scrollback: VecDeque<Row>,
3883    point: (usize, usize),
3884    extra_abs: &[(usize, usize)],
3885    dims: ReflowDims,
3886) -> PaneReflow {
3887    let scroll_len = scrollback.len();
3888    let mut all: Vec<Row> = scrollback.into();
3889    all.extend(screen);
3890
3891    // The cursor is screen-relative; lift it to absolute, then track it together
3892    // with the already-absolute extras.
3893    let mut pts: Vec<(usize, usize)> = Vec::with_capacity(1 + extra_abs.len());
3894    pts.push((scroll_len + point.0, point.1));
3895    pts.extend_from_slice(extra_abs);
3896
3897    let pts = if dims.reflow && dims.cols != dims.old_cols {
3898        let (reflowed, np) = crate::grid::reflow(all, dims.cols, &pts);
3899        all = reflowed;
3900        np
3901    } else {
3902        pts
3903    };
3904
3905    // The cursor can land one row past everything the reflow emitted — "just after the content"
3906    // when the content ends on a full row (#562). That row is real, and while the pane is shorter
3907    // than the screen the caller's fit supplies it for free. When the content already fills the
3908    // pane it has to be bought, and the price is one row of history: the pane **scrolls**, which is
3909    // what a terminal does when content grows past the bottom. Without it the cursor was pulled
3910    // back onto the last glyph and the next byte destroyed a character — the ordinary shell shape,
3911    // a prompt at the bottom of a full screen.
3912    //
3913    // Five earlier designs made `reflow` itself materialise the row and were rejected on
3914    // measurements (a cursor at column 59 resized to width 4 emptied the buffer; a blank-line
3915    // exemption turned 22 alt lines into 21). `reflow` cannot see this pane's budget, so it spent
3916    // what it did not have. Here the budget is in scope, and it is the gate: a pane with no history
3917    // cannot pay — the displaced row would be destroyed rather than archived — so it keeps clamping.
3918    //
3919    // `limit > 0`, deliberately, and not "is this the alt screen": since #567 the alt panes pass
3920    // `limit: 0` because that is what an alt screen's history is, so they are excluded by the budget
3921    // rather than by a branch. That branch is what the design carrying this rule was rejected for
3922    // needing.
3923    //
3924    // This **amends** ADR-0025 rather than reading it narrowly: `reflow` does not create rows; the
3925    // seam may, when the pane can pay. What that record measured is that materialising
3926    // *unconditionally* destroys content.
3927    let cursor_abs = pts[0].0 + usize::from(pts[0].1 == dims.cols);
3928    if dims.limit > 0 {
3929        while all.len() <= cursor_abs {
3930            all.push(Row::blank(dims.cols));
3931        }
3932    }
3933
3934    let split = all.len().saturating_sub(dims.rows);
3935    let history: Vec<Row> = all.drain(0..split).collect();
3936    let mut sb: VecDeque<Row> = history.into();
3937    let mut dropped = 0usize;
3938    while sb.len() > dims.limit {
3939        sb.pop_front();
3940        dropped += 1;
3941    }
3942
3943    // `reflow` may answer `col == cols` — "just after the last cell", which is a real place in the
3944    // logical line and no place in the grid (#562). The **cursor's** reading of it is the next
3945    // *write* position, so a full row means the start of the row after; the caller's row fit
3946    // provides that row (`Grid::set_screen` pads at the bottom). A mark reads the same value the
3947    // opposite way and keeps it verbatim — see `Term::resize`.
3948    let cursor_row = pts[0].0.saturating_sub(split);
3949    let cursor = if pts[0].1 == dims.cols {
3950        (cursor_row + 1, 0)
3951    } else {
3952        (cursor_row, pts[0].1)
3953    };
3954
3955    // The bound on a tracked line belongs **here**, not inside `reflow`: this is where the final
3956    // geometry is known. The screen is padded to `dims.rows` whatever `reflow` emitted, so this
3957    // pane's last addressable line is `split + dims.rows - 1`. Bounding against `reflow`'s own row
3958    // count instead clamped away rows the fit was about to create (#562), while still being the
3959    // only thing standing between an out-of-range anchor and a panic in the consumer's process —
3960    // selection anchors and marks are written back raw, unlike the cursor (`Cursor::set_point`).
3961    // Expressed in this pane's own frame, so it is the same frame `extras` and `evicted` are in.
3962    let max_line = split + dims.rows - 1;
3963
3964    // The cursor returns to screen-relative (its absolute index minus the history split). The
3965    // extras stay in this pane's frame — see the field docs for why they are not shifted here.
3966    PaneReflow {
3967        cursor,
3968        extras: pts[1..]
3969            .iter()
3970            .map(|&(l, c)| (l.min(max_line), c))
3971            .collect(),
3972        evicted: dropped,
3973        screen: all,
3974        scrollback: sb,
3975    }
3976}
3977
3978/// Default tab stops: one every 8 columns (incl. column 0), matching xterm.
3979fn default_tabs(cols: usize) -> Vec<bool> {
3980    (0..cols).map(|i| i % 8 == 0).collect()
3981}
3982
3983/// First sub-parameter of CSI param `idx`, or `default` when absent or zero
3984/// (a zero/omitted numeric param means "1" for cursor movement and "0" for
3985/// erase — callers pass the right default).
3986fn param_or(params: &Params, idx: usize, default: u16) -> u16 {
3987    match params.iter().nth(idx).and_then(|p| p.first().copied()) {
3988        Some(v) if v != 0 => v,
3989        _ => default,
3990    }
3991}
3992
3993impl Term {
3994    /// Apply one DEC private mode set (`'h'`) or reset (`'l'`). DECSET/DECRST
3995    /// carry a list of modes, so `csi_dispatch` folds this over every parameter
3996    /// (#56); each mode is an independent toggle, not a stack.
3997    fn set_dec_private_mode(&mut self, action: char, mode: u16) {
3998        match (action, mode) {
3999            ('h', 1049) => self.enter_alt_screen(),
4000            ('l', 1049) => self.leave_alt_screen(),
4001            // Legacy alt-screen variants (#72): ?47/?1047 switch the buffer
4002            // without saving the cursor; ?1048 saves/restores the cursor without
4003            // switching. ?1049 is the two combined.
4004            ('h', 47) | ('h', 1047) => self.switch_to_alt(),
4005            ('l', 47) | ('l', 1047) => self.switch_to_primary(),
4006            ('h', 1048) => self.save_alt_cursor(),
4007            ('l', 1048) => self.restore_alt_cursor(),
4008            ('h', 6) => {
4009                // DECOM: set homes the cursor to the region top.
4010                self.origin_mode = true;
4011                self.goto(0, 0);
4012            }
4013            ('l', 6) => self.origin_mode = false, // unset leaves the cursor put
4014            ('h', 7) => self.autowrap = true,     // DECAWM
4015            ('l', 7) => self.autowrap = false,
4016            ('h', 45) => self.reverse_wraparound = true, // reverse wraparound (#80)
4017            ('l', 45) => self.reverse_wraparound = false,
4018            // DECCOLM (#82): the engine is dimension-free, so emit a request the
4019            // consumer may honor by resizing — no screen/cursor/margin change here.
4020            ('h', 3) => self.events.push(TermEvent::ColumnMode { cols: 132 }),
4021            ('l', 3) => self.events.push(TermEvent::ColumnMode { cols: 80 }),
4022            ('h', 25) => self.cursor.visible = true, // DECTCEM show
4023            ('l', 25) => self.cursor.visible = false, // DECTCEM hide
4024            ('h', 12) => self.cursor.blink = true,   // att610 cursor blink (#81)
4025            ('l', 12) => self.cursor.blink = false,
4026            ('h', 2004) => self.bracketed_paste = true,
4027            ('l', 2004) => self.bracketed_paste = false,
4028            ('h', 2026) => self.synchronized_output = true, // synchronized output (#73)
4029            ('l', 2026) => self.synchronized_output = false,
4030            ('h', 2027) => self.grapheme_clustering = true, // grapheme-cluster mode (#295)
4031            ('l', 2027) => self.grapheme_clustering = false,
4032            ('h', 2031) => self.color_scheme_updates = true, // color-scheme notifications (#85)
4033            ('l', 2031) => self.color_scheme_updates = false,
4034            ('h', 9001) => self.win32_input_mode = true, // win32-input-mode (#86)
4035            ('l', 9001) => self.win32_input_mode = false,
4036
4037            // Input-encoding modes (#11): DECCKM, mouse tracking + encoding,
4038            // focus reporting. Each set assigns the level; each reset clears
4039            // it (apps enable/disable the same mode, not a stack).
4040            ('h', 1) => self.app_cursor_keys = true, // DECCKM
4041            ('l', 1) => self.app_cursor_keys = false,
4042            ('h', 66) => self.application_keypad = true, // DECNKM (#74)
4043            ('l', 66) => self.application_keypad = false,
4044            // DECANM (#84): set = ANSI (the normal state); reset enters VT52. Only
4045            // the reset is meaningful — `?2h` is a no-op (already ANSI).
4046            ('l', 2) => self.vt52_mode = true,
4047            ('h', 9) => self.mouse_protocol = MouseProtocol::X10, // X10 mouse (#70)
4048            ('h', 1000) => self.mouse_protocol = MouseProtocol::Normal,
4049            ('h', 1002) => self.mouse_protocol = MouseProtocol::ButtonEvent,
4050            ('h', 1003) => self.mouse_protocol = MouseProtocol::AnyEvent,
4051            ('l', 9) | ('l', 1000) | ('l', 1002) | ('l', 1003) => {
4052                self.mouse_protocol = MouseProtocol::Off
4053            }
4054            ('h', 1006) => self.mouse_encoding = MouseEncoding::Sgr,
4055            ('l', 1006) => self.mouse_encoding = MouseEncoding::Default,
4056            ('h', 1015) => self.mouse_encoding = MouseEncoding::Urxvt,
4057            ('l', 1015) => self.mouse_encoding = MouseEncoding::Default,
4058            ('h', 1005) => self.mouse_encoding = MouseEncoding::Utf8,
4059            ('l', 1005) => self.mouse_encoding = MouseEncoding::Default,
4060            ('h', 1016) => self.mouse_encoding = MouseEncoding::SgrPixels,
4061            ('l', 1016) => self.mouse_encoding = MouseEncoding::Default,
4062            ('h', 1004) => self.focus_events = true,
4063            ('l', 1004) => self.focus_events = false,
4064
4065            _ => {} // other DEC modes are later slices
4066        }
4067    }
4068
4069    /// Dispatch one VT52 escape sequence (`ESC <final>`), reached only while
4070    /// `vt52_mode` is set (#84). VT52 is a pre-ANSI dialect: the cursor/erase
4071    /// finals map to the same `Term` primitives the ANSI path uses. `ESC <`
4072    /// returns to ANSI. Unknown finals are ignored.
4073    fn vt52_dispatch(&mut self, byte: u8) {
4074        match byte {
4075            b'A' => self.move_up(1),         // cursor up
4076            b'B' => self.move_down(1),       // cursor down
4077            b'C' => self.move_forward(1),    // cursor right
4078            b'D' => self.move_back(1),       // cursor left
4079            b'H' => self.goto(0, 0),         // cursor home
4080            b'I' => self.reverse_index(),    // reverse line feed
4081            b'J' => self.erase_display(0),   // erase cursor → end of screen
4082            b'K' => self.erase_line(0),      // erase cursor → end of line
4083            b'Y' => self.vt52_y_pending = 2, // direct address: two coord bytes follow
4084            // Identify (DECID): reply `ESC / Z` — "I am a VT52".
4085            b'Z' => self.replies.extend_from_slice(b"\x1b/Z"),
4086            b'=' => self.application_keypad = true, // enter alternate keypad
4087            b'>' => self.application_keypad = false, // exit alternate keypad
4088            b'<' => self.vt52_mode = false,         // exit VT52, return to ANSI
4089            // RIS (`ESC c`) is honored even here: it is a hard "recover from any
4090            // state" reset, and `full_reset` rebuilds `Term` with `vt52_mode`
4091            // cleared, so RIS always escapes VT52 back to ANSI. VT52 defines no
4092            // other meaning for `ESC c`.
4093            b'c' => self.full_reset(),
4094            // Graphics mode (`ESC F`/`ESC G`) is a documented non-goal: the VT52
4095            // graphics glyph set differs from DEC Special Graphics, so reusing that
4096            // charset would render the wrong glyphs. No-op rather than approximate.
4097            b'F' | b'G' => {}
4098            _ => {} // unknown VT52 finals are ignored
4099        }
4100    }
4101
4102    /// Consume one `ESC Y` coordinate byte (#84). The first byte is the row, the
4103    /// second the column; each decodes as `value - 0x20`. On the second byte the
4104    /// cursor is addressed (`goto` clamps out-of-range coordinates). Reached only
4105    /// from `print` while `vt52_y_pending > 0`.
4106    fn vt52_take_coord(&mut self, c: char) {
4107        let coord = (c as usize).saturating_sub(0x20);
4108        if self.vt52_y_pending == 2 {
4109            self.vt52_y_row = coord;
4110            self.vt52_y_pending = 1;
4111        } else {
4112            self.vt52_y_pending = 0;
4113            self.goto(self.vt52_y_row, coord);
4114        }
4115    }
4116
4117    /// The allocation an OSC 8 `id=` names: the live one if that id already named a link
4118    /// with this same URI, else a fresh one recorded under the key (#635).
4119    ///
4120    /// Keyed on **id and URI together**, mirroring xterm.js's `_getEntryIdKey`
4121    /// (`` `${id};;${uri}` ``, `OscLinkService.ts:87`). Keying on the id alone would follow
4122    /// a reused id to a stale target — an application saying "same link" about two
4123    /// different destinations has not said anything the engine should honour.
4124    fn link_for_id(&mut self, id: &str, uri: &str) -> std::sync::Arc<str> {
4125        let key = format!("{id};;{uri}");
4126        // A key whose link has left the buffer is *absent*, not stale — the group it named
4127        // is gone, so this open starts a new one. That is xterm.js's behaviour too, reached
4128        // by deleting the entry rather than by letting a reference die.
4129        if let Some(live) = self.link_ids.get(&key).and_then(std::sync::Weak::upgrade) {
4130            return live;
4131        }
4132        // Amortised sweep before inserting, so dangling keys stay O(live) rather than
4133        // O(ids ever declared). Doubling the threshold keeps it O(1) per open.
4134        if self.link_ids.len() >= self.link_ids_sweep_at {
4135            self.link_ids.retain(|_, weak| weak.strong_count() > 0);
4136            self.link_ids_sweep_at = (self.link_ids.len() * 2).max(LINK_IDS_FIRST_SWEEP);
4137        }
4138        let fresh: std::sync::Arc<str> = std::sync::Arc::from(uri);
4139        self.link_ids.insert(key, std::sync::Arc::downgrade(&fresh));
4140        fresh
4141    }
4142}
4143
4144impl Perform for Term {
4145    fn print(&mut self, c: char) {
4146        // VT52 `ESC Y` direct addressing (#84): vte delivers the two coordinate
4147        // bytes here (it returned to ground after the `Y` final), so intercept
4148        // them before they would be written as glyphs.
4149        if self.vt52_y_pending > 0 {
4150            self.vt52_take_coord(c);
4151            return;
4152        }
4153        // Translate through the active (GL) character set first (#62): under DEC
4154        // Special Graphics a printable byte becomes a line-drawing glyph.
4155        let c = self.charsets[self.gl].map(c);
4156        // Grapheme-cluster mode (DEC ?2027, #295): if `c` extends the previous cell's cluster,
4157        // join it there instead of placing a new cell. OFF → the per-char (wcwidth) path below.
4158        if self.grapheme_clustering && self.try_grapheme_join(c) {
4159            return;
4160        }
4161        match c.width() {
4162            // Zero-width (combining marks): the grapheme-cluster side-table is a
4163            // later slice; drop for now rather than mis-place it as its own cell.
4164            // A zero-width code point is a combining mark — attach it to the
4165            // previous base glyph rather than dropping it.
4166            Some(0) => self.push_combining(c),
4167            None => {}
4168            // Coerced to a pair, because a pair is the only multi-column shape the cell model
4169            // has (`WIDE_CHAR` + exactly one `WIDE_CHAR_SPACER`) — see ADR-0025, which states
4170            // every clause over "a pair" and never over a wider run. `unicode-width` genuinely
4171            // returns 3 for at least one codepoint (U+17D8 KHMER SIGN BEYYAL, a ligature drawn
4172            // as three characters), and that value is not wrong — it is unrepresentable here.
4173            //
4174            // Left uncoerced, the width fell through *every* wide branch in `write_glyph`
4175            // (each gated on `width == 2`) while still driving the cursor advance, so the glyph
4176            // landed as a lone narrow cell followed by columns that no flag distinguished from
4177            // real blanks: search could not find the text on screen and word selection split
4178            // the run, handing the clipboard a space the buffer never held (#595).
4179            //
4180            // All three references bound it, and ghostty says why in the same words —
4181            // `unicode/props.zig:11-13`, *"We clamp to [0, 2] … i.e. 3-em dash becomes a 2-em
4182            // dash"*. Clamping *here* rather than inside `write_glyph` keeps the two jobs apart:
4183            // this is the policy for an out-of-range external value, and the invariant it
4184            // establishes is asserted at the site that depends on it.
4185            Some(width) => self.write_glyph(c, width.min(2)),
4186        }
4187    }
4188
4189    fn execute(&mut self, byte: u8) {
4190        match byte {
4191            // LF, VT, FF all line-feed.
4192            b'\n' | 0x0b | 0x0c => self.linefeed(),
4193            b'\r' => self.carriage_return(),
4194            0x08 => self.backspace(),
4195            b'\t' => self.put_tab(),
4196            0x07 => self.events.push(TermEvent::Bell), // BEL (#12)
4197            0x0e => self.gl = 1,                       // SO (LS1): GL = G1 (#62)
4198            0x0f => self.gl = 0,                       // SI (LS0): GL = G0
4199            _ => {}
4200        }
4201    }
4202
4203    fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
4204        // Kitty keyboard-protocol negotiation: CSI > / = / < / ? ... u. The
4205        // leading intermediate distinguishes it from plain `CSI u` (SCORC) (#23).
4206        if action == 'u'
4207            && let Some(&lead) = intermediates.first()
4208            && matches!(lead, b'>' | b'<' | b'=' | b'?')
4209        {
4210            self.kitty_dispatch(lead, params);
4211            return;
4212        }
4213        // DEC private modes arrive with a '?' intermediate.
4214        if intermediates.first() == Some(&b'?') {
4215            // DECRQM (CSI ? Ps $ p) — report whether mode Ps is set. The '$'
4216            // intermediate distinguishes it from a plain `?...p`. It queries a
4217            // single mode, so it keys off the first parameter only.
4218            if action == 'p' && intermediates.contains(&b'$') {
4219                self.decrqm(param_or(params, 0, 0));
4220                return;
4221            }
4222            // Private DSR (CSI ? Ps n): ?996 = color-scheme query (#85). The
4223            // theme-agnostic engine relays it as an event for the consumer.
4224            if action == 'n' {
4225                if param_or(params, 0, 0) == 996 {
4226                    self.events.push(TermEvent::ColorSchemeQuery);
4227                }
4228                return;
4229            }
4230            // DECSET/DECRST carry a *list* of modes; apply set/reset to EVERY
4231            // parameter, not just the first — htop batches `?1006;1000h` into one
4232            // CSI, so folding only params[0] dropped the 1000 (#56).
4233            for mode in params.iter().filter_map(|p| p.first().copied()) {
4234                self.set_dec_private_mode(action, mode);
4235            }
4236            return;
4237        }
4238        // DECSTR soft reset: CSI ! p (#53).
4239        if intermediates.first() == Some(&b'!') && action == 'p' {
4240            self.soft_reset();
4241            return;
4242        }
4243        // DECSCUSR set cursor style: CSI Ps SP q (space intermediate) (#89). An
4244        // absent param means 1 (block blink); an explicit 0 means reset — so the
4245        // raw value matters and `param_or` (which folds 0 to its default) is wrong.
4246        if intermediates.first() == Some(&b' ') && action == 'q' {
4247            let param = params.iter().next().and_then(|p| p.first().copied());
4248            self.set_cursor_style(param.unwrap_or(1));
4249            return;
4250        }
4251        // Other private/intermediate sequences are later slices; ignore them
4252        // rather than misinterpret.
4253        if !intermediates.is_empty() {
4254            return;
4255        }
4256        match action {
4257            'A' => self.move_up(param_or(params, 0, 1) as usize),
4258            'B' | 'e' => self.move_down(param_or(params, 0, 1) as usize),
4259            'C' | 'a' => self.move_forward(param_or(params, 0, 1) as usize),
4260            'D' => self.move_back(param_or(params, 0, 1) as usize),
4261            'G' | '`' => self.set_col(param_or(params, 0, 1) as usize - 1),
4262            'd' => self.set_row(param_or(params, 0, 1) as usize - 1),
4263            'H' | 'f' => {
4264                let row = param_or(params, 0, 1) as usize - 1;
4265                let col = param_or(params, 1, 1) as usize - 1;
4266                self.goto(row, col);
4267            }
4268            'J' => self.erase_display(param_or(params, 0, 0)),
4269            'K' => self.erase_line(param_or(params, 0, 0)),
4270            'X' => self.erase_chars(param_or(params, 0, 1) as usize),
4271            '@' => self.insert_chars(param_or(params, 0, 1) as usize),
4272            'P' => self.delete_chars(param_or(params, 0, 1) as usize),
4273            'S' => self.scroll_up_lines(param_or(params, 0, 1) as usize),
4274            'T' => self.scroll_down_lines(param_or(params, 0, 1) as usize),
4275            'L' => self.insert_lines(param_or(params, 0, 1) as usize),
4276            'M' => self.delete_lines(param_or(params, 0, 1) as usize),
4277            'g' => self.clear_tab_stop(param_or(params, 0, 0)),
4278            'r' => {
4279                let rows = self.grid.rows() as u16;
4280                let top = param_or(params, 0, 1) as usize;
4281                let bottom = param_or(params, 1, rows) as usize;
4282                self.set_scroll_region(top, bottom);
4283            }
4284            'm' => self.sgr(params),
4285            's' => self.save_cursor(),    // SCOSC (CSI s) — alias of DECSC
4286            'u' => self.restore_cursor(), // SCORC (CSI u) — alias of DECRC
4287            // DA1 (primary device attributes, CSI c): advertise VT220 + ANSI
4288            // colour — the levels justerm actually implements (#27).
4289            'c' => self.replies.extend_from_slice(b"\x1b[?62;22c"),
4290            'n' => self.device_status_report(param_or(params, 0, 0)),
4291            // Non-private SM/RM. Folded over every parameter (modes can batch,
4292            // like the private path #56). IRM (4) and LNM (20) so far.
4293            'h' => {
4294                for m in params.iter().filter_map(|p| p.first().copied()) {
4295                    match m {
4296                        4 => self.insert_mode = true,
4297                        20 => self.newline_mode = true,
4298                        _ => {}
4299                    }
4300                }
4301            }
4302            'l' => {
4303                for m in params.iter().filter_map(|p| p.first().copied()) {
4304                    match m {
4305                        4 => self.insert_mode = false,
4306                        20 => self.newline_mode = false,
4307                        _ => {}
4308                    }
4309                }
4310            }
4311            _ => {}
4312        }
4313    }
4314
4315    fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
4316        // VT52 mode (#84): the pre-ANSI dialect reuses the same `ESC <final>`
4317        // tokens vte already produces, but with different meanings, so it is a
4318        // mode-gated branch here rather than a separate parser. All VT52 sequences
4319        // are intermediate-free; anything with an intermediate is not VT52.
4320        if self.vt52_mode && intermediates.is_empty() {
4321            self.vt52_dispatch(byte);
4322            return;
4323        }
4324        if let Some(&i) = intermediates.first() {
4325            // SCS: designate a charset to G0 (`ESC ( F`) or G1 (`ESC ) F`) (#62).
4326            if matches!(i, b'(' | b')') {
4327                let set = match byte {
4328                    b'0' => Charset::DecSpecialGraphics,
4329                    b'A' => Charset::Uk,
4330                    b'B' => Charset::Ascii,
4331                    _ => return, // other sets are later slices
4332                };
4333                self.charsets[if i == b'(' { 0 } else { 1 }] = set;
4334            }
4335            // Other intermediates (G2/G3 designators, etc.) are later slices.
4336            return;
4337        }
4338        match byte {
4339            b'D' => self.linefeed(), // IND (line-feed without CR)
4340            b'E' => {
4341                // NEL (next line): carriage return + line-feed.
4342                self.carriage_return();
4343                self.linefeed();
4344            }
4345            b'H' => self.set_tab_stop(),             // HTS
4346            b'M' => self.reverse_index(),            // RI
4347            b'7' => self.save_cursor(),              // DECSC
4348            b'8' => self.restore_cursor(),           // DECRC
4349            b'c' => self.full_reset(),               // RIS (#53)
4350            b'=' => self.application_keypad = true,  // DECKPAM (#74)
4351            b'>' => self.application_keypad = false, // DECKPNM
4352            _ => {}
4353        }
4354    }
4355
4356    /// OSC dispatch (#12 event surface): title (0/2), cwd (7). OSC 8 hyperlink
4357    /// is per-cell state, handled in its own slice (#26), not here.
4358    fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
4359        // params[0] is the OSC number; params[1..] the payload fields.
4360        let Some(&number) = params.first() else {
4361            return;
4362        };
4363        match number {
4364            // OSC 0 = icon + window title, OSC 2 = window title. Both set title.
4365            b"0" | b"2" => {
4366                if let Some(&title) = params.get(1) {
4367                    self.events.push(TermEvent::Title(
4368                        String::from_utf8_lossy(title).into_owned(),
4369                    ));
4370                }
4371            }
4372            // OSC 7 = current working directory (a file:// URI).
4373            b"7" => {
4374                if let Some(&cwd) = params.get(1) {
4375                    self.events
4376                        .push(TermEvent::Cwd(String::from_utf8_lossy(cwd).into_owned()));
4377                }
4378            }
4379            // OSC 133 = FinalTerm/iTerm2 shell-integration command marks (#158):
4380            // `A` prompt start, `B` command start, `C` output start, `D[;exit]`
4381            // command finished. Each anchors a kinded marker at the cursor line;
4382            // pairing + navigation is consumer policy (#160). Unknown subcommands
4383            // (or none) are ignored. `D`'s exit field parses to `i32`, else None.
4384            b"133" => match params.get(1).copied() {
4385                Some(b"A") => self.add_command_mark(MarkerKind::PromptStart),
4386                Some(b"B") => self.add_command_mark(MarkerKind::CommandStart),
4387                Some(b"C") => self.add_command_mark(MarkerKind::OutputStart),
4388                Some(b"D") => {
4389                    let exit = params
4390                        .get(2)
4391                        .and_then(|p| core::str::from_utf8(p).ok())
4392                        .and_then(|s| s.parse::<i32>().ok());
4393                    self.add_command_mark(MarkerKind::CommandFinished(exit));
4394                }
4395                _ => {}
4396            },
4397            // OSC 8 = hyperlink: `OSC 8 ; params ; URI`. A non-empty URI opens a
4398            // link (made current); an empty URI closes it. `params` carries the
4399            // optional `id=` that groups runs into one link (#635).
4400            b"8" => {
4401                // One allocation per *open*, shared by that open's cells and dropped
4402                // with the last row holding it (#628 — there is no pool). Two opens of
4403                // an identical URI stay two links, deliberately: merging them would
4404                // override a distinction the application controls through `id=`. That
4405                // parameter is the *only* dedup performed, which is one rule and not
4406                // two — xterm.js states it as "links with no id will only ever be
4407                // registered a single time" beside a lookup keyed on id-plus-uri
4408                // (`OscLinkService.ts:34`, `:49-54`).
4409                // The URI is `params[2..]` **rejoined**, not `params[2]` (#650). vte splits the
4410                // OSC payload on `;`, so a URI carrying an unencoded `;` arrives in pieces and
4411                // reading only the first dropped the rest — silently, with no error. Nothing is
4412                // lost at the parser: measured, `]8;;https://x/a;b=c` arrives as
4413                // `["8", "", "https://x/a", "b=c"]`. xterm.js special-cases the same thing from
4414                // the other side, splitting on the *first* `;` only and taking all the rest as
4415                // the URI, *"to support unencoded semi-colons in the URIs"*
4416                // (`InputHandler.ts:3106-3112`). Reachable without anything exotic: `?a=1;b=2`
4417                // is a legal query string and `;` is a legal filename byte.
4418                //
4419                // The close survives this: `]8;;` arrives as `["8", "", ""]`, whose rejoin is
4420                // empty, and an empty URI still closes. Never decoded — a `%3B` stays `%3B`,
4421                // because the engine hands the target over exactly as declared (ADR-0017).
4422                let uri: Vec<u8> = params.get(2..).unwrap_or_default().join(&b';');
4423                self.current_link = if uri.is_empty() {
4424                    None
4425                } else {
4426                    let uri = String::from_utf8_lossy(&uri);
4427                    Some(match osc8_link_id(params.get(1).copied().unwrap_or(b"")) {
4428                        // No id declared: fresh per open, the reference-correct default.
4429                        None => std::sync::Arc::from(&*uri),
4430                        Some(id) => self.link_for_id(&String::from_utf8_lossy(id), &uri),
4431                    })
4432                };
4433            }
4434            // OSC 4 = set/query an ANSI palette entry: `OSC 4 ; index ; spec`
4435            // (#122). The engine forwards index + raw spec; the consumer applies
4436            // it to its palette (theme-agnostic — the cell keeps `Indexed`).
4437            b"4" => {
4438                // One event per `index ; spec` pair (xterm's `while slots > 1`).
4439                let mut rest = &params[1..];
4440                while let [idx, spec, tail @ ..] = rest {
4441                    rest = tail;
4442                    if let Ok(index) = String::from_utf8_lossy(idx).parse::<u8>() {
4443                        if *spec == b"?" {
4444                            self.events.push(TermEvent::QueryPaletteColor { index });
4445                        } else {
4446                            self.events.push(TermEvent::SetPaletteColor {
4447                                index,
4448                                spec: String::from_utf8_lossy(spec).into_owned(),
4449                            });
4450                        }
4451                    }
4452                }
4453            }
4454            // OSC 104 = reset palette entries (#122): no arg resets the whole
4455            // table, else one event per named index.
4456            b"104" => {
4457                if params.len() <= 1 {
4458                    self.events.push(TermEvent::ResetPaletteColor(None));
4459                } else {
4460                    for &idx in &params[1..] {
4461                        if let Ok(index) = String::from_utf8_lossy(idx).parse::<u8>() {
4462                            self.events.push(TermEvent::ResetPaletteColor(Some(index)));
4463                        }
4464                    }
4465                }
4466            }
4467            // OSC 10/11 = set/query the default foreground/background, stacking
4468            // specs across the [fg, bg] slots (#122, #137). OSC 10 starts at fg,
4469            // OSC 11 at bg. The engine forwards raw specs (theme-agnostic).
4470            b"10" => self.special_color(params, 0),
4471            b"11" => self.special_color(params, 1),
4472            // OSC 110 / 111 = reset the default foreground / background (#122).
4473            b"110" => self.events.push(TermEvent::ResetForeground),
4474            b"111" => self.events.push(TermEvent::ResetBackground),
4475            _ => {} // other OSCs are later slices
4476        }
4477    }
4478}
4479
4480#[cfg(test)]
4481mod tests {
4482    use super::cap_scroll;
4483    use crate::Engine;
4484    use crate::damage::ScrollOp;
4485    use crate::serialize::MAX_SCROLL_COUNT;
4486
4487    /// #661 — the wire's `i16` bound, not the region-height one.
4488    ///
4489    /// In-crate on purpose, and the reason is cost rather than visibility: reaching
4490    /// this through `Engine` needs a screen taller than 32 767 rows *and* 32 768
4491    /// scrolls of it, each rotating a `line_damage` of that length. Measured at
4492    /// 15.8 s in a debug build for a single assertion — the whole `serialize` suite
4493    /// is 0.2 s without it. See `cap_scroll`'s note for how the coverage is split.
4494    #[test]
4495    fn a_region_taller_than_the_wire_field_truncates_rather_than_wraps() {
4496        // 40 000 rows: over i16::MAX, under MAX_ROWS (u16::MAX), so the region
4497        // height alone would let 35 000 through — and 35 000 as i16 is -30 536.
4498        let up = cap_scroll(ScrollOp {
4499            top: 0,
4500            bottom: 40_000,
4501            count: 35_000,
4502        });
4503        assert_eq!(up.count, MAX_SCROLL_COUNT, "capped, and still an up-scroll");
4504
4505        let down = cap_scroll(ScrollOp {
4506            top: 0,
4507            bottom: 40_000,
4508            count: -35_000,
4509        });
4510        assert_eq!(down.count, -MAX_SCROLL_COUNT, "sign survives the cap");
4511    }
4512
4513    /// The cap is a ceiling, not a rewrite: a count inside both bounds is reported
4514    /// exactly, and the region it names is untouched.
4515    #[test]
4516    fn a_scroll_inside_both_bounds_passes_through_unchanged() {
4517        let op = ScrollOp {
4518            top: 4,
4519            bottom: 9,
4520            count: -2,
4521        };
4522        assert_eq!(cap_scroll(op), op);
4523    }
4524
4525    /// #628 — a hyperlink's storage is released once no live row references it.
4526    ///
4527    /// In-crate on purpose: this defect has **no public observable**, which is why it
4528    /// survived from #46 until #621's completeness pass went looking. Pool indices never
4529    /// cross the wire (`Term::frame` remaps them to frame-local `link_table` positions),
4530    /// and the one public reader takes an index the caller already holds — so from
4531    /// outside the crate a pool of 5 entries and a pool of 50 000 are indistinguishable.
4532    /// The assertion has to stand where the storage does.
4533    ///
4534    /// The fixture is a buffer that cannot hold what it is fed: 2 rows plus 2 lines of
4535    /// scrollback is four lines total, so by the end all but the last four opens have
4536    /// been evicted and nothing on screen or in history refers to them.
4537    #[test]
4538    fn a_link_evicted_from_the_buffer_stops_being_stored() {
4539        let mut e = Engine::with_scrollback(20, 2, 2);
4540        for i in 0..50 {
4541            e.feed(format!("\x1b]8;;https://example.com/{i}\x07L{i}\x1b]8;;\x07\r\n").as_bytes());
4542        }
4543
4544        // The observable had to move with the storage — there is no pool left to count.
4545        // A `Weak` is the stronger form of the same claim anyway: a bounded count can be
4546        // bounded and still wrong, while a dead `Weak` says *this exact allocation* was
4547        // released.
4548        //
4549        // **`e2` must outlive the assertion, and that is the whole test.** The first
4550        // version of this scoped the engine to the block that built the `Weak`, so the
4551        // engine was dropped before the check and the `Weak` died for that reason
4552        // instead. Measured: with a deliberate leak reintroduced (a `Vec<Arc<str>>` on
4553        // `Term`, retaining every open), that version stayed **green** — a tautological
4554        // proof, confirming only that dropping an `Engine` frees its own memory. Keeping
4555        // the engine alive is what makes the assertion about reclamation.
4556        let mut e2 = Engine::with_scrollback(20, 2, 2);
4557        e2.feed(b"\x1b]8;;https://example.com/first\x07L\x1b]8;;\x07\r\n");
4558        let weak = {
4559            let arc = e2
4560                .term
4561                .grid
4562                .row_ref(0)
4563                .link_at(0)
4564                .expect("on screen")
4565                .clone();
4566            std::sync::Arc::downgrade(&arc)
4567        };
4568        // The live half first: a fix that simply never stored the URI would satisfy the
4569        // dead-`Weak` assertion below for the wrong reason.
4570        assert!(
4571            weak.upgrade().is_some(),
4572            "the URI must be alive while its cell is on screen",
4573        );
4574        for i in 0..50 {
4575            e2.feed(format!("filler {i}\r\n").as_bytes());
4576        }
4577        assert!(
4578            weak.upgrade().is_none(),
4579            "the first link scrolled out of a 4-line buffer and nothing should still \
4580             hold its URI — before #628 every OSC 8 open lived for the life of the Term",
4581        );
4582
4583        // The whole-buffer form of the same claim: 50 distinct opens through a buffer
4584        // that holds four lines leaves at most four entries *owned*.
4585        //
4586        // `owned_link_count` and not `link_at`, and that distinction is the test. The
4587        // first version summed the gated reader, which counts **linked cells** — measured
4588        // on an erased screen it read 0 while every URI was still allocated, so it could
4589        // not fail for the property this test exists to assert.
4590        // Deduped by allocation: one open covering three cells is three map entries and
4591        // one URI, so counting entries would fail at 9 for a buffer holding four links.
4592        let owned: std::collections::HashSet<*const u8> = e
4593            .term
4594            .scrollback
4595            .iter()
4596            .chain((0..2).map(|r| e.term.grid.row_ref(r)))
4597            .flat_map(|r| r.owned_links())
4598            .map(|u| std::sync::Arc::as_ptr(u) as *const u8)
4599            .collect();
4600        assert!(
4601            owned.len() <= 4,
4602            "a 4-line buffer cannot own more than 4 distinct URIs, found {}",
4603            owned.len(),
4604        );
4605    }
4606
4607    /// #628 — erasing a cell in place releases its URI, not just its presence bit.
4608    ///
4609    /// The sibling of the eviction test above, and the case that one structurally cannot
4610    /// see: `clear_cells` / `free_cell` blank a cell **without dropping its row**, so no
4611    /// row-lifetime event fires. Under `row-keyed-side-maps` rule 3 leaving the map entry
4612    /// is sanctioned — *"a write that clears the cell owes the bit, not the map"* — and
4613    /// that was exactly right while the value was a 4-byte index: a stale entry is
4614    /// unreadable through the gate and costs nothing.
4615    ///
4616    /// #628 changed what the entry *is*. The map now owns a heap string, so the same
4617    /// sanctioned line retains one. Rule 3 still holds as stated — purging is not the
4618    /// correctness step, and missing a site costs bounded retention rather than a wrong
4619    /// answer — but the optimisation it calls optional became worth taking here.
4620    /// All three references release at this point: alacritty's `Cell::reset` drops the
4621    /// `Option<Arc<CellExtra>>` outright, ghostty's ref-counted set frees at zero, and
4622    /// xterm.js's `_resetBufferLine` clears `_extendedAttrs` and disposes the line's
4623    /// markers so `OscLinkService` deletes the entry.
4624    #[test]
4625    fn an_erased_cell_releases_its_uri_not_only_its_bit() {
4626        let mut e = Engine::new(80, 24);
4627        e.feed(b"]8;;https://example.com/erasedL]8;;");
4628        let weak = {
4629            let a = e
4630                .term
4631                .grid
4632                .row_ref(0)
4633                .link_at(0)
4634                .expect("on screen")
4635                .clone();
4636            std::sync::Arc::downgrade(&a)
4637        };
4638        assert!(weak.upgrade().is_some(), "alive while on screen");
4639
4640        e.feed(b""); // ED 2 — erases in place; no row is dropped or reused
4641
4642        // The gated reader already says "no link", and so does the frame. Neither can
4643        // see the retention, which is why this assertion holds the `Weak` instead:
4644        // measured before the purge, both public views read 0 while the URI lived.
4645        assert!(e.link_at(0, 0).is_none(), "the presence bit is cleared");
4646        assert!(
4647            weak.upgrade().is_none(),
4648            "and the URI itself is released — before the purge the map kept owning it,              so an erased screen retained every link it had shown",
4649        );
4650    }
4651
4652    /// `Arc`, not `Rc`, and this is what makes that a fact rather than a comment.
4653    ///
4654    /// #628 chose `Arc<str>` for the row's link map on the stated ground that `Engine` is
4655    /// `Send + Sync`; `Rc` would have removed both **silently** — no signature changes
4656    /// here, and a downstream `Mutex<Engine>` failing to compile instead. The claim was
4657    /// load-bearing and unpinned: a repo-wide grep for it found only prose.
4658    #[test]
4659    fn the_engine_stays_send_and_sync() {
4660        fn assert_send_sync<T: Send + Sync>() {}
4661        assert_send_sync::<Engine>();
4662    }
4663
4664    /// Two OSC 8 opens of an identical URI are **two links**, not one.
4665    ///
4666    /// Deliberate, and the reason is #635: merging them would override the grouping the
4667    /// application controls through `id=`, which is the one dedup xterm.js performs.
4668    /// Asserted by allocation identity through the in-crate observer rather than through
4669    /// a public accessor — the behaviour is real now, a consumer asking about it is not.
4670    #[test]
4671    fn two_opens_of_one_uri_are_two_links() {
4672        let mut e = Engine::new(40, 2);
4673        // One open covering two cells, then a *separate* open of the very same URI.
4674        e.feed(b"]8;;https://example.com/xAB]8;;");
4675        e.feed(b"]8;;https://example.com/xC]8;;");
4676
4677        let row = e.term.grid.row_ref(0);
4678        let ptr = |c: usize| std::sync::Arc::as_ptr(row.link_at(c).expect("linked")) as *const u8;
4679        assert_eq!(
4680            e.link_at(0, 0).map(|h| h.uri().to_owned()),
4681            e.link_at(0, 2).map(|h| h.uri().to_owned()),
4682            "the text is the same",
4683        );
4684        assert_eq!(ptr(0), ptr(1), "A and B are one open, so one allocation");
4685        assert_ne!(
4686            ptr(0),
4687            ptr(2),
4688            "…but C is a second open — merging the two would override the distinction              `id=` exists to express (#635)",
4689        );
4690    }
4691
4692    /// The other half of the rule above: an `id=` the application declared **does** group
4693    /// (#635). One rule, not two — "never merge on URI alone, always merge on a declared
4694    /// id" is how xterm.js states it (`OscLinkService.ts:34`, `:51` at the pinned SHA), and
4695    /// justerm shipped the first half only because #26 ported `registerLink`'s id-minting
4696    /// and not its lookup.
4697    ///
4698    /// Grouping is asserted as **allocation identity**, which is not an implementation
4699    /// detail leaking into a test: since #628 the `Arc`'s address *is* link identity —
4700    /// `Term::frame` interns `link_table` by `Arc::as_ptr`, so one allocation is what makes
4701    /// two runs one link index on the wire, and that index is what a consumer groups by.
4702    #[test]
4703    fn the_same_id_and_uri_group_into_one_link() {
4704        let mut e = Engine::new(40, 2);
4705        // Two separate opens, same `id=` and same URI, on two different lines — the case
4706        // the parameter exists for (a link that cannot be one contiguous run).
4707        e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07A\x1b]8;;\x07\r\n");
4708        e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07B\x1b]8;;\x07");
4709
4710        let ptr = |r: usize, c: usize| {
4711            std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
4712        };
4713        assert_eq!(
4714            ptr(0, 0),
4715            ptr(1, 0),
4716            "the application said these two runs are one link, so they share one allocation",
4717        );
4718
4719        // And the wire agrees, which is the half a consumer can actually see: one entry in
4720        // `link_table`, referenced by both spans. Two entries is the defect.
4721        let f = e.frame();
4722        assert_eq!(f.link_table.len(), 1, "one link ships once");
4723    }
4724
4725    /// Keyed on `id` **and** URI, not on `id` alone — xterm.js's `_getEntryIdKey` is
4726    /// `` `${id};;${uri}` `` (`OscLinkService.ts:87`). An application reusing an id for a
4727    /// different target has not said "same link"; treating it as one would follow a stale
4728    /// declaration to the wrong URI.
4729    #[test]
4730    fn the_same_id_with_a_different_uri_stays_two_links() {
4731        let mut e = Engine::new(40, 2);
4732        e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07A\x1b]8;;\x07\r\n");
4733        e.feed(b"\x1b]8;id=xyz;https://example.com/b\x07B\x1b]8;;\x07");
4734
4735        let ptr = |r: usize, c: usize| {
4736            std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
4737        };
4738        assert_ne!(
4739            ptr(0, 0),
4740            ptr(1, 0),
4741            "same id, different target — two links"
4742        );
4743        assert_eq!(e.frame().link_table.len(), 2, "and both ship");
4744    }
4745
4746    /// `id=` with an **empty value** is no id at all, so the no-id rule applies and each
4747    /// open is its own link. xterm.js reaches this by `parsedParams[i].slice(3) || undefined`
4748    /// (`InputHandler.ts:3130`) — the `||` is the whole behaviour, and reading `slice(3)`
4749    /// alone gives the opposite answer.
4750    ///
4751    /// Worth a test rather than a comment because the empty-string key is the one that
4752    /// would group *every* `id=`-with-no-value link in a session into one, across unrelated
4753    /// URIs — a wrong answer that grows with uptime.
4754    #[test]
4755    fn an_empty_id_value_is_no_id_at_all() {
4756        let mut e = Engine::new(40, 2);
4757        e.feed(b"\x1b]8;id=;https://example.com/a\x07A\x1b]8;;\x07\r\n");
4758        e.feed(b"\x1b]8;id=;https://example.com/a\x07B\x1b]8;;\x07");
4759
4760        let ptr = |r: usize, c: usize| {
4761            std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
4762        };
4763        assert_ne!(
4764            ptr(0, 0),
4765            ptr(1, 0),
4766            "no id declared, so the reference-correct fresh-per-open rule still holds",
4767        );
4768    }
4769
4770    /// `params` is a **`:`-separated** key=value list (`id=xyz123:foo=bar:baz=quux`), and
4771    /// `id` may sit anywhere in it — xterm.js scans with `findIndex(e =>
4772    /// e.startsWith('id='))` (`InputHandler.ts:3129`). Testing only a leading `id=` would
4773    /// pass with a `starts_with` on the whole field, which is the wrong parse.
4774    #[test]
4775    fn the_id_param_is_found_among_other_params() {
4776        let mut e = Engine::new(40, 2);
4777        e.feed(b"\x1b]8;foo=bar:id=xyz:baz=quux;https://example.com/a\x07A\x1b]8;;\x07\r\n");
4778        e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07B\x1b]8;;\x07");
4779
4780        let ptr = |r: usize, c: usize| {
4781            std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
4782        };
4783        assert_eq!(
4784            ptr(0, 0),
4785            ptr(1, 0),
4786            "the id is the same whatever else rides beside it",
4787        );
4788    }
4789
4790    /// The grouping registry must not become the pool #628 deleted.
4791    ///
4792    /// Whatever maps an `id=` to its link has to hold it **weakly**: a strong reference
4793    /// would make every id'd link immortal for the life of the `Term` — the exact defect
4794    /// #628 removed, re-entering through the door #635 opens. xterm.js's equivalent map is
4795    /// reclaimed rather than weak (`_entriesWithId.delete` when the entry's last line
4796    /// marker is disposed, `OscLinkService.ts:98-100`); justerm has no disposal hook by
4797    /// design, so `Weak` is how the same lifetime is expressed here.
4798    ///
4799    /// This is the test that discriminates the two, and nothing public can: both spellings
4800    /// group correctly, and they differ only in what stays alive afterwards.
4801    #[test]
4802    fn the_id_registry_does_not_keep_a_link_alive() {
4803        let mut e = Engine::new(80, 24);
4804        e.feed(b"\x1b]8;id=xyz;https://example.com/grouped\x07L\x1b]8;;\x07");
4805        let weak = {
4806            let a = e
4807                .term
4808                .grid
4809                .row_ref(0)
4810                .link_at(0)
4811                .expect("on screen")
4812                .clone();
4813            std::sync::Arc::downgrade(&a)
4814        };
4815        assert!(weak.upgrade().is_some(), "alive while on screen");
4816
4817        e.feed(b"\x1b[2J"); // ED 2 — the in-place erase that releases the row's side maps
4818
4819        assert!(
4820            weak.upgrade().is_none(),
4821            "the id registry must hold a Weak — a strong entry would outlive the screen and              rebuild #628's leak one id at a time",
4822        );
4823    }
4824}