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, UnderlineStyle};
11use crate::color::Color;
12use crate::cursor::{Cursor, CursorShape, Pen};
13use crate::damage::{LineBounds, LineDamage, ScrollOp, TermDamage};
14use crate::event::{ClipboardTarget, TermEvent, Terminator};
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.
50///
51/// **No `#[non_exhaustive]` ([#844](https://github.com/kihyun1998/justerm/issues/844)): nothing outside this crate has a reason to build one.** No
52/// public function accepts it — the engine hands it out — and there are zero out-of-crate literal
53/// sites, so the attribute would bind nothing it does not already bind.
54pub struct Term {
55    grid: Grid,
56    /// The inactive screen. Swapped with `grid` on alt-screen enter/leave; holds
57    /// whichever of primary/alternate is not currently shown. The alt screen has
58    /// no scrollback (#3 only rings the primary).
59    alt_grid: Grid,
60    cursor: Cursor,
61    /// Cursor saved on alt-screen enter (DEC 1049), restored on leave.
62    saved_cursor: Cursor,
63    /// Whether the alternate screen is currently active. Guards enter/leave so a
64    /// double-enter or double-leave is a no-op.
65    on_alt: bool,
66    /// One flag per column: is there a tab stop here? Explicit per-column state
67    /// (HTS sets, TBC clears), not a fixed modulo. Default = every 8th column.
68    tabs: Vec<bool>,
69    /// Which characters end a word for Word (semantic) selection — **consumer policy**,
70    /// not engine state (ADR-0017). Defaults to [`DEFAULT_WORD_SEPARATORS`]; replaced
71    /// through `set_word_separators`, which is also where the `' '` floor is enforced.
72    ///
73    /// Being *policy* is what puts it on the short list `full_reset` carries across RIS,
74    /// beside `replies` and `events`: `ESC c` resets the terminal's state, and a
75    /// consumer's configuration is not that. All three references survive RIS here by
76    /// construction rather than by remembering to — alacritty's `reset_state` never
77    /// touches `self.config`, xterm.js holds it in `OptionsService`, and ghostty passes
78    /// the set in per call.
79    word_separators: String,
80    /// The window title the application last set (OSC 0/2), retained so that a
81    /// title *pop* has something to restore (#823). Until XTWINOPS 22/23 landed
82    /// the engine forwarded the string and forgot it, which is exactly why a pop
83    /// could not be answered: a party that does not know what it is stacking
84    /// cannot maintain a stack, and the consumer cannot do it either — it holds
85    /// a frame and an event queue and never sees that a pop was requested.
86    ///
87    /// This is **terminal state the application owns**, so it dies on RIS. It
88    /// needs no entry in `full_reset`'s copy-back list — the wholesale rebuild
89    /// drops it, which is the behaviour alacritty spells out (`title_stack =
90    /// Vec::new()` in its `reset_state`). Note it is a *fifth* kind for the RIS
91    /// invariant note's table, which sorts fields into configuration / buffer
92    /// coordinate / pending obligation / id counter: a retained title is none of
93    /// those, and it dies because the application wrote it, not the embedder.
94    window_title: String,
95    /// The icon name, the second axis XTWINOPS addresses (#823). Narrower than
96    /// it looks: **OSC 1 is not parsed**, so the only thing that ever writes
97    /// this is OSC 0, which sets both axes at once. It is retained and stacked
98    /// anyway so that mixed push/pop sequences keep the two axes aligned — the
99    /// engine has no icon-name event, so restoring one has no observable output
100    /// today. That asymmetry is deliberate and stated rather than an oversight;
101    /// xterm.js takes the same route (`setIconName` fires nothing).
102    ///
103    /// **The obligation that outlives this comment: no test can observe this
104    /// field, so a fourth writer would disagree with the other three in
105    /// silence.** There are exactly three today, all in this file — `OSC 0` in
106    /// `osc_dispatch`, and the push and pop in [`Term::window_ops`] — and the
107    /// window axis funnels through [`Term::set_window_title`] precisely so its
108    /// retained string and its event cannot drift apart. This axis has no such
109    /// funnel because it has no event to keep in step with. **Adding `OSC 1` is
110    /// the foreseeable fourth writer** (it is the natural neighbour of the OSC
111    /// work in #47's tail), and whoever adds it should route every write through
112    /// one owner the way the window axis does — not because a test will fail,
113    /// but because none can.
114    icon_name: String,
115    /// The window-title stack (XTWINOPS `CSI 22 t` / `CSI 23 t`), bounded at
116    /// [`TITLE_STACK_DEPTH`]. Separate from `icon_name_stack` because the
117    /// sequence's second parameter selects an axis and real applications use it:
118    /// `vim` emits a fully nested `22;0;0t · 22;2t · 22;1t … 23;2t · 23;1t ·
119    /// 23;0;0t`.
120    ///
121    /// **This is a choice among three models, not two, and the spec does not
122    /// make it** — `ctlseqs.txt:1688-1693` names the two axes and never says how
123    /// many stacks there are. (a) One stack with the axis ignored: alacritty,
124    /// and it restores the wrong string on the sequence above. (b) Two
125    /// independent stacks: xterm.js, and this. (c) One stack of `{icon, window}`
126    /// **pairs**, where an axis-limited push leaves the other member empty and a
127    /// pop that finds an empty member walks *back* through older slots for one:
128    /// xterm (`ptyx.h:2361`, `misc.c:8011`). (c) handles the axis correctly by a
129    /// different mechanism, so the argument against (a) is not an argument
130    /// against it; what separates (b) from (c) is that they do not share a depth
131    /// budget — ten each here, ten *total* there — and that a repeated same-axis
132    /// pop goes silent under (b) while (c) re-emits from an older slot. Neither
133    /// shape is reachable by anything measured, and (b) is the one whose depth
134    /// accounting a reader can predict without simulating a ring.
135    window_title_stack: Vec<String>,
136    /// The icon-name stack — the other half of the pair above.
137    icon_name_stack: Vec<String>,
138    /// Origin mode (DECOM ?6): when set, cursor addressing is relative to the
139    /// scroll region's top margin (and clamped to it).
140    origin_mode: bool,
141    /// Autowrap (DECAWM ?7): default on. When off, a glyph past the right margin
142    /// pins the cursor to the last column and overwrites in place instead of
143    /// wrapping to the next line (matches xterm.js) (#63).
144    autowrap: bool,
145    /// Insert mode (IRM, the non-private SM/RM mode 4): default off (replace).
146    /// When on, a printed glyph shifts the row's tail right first (#64).
147    insert_mode: bool,
148    /// New-line mode (LNM, the non-private SM/RM mode 20): default off. When on,
149    /// a line feed also carriage-returns (`convertEol`). Output-only — the Enter
150    /// key still encodes CR, matching xterm.js (#71).
151    newline_mode: bool,
152    /// Reverse wraparound (DEC ?45): default off. When on, a step back at column 0
153    /// of a soft-wrapped row moves to the end of the previous row, and a step back
154    /// from a parked cursor spends the deferred wrap instead of moving. Both verbs
155    /// take that step — `BS` and `CSI D` alike, through `Term::step_back` (#80, #873).
156    /// Soft wraps only, and **both halves need `?7h` as well as this flag**: xterm reaches
157    /// them through one `rev`, which is `?45 AND ?7h` (`cursor.c:123-127`). The walk leaves
158    /// the wrap link alone — see the per-verb table on [`Term::end_wrap`].
159    reverse_wraparound: bool,
160    /// Bracketed-paste mode (DEC ?2004). The engine owns the flag; the input
161    /// encoder (#11) reads it to decide whether to wrap pasted text in markers.
162    bracketed_paste: bool,
163    /// Synchronized output (DEC ?2026): the app brackets a frame of output so the
164    /// renderer can paint it atomically. The engine only *tracks* the flag — the
165    /// consumer owns the paint-hold and the spec-mandated timeout.
166    synchronized_output: bool,
167    /// Color-scheme-update notifications (DEC ?2031): the app asked to be told
168    /// when the light/dark scheme changes. The engine is theme-agnostic — it only
169    /// tracks the flag; the consumer (which knows the scheme) drives the ?997
170    /// notification via `report_color_scheme` (#85).
171    color_scheme_updates: bool,
172    /// Grapheme-cluster mode (DEC ?2027, default OFF): the app opted into UAX #29 grapheme-cluster
173    /// width — a ZWJ / skin-tone / flag / emoji+VS16 sequence is clustered into ONE cell instead of
174    /// one cell per scalar (#295). OFF keeps the per-char (wcwidth-compatible) behaviour so the
175    /// cursor stays in sync with wcwidth apps — clustering is opt-in for exactly that reason (#301).
176    grapheme_clustering: bool,
177    /// Where the last content-producing print landed — `(row, col)` of that cluster's
178    /// lead cell — or `None` when nothing has been printed since the last thing that
179    /// cleared it.
180    ///
181    /// **One reader again — but the constraint the second one imposed is still binding.**
182    /// `REP` (CSI b) reads the grapheme back off this cell, and for a while
183    /// `Term::cursor_cluster_col` also asked *is the cursor standing on the cell the
184    /// print wrote*, because the deferred-wrap flag could not express a pin under `?7l`
185    /// (#865). #869 fixed the flag and that reader went back to it.
186    ///
187    /// **The anchor must still name a cell that holds the cluster, and the reason is
188    /// `REP`'s own, not the departed reader's.** A promotion at the last column
189    /// relocates the cluster to the next row (#303), so the join anchors on where it
190    /// landed rather than where it was joined; anchored on the vacated column, `REP`
191    /// repeats the blanks `vacate_for_wrap` left. Pinned by
192    /// `rep_after_a_promotion_relocated_the_cluster_repeats_the_cluster`. Stated here
193    /// because the rationale that arrived with it named the other reader, and removing
194    /// that reader must not read as permission to undo this.
195    ///
196    /// The grapheme itself is not stored: it is read back from this cell, which is what
197    /// keeps the repeated unit in step with what the cell model actually holds. The
198    /// **position** is recorded rather than re-derived, because re-deriving it from the
199    /// cursor cannot be done: with autowrap off the cursor reaches the last column both
200    /// by *filling* it (pinned, the cluster is under the cursor) and by *advancing onto*
201    /// it (the cluster is one to the left), and no cursor state distinguishes them. An
202    /// earlier version of this guessed with `pending_wrap || (!autowrap && col + 1 ==
203    /// cols)` and repeated whatever happened to sit in the last column — including, on
204    /// `?7l` + `ZZZZZ` + `CUP` + `abcd` + `CSI 1 b`, a `Z` from an unrelated earlier part
205    /// of the stream. Recording the site cannot be wrong about it.
206    ///
207    /// **Set** by the three sites that give a cell content, all reached through
208    /// `place_grapheme`, each returning where it wrote; **cleared** by every other
209    /// `Perform` callback and by [`Term::resize`], whose reflow moves the cell out from
210    /// under the anchor.
211    ///
212    /// That enumeration is xterm's rule (`charproc.c:6478` — assign the retained char
213    /// only when the parser is back in the ground state, and it is unset unless a
214    /// graphic character was printed) expressed the only way this crate can express it.
215    /// `vte`'s `Parser` publishes `new` / `new_with_size` / `advance` /
216    /// `advance_until_terminated` and **nothing about its state**, so the structural
217    /// formulation is unavailable and an enumeration is what is left.
218    ///
219    /// **The enumeration is incomplete, and cannot be completed against `vte` 0.15.**
220    /// Two parser states return to ground with no callback at all, so nothing here can
221    /// observe the sequence ending (measured by feeding `-`, the sequence, then
222    /// `CSI 3 b`; xterm gives one dash for every row):
223    ///
224    /// | input | reaches | dashes |
225    /// |---|---|---|
226    /// | `CSI 1 ? b`, `CSI SP 1 p`, `CSI ? ? m` | `State::CsiIgnore`, exiting at `vte-0.15.0/src/lib.rs:222` (`0x40..=0x7E => self.state = Ground`) | 4 |
227    /// | `DCS 1 ? q … ST` | `State::DcsIgnore`, which routes to `anywhere` and never calls `hook`/`unhook` | 4 |
228    ///
229    /// All of these are malformed sequences. The divergence is bounded by the anchor's
230    /// shape: with a *position* rather than a heuristic, a missed clear repeats the
231    /// genuinely last-printed grapheme where xterm repeats nothing — which is ghostty's
232    /// behaviour (`Terminal.zig:4467` clears only on full reset), not a wrong glyph.
233    ///
234    /// `Perform` methods that clear: `execute`, `esc_dispatch`, `osc_dispatch`, `unhook`,
235    /// and `csi_dispatch` (which takes it up-front and lets `REP` disarm itself —
236    /// xterm's behaviour, and not ghostty's, whose `printRepeat` re-arms through
237    /// `print`). `print` clears only on its zero-cell path; `hook` and `put` do not,
238    /// because nothing that could read this can arrive before `unhook` does.
239    repeat_anchor: Option<(usize, usize)>,
240    /// win32-input-mode (DEC ?9001): the app asked for keys as raw Windows
241    /// key-records. The engine only *tracks* the flag — the raw record encoding
242    /// (`CSI Vk;Sc;Uc;Kd;Cs;Rc _`) is a non-goal (raw passthrough, no semantic
243    /// conversion), left to the ConPTY consumer; `encode_key` is unchanged (#86).
244    win32_input_mode: bool,
245    /// Application cursor keys (DECCKM ?1): when set, cursor keys / Home / End
246    /// encode as SS3 rather than CSI (see `input.rs`).
247    app_cursor_keys: bool,
248    /// Application keypad mode (DECNKM ?66 / DECKPAM `ESC =` / DECKPNM `ESC >`):
249    /// tracked for protocol completeness + DECRQM, but NOT yet acted on in key
250    /// encoding — xterm.js tracks it the same way and never reads it (#74).
251    application_keypad: bool,
252    /// VT52 compatibility mode (DECANM ?2 *reset*): when set, `esc_dispatch` is
253    /// re-routed into the pre-ANSI VT52 dialect (`ESC A`-style sequences) instead
254    /// of the ANSI meaning. `ESC <` clears it. Default off (ANSI). (#84)
255    vt52_mode: bool,
256    /// VT52 `ESC Y row col` direct-addressing state (#84). vte tokenizes `ESC Y`
257    /// as a final and returns to ground, so the two coordinate bytes arrive as
258    /// `print()` calls — not part of the escape sequence. This counts them down
259    /// (2 → 1 → 0; 0 = not addressing) and `vt52_y_row` parks the first (row)
260    /// until the second (col) lands. Each byte decodes as `value - 0x20`.
261    vt52_y_pending: u8,
262    vt52_y_row: usize,
263    /// Mouse tracking mode — what events the app asked to be reported
264    /// (?1000/?1002/?1003). `Off` by default.
265    mouse_protocol: MouseProtocol,
266    /// Mouse coordinate encoding (default X10 vs ?1006 SGR).
267    mouse_encoding: MouseEncoding,
268    /// Focus in/out reporting (?1004): emit `CSI I`/`CSI O` on focus change.
269    focus_events: bool,
270    /// Kitty keyboard-protocol progressive-enhancement flags currently in effect
271    /// (bit0 disambiguate, bit1 report-events, bit2 alt-keys, bit3 all-as-escape,
272    /// bit4 associated-text). 0 = legacy. `encode_key` consults these (#23).
273    kitty_flags: u8,
274    /// Saved `kitty_flags` for the protocol's push/pop stack (`CSI > u` pushes,
275    /// `CSI < u` pops). Capped depth — overflow drops the oldest entry.
276    kitty_stack: Vec<u8>,
277    /// The other screen's `kitty_flags` and `kitty_stack`: each screen keeps its own, and
278    /// [`Self::swap_kitty_keyboard`] exchanges them when the screen changes.
279    kitty_flags_inactive: u8,
280    kitty_stack_inactive: Vec<u8>,
281    /// xterm's `modifyOtherKeys` at level 2 or above, asked for with
282    /// `CSI > 4 ; Pv m` (XTMODKEYS) and what `vim` turns on at startup (#890).
283    /// `encode_key` consults it *after* `kitty_flags`, because it belongs to the
284    /// legacy encoding rather than competing with the newer protocol.
285    ///
286    /// **Both resets clear it**, RIS by the wholesale rebuild in [`Self::full_reset`] and
287    /// DECSTR by a line of its own in [`Self::soft_reset`]. Which it is, and why the answer
288    /// is the reference's rather than a convenience, is in
289    /// `docs/map/invariant/ris-keeps-configuration-drops-coordinates.md`, whose table this
290    /// field is the fifth row of.
291    modify_other_keys_2: bool,
292    /// Consumer events (title / bell / cwd) accumulated since the last
293    /// `drain_events` (#12). Pull, not push — see `event.rs`.
294    events: Vec<TermEvent>,
295    /// Outbound reply bytes (DA/DSR/DECRQM query answers, #27) accumulated
296    /// during `feed` for the consumer to write back to the PTY. Raw bytes →
297    /// PTY, kept separate from typed `events` → UI.
298    replies: Vec<u8>,
299    /// The hyperlink currently open (OSC 8 with a URI), stamped onto every glyph
300    /// written until closed (OSC 8 with empty URI). Ambient pen-like state — not
301    /// part of the pen/SGR, and *not* cleared by an SGR reset.
302    ///
303    /// The URI itself, not a pool index — there is no pool (#628). The lead paragraph
304    /// here used to describe one (*"Hyperlink side-table … referenced by `Cell.link`
305    /// (1-based). Append-only (#26)"*), left behind when its field was deleted.
306    current_link: Option<std::sync::Arc<str>>,
307    /// Live OSC 8 `id=` groups: `"id;;uri"` → the allocation that key already named,
308    /// held **weakly**.
309    ///
310    /// `Weak`, not `Arc`, is the whole lifetime story. A strong entry here would make
311    /// every id'd link immortal for the life of the `Term` — precisely the leak #628
312    /// deleted, re-entering through the door grouping opens. A dangling key is the
313    /// correct answer rather than a hole: the link it named has left the buffer, so a
314    /// later open of the same id is genuinely a new link. xterm.js expresses the same
315    /// lifetime by *deleting* its `_entriesWithId` entry when the last line marker
316    /// referencing it is disposed (`OscLinkService.ts:98-100`); justerm has no disposal
317    /// hook by design, and `Weak` is that lifetime without one.
318    link_ids: std::collections::HashMap<String, std::sync::Weak<str>>,
319    /// Map length at which [`Self::link_ids`] is swept for dangling keys, doubling each
320    /// time so the sweep is amortised O(1) per open and dead keys stay O(live).
321    ///
322    /// A sweep is affordable here for the reason #628's rejected option (c) was not:
323    /// staleness is observable in O(1) (`Weak::strong_count`), where (c) had to decide
324    /// "is the pool oversized" by counting live references — the O(buffer) walk it was
325    /// trying to avoid.
326    link_ids_sweep_at: usize,
327    /// Scroll region top/bottom margins (DECSTBM), 0-based inclusive. A
328    /// line-feed at `scroll_bottom` scrolls only rows `[scroll_top..=scroll_bottom]`.
329    /// Default = the full screen.
330    scroll_top: usize,
331    scroll_bottom: usize,
332    /// Lines that have scrolled off the top of the primary screen, oldest at the
333    /// front. Accrues only on a top-anchored, primary-screen scroll.
334    scrollback: VecDeque<Row>,
335    /// How many lines the viewport is scrolled up from the bottom. 0 = following
336    /// the live screen; clamped to `[0, scrollback.len()]`.
337    display_offset: usize,
338    /// Maximum scrollback lines retained; the oldest are evicted past this.
339    scrollback_limit: usize,
340    /// A spare row buffer recycled across full-screen scrolls: the cap-evicted
341    /// oldest line is parked here and reused as the next scroll's blank bottom,
342    /// so a steady-state flood allocates nothing (ADR-0009).
343    recycled_row: Option<Row>,
344    /// Per-line damage bounds since the last `reset_damage` (ack), one per row.
345    line_damage: Vec<LineBounds>,
346    /// A first-class scroll recorded since the last `reset_damage`.
347    scroll: Option<ScrollOp>,
348    /// The whole screen changed (alt switch / clear / later resize+flood) — the
349    /// renderer must redraw everything.
350    full_damage: bool,
351    /// The cursor `(row, col)` at the last `reset_damage` (ack) — where the
352    /// consumer last saw the caret. A pure cursor move records no content
353    /// damage, so `damage()` folds this *old* cell plus the current one into the
354    /// frame; without it a cell-invert caret ghosts at the old spot (mirrors
355    /// Alacritty's `last_cursor`). #38.
356    prev_cursor: (usize, usize),
357    /// The live selection, in absolute buffer coordinates. `None` when nothing
358    /// is selected. See `selection.rs`.
359    selection: Option<Selection>,
360    /// The search highlights the consumer asked to paint (#108). Search
361    /// matches are consumer-owned (it drives next/prev), so the engine holds only
362    /// the set handed back via `set_search_highlights`, and `frame()` projects it
363    /// onto the viewport — the same anchoring path as the selection.
364    search_highlights: Vec<Match>,
365    /// The *active* (current) search match (#428), stored as its absolute span
366    /// (#436) — designated by the consumer (next/prev is its policy) either as
367    /// an index into `search_highlights` (resolved to the span at call time) or
368    /// directly by span, which a capping backend uses for a past-cap match
369    /// (xterm creates its active decoration from the found result, OUTSIDE the
370    /// capped highlight list). A span is NOT structurally tied to the set, so
371    /// every path that voids the set must void this too: `set_search_highlights`
372    /// (hand-over reset, #428) and `invalidate_search_highlights` (the single
373    /// funnel for eviction / every region scroll incl. the accrual sub-region
374    /// branch (#449) / reflow / both alt swaps) — a stale span would otherwise
375    /// keep painting coordinates that now hold other text.
376    ///
377    /// **That list is the *motion* funnel, and it is complete only for motion.** An
378    /// in-place erase or overwrite stales this set too, and deliberately does not
379    /// funnel — read `invalidate_search_highlights`, which owns that decision and its
380    /// grounds. Stated here because this comment enumerating the callers reads as the
381    /// whole rule, and a reader who stops at it concludes the erase verbs were
382    /// forgotten.
383    active_search_highlight: Option<Match>,
384    /// Engine-owned decoration markers (#118), split per buffer like xterm's
385    /// `BufferSet` (#177 S0): each a stable id bound to an absolute buffer line
386    /// that re-anchors through eviction/scroll/reflow like a selection anchor. The
387    /// active buffer's list is selected by `on_alt` — `markers`/`markers_mut`.
388    /// `alt_markers` holds the plain anchors `add_marker` makes on the alt screen
389    /// (#187); OSC 133 command marks never land there, because `add_command_mark`
390    /// returns on the alt screen (#192). It is disposed on alt-leave (xterm
391    /// `clearAllMarkers`). `next_marker_id` hands
392    /// out monotonic ids across both buffers so ids never alias.
393    normal_markers: VecDeque<Marker>,
394    alt_markers: VecDeque<Marker>,
395    next_marker_id: u32,
396    /// The basis that keeps a *pulled* marker index valid without re-pulling
397    /// (#490). Both are reported by [`Term::marker_index`] and — from the wire
398    /// slice on — by the frame header, so a consumer can compare what it holds
399    /// against what is current.
400    ///
401    /// `evicted_total` counts lines popped off the front of scrollback since
402    /// startup or RIS — one at a time by the scrollback cap, all of history at once
403    /// by `ED 3` and [`Term::clear`] (#936). Eviction shifts **every** live marker
404    /// by the same amount, so
405    /// that whole class of movement is one number rather than M facts, and a
406    /// consumer rebases a held line by the delta.
407    ///
408    /// `marker_epoch` covers everything the delta cannot express: a mutation
409    /// after which a held line is wrong for a reason no single offset repairs.
410    /// It says *"what you pulled no longer describes this buffer"* — not *"a verb
411    /// ran"*, which is why the movers bump it only when a surviving marker's line
412    /// actually moved. Disposal is deliberately **not** a bump: a consumer learns
413    /// of that through `TermEvent::MarkerDisposed` and can drop the entry without
414    /// asking for the rest again.
415    evicted_total: u64,
416    marker_epoch: u32,
417    /// Positions the engine keeps on their content for a holder that lives
418    /// *outside* it (#691). Split per buffer and re-anchored by the same fixups as
419    /// the markers beside them; the difference is that nothing here reaches a
420    /// frame — a tracked point is answered on request, never projected.
421    normal_tracked: Vec<TrackedPoint>,
422    alt_tracked: Vec<TrackedPoint>,
423    next_tracked_id: u32,
424    /// Cursor state saved by DECSC (ESC 7), restored by DECRC (ESC 8). A slot
425    /// separate from `saved_cursor` (which is the alt-screen save). Defaults to
426    /// home/default so a DECRC with no prior DECSC restores a sane state.
427    decsc: SavedCursor,
428    /// SCS-designated character sets G0..G3 (#62). `gl` indexes the active (GL)
429    /// set, switched by SI (→G0) / SO (→G1). First cut uses G0/G1.
430    charsets: [Charset; 4],
431    gl: usize,
432}
433
434/// A character set designated by SCS (#62). First cut: ASCII (default), DEC
435/// Special Graphics (line-drawing), and UK. G2/G3 and the GR half are later.
436#[derive(Clone, Copy, PartialEq, Eq, Default)]
437enum Charset {
438    #[default]
439    Ascii,
440    DecSpecialGraphics,
441    Uk,
442}
443
444impl Charset {
445    /// Map one GL byte (a `char` in the 7-bit range) through this set. ASCII and
446    /// any out-of-range char pass through; UK swaps `#`→£; DEC Special Graphics
447    /// translates `_`..`~` to the line-drawing / symbol glyphs.
448    fn map(self, c: char) -> char {
449        match self {
450            Charset::Ascii => c,
451            Charset::Uk if c == '#' => '£',
452            Charset::Uk => c,
453            Charset::DecSpecialGraphics => dec_special_graphics(c),
454        }
455    }
456}
457
458/// The VT100 DEC Special Graphics set: bytes `_`..`~` (0x5F..0x7E) map to the
459/// box-drawing and symbol glyphs. Matches xterm/alacritty; anything outside the
460/// range passes through unchanged.
461fn dec_special_graphics(c: char) -> char {
462    // Keys ``..`~` only — `_` (0x5F) is deliberately absent, matching xterm.js /
463    // alacritty (it passes through as a literal underscore), not the strict-DEC
464    // "0x5F = blank" reading.
465    match c {
466        '`' => '◆',
467        'a' => '▒',
468        'b' => '␉',
469        'c' => '␌',
470        'd' => '␍',
471        'e' => '␊',
472        'f' => '°',
473        'g' => '±',
474        'h' => '␤',
475        'i' => '␋',
476        'j' => '┘',
477        'k' => '┐',
478        'l' => '┌',
479        'm' => '└',
480        'n' => '┼',
481        'o' => '⎺',
482        'p' => '⎻',
483        'q' => '─',
484        'r' => '⎼',
485        's' => '⎽',
486        't' => '├',
487        'u' => '┤',
488        'v' => '┴',
489        'w' => '┬',
490        'x' => '│',
491        'y' => '≤',
492        'z' => '≥',
493        '{' => 'π',
494        '|' => '≠',
495        '}' => '£',
496        '~' => '·',
497        other => other,
498    }
499}
500
501/// Default scrollback retention when not specified.
502const DEFAULT_SCROLLBACK: usize = 10_000;
503
504/// The narrowest screen the engine represents: **two columns**.
505///
506/// A width-2 glyph occupies a `WIDE_CHAR` lead *and* the `WIDE_CHAR_SPACER` that
507/// stands for its second half, so one column cannot hold one — and a pair with only
508/// one half written is the malformed state every repair path in this crate keys off
509/// ([ADR-0025](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0025-row-and-wide-pair-cell-state-ownership.md) D4). `Term::with_scrollback` and [`Term::resize`] clamp `cols` up to
510/// this, which is what makes D4 (*both halves of a pair move together*)
511/// unconditionally satisfiable rather than true only above some unstated width.
512///
513/// Both references that a terminal *engine* can be compared to forbid one column for
514/// exactly this reason — alacritty's `MIN_COLUMNS = 2` and xterm.js's
515/// `MINIMUM_COLS = 2` — and the third (ghostty) permits it only by destroying the
516/// glyph.
517///
518/// The clamp is **silent and pull-only**: a `resize(1, rows)` during a pane drag is
519/// widened rather than rejected, and no event reports it. Both references instead
520/// make the clamped size the one that travels outward — alacritty derives its
521/// `WindowSize` from the clamped `SizeInfo`, xterm.js fires `onResize` with the
522/// clamped pair — so a justerm consumer must do that correlation itself: read the
523/// width back from [`Term::grid`] / the frame header and size the PTY from *that*,
524/// never from the value it requested. Sizing a PTY to one column leaves the
525/// application rendering for a width the buffer does not have.
526pub const MIN_COLUMNS: usize = 2;
527
528/// The built-in word-boundary set for Word (semantic) selection — the default value of
529/// [`Term::set_word_separators`], and **policy the consumer may replace** ([ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md):
530/// mechanism in core, policy injected).
531///
532/// It is alacritty's `SEMANTIC_ESCAPE_CHARS` (`alacritty_terminal/src/term/mod.rs:45`
533/// @ `852e971`) verbatim — space, tab and a punctuation set that deliberately omits
534/// `.`, `/` and `-` so a path or URL stays one word — **plus U+3000 IDEOGRAPHIC
535/// SPACE**, which is justerm's one divergence from every reference default.
536///
537/// Two properties of this list are load-bearing and neither is obvious:
538///
539/// - **It is a literal set, not the Unicode `White_Space` property.** A
540///   `char::is_whitespace()` predicate would end a word at every space-like codepoint —
541///   including the four `Line_Break=GL` (glue) ones U+00A0, U+2007, U+202F and U+205F,
542///   whose whole purpose is "do not break here". A locale-formatted `1<NNBSP>234`
543///   double-clicked as `1`. All three references are literal sets for the same reason.
544/// - **U+3000 is in it, and no reference's default has it.** It is the only
545///   East-Asian-Wide codepoint `White_Space` accepts (measured over U+0000–U+10FFFF),
546///   so on alacritty and xterm.js ` abc` is one word while justerm gives the useful
547///   answer. Keeping it was once argued *on the grounds that core had no injection
548///   point*; that ground is gone, so it survives here as a **default**, and a consumer
549///   who wants reference-exact behaviour removes it.
550///
551/// [`Term::set_word_separators`] additionally forces `' '` into whatever it is given —
552/// see there for why that floor is not optional.
553pub const DEFAULT_WORD_SEPARATORS: &str = ",│`|:\"' ()[]{}<>\t\u{3000}";
554
555/// A declared OSC 8 hyperlink, as handed to a consumer.
556///
557/// **Owned, not borrowed**, and that is the point: the URI lives in a row's side map, so
558/// a `&str` into it would be tied to `&Engine` and a caller could not hold the link
559/// across the next `feed()` — which is precisely what a hover handler does. Measured on
560/// the alternative: reading a borrow costs 0.75 ns, but keeping it *does not compile*, so
561/// the caller copies the string instead at 62.6 ns. Handing back this handle is 17.9 ns
562/// — cheaper than the workaround it removes, on a call made once per hover.
563///
564/// Cloning is a refcount bump; the allocation is shared with every cell of the same OSC 8
565/// open and released when the last row holding it dies.
566///
567/// **A struct rather than a bare `Arc<str>`** for two reasons: it keeps `Arc` out of the
568/// published signature, and OSC 8's `id=` parameter lands here as a field without
569/// changing the return type again. Same shape as alacritty's `Hyperlink`, for the same
570/// reasons (`alacritty_terminal/src/term/cell.rs`).
571///
572/// **Link *identity* is deliberately not exposed yet.** Two OSC 8 opens of an identical
573/// URI are two links here, so `uri() == uri()` cannot answer "is this cell part of the
574/// same link as that one?" — an `Arc::ptr_eq` accessor would. It is left out because no
575/// consumer asks it today (nothing outside this crate's tests calls `link_at` at all),
576/// and unlike this type's *shape*, adding a method later is not a breaking change. The
577/// asymmetry decides it: shipping an accessor nobody uses is hard to undo, adding one
578/// when a caller appears is free.
579///
580/// **No `#[non_exhaustive]` ([#844](https://github.com/kihyun1998/justerm/issues/844)): nothing outside this crate has a reason to build one.** No
581/// public function accepts it — the engine hands it out — and there are zero out-of-crate literal
582/// sites, so the attribute would bind nothing it does not already bind.
583#[derive(Clone, PartialEq, Eq, Debug)]
584pub struct Hyperlink {
585    uri: std::sync::Arc<str>,
586}
587
588impl Hyperlink {
589    pub(crate) fn new(uri: std::sync::Arc<str>) -> Self {
590        Hyperlink { uri }
591    }
592
593    /// The link target, exactly as the application declared it — never validated,
594    /// never resolved. Whether it is a URL a consumer is willing to open is that
595    /// consumer's policy ([ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md)), the same way colour resolution is.
596    ///
597    /// **One exception to "exactly":** a URI containing 14 or more unencoded `;` arrives
598    /// cut short, because the parser this engine builds on passes at most 16 OSC fields.
599    /// The shorter URI is not marked as cut. A percent-encoded `%3B` is unaffected.
600    pub fn uri(&self) -> &str {
601        &self.uri
602    }
603}
604
605/// Length at which the `id=` group map is first swept for dangling keys, doubling from
606/// there. Small enough that a session declaring a handful of ids never pays a sweep,
607/// large enough that the sweep is not the common path.
608const LINK_IDS_FIRST_SWEEP: usize = 16;
609
610/// How deep an XTWINOPS title stack goes before a push starts dropping the
611/// oldest entry.
612///
613/// Ten is **two implementations plus a spec inference**, not a clean sweep, and
614/// the difference is worth stating so nobody reads it as more than it is:
615/// xterm's `MAX_SAVED_TITLES` (`ptyx.h:2357`) and xterm.js's
616/// `Constants.STACK_LIMIT` both say ten, and the spec describes the
617/// direct-access parameter as taking a value *"in the range 1 through 10"*
618/// (`ctlseqs.txt:1698`), which only makes sense against a ten-slot stack.
619/// **alacritty is 4096** (`TITLE_STACK_MAX_DEPTH`), so the corpus is 2–1 on the
620/// number. Nothing observed nests deeper than three, so the bound is a
621/// robustness cap rather than a compatibility one, and ten is the value two
622/// references chose for exactly that job.
623///
624/// The **overflow rule**, unlike the number, is 4-for-4: drop the oldest and let
625/// the push succeed. xterm.js `shift()`s, alacritty `remove(0)`s, and xterm gets
626/// there by arithmetic rather than by saying so — `which = used++ %
627/// MAX_SAVED_TITLES` writes an eleventh push over slot 0, which is the oldest.
628/// Refusing the push instead would break the pairing for the *innermost*
629/// nesting levels — the ones a user unwinds first — and nobody does it.
630const TITLE_STACK_DEPTH: usize = 10;
631
632/// The `id=` value out of an OSC 8 `params` field, or `None` when it is absent or empty.
633///
634/// Three rules, each taken from xterm.js's `_createHyperlink` verbatim rather than from
635/// the spec prose, because each is a place a reasonable reading goes wrong
636/// (`src/common/InputHandler.ts:3128-3131` at the pinned SHA `699f5537b023`):
637///
638/// - **`:`-separated**, not `;` — `params` is one OSC argument holding a key=value list
639///   (`id=xyz123:foo=bar:baz=quux`), so the split is on colons (`params.split(':')`).
640/// - **`id` may sit anywhere in it** (`findIndex(e => e.startsWith('id='))`), so matching
641///   only a leading `id=` is the wrong parse and passes a single-parameter test.
642/// - **an empty value is not an id** (`slice(3) || undefined`). This is the one with teeth:
643///   an empty key would group every `id=`-with-no-value link in a session into one link
644///   across unrelated URIs, and it is a wrong answer that grows with uptime.
645///
646/// Only the first `id=` is consulted, matching `findIndex` — an empty first one yields
647/// `None` rather than searching on for a non-empty sibling.
648fn osc8_link_id(params: &[u8]) -> Option<&[u8]> {
649    params
650        .split(|b| *b == b':')
651        .find_map(|kv| kv.strip_prefix(b"id="))
652        .filter(|value| !value.is_empty())
653}
654
655/// The widest grid the engine will hold, and the mirror of [`MIN_COLUMNS`] — but derived
656/// from a different kind of constraint, which is why the two are not symmetric.
657///
658/// The floor is **semantic**: a width-2 glyph needs two cells, so one column is a screen
659/// no correct grid can be. The ceiling is **representational**: the frame header stores
660/// `cols` and `rows` as `u16` each, so a grid wider than `u16::MAX` cannot be *described*
661/// to a consumer even though the engine could hold it. Without the clamp that mismatch was
662/// silent — measured, `Engine::new(70_000, 2)` built a 70 000-column grid whose frame
663/// declared `cols = 4464` and decoded `Ok`, so a consumer laid out 4464 columns of a
664/// 70 000-column screen with nothing reporting the difference.
665///
666/// No reference bounds a grid this way, and that is expected rather than a divergence:
667/// none of them serializes a grid, so none has a header field to overflow. This is the
668/// one axis where justerm's own wire is the only authority.
669///
670/// **A backstop, not a policy.** A 4K display at a very small font is roughly 550 columns;
671/// this is two orders of magnitude past any real terminal, so it should never be reached
672/// by a consumer that is not already doing something wrong. The clamp is silent and
673/// pull-only on the same terms as [`MIN_COLUMNS`] — read the size back from
674/// [`Term::grid`] rather than trusting the value you passed in.
675pub const MAX_COLUMNS: usize = u16::MAX as usize;
676
677/// The tallest grid the engine will hold. The row half of [`MAX_COLUMNS`] — same
678/// `u16` header field, same reasoning, same silent-clamp contract.
679pub const MAX_ROWS: usize = u16::MAX as usize;
680
681/// The most live markers one buffer will hold.
682///
683/// Derived from the wire the same way [`MAX_COLUMNS`] is: the marker group's count
684/// are `u16`, so a population past `u16::MAX` encodes a wrapped count while writing
685/// every record, and `decode` then reads the next group's count out of the middle of
686/// a marker record and returns `Ok`. The field bounds the value; this constant only
687/// writes that bound down where the value is produced, because `encode` returns
688/// `Vec<u8>` and has no channel to refuse.
689///
690/// **Why a bound is needed at all**, rather than a wider field: markers are allocated
691/// by the *stream*. `add_command_mark` appends per OSC 133 sequence, several marks can
692/// share one line, and scrollback eviction only drops a marker when its line reaches
693/// absolute 0 — so a stream that never emits a newline accumulates marks in a 24-row
694/// buffer without bound (measured: 70 000). [`crate::Engine::feed`] is an untrusted
695/// entry point ([ADR-0007](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0007-robustness-testing-property-and-fuzz.md)), and unbounded allocation behind it is a defect class that
696/// record exists to catch.
697///
698/// **A backstop, not a policy**, on the same terms as [`MAX_COLUMNS`]. Ordinary shell
699/// integration emits at most four marks per command and a command occupies at least one
700/// line, so a default-scrollback session tops out near 40 000 — this is not reached by a
701/// consumer that is not already being fed something hostile. Overflow disposes the
702/// *oldest* marker and announces it through `TermEvent::MarkerDisposed`, which is the
703/// channel scrollback eviction already uses for the same event.
704pub const MAX_MARKERS: usize = u16::MAX as usize;
705
706/// The longest command text an OSC-133 `OutputStart` mark will freeze, in `char`s.
707/// A longer command is captured truncated to this many characters.
708///
709/// **Why a bound at all** is [`MAX_MARKERS`]'s argument one field over: the *stream*
710/// decides the size. The text spans `[B, C)`, and nothing bounds how far apart those
711/// two sequences are — a stream that emits `B`, dumps a full screen and then `C` names
712/// a command as long as the buffer. Re-extracting on demand made that a transient
713/// allocation; freezing it at `C` makes it resident, for as long as the mark lives, and
714/// [`crate::Engine::feed`] is an untrusted entry point ([ADR-0007](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0007-robustness-testing-property-and-fuzz.md)).
715///
716/// **A display bound, not a semantic one.** [`CommandLine::command`]'s consumer
717/// announces it and lists it; a prefix is a usable answer and an absent one is not, so
718/// overflow truncates rather than declining to capture. The truncation is at a `char`
719/// boundary, so the answer is always valid text. No ordinary command reaches it — this
720/// is not a limit a shell user can type into.
721pub const MAX_COMMAND_TEXT: usize = 4096;
722
723/// The longest `OSC 52` base64 payload the engine will decode, in bytes (#828).
724/// A longer one is **dropped whole**, never truncated.
725///
726/// **What this bounds is ours, not the parser's, and the difference was
727/// measured.** `vte` is built with its default features, so its OSC accumulator
728/// is a `Vec<u8>` rather than the `ArrayVec<_, 1024>` of its `no_std` path: a
729/// 4 MB payload arrives at `osc_dispatch` complete, 4 000 003 bytes across three
730/// fields. Anyone reading this bound as "the engine cannot be made to allocate"
731/// has the wrong model; `vte` already did, before the handler ran.
732///
733/// What it does refuse is the **second** allocation — the rejoin, which copies
734/// every field again — and everything downstream of it. That is why the check
735/// sums the field lengths rather than measuring the joined payload: a bound
736/// applied *after* the join would let a hostile stream buy the copy it was
737/// meant to prevent, which is what the first draft did. The decoded `Vec` is a
738/// third; the `String` is not a fourth, since `String::from_utf8` reuses the
739/// buffer it is given. The property all of it buys is the one worth naming: no
740/// unbounded string crosses the boundary into a consumer that then has to hold
741/// it.
742///
743/// **It does not bound the queue, and the queue is unbounded.** `drain_events`
744/// is pull-style with no back-pressure, so a consumer that does not drain
745/// accumulates stores at up to this size each — the pre-existing hole
746/// `docs/map/territory/events-and-replies.md` records, which this sequence
747/// enlarges by roughly three orders of magnitude over the next-largest payload
748/// (`MAX_COMMAND_TEXT`, 4096). Bounding a queue nobody drains is a different
749/// decision from bounding a payload, and it is not taken here.
750///
751/// **Dropped rather than truncated**, which is the opposite of
752/// [`MAX_COMMAND_TEXT`] one field up, and the asymmetry is the point: a prefix of
753/// a command is a usable answer, while a prefix of a clipboard is text the user
754/// pastes somewhere believing it is what they copied. An ignored copy is visible
755/// the moment they paste; a truncated one is not.
756///
757/// **A backstop, not a policy**, and sized so that no real copy reaches it: 16
758/// MiB of base64 is ~12 MiB of text, well past a whole scrollback buffer's worth
759/// of `tmux set-buffer`. Neither reference has a hard cap to import — alacritty
760/// has none at all and ghostty's `MAX_BUF = 2048` is an inline-buffer threshold
761/// with an allocator path past it (`src/terminal/osc.zig:298`) — so this number
762/// is justerm's own and is chosen by what it must not break rather than by what
763/// it permits.
764pub const MAX_CLIPBOARD_BASE64: usize = 16 * 1024 * 1024;
765
766/// The state DECSC (ESC 7) saves and DECRC (ESC 8) restores: position, pen/SGR,
767/// pending-wrap, and origin mode (per ADR-0004 — DECRC restores origin mode,
768/// which Alacritty omits). Cursor *visibility* is deliberately not part of this
769/// (DECTCEM is separate from DECSC).
770#[derive(Clone, Copy, Default)]
771struct SavedCursor {
772    row: usize,
773    col: usize,
774    pen: Pen,
775    pending_wrap: bool,
776    origin_mode: bool,
777    /// SCS charset state at save time — DECSC/DECRC round-trip the designated
778    /// sets and the active GL shift (#62).
779    charsets: [Charset; 4],
780    gl: usize,
781}
782
783/// An engine-owned decoration marker (#118): a stable id bound to an absolute
784/// buffer line. The line shifts in lockstep with eviction/region scroll/reflow
785/// (the same coordinate moves the selection anchor tracks); the marker is
786/// dropped when its line leaves the buffer — **or when `ED` blanks the whole row
787/// it stands on** (#750), which is the one death that is not the buffer moving.
788struct Marker {
789    id: MarkerId,
790    line: usize,
791    /// The cursor column at emit time (#166). Meaningful for OSC-133 command
792    /// marks — CommandStart(B)/OutputStart(C) columns bound the *typed command*
793    /// (excluding the prompt), like VSCode's `commandStartX`/`commandExecutedX`.
794    /// Plain `add_marker` decorations are row-granular and carry `col = 0`.
795    ///
796    /// **Domain is `[0, cols]`, not `[0, cols - 1]` (#562)** — a bound, not a cell.
797    /// A command that exactly fills its row ends *one past* the last column, and
798    /// that value is what `extract_lines` wants: it clips `[b_col, c_col)`, so the
799    /// exclusive end absorbs it through `.min(cells.len())`. Storing `cursor.col`
800    /// alone (the cursor is held at `cols - 1` with `pending_wrap`) cost such a
801    /// command its last character with no resize involved. The **inclusive** side
802    /// cannot absorb it, so `extract_lines` steps a `from` of `cells.len()` to the
803    /// next line rather than selecting an empty run and flushing a `\n`.
804    col: usize,
805    /// Plain for a `add_marker` decoration; a command-boundary role for an
806    /// OSC 133 mark (#158). All kinds share the anchor/eviction machinery.
807    kind: MarkerKind,
808    /// What an `OutputStart` mark froze about the command it closes (#750) —
809    /// `None` on every other kind, and the reason this is one boxed pointer rather
810    /// than two inline fields: three marks in four never carry it, and the
811    /// population is bounded at [`MAX_MARKERS`].
812    ///
813    /// It dies with the marker, which is the point: a side table keyed by
814    /// [`MarkerId`] would need its own purge at every disposal site, i.e. exactly
815    /// the missing-destruction-funnel defect this issue is about (ADR-0025 D1 —
816    /// a fact lives with its owner).
817    command: Option<Box<CommandRecord>>,
818}
819
820/// The part of a command that is **not** in the buffer, frozen on its `OutputStart`
821/// mark.
822///
823/// Both fields are recorded at the instant they are first true, and neither can be
824/// recovered afterwards:
825///
826/// - `text` is complete and on screen exactly when `C` arrives. Re-reading it later
827///   through the recorded `[b_col, c_col)` clip names whatever now occupies those
828///   cells — measured for a plain overwrite, ICH, DCH and an erase, and only the last
829///   of those is a verb any mark-lifetime rule could reach.
830/// - `exit` arrives with `D`, one mark later, and lives in no cell at all. Resolving it
831///   at query time meant pairing over *survivors* (`out.last_mut()`), which re-parented
832///   a code onto the previous command as soon as a disposal broke the run. Written here
833///   when `D` is parsed, a disposal can only drop an answer, never move one.
834struct CommandRecord {
835    text: Box<str>,
836    exit: Option<i32>,
837}
838
839/// A stable handle to a tracked buffer position, handed out by
840/// [`Term::track_point`].
841///
842/// It is deliberately **not** a [`MarkerId`]: a marker is a decoration anchor and
843/// rides two frame groups, so every marker a consumer registers is something the
844/// renderer paints. A tracked point is private to whoever asked for it.
845///
846/// **No `#[non_exhaustive]` ([#844](https://github.com/kihyun1998/justerm/issues/844)): the attribute is already implied.** The field is `pub(crate)`,
847/// so no literal is possible outside this crate however many fields it grows.
848#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
849pub struct TrackedId(pub(crate) u32);
850
851/// One tracked position: an absolute buffer `(line, col)` the write path keeps on
852/// its content (#691). The element type of `normal_tracked` / `alt_tracked`.
853struct TrackedPoint {
854    id: TrackedId,
855    line: usize,
856    col: usize,
857}
858
859/// One live marker, as the pull query reports it: its stable id, its
860/// **absolute** `[scrollback ++ screen]` line, and the static facts a consumer
861/// would otherwise have to re-learn from every frame.
862///
863/// `kind` and `exit` ride here rather than on the frame because they never change
864/// after `push_marker` — re-sending them per frame is the same class of waste as
865/// re-sending the line.
866///
867/// **No `#[non_exhaustive]` ([#844](https://github.com/kihyun1998/justerm/issues/844)): nothing outside this crate has a reason to build one.** No
868/// public function accepts it — the engine hands it out — and there are zero out-of-crate literal
869/// sites, so the attribute would bind nothing it does not already bind.
870#[derive(Debug, Clone, PartialEq, Eq)]
871pub struct MarkerEntry {
872    pub id: MarkerId,
873    pub line: u32,
874    pub kind: MarkerKind,
875}
876
877/// The answer to [`Term::marker_index`] — every live marker of the *active*
878/// buffer, plus the basis that says how long the answer stays usable.
879///
880/// The consumer keeps this and rebases per frame:
881/// `current = line - (evicted_total_now - evicted_total)`, valid for exactly as long
882/// as `epoch` is unchanged. When the epoch moves, the held lines are wrong in a way
883/// no offset repairs and the consumer asks again.
884///
885/// It reports the active buffer because an absolute index means a different thing on
886/// each screen — the same reason `markers`/`markers_mut` route by `on_alt`. An
887/// alt-screen switch therefore bumps the epoch even though no line moved: what the
888/// answer *describes* changed.
889///
890/// **No `#[non_exhaustive]` ([#844](https://github.com/kihyun1998/justerm/issues/844)): nothing outside this crate has a reason to build one.** No
891/// public function accepts it — the engine hands it out — and there are zero out-of-crate literal
892/// sites, so the attribute would bind nothing it does not already bind.
893#[derive(Debug, Clone, PartialEq, Eq)]
894pub struct MarkerIndex {
895    pub markers: Vec<MarkerEntry>,
896    pub evicted_total: u64,
897    pub epoch: u32,
898}
899
900/// One executed shell command recovered from OSC-133 marks, for
901/// screen-reader command navigation. The consumer jumps prompt-to-prompt over
902/// these and announces `command` + a success/fail signal from `exit`.
903///
904/// **No `#[non_exhaustive]` ([#844](https://github.com/kihyun1998/justerm/issues/844)): nothing outside this crate has a reason to build one.** No
905/// public function accepts it — the engine hands it out — and there are zero out-of-crate literal
906/// sites, so the attribute would bind nothing it does not already bind.
907#[derive(Debug, Clone, PartialEq, Eq)]
908pub struct CommandLine {
909    /// The command's jump anchor as a *document* line — the logical-line index of
910    /// the CommandStart(B) mark within [`Term::accessible_text`], so the consumer
911    /// reveals the right row of the accessible view (soft-wrapped rows collapse to
912    /// one logical line). This is core's analog of VSCode's
913    /// `bufferToEditorLineMapping`; the frame-mode web side has no wrap info to
914    /// map it itself.
915    ///
916    /// **It is an index into a document, so it is only meaningful together with the
917    /// document it indexes** — the one [`Term::accessible_text`] returns *at the same
918    /// instant, on the primary screen*. Neither half is expressible as a number on this
919    /// struct, and they are the two that recur; they are not a proof of
920    /// sufficiency. The hedge was earned: a mark whose row is erased in place also
921    /// answers about content that is gone — on the primary screen, at one instant, and
922    /// a re-ask reproduces it, so neither half below reaches it. That was a
923    /// defect in mark *lifetime* rather than in dating, and it is fixed at the
924    /// lifetime: `ED` now retires the marks on each whole row it blanks, and the
925    /// command's text and exit are frozen when the stream reveals them rather than
926    /// re-read from cells (see [`Term::command_lines`]). One residue is deliberate and
927    /// belongs to this field — `EL`/`ECH` retire nothing, so a mark can still name a
928    /// row they blanked, and this line then resolves onto it:
929    ///
930    /// - **the instant.** No scalar this engine publishes dates a document line, and
931    ///   the reason is not one axis but two. Eviction moves it by the number of evicted
932    ///   *line-ends*, which equals the row count except when an evicted row soft-wraps
933    ///   into the next — measured, one eviction took the absolute lines
934    ///   `[12, 12, 12, 13]` → `[11, 11, 11, 12]` while this line stayed at `11`, and the
935    ///   very next eviction moved both. And flipping a row's wrap bit, which ordinary
936    ///   output does, moves this line while the absolute lines, `evicted_total` and
937    ///   `marker_epoch` all stay put — a motion the absolute space does not have.
938    ///   Carrying the instant is therefore *buildable but expensive*: a line-end counter
939    ///   **and** a generation of its own. [ADR-0029](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0029-a-published-coordinate-carries-its-instant-or-is-re-asked.md) defers it (alternative D) and takes
940    ///   the re-ask discharge, which D3 grants this surface on its own merits;
941    /// - **the screen.** The document is `[scrollback ++ primary]`, always. While the
942    ///   alt screen is up [`Term::accessible_text`] returns the *alt* document, and this
943    ///   line indexes the other one. When the alt screen is the taller of the two the
944    ///   index still **resolves**, onto unrelated content — so this is not a bounds
945    ///   problem a caller can check its way out of.
946    ///
947    /// So: ask for both together, keep them together, and re-ask rather than rebase.
948    pub line: usize,
949    /// The typed command text, prompt- and output-excluded (B→C columns).
950    ///
951    /// **Frozen at the `133;C` that closed the command**, not re-read from the
952    /// cells when you ask. Those cells are not reserved for it: a plain overwrite,
953    /// `ICH`, `DCH` and an erase were each measured making the recorded column range
954    /// name somebody else's content, and only the last of the four is a verb any
955    /// mark-lifetime rule could reach. Bounded at [`MAX_COMMAND_TEXT`] `char`s.
956    pub command: String,
957    /// The CommandFinished(D) exit code, if the shell reported one and the
958    /// command has finished.
959    ///
960    /// **Recorded when `133;D` is parsed**, onto the mark that closed the
961    /// command — not paired here at query time. It lives in no cell, so nothing on
962    /// screen can reconstruct it, and pairing over *survivors* re-parented a code onto
963    /// the previous command as soon as a disposal broke the run.
964    pub exit: Option<i32>,
965}
966
967/// Collect per-line damage bounds into damaged `LineDamage` spans (undamaged
968/// lines dropped). Shared by `damage` (content-only) and `frame_damage`
969/// (content + cursor cells).
970fn bounds_to_lines(bounds: &[LineBounds]) -> Vec<LineDamage> {
971    bounds
972        .iter()
973        .enumerate()
974        .filter(|(_, b)| b.is_damaged())
975        .map(|(line, b)| {
976            let (left, right) = b.span();
977            LineDamage { line, left, right }
978        })
979        .collect()
980}
981
982impl Term {
983    pub fn new(cols: usize, rows: usize) -> Self {
984        Self::with_scrollback(cols, rows, DEFAULT_SCROLLBACK)
985    }
986
987    pub fn with_scrollback(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
988        // Both clamps mirror `resize` exactly, so a screen cannot be born at a size a
989        // resize would refuse. They are not the same *kind* of rule, though: the width
990        // floor is a published contract (#547 — one column was supported and no longer
991        // is), while the row floor is `resize`'s own long-standing "a terminal is never
992        // 0-tall" that this constructor merely failed to enforce while carrying the
993        // same `scroll_bottom: rows - 1` below. That gap was a subtract-overflow panic
994        // on `rows == 0`, not a degenerate screen.
995        // …and the ceiling is the header's, not the glyph's: `frame.cols`/`rows` are u16,
996        // so a wider grid would be built and then misdescribed on the wire (#621).
997        let cols = cols.clamp(MIN_COLUMNS, MAX_COLUMNS);
998        let rows = rows.clamp(1, MAX_ROWS);
999        Term {
1000            grid: Grid::new(cols, rows),
1001            alt_grid: Grid::new(cols, rows),
1002            cursor: Cursor::default(),
1003            saved_cursor: Cursor::default(),
1004            on_alt: false,
1005            origin_mode: false,
1006            autowrap: true,
1007            insert_mode: false,
1008            newline_mode: false,
1009            reverse_wraparound: false,
1010            bracketed_paste: false,
1011            synchronized_output: false,
1012            color_scheme_updates: false,
1013            grapheme_clustering: false,
1014            repeat_anchor: None,
1015            win32_input_mode: false,
1016            app_cursor_keys: false,
1017            application_keypad: false,
1018            vt52_mode: false,
1019            vt52_y_pending: 0,
1020            vt52_y_row: 0,
1021            mouse_protocol: MouseProtocol::Off,
1022            mouse_encoding: MouseEncoding::Default,
1023            focus_events: false,
1024            kitty_flags: 0,
1025            kitty_stack: Vec::new(),
1026            kitty_flags_inactive: 0,
1027            kitty_stack_inactive: Vec::new(),
1028            modify_other_keys_2: false,
1029            events: Vec::new(),
1030            replies: Vec::new(),
1031            current_link: None,
1032            link_ids: std::collections::HashMap::new(),
1033            link_ids_sweep_at: LINK_IDS_FIRST_SWEEP,
1034            tabs: default_tabs(cols),
1035            word_separators: DEFAULT_WORD_SEPARATORS.to_owned(),
1036            window_title: String::new(),
1037            icon_name: String::new(),
1038            window_title_stack: Vec::new(),
1039            icon_name_stack: Vec::new(),
1040            scroll_top: 0,
1041            scroll_bottom: rows - 1,
1042            scrollback: VecDeque::new(),
1043            display_offset: 0,
1044            scrollback_limit,
1045            recycled_row: None,
1046            line_damage: vec![LineBounds::undamaged(cols); rows],
1047            scroll: None,
1048            full_damage: false,
1049            prev_cursor: (0, 0), // matches the default cursor's home position
1050            selection: None,
1051            search_highlights: Vec::new(),
1052            active_search_highlight: None,
1053            normal_markers: VecDeque::new(),
1054            alt_markers: VecDeque::new(),
1055            next_marker_id: 0,
1056            evicted_total: 0,
1057            marker_epoch: 0,
1058            normal_tracked: Vec::new(),
1059            alt_tracked: Vec::new(),
1060            next_tracked_id: 0,
1061            decsc: SavedCursor::default(),
1062            charsets: [Charset::Ascii; 4],
1063            gl: 0,
1064        }
1065    }
1066
1067    /// What changed since the last `reset_damage()` — line ranges, each with a
1068    /// changed column span. See [ADR-0003](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0003-damage-model-incremental-bounds.md).
1069    pub fn damage(&self) -> TermDamage {
1070        if self.full_damage {
1071            return TermDamage::Full;
1072        }
1073        // Scrolled up under follow-bottom "stay": the viewport is frozen, so
1074        // screen changes below it are not visible — report nothing. (A user
1075        // scroll that moves the viewport sets full_damage above.)
1076        if self.display_offset > 0 {
1077            return TermDamage::Partial(Vec::new());
1078        }
1079        TermDamage::Partial(bounds_to_lines(&self.line_damage))
1080    }
1081
1082    /// Render damage: content damage plus the cursor cells, for [`Term::frame`].
1083    ///
1084    /// A pure cursor move changes no cell *content*, so [`Term::damage`] (which
1085    /// stays content-only, the cadence/flow-control primitive) would miss it —
1086    /// yet a cell-invert caret must clear its old spot and ink the new one. So
1087    /// the frame producer folds the old (last-acked) + current cursor cells in,
1088    /// but only when the cursor actually moved: a still cursor needs no redraw,
1089    /// keeping an idle frame empty. Mirrors Alacritty's `last_cursor`. #38.
1090    fn frame_damage(&self) -> TermDamage {
1091        if self.full_damage {
1092            return TermDamage::Full;
1093        }
1094        if self.display_offset > 0 {
1095            return TermDamage::Partial(Vec::new());
1096        }
1097        let cur = self.cursor.point();
1098        if cur == self.prev_cursor {
1099            return TermDamage::Partial(bounds_to_lines(&self.line_damage));
1100        }
1101        let mut bounds = self.line_damage.clone();
1102        bounds[cur.0].expand(cur.1, cur.1);
1103        let pr = self.prev_cursor.0.min(self.grid.rows() - 1);
1104        let pc = self.prev_cursor.1.min(self.grid.cols() - 1);
1105        bounds[pr].expand(pc, pc);
1106        TermDamage::Partial(bounds_to_lines(&bounds))
1107    }
1108
1109    /// Clear accumulated damage. The consumer calls this after applying a frame
1110    /// (the ack); the next `damage()` reflects only changes since.
1111    pub fn reset_damage(&mut self) {
1112        for b in &mut self.line_damage {
1113            b.reset();
1114        }
1115        self.scroll = None;
1116        self.full_damage = false;
1117        // The consumer has now seen the caret at the current position; the next
1118        // frame's cursor-move damage is measured from here (#38).
1119        self.prev_cursor = self.cursor.point();
1120    }
1121
1122    /// Mark the whole screen damaged (alt switch / clear / flood, and a consumer
1123    /// reattach that needs a full re-sync — see [`crate::Engine::mark_fully_damaged`]).
1124    pub fn mark_fully_damaged(&mut self) {
1125        self.full_damage = true;
1126    }
1127
1128    /// Record that columns `[left, right]` of `row` changed.
1129    ///
1130    /// Both columns are **clamped to the last column, and asserted in debug** (#536).
1131    ///
1132    /// Ten of the fourteen call sites derive their bound from a cursor column or from `cols`.
1133    /// **Four derive it from a wide pair's width**, and that is the shape worth centralising:
1134    /// `write_glyph`'s `col + width - 1` (which had no guard — this issue), `promote_cluster_to_wide`'s
1135    /// `col + 1` (guarded by its own `col + 1 >= cols` early return), `demote_cluster_to_narrow`'s
1136    /// `(col + 1).min(cols - 1)` (self-clamped), and `relocate_cluster_wide`'s literal `(0, 1)`
1137    /// (valid only because `MIN_COLUMNS = 2`, #547). Three carried a private guard and one did not.
1138    ///
1139    /// No reference has this shape to port a clamp from: alacritty computes damage ranges too
1140    /// (`term/mod.rs:1406`, `:1649` @ `852e971`) but always from a column or `columns()` — its print
1141    /// path records no damage at all, relying on the previous and current cursor *points* to bracket
1142    /// the line — while xterm.js tracks whole rows (`markDirty(y)`) and ghostty a per-row
1143    /// `dirty: bool`. alacritty's `LineDamageBounds::expand`, which this one is a copy of, is equally
1144    /// unguarded.
1145    ///
1146    /// The two halves do different jobs:
1147    ///
1148    /// - the **`debug_assert` is the detector**. An out-of-range bound is stored silently and
1149    ///   detonates later, when `frame()` slices the row, so the stack trace accuses the reader
1150    ///   rather than the writer. That delay is what #536 was filed about, and the assert collapses
1151    ///   it — a bad caller dies here, at the site that recorded it (measured: an injected off-by-one
1152    ///   moved the panic from `frame()`'s slice to this line).
1153    /// - the **clamp is the release backstop**, and it clamps *toward a false positive*. justerm is
1154    ///   a library, so a panic crosses into the consumer's process; over-damaging repaints a cell
1155    ///   that did not change, which costs nothing a consumer can see. ghostty states the asymmetry
1156    ///   as a rule: *"Dirty tracking may have false positives but should never have false negatives.
1157    ///   A false negative would result in a visual artifact on the screen."* (`page.zig:1993-1995`).
1158    ///
1159    /// **`left` is guarded for that reason, and it is the axis that can actually lose a cell.**
1160    /// Clamping `right` cannot under-report — columns past the last do not exist. But `LineBounds`
1161    /// marks a line undamaged with `left = cols, right = 0` and `is_damaged()` is `left <= right`,
1162    /// so a single `expand` with `left > right` on an otherwise-clean line leaves the line reading
1163    /// as undamaged and **drops its whole span silently**. Unreachable from the ten column-derived
1164    /// sites today; guarded because that is precisely the failure ghostty's rule forbids.
1165    ///
1166    /// `row` is deliberately left to panic on the index, and that is **not** in tension with
1167    /// `frame_damage` clamping a row fifty lines below (`prev_cursor.0.min(rows - 1)`). The two
1168    /// rows are different kinds of thing under the same rule: `prev_cursor` is a *stale remembered*
1169    /// coordinate that a shrinking resize may have put out of range, so clamping it repaints the
1170    /// nearest surviving cell — a false positive. `row` here is a *live computed* index for the
1171    /// mutation just made, so clamping it would damage a different line than the one that changed:
1172    /// a false negative on the real line, which is the outcome the rule forbids.
1173    fn damage_span(&mut self, row: usize, left: usize, right: usize) {
1174        let last = self.grid.cols().saturating_sub(1);
1175        debug_assert!(
1176            left <= right && right <= last,
1177            "damage_span({row}, {left}, {right}) is not a span inside [0, {last}]"
1178        );
1179        self.line_damage[row].expand(left.min(last), right.min(last));
1180    }
1181
1182    /// The first-class scroll recorded since the last `reset_damage`, if any.
1183    /// Suppressed while scrolled up — a content scroll must not shift the frozen
1184    /// viewport.
1185    ///
1186    /// **The count is capped at the region's own height.** Shifting a region
1187    /// by more than its height moves every source row outside it, so the surplus
1188    /// names nothing a consumer can act on — while it does overflow the wire's
1189    /// `i16` and turn an up-scroll into a down-scroll: measured, a single
1190    /// 32 770-byte `feed()` of newlines, no slow consumer required. Both references
1191    /// that state a quantity at their own scroll sites clamp it to the same bound
1192    /// (alacritty `term/mod.rs:773`, ghostty `Terminal.zig:2703`).
1193    ///
1194    /// The cap is here, on the **read**, and not on the accumulator in
1195    /// `record_scroll`: a region that scrolls far and comes back then still reports
1196    /// its true small net, instead of one walked down from a saturated value.
1197    ///
1198    /// A second, crate-internal bound backs it up, and it is representational rather
1199    /// than semantic: [`MAX_ROWS`] is `u16::MAX` while the wire field is `i16`, so a
1200    /// region can legally be taller than any count that field can hold. In that
1201    /// corner the magnitude truncates. What it never does is wrap — a wrapped count
1202    /// arrives with the opposite sign and the consumer shifts the wrong way, which is
1203    /// the whole reason the saturation is here.
1204    pub fn scroll_delta(&self) -> Option<ScrollOp> {
1205        if self.display_offset > 0 {
1206            return None;
1207        }
1208        self.scroll.map(cap_scroll)
1209    }
1210
1211    /// Build a serializable [`Frame`] from the current damage + grid + grapheme
1212    /// pool. `Full` ships every row; `Partial` ships the damaged spans. The
1213    /// global side-table is remapped to **frame-local** indices — the engine pool
1214    /// is append-only and leaky, so a frame carries only the clusters its cells
1215    /// reference, renumbered, with each cell's `extra` rewritten to the local id.
1216    pub fn frame(&self) -> Frame {
1217        let cols = self.grid.cols();
1218        let rows = self.grid.rows();
1219        let (kind, line_spans): (FrameKind, Vec<(usize, usize, usize)>) = match self.frame_damage()
1220        {
1221            TermDamage::Full => (
1222                FrameKind::Full,
1223                (0..rows).map(|l| (l, 0, cols - 1)).collect(),
1224            ),
1225            TermDamage::Partial(lines) => (
1226                FrameKind::Partial,
1227                lines
1228                    .into_iter()
1229                    .map(|d| (d.line, d.left, d.right))
1230                    .collect(),
1231            ),
1232        };
1233
1234        // Frame-local numbering for the hyperlink table (#26). Keyed by the URI's
1235        // *identity* — the `Arc` pointer — so two cells sharing one open share one entry
1236        // and a distinct open gets its own, which is exactly the semantics the pool
1237        // index used to carry.
1238        //
1239        // Sized by this frame, not by session history (#628). It was
1240        // `vec![0u32; hyperlink_pool.len() + 1]`, allocated and zeroed on **every**
1241        // frame against every OSC 8 the session had ever seen — measured at 10 µs per
1242        // frame with 100 000 opens retained. With the pool gone there is nothing left to
1243        // size it by, and the cost disappears rather than being reduced.
1244        let mut link_table: Vec<String> = Vec::new();
1245        let mut link_remap: std::collections::HashMap<*const u8, u32> =
1246            std::collections::HashMap::new();
1247        // Cells come from the viewport at `display_offset`, not the live grid:
1248        // viewport row `line` is absolute buffer line `top + line` (scrollback
1249        // when scrolled up, the live grid when `display_offset == 0`, where
1250        // `top == scrollback.len()` and this is identical to reading the grid).
1251        // Without this, a wire consumer — cells reach it only through `frame()` —
1252        // could never display scrollback (#48).
1253        let top = self.scrollback.len() - self.display_offset;
1254        let mut spans = Vec::with_capacity(line_spans.len());
1255        for (line, left, right) in line_spans {
1256            let mut cells = Vec::with_capacity(right - left + 1);
1257            let mut combining = std::collections::BTreeMap::new();
1258            let mut links = std::collections::BTreeMap::new();
1259            let mut ucolors = std::collections::BTreeMap::new();
1260            let row = self.abs_row(top + line);
1261            let last_col = row.len().saturating_sub(1);
1262            for col in left..=right {
1263                let mut cell = row[col];
1264                // Soft-wrap is a row property (#538), but the wire has no per-row slot — so it is
1265                // *derived* back onto the last cell's WRAPLINE bit here, which keeps the format
1266                // byte-identical and is why moving the storage needed no VERSION bump. The bit is
1267                // therefore wire-only: on a live grid it is never set, and `Row::is_wrapped` is
1268                // the question to ask.
1269                if col == last_col && row.is_wrapped() {
1270                    cell.insert_flags(CellFlags::WRAPLINE);
1271                }
1272                // Combining clusters and hyperlinks live in the row's maps; each
1273                // tagged cell contributes its reference to the frame, recorded on
1274                // the span by span-relative column (the cell holds only the bit).
1275                if let Some(marks) = row.combining_at(col) {
1276                    // The cluster itself, at its column — no side table and no index
1277                    // since v14 (#621). Nothing interned these (this push was
1278                    // unconditional), so the index only ever bought indirection.
1279                    combining.insert(col - left, marks.to_vec());
1280                }
1281                if let Some(uri) = row.link_at(col) {
1282                    // Number each distinct open once per frame (only referenced URIs
1283                    // ship). The wire keeps its interning — #621 measured inlining a URI
1284                    // per linked cell at +171…403% — so this stays an index into
1285                    // `link_table`; only the *engine* side stopped being a table.
1286                    let key = std::sync::Arc::as_ptr(uri) as *const u8;
1287                    let next = link_table.len() as u32 + 1;
1288                    let fidx = *link_remap.entry(key).or_insert_with(|| {
1289                        link_table.push(uri.to_string());
1290                        next
1291                    });
1292                    let fidx = core::num::NonZeroU32::new(fidx)
1293                        .expect("frame-local link indices are 1-based");
1294                    links.insert(col - left, fidx);
1295                }
1296                // Underline colour (SGR 58, #520): a colour reference, not a
1297                // side-table index, so it rides the span inline. `ucolor_at` is
1298                // flag-gated + already Default-filtered (the stamp only fires on an
1299                // underlined cell), so a present entry is a real non-default colour.
1300                if let Some(color) = row.ucolor_at(col) {
1301                    ucolors.insert(col - left, color);
1302                }
1303                cells.push(cell);
1304            }
1305            spans.push(Span {
1306                line: line as u16,
1307                left: left as u16,
1308                right: right as u16,
1309                cells,
1310                combining,
1311                links,
1312                ucolors,
1313            });
1314        }
1315
1316        Frame {
1317            cols: cols as u16,
1318            rows: rows as u16,
1319            kind,
1320            // The live cursor: position in screen coords + DECTCEM visibility.
1321            // Reported, not drawn — the consumer renders the caret (#38).
1322            cursor_row: self.cursor.row as u16,
1323            cursor_col: self.cursor.col as u16,
1324            // Hidden while scrolled up: the live cursor is off the frozen
1325            // viewport, and a cell-invert caret would otherwise ink over
1326            // scrollback. Consistent with the frozen-damage policy (no cursor
1327            // damage is emitted while scrolled) and with xterm.js / alacritty,
1328            // which hide the caret when it falls outside the visible rows (#48).
1329            cursor_visible: self.cursor.visible && self.display_offset == 0,
1330            cursor_shape: self.cursor.shape,
1331            cursor_blink: self.cursor.blink,
1332            // Viewport scroll position for the consumer's scrollbar (ADR-0013).
1333            display_offset: self.display_offset as u32,
1334            scrollback_len: self.scrollback.len() as u32,
1335            evicted_total: self.evicted_total,
1336            marker_epoch: self.marker_epoch,
1337            // The active buffer's population, which is what `marker_index` reports and
1338            // therefore what a consumer's held index is compared against (#490).
1339            marker_count: self.markers().len() as u32,
1340            // The mouse tracking mode as a routing mask (#129): which mouse events
1341            // the app wants, derived from the protocol by the single source
1342            // `encode_mouse` shares. The consumer routes app-vs-local on it.
1343            mouse_events: self.mouse_protocol.wanted_events(),
1344            // Alt-screen flag (#149): buffer-global state the consumer can't
1345            // derive from viewport damage; the a11y announce policy gates on it.
1346            alt_screen: self.on_alt,
1347            // Which modified C0 keys reach the application under the current keyboard
1348            // modes (#941), derived from the encoder `encode_key` runs.
1349            modified_keys: crate::input::modified_keys(
1350                self.app_cursor_keys,
1351                self.application_keypad,
1352                self.kitty_flags,
1353                self.modify_other_keys_2,
1354            ),
1355            scroll: self.scroll_delta(),
1356            spans,
1357            link_table,
1358            // Interaction overlays projected onto this viewport (#108): the
1359            // engine-owned selection and the consumer-supplied search highlights,
1360            // each re-projected here so the scroll offset is applied once, by the
1361            // same authority that projects the cells.
1362            overlay: Overlay {
1363                selection: self.selection_range(),
1364                matches: self
1365                    .search_highlights
1366                    .iter()
1367                    .flat_map(|m| self.match_spans(m))
1368                    .collect(),
1369                // The consumer-designated active match (#428), projected through
1370                // the same `match_spans` math — usually also present in `matches`
1371                // above (the renderer's ranking resolves the overlap, #424), but
1372                // a span designation may sit OUTSIDE a capped hand-over (#436).
1373                active_match: self
1374                    .active_search_highlight
1375                    .as_ref()
1376                    .map(|m| self.match_spans(m))
1377                    .unwrap_or_default(),
1378                markers: self.marker_positions(),
1379            },
1380        }
1381    }
1382
1383    /// Record a scroll of rows `[top, bottom]` by `count` (positive = up).
1384    ///
1385    /// Damage is indexed by row position, so it must follow the content the
1386    /// scroll just moved: rotate the bounds the same way and mark the newly
1387    /// exposed line fully damaged (it is new blank content for the consumer).
1388    fn record_scroll(&mut self, top: usize, bottom: usize, count: isize) {
1389        let cols = self.grid.cols();
1390        match count {
1391            1 => {
1392                self.line_damage[top..=bottom].rotate_left(1);
1393                self.line_damage[bottom] = LineBounds::fully_damaged(cols);
1394            }
1395            -1 => {
1396                self.line_damage[top..=bottom].rotate_right(1);
1397                self.line_damage[top] = LineBounds::fully_damaged(cols);
1398            }
1399            _ => {}
1400        }
1401        // Accumulate repeated scrolls of the same region into one op (flow
1402        // control). A *different* region cannot be expressed as one op, so
1403        // degrade to full rather than silently dropping the earlier scroll.
1404        match self.scroll {
1405            Some(op) if op.top == top && op.bottom == bottom => {
1406                self.scroll = Some(ScrollOp {
1407                    top,
1408                    bottom,
1409                    count: op.count + count,
1410                });
1411            }
1412            None => self.scroll = Some(ScrollOp { top, bottom, count }),
1413            Some(_) => {
1414                self.scroll = None;
1415                self.mark_fully_damaged();
1416            }
1417        }
1418    }
1419
1420    /// Number of lines currently held in scrollback history.
1421    /// Replace the word-boundary set used by Word (semantic) selection — the policy half
1422    /// of `selection_begin(.., SelectionType::Word)`, injected per [ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md) (core owns
1423    /// the buffer walk, the consumer owns which characters separate words). The default
1424    /// is [`DEFAULT_WORD_SEPARATORS`].
1425    ///
1426    /// **`' '` is forced into whatever you pass**, and that is not a convenience. A
1427    /// blank cell packs `' '`, so the space terminates the walk at the end of a row's
1428    /// written text *and* backstops the wide-pair rule: without it, double-clicking next
1429    /// to a wide separator starts the highlight on that separator's trailing spacer,
1430    /// bisecting the glyph, and the walk then runs to the row's end through the padding.
1431    /// Enforcing it here rather than in the walk is ghostty's shape — it prepends its own
1432    /// blank codepoint to every parsed set at the config intake (`config/Config.zig`,
1433    /// *"Always include null as first boundary"*), so `selectWord` never has to.
1434    ///
1435    /// A consequence worth knowing before you narrow the set: this predicate is the only
1436    /// thing bounding the walk, so a set that omits the separators actually present in
1437    /// the buffer makes one double-click walk the whole soft-wrap run — measured at 11.7 ms
1438    /// (release) selecting 801,920 chars.
1439    ///
1440    /// If a length bound is ever wanted, **it is a field beside this one, not an argument**:
1441    /// `word_start` / `word_end` are `pub(super)`, reached through
1442    /// [`Term::selection_begin`], so there is no call site for a consumer to inject into.
1443    /// This setter is the shape it would take (injected policy over a core
1444    /// mechanism, [ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md)).
1445    pub fn set_word_separators(&mut self, separators: &str) {
1446        let mut set: String = separators.to_owned();
1447        if !set.contains(' ') {
1448            set.push(' ');
1449        }
1450        self.word_separators = set;
1451    }
1452
1453    /// The word-boundary set currently in force — what was passed to
1454    /// [`Term::set_word_separators`] plus the forced `' '`, or
1455    /// [`DEFAULT_WORD_SEPARATORS`] if it was never called.
1456    pub fn word_separators(&self) -> &str {
1457        &self.word_separators
1458    }
1459
1460    pub fn scrollback_len(&self) -> usize {
1461        self.scrollback.len()
1462    }
1463
1464    /// Whether the app has an open synchronized-output block (DEC ?2026).
1465    pub fn synchronized_output(&self) -> bool {
1466        self.synchronized_output
1467    }
1468
1469    /// Whether the app enabled color-scheme-update notifications (DEC ?2031).
1470    pub fn color_scheme_updates(&self) -> bool {
1471        self.color_scheme_updates
1472    }
1473
1474    /// Whether the app enabled grapheme-cluster mode (DEC ?2027): emoji ZWJ / skin-tone /
1475    /// flag / VS16 sequences are clustered into one cell. OFF (default) is per-char, wcwidth-compat.
1476    pub fn grapheme_clustering(&self) -> bool {
1477        self.grapheme_clustering
1478    }
1479
1480    /// Whether the app enabled win32-input-mode (DEC ?9001). The engine does
1481    /// not encode the raw key-records itself (a non-goal); a ConPTY consumer reads
1482    /// this to decide whether to emit them.
1483    pub fn win32_input_mode(&self) -> bool {
1484        self.win32_input_mode
1485    }
1486
1487    /// Queue a color-scheme report (`CSI ? 997 ; 1 n` dark / `; 2 n` light) on the
1488    /// reply channel. The consumer calls this to answer a `ColorSchemeQuery` event
1489    /// or, when its scheme changes and `color_scheme_updates()` is set, to send the
1490    /// unsolicited notification. The engine never stores or interprets the scheme.
1491    pub fn report_color_scheme(&mut self, dark: bool) {
1492        let ps = if dark { 1 } else { 2 };
1493        self.replies
1494            .extend_from_slice(format!("\x1b[?997;{ps}n").as_bytes());
1495    }
1496
1497    /// OSC 10/11/12 set/query the default fg/bg/cursor colour, stacking the
1498    /// `;`-separated specs across the `[foreground, background, cursor]` slots —
1499    /// xterm's `ChangeColorsRequest` offset loop (`misc.c:3679`, walking
1500    /// `OSC_TEXT_FG` → `OSC_TEXT_BG` → `OSC_TEXT_CURSOR`, `ptyx.h:1018-1020`).
1501    /// OSC 10 starts at slot 0, OSC 11 at slot 1, OSC 12 at slot 2 (#137, #832).
1502    /// A `?` spec is a query.
1503    ///
1504    /// **An empty spec addresses its slot and leaves it alone** — neither a set
1505    /// nor a reset — and the stack still advances past it, so `OSC 10 ; ; <bg>`
1506    /// is how xterm reaches the background alone. That skip-and-advance is xterm's
1507    /// *implementation*, not documented behaviour: `ctlseqs.txt:2082` documents only
1508    /// the stack (*"each successive parameter changes the next color in the list"*)
1509    /// and expects at least one parameter. The two empty
1510    /// cases are the same rule from both ends: nothing left in the string yields
1511    /// no name (`misc.c:3684-3685`), and a separator where a name should be yields no
1512    /// name either (`misc.c:3687`) before the parse steps past it.
1513    ///
1514    /// That rule is not a hardening detail here, it is a precondition: the empty
1515    /// form is the *only* one real applications emit for OSC 12 (`nvim` sends
1516    /// `ESC ] 12 ; BEL` four to five times per session), so a cursor slot without
1517    /// it would relay a burst of empty-string colour changes every time a user
1518    /// opens an editor (#832).
1519    ///
1520    /// The stack ends after the cursor. xterm's next slots are the pointer
1521    /// colours (`OSC_MOUSE_FG` = 13, `OSC_MOUSE_BG` = 14), which justerm does not
1522    /// model — dropping a fourth spec is better than mis-addressing it.
1523    fn special_color(&mut self, params: &[&[u8]], start: usize, terminator: Terminator) {
1524        for (i, &spec) in params[1..].iter().enumerate() {
1525            if spec.is_empty() {
1526                continue; // skip this slot, but still advance to the next
1527            }
1528            let event = match start + i {
1529                0 if spec == b"?" => TermEvent::QueryForeground { terminator },
1530                0 => TermEvent::SetForeground(String::from_utf8_lossy(spec).into_owned()),
1531                1 if spec == b"?" => TermEvent::QueryBackground { terminator },
1532                1 => TermEvent::SetBackground(String::from_utf8_lossy(spec).into_owned()),
1533                2 if spec == b"?" => TermEvent::QueryCursorColor { terminator },
1534                2 => TermEvent::SetCursorColor(String::from_utf8_lossy(spec).into_owned()),
1535                _ => break, // past [fg, bg, cursor] — the pointer colours are unmodelled
1536            };
1537            self.events.push(event);
1538        }
1539    }
1540
1541    /// OSC 52 (`OSC 52 ; Pc ; Pd`) — an application asking to put text on a
1542    /// selection, or to read one back (#828).
1543    ///
1544    /// The engine's half is *mechanism* and nothing else: recognise the
1545    /// sequence, resolve `Pc` to a [`ClipboardTarget`], decode `Pd`, and relay.
1546    /// It never touches a clipboard, never holds one, and carries no allow/deny
1547    /// knob — under ADR-0017 that gate is the consumer's, and a consumer that
1548    /// drops the event has refused the request. alacritty puts a gate at the
1549    /// equivalent site (`alacritty_terminal/src/term/mod.rs:1706`) — **not because
1550    /// it is the whole terminal, which is what this comment used to say and is
1551    /// not the distinction (#841)**: that gate is in alacritty's *engine* crate
1552    /// too, reading a policy the application injects through `Config`
1553    /// (`:353`, written at `alacritty/src/config/ui_config.rs:125`). The reason
1554    /// this crate has none is that it holds no clipboard at all, so a gate in
1555    /// front of a relay refuses nothing a dropped event does not already refuse.
1556    ///
1557    /// **An absent target field means the clipboard, and that is a divergence
1558    /// from the spec taken deliberately.** `ctlseqs.txt:2161` says *"If the
1559    /// parameter is empty, xterm uses s 0, to specify the configurable
1560    /// primary/clipboard selection and cut-buffer 0"*, and `misc.c:3359` is that
1561    /// sentence in code. Neither half is representable here: `s` is whichever
1562    /// selection a *user resource* has configured — policy, which ADR-0017 puts
1563    /// in the consumer — and cut buffers are not modelled at all. So "follow the
1564    /// spec" is not a well-defined instruction, exactly as it was not for #834's
1565    /// empty colour spec, where xterm's trigger (*a colour that failed to parse*)
1566    /// is a condition this engine structurally cannot observe.
1567    ///
1568    /// Two qualifications, because the short version of this argument overclaims
1569    /// in both directions. **The cut buffer is what is unrepresentable; `s` is
1570    /// merely unmodelled** — xterm resolves it through the `selectToClipboard`
1571    /// resource (`button.c:2081`), and DECSET 1041 sets that same resource *from
1572    /// the stream* (`ctlseqs.txt:1008`), so an engine tracking 1041 could
1573    /// resolve it. justerm declines to model 1041; that is a choice, not an
1574    /// impossibility. And **xterm-as-shipped reads the empty field as PRIMARY**,
1575    /// since the resource defaults to false — so this diverges from what the
1576    /// reference does by default, not merely from a sentence in its manual.
1577    ///
1578    /// What decides it is the other two lines of evidence. **Independent
1579    /// lineages, and there are fewer than a naive count gives**: alacritty never
1580    /// sees an empty field at all, because `vte` substitutes `b'c'` first
1581    /// (`vte-0.15.0/src/ansi.rs:1488`) — those two are *one* lineage, not two.
1582    /// The genuinely separate ones are ghostty, by an explicit byte-scan branch
1583    /// pinned under a test (`clipboard_operation.zig:24`, `:64`), and xterm.js's
1584    /// clipboard addon, whose browser provider ignores the selector entirely and
1585    /// always writes `navigator.clipboard`
1586    /// (`addons/addon-clipboard/src/ClipboardAddon.ts:77`). Three lineages, one
1587    /// answer. **And the emitter's own documentation agrees**: `tmux`'s
1588    /// `set-clipboard` is documented as setting *the terminal clipboard* — never
1589    /// the primary selection — and `tmux` 3.2a is measured sending exactly this
1590    /// empty form.
1591    ///
1592    /// **A missing payload *field* is not an empty payload.** `OSC 52 ; c`
1593    /// arrives as two fields and does nothing; `OSC 52 ; c ;` arrives as three,
1594    /// the third empty, and is a store of the empty string — which is how the
1595    /// sequence clears a selection. xterm makes the same split by construction,
1596    /// its whole handler sitting inside `if (*buf == ';')` (`misc.c:3353`), and
1597    /// ghostty rejects the payload-less form for the same reason
1598    /// (`clipboard_operation.zig:20`).
1599    ///
1600    /// **The payload is `params[2..]` rejoined**, the rule #650 established for
1601    /// OSC 8: `vte` splits the whole OSC body on `;`, so reading `params[2]`
1602    /// alone would take a payload containing a `;` and decode its first piece —
1603    /// which for a well-formed prefix is a *successful* decode of a truncated
1604    /// clipboard, the one failure mode this handler must not have. Rejoined, the
1605    /// stray `;` reaches the decoder and is refused there.
1606    ///
1607    /// **Malformed in, nothing out**, which is the second deliberate divergence
1608    /// — and it is narrower than the spec sentence makes it look. The spec ends
1609    /// a payload that is *"neither a base64 string nor ?"* by clearing the
1610    /// selection (`ctlseqs.txt:2174`); this drops it silently. Clearing is
1611    /// destructive, and inferring one from bytes the engine could not parse
1612    /// means corruption on the wire wipes what the user copied by hand.
1613    ///
1614    /// The family, counted rather than asserted: **three drop** — alacritty
1615    /// (`alacritty_terminal/src/term/mod.rs:1717`), ghostty, which returns on a
1616    /// decode failure (`src/Surface.zig:2186`) and states the rule beside its
1617    /// test as *"Read requests and malformed base64 must never reach the
1618    /// callback"* (`src/terminal/c/terminal.zig:2961`), and this. **One clears**
1619    /// — xterm.js's addon, deliberately: *"Clear clipboard if text is not a
1620    /// base64 encoded string"* (`addons/addon-clipboard/src/ClipboardAddon.ts:55`).
1621    /// **And one neither** — xterm, below.
1622    ///
1623    /// What the divergence actually is, stated precisely because reading the
1624    /// spec alone gets it wrong: **xterm has no validator to disagree with.**
1625    /// `AppendToSelectionBuffer` (`button.c:4679`) decodes one character at a
1626    /// time and `return`s on any byte outside the alphabet (`:4698`), so xterm
1627    /// *filters* rather than rejects — `Zm9v-Zm9v` yields `foofoo` there — and
1628    /// since the store path clears the buffer first (`misc.c:3410`), the spec's
1629    /// "cleared" is what falls out when the filter finds nothing to keep. So the
1630    /// disagreement is about what to **accept**, not about what to do on
1631    /// refusal, and this engine is the stricter of the two on purpose: a filter
1632    /// hands the consumer text assembled from bytes the application did not
1633    /// send.
1634    ///
1635    /// Non-UTF-8 is refused on the same principle one level up: every text
1636    /// surface this crate publishes is UTF-8, and a lossy conversion would hand
1637    /// the consumer characters the application never sent.
1638    fn clipboard(&mut self, params: &[&[u8]], terminator: Terminator) {
1639        let Some(&field) = params.get(1) else {
1640            return;
1641        };
1642        let target = match field {
1643            // Empty and `c` are the same answer; see the divergence note above.
1644            b"" | b"c" => ClipboardTarget::Clipboard,
1645            // `p` and `s` are NOT the same answer, and the first draft made them
1646            // one. See `ClipboardTarget`: a collapse here would put a selector
1647            // the application never wrote into the reply.
1648            b"p" => ClipboardTarget::Primary,
1649            b"s" => ClipboardTarget::Selection,
1650            // Anything else — an unmodelled target like `q` or a cut buffer, and
1651            // also a *multi*-target list like `pc`, which the spec permits
1652            // (`ctlseqs.txt:2156`) and this engine cannot express. Both are
1653            // dropped rather than approximated: honouring one target of two is
1654            // the same defect as truncating a payload, one axis over, and
1655            // `vte`/alacritty's first-byte-wins would do exactly that. ghostty
1656            // rejects a multi-byte field too (`clipboard_operation.zig:36`).
1657            _ => return,
1658        };
1659        // Two fields is `OSC 52 ; c` — no payload field at all, not an empty one.
1660        if params.len() < 3 {
1661            return;
1662        }
1663        // The bound is checked on the fields, BEFORE the join — `join` is itself
1664        // an unconditional full copy, so checking after it would let a hostile
1665        // payload buy a second allocation the size of the first. `+ len - 3` is
1666        // the separators the join puts back.
1667        let fields = &params[2..];
1668        if fields.iter().map(|f| f.len()).sum::<usize>() + fields.len() - 1 > MAX_CLIPBOARD_BASE64 {
1669            return;
1670        }
1671        let payload: Vec<u8> = fields.join(&b';');
1672        if payload == b"?" {
1673            self.events
1674                .push(TermEvent::QueryClipboard { target, terminator });
1675            return;
1676        }
1677        if let Some(bytes) = crate::base64::decode(&payload)
1678            && let Ok(text) = String::from_utf8(bytes)
1679        {
1680            self.events.push(TermEvent::ClipboardStore { target, text });
1681        }
1682    }
1683
1684    /// Answer an OSC 52 [`TermEvent::QueryClipboard`]: base64-encode the
1685    /// consumer's text into the OSC 52 reply envelope, ST-terminated.
1686    ///
1687    /// The consumer hands the target back rather than the engine remembering
1688    /// which one was asked about — the same shape as
1689    /// [`Term::report_palette_color`], which takes its `index` back for the same
1690    /// reason. alacritty is the alternative: its query captures the target and
1691    /// the terminator in a closure the consumer later calls
1692    /// (`alacritty_terminal/src/term/mod.rs:1740`), which is one more piece of
1693    /// hidden state and one more question ("what if replies interleave?") bought
1694    /// for nothing the consumer does not already hold.
1695    ///
1696    /// **Answering is optional, and that is the security property.** The engine
1697    /// holds no clipboard, so a query it is never asked to answer reveals
1698    /// nothing; a consumer refuses a *read* simply by not calling this, whatever
1699    /// it does about *writes*.
1700    ///
1701    /// **The selector round-trips.** `c` / `p` / `s` in, the same one out, which
1702    /// is why [`ClipboardTarget`] keeps `p` and `s` apart: every reference echoes
1703    /// the field the application wrote — xterm the recognised list
1704    /// (`misc.c:3384`), alacritty the raw byte (`…/term/mod.rs:1744`), ghostty
1705    /// its three locations (`src/Surface.zig:5954`) — and it is the one field a
1706    /// client can pair a reply on. The single exception is an **empty** field,
1707    /// answered naming `c`, which is what alacritty also sends once `vte` has
1708    /// defaulted it: the reply says what the engine understood, and there is no
1709    /// selector to echo.
1710    ///
1711    /// The reply echoes the terminator the query arrived with, like every other
1712    /// reply this crate queues — settled for the whole channel rather than for
1713    /// this sequence alone.
1714    pub fn report_clipboard(
1715        &mut self,
1716        target: ClipboardTarget,
1717        text: &str,
1718        terminator: Terminator,
1719    ) {
1720        let field = match target {
1721            ClipboardTarget::Clipboard => 'c',
1722            ClipboardTarget::Primary => 'p',
1723            ClipboardTarget::Selection => 's',
1724        };
1725        let data = crate::base64::encode(text.as_bytes());
1726        self.replies
1727            .extend_from_slice(format!("\x1b]52;{field};{data}").as_bytes());
1728        self.replies.extend_from_slice(terminator.bytes());
1729    }
1730
1731    /// Answer an OSC 4 palette query: wrap the consumer-supplied spec for
1732    /// `index` in the OSC 4 reply envelope.
1733    ///
1734    /// The reply echoes the terminator the query arrived with, which the
1735    /// consumer takes off the `Query…` event and hands back here: the
1736    /// spec says a terminal *"uses the same terminator used in a query"*
1737    /// (`ctlseqs.txt:2020`), and the engine cannot choose on the consumer's
1738    /// behalf because only the parser ever saw which byte arrived.
1739    pub fn report_palette_color(&mut self, index: u8, spec: &str, terminator: Terminator) {
1740        self.replies
1741            .extend_from_slice(format!("\x1b]4;{index};{spec}").as_bytes());
1742        self.replies.extend_from_slice(terminator.bytes());
1743    }
1744
1745    /// Answer an OSC 10 foreground query: wrap the consumer-supplied spec
1746    /// in the OSC 10 reply envelope.
1747    ///
1748    /// The reply echoes the terminator the query arrived with, which the
1749    /// consumer takes off the `Query…` event and hands back here: the
1750    /// spec says a terminal *"uses the same terminator used in a query"*
1751    /// (`ctlseqs.txt:2020`), and the engine cannot choose on the consumer's
1752    /// behalf because only the parser ever saw which byte arrived.
1753    pub fn report_foreground(&mut self, spec: &str, terminator: Terminator) {
1754        self.replies
1755            .extend_from_slice(format!("\x1b]10;{spec}").as_bytes());
1756        self.replies.extend_from_slice(terminator.bytes());
1757    }
1758
1759    /// Answer an OSC 11 background query: wrap the consumer-supplied spec
1760    /// (it knows its palette) in the OSC 11 reply envelope. The engine formats
1761    /// the envelope only — it never knows the colour.
1762    ///
1763    /// The reply echoes the terminator the query arrived with, which the
1764    /// consumer takes off the `Query…` event and hands back here: the
1765    /// spec says a terminal *"uses the same terminator used in a query"*
1766    /// (`ctlseqs.txt:2020`), and the engine cannot choose on the consumer's
1767    /// behalf because only the parser ever saw which byte arrived.
1768    pub fn report_background(&mut self, spec: &str, terminator: Terminator) {
1769        self.replies
1770            .extend_from_slice(format!("\x1b]11;{spec}").as_bytes());
1771        self.replies.extend_from_slice(terminator.bytes());
1772    }
1773
1774    /// Answer an OSC 12 cursor-colour query: the same envelope one slot
1775    /// over, terminated like its siblings. The consumer supplies the spec — it
1776    /// owns the palette, and the engine never learns the colour.
1777    ///
1778    /// The reply echoes the terminator the query arrived with, which the
1779    /// consumer takes off the `Query…` event and hands back here: the
1780    /// spec says a terminal *"uses the same terminator used in a query"*
1781    /// (`ctlseqs.txt:2020`), and the engine cannot choose on the consumer's
1782    /// behalf because only the parser ever saw which byte arrived.
1783    pub fn report_cursor_color(&mut self, spec: &str, terminator: Terminator) {
1784        self.replies
1785            .extend_from_slice(format!("\x1b]12;{spec}").as_bytes());
1786        self.replies.extend_from_slice(terminator.bytes());
1787    }
1788
1789    /// The cells of visible row `i` (0..rows) at the current scroll position.
1790    /// The viewport windows into `[history.. ; screen..]`: rows above
1791    /// `scrollback.len()` come from history, the rest from the live screen.
1792    pub fn viewport_line(&self, i: usize) -> &[Cell] {
1793        let top = self.scrollback.len() - self.display_offset;
1794        let idx = top + i;
1795        if idx < self.scrollback.len() {
1796            &self.scrollback[idx]
1797        } else {
1798            self.grid.row(idx - self.scrollback.len())
1799        }
1800    }
1801
1802    /// Scroll the viewport up by `n` lines into history (clamped to the oldest).
1803    pub fn scroll_up(&mut self, n: usize) {
1804        let target = (self.display_offset + n).min(self.scrollback.len());
1805        self.set_display_offset(target);
1806    }
1807
1808    /// Scroll the viewport down by `n` lines toward the live screen.
1809    pub fn scroll_down(&mut self, n: usize) {
1810        let target = self.display_offset.saturating_sub(n);
1811        self.set_display_offset(target);
1812    }
1813
1814    /// Jump the viewport back to the live screen (follow the bottom).
1815    pub fn scroll_to_bottom(&mut self) {
1816        self.set_display_offset(0);
1817    }
1818
1819    /// Move the viewport. A user scroll changes which lines are visible, so the
1820    /// whole viewport is repainted (full damage) when the offset actually moves.
1821    fn set_display_offset(&mut self, offset: usize) {
1822        // The alt screen has no scrollback to view; scroll intents are no-ops.
1823        if self.on_alt {
1824            return;
1825        }
1826        if offset != self.display_offset {
1827            self.display_offset = offset;
1828            self.mark_fully_damaged();
1829        }
1830    }
1831
1832    // ---- selection -----------------------------------------------------------
1833
1834    /// Map a viewport cell `(row, col)` to an absolute buffer point. The top
1835    /// visible row is `scrollback.len() - display_offset`, so viewport row `i`
1836    /// is that plus `i`.
1837    ///
1838    /// **`row` is clamped to the last visible row, and that is the anchor's whole defence
1839    /// against a caller that hands it one past the end (#660).** The row arrives from a
1840    /// pointer position, so it is off the end whenever a drag leaves the grid — including
1841    /// the ordinary case where the container is a sub-cell remainder taller than
1842    /// `rows × cell_height` and a click lands in that strip. Stored unclamped it does not
1843    /// fail here: it detonates later, in whichever read walks the selection
1844    /// (`selection_range`, `selection_text`, the word extents), so the stack trace accuses
1845    /// the reader rather than the caller — the delay `damage_span`'s doc describes and
1846    /// #536 was filed about.
1847    ///
1848    /// **Clamped, and deliberately *not* `debug_assert`ed** — which is where this differs
1849    /// from `damage_span`, whose split it otherwise mirrors. That function is engine-
1850    /// internal, so an out-of-range span there is a justerm bug and the assert names its
1851    /// producer. This one is reached from `Engine::selection_begin` / `selection_extend`,
1852    /// whose documented input is *"what a mouse event carries"* — and a pointer leaves the
1853    /// grid whenever a drag does, so a row past the end is **ordinary input, not a defect**.
1854    /// Asserting on it would panic a consumer's debug build for a legal gesture.
1855    ///
1856    /// Clamping is also what every producer already wants: a drag past the bottom edge
1857    /// selects to the edge. alacritty clamps at the same boundary (`Point::grid_clamp`);
1858    /// ghostty's pins cannot express an out-of-range row at all.
1859    ///
1860    /// **This is a backstop, and since #667 nothing in the family relies on it.** The
1861    /// sentence here used to read that justerm-web's selection converter did *not* clamp,
1862    /// which was what made an unclamped row reachable in the shipped stack rather than
1863    /// only in theory; that converter now bounds both axes at its own seam, as all three
1864    /// references do at theirs. The claim is retracted rather than deleted because it was
1865    /// the record of why this clamp was worth adding.
1866    ///
1867    /// **`col` is bounded the same way, and against the grid rather than the line
1868    /// (#671).** #660 reasoned about the row alone and this function passed the column
1869    /// through, which was not a smaller version of the same gap — it was a *different*
1870    /// one, because the two axes are consumed differently downstream. A column reaches
1871    /// `resolve`, where the `Side` decides whether it gets a `+ 1`, and the two readers
1872    /// then bound only one end each: `selection_range`'s Linear arm clips `right_excl`
1873    /// and not `left`, `selection_text`'s Block arm clips `hi` and not `from`. So
1874    /// `Side::Right` was already safe by accident (its `+ 1` lands past the end and the
1875    /// clip catches it) while **`Side::Left` had no `+ 1` to clip**, and the raw column
1876    /// survived into `left` — silently deleting the anchor's own row from both the
1877    /// projection and the copy. `usize::MAX` was the one value that panicked instead,
1878    /// on the `+ 1`.
1879    ///
1880    /// Bounding here rather than in `resolve` keeps one site answering "what does a
1881    /// viewport coordinate mean", and makes both axes total for the same reason; the
1882    /// alternative — clamping at each `+ 1` — is five sites for one rule. alacritty
1883    /// bounds both endpoints' columns in `Selection::to_range` *before* its own side
1884    /// arithmetic and pairs the `+ 1` with an explicit *"column == columns → wrap to the
1885    /// next line"*; justerm reaches that same outcome through the reader's
1886    /// `right_excl > left`, which is why an **in-range** `Side::Right` on the last column
1887    /// still starts the selection on the following row and is pinned as unchanged.
1888    ///
1889    /// The grid, not `abs_line(..).len()`, is the bound: `SelectionType::Line` already
1890    /// resolves `to` as `grid.cols()`, so the whole type works in grid coordinates and a
1891    /// short line must not shrink a selection that reaches past it.
1892    ///
1893    /// **`resolve`'s five `+ 1`s stay unguarded, and that is sound only while every stored
1894    /// anchor arrives through here.** The completeness pass enumerated the writers: the
1895    /// three coordinate fixups move `.line` or write columns that are in range by
1896    /// construction, `resize`'s primary branch re-clamps the reflowed points (#562) and its
1897    /// alt branch drops the selection outright (#660), and `Term::resize` is the only writer
1898    /// of `grid.cols()`. So no path strands a column that was clamped here. The condition is
1899    /// **a fourth writer of `self.selection`** — one that builds an `Anchor` without this
1900    /// function would put `resolve` back in reach of its own arithmetic.
1901    fn viewport_to_abs(&self, row: usize, col: usize) -> BufferPoint {
1902        let top = self.scrollback.len() - self.display_offset;
1903        let last = self.grid.rows().saturating_sub(1);
1904        BufferPoint {
1905            line: top + row.min(last),
1906            col: col.min(self.grid.cols().saturating_sub(1)),
1907        }
1908    }
1909
1910    /// The hyperlink **URI** at **screen** `(row, col)` (the live grid), or `None` —
1911    /// flag-gated through the row's link map. Since #628 the map holds the URI itself,
1912    /// so there is no index and no second call to resolve one.
1913    /// Mirrors `grid().cell(row, col)`.
1914    pub(crate) fn screen_link_at(&self, row: usize, col: usize) -> Option<Hyperlink> {
1915        self.grid
1916            .row_ref(row)
1917            .link_at(col)
1918            .cloned()
1919            .map(Hyperlink::new)
1920    }
1921
1922    /// The underline colour (SGR 58, #520) at screen `(row, col)`, as a theme-agnostic
1923    /// reference. `Color::Default` means "follow the fg" — the common case, and what an
1924    /// unset cell returns. Mirror of [`Term::screen_link_at`].
1925    pub(crate) fn screen_underline_color_at(&self, row: usize, col: usize) -> Color {
1926        self.grid.row_ref(row).ucolor_at(col).unwrap_or_default()
1927    }
1928
1929    /// The hyperlink URI at **viewport** `(row, col)` (visible window, history
1930    /// included at the current scroll), or `None`. Mirrors `viewport_line(row)`.
1931    pub(crate) fn viewport_link_at(&self, row: usize, col: usize) -> Option<Hyperlink> {
1932        let idx = self.scrollback.len() - self.display_offset + row;
1933        self.abs_row(idx).link_at(col).cloned().map(Hyperlink::new)
1934    }
1935
1936    /// Resize the screen to `cols` x `rows`. Rows dropped off the top (on shrink)
1937    /// enter scrollback. Column reflow of soft-wrapped lines is layered on top
1938    /// separately. The whole screen is damaged.
1939    ///
1940    /// `cols` is widened to [`MIN_COLUMNS`] — a narrower screen cannot hold a
1941    /// width-2 glyph, so it is clamped rather than represented.
1942    pub fn resize(&mut self, cols: usize, rows: usize) {
1943        // Not a print — and not a parser callback either, which is the whole reason
1944        // [`Term::repeat_anchor`] is stated as a rule rather than as a list of
1945        // `Perform` methods. A reflow rewrites cells and moves the cursor, so the cell
1946        // `REP` would read back is no longer the one the arming print wrote (#825).
1947        self.repeat_anchor = None;
1948        // A terminal is never 0-tall; clamp so the math below (rows - 1) can't
1949        // underflow. Columns clamp to MIN_COLUMNS, not 1: chunking by cols needs a
1950        // non-zero width, but a *wide glyph* needs two (#547). The ceiling is the frame
1951        // header's u16, clamped here as well as in the constructor — a ceiling that held
1952        // only until the first resize is the gap `new` had against this function's own row
1953        // floor before #547 (#621).
1954        let cols = cols.clamp(MIN_COLUMNS, MAX_COLUMNS);
1955        let rows = rows.clamp(1, MAX_ROWS);
1956        let old_cols = self.grid.cols();
1957        let old_rows = self.grid.rows();
1958        let limit = self.scrollback_limit;
1959
1960        // A reflow rewrites marker lines outright, at three separate sites below and in
1961        // two different frames of reference — so a held index goes stale in a way no
1962        // offset repairs (#490). Bumped **here**, once, rather than beside each rewrite:
1963        // this function is the only way any of them runs, and a per-site obligation is
1964        // the shape `docs/map/territory/marker.md` already records as a known hole for
1965        // the alt guard. Gated on a *dimension change* as well as on there being a marker:
1966        // `resize` has no early return for unchanged geometry and `ResizePort` states no
1967        // idempotency guarantee, so a consumer may call this with the size it already has —
1968        // `justerm-web`'s fit does exactly that when the *cell* moves and the proposed grid
1969        // does not, since it dedupes on cell and grid together. An ungated bump would then be
1970        // a full re-pull for a resize that changed nothing — measured at 100 bumps for 100
1971        // no-op resizes.
1972        //
1973        // This comment said the fit loop *"re-asserts the size every frame"*, which stopped being true
1974        // when #632 gave `FitController` its four-field dedupe. The gate is unaffected: what it
1975        // rests on is the missing early return, and that is still measured.
1976        if (cols != old_cols || rows != old_rows)
1977            && (!self.normal_markers.is_empty() || !self.alt_markers.is_empty())
1978        {
1979            self.bump_marker_epoch();
1980        }
1981
1982        // A reflow moves match coordinates (and can change the match set), so the
1983        // query-derived highlights are invalidated; the consumer re-searches at
1984        // the new width. The selection re-anchors below — it is user-authored.
1985        self.invalidate_search_highlights();
1986
1987        // ...except on the alt screen, where a *shrink* drops the selection (#660). The
1988        // primary pane carries user-authored points through `reflow_pane` and gets them back
1989        // mapped to the new geometry; on alt the selection is dropped instead.
1990        //
1991        // **This is a policy choice, not a capability limit, and the difference matters
1992        // because the first version of this comment got it wrong.** It claimed the alt pane
1993        // has "nothing to re-anchor through" — measurably false: the alt branch below makes
1994        // its own `reflow_pane` call with tracked points and already uses the returned
1995        // `extras` / `evicted` to rotate and dispose alt *markers*. `reflow: false` disables
1996        // the column re-split, not the point tracking. Installing a false "cannot" as the
1997        // justification for fixing a false "cannot" is precisely the failure #660 is.
1998        //
1999        // What actually makes rotation the wrong trade here is that a marker and a selection
2000        // are different shapes. A marker is one point with a binary fate — survive, or be
2001        // disposed with an event. A selection is two *ordered* endpoints, so when a shrink
2002        // destroys the row under one of them and not the other, "dispose" has no meaning and
2003        // the correct behaviour is the clamp-and-overtake policy `selection_rotate_region`
2004        // implements for scrolls. Reusing the marker path would give a selection whose ends
2005        // moved by different rules; writing the second policy is a feature, not this fix.
2006        // And on a column shrink an alt anchor would additionally need the `.min(cols - 1)`
2007        // the primary branch applies, which alt markers deliberately do *not* take.
2008        //
2009        // Both references drop rather than rotate on the axis they consider unsafe —
2010        // alacritty on a width change (`term/mod.rs:680-682`), xterm.js on a height change,
2011        // its comment naming this bug class outright (`SelectionService.ts:156-160`).
2012        //
2013        // **Any geometry change, not just a shrink — and the `!=` is load-bearing in the
2014        // direction that looks wasteful.** The obvious refinement is to keep the selection on
2015        // a *grow*, since the alt pane pads rather than moving content, so no anchor looks
2016        // invalidated. That was tried and a randomised sweep refuted it in one run: an alt
2017        // resize also reflows the **primary** pane below, and on the alt screen `scrollback`
2018        // *is* that primary history — so `scrollback.len()` moves under an anchor whose
2019        // absolute line was measured from the old base, even when the alt grid itself does not
2020        // move at all. `selection_text` then walks off the end. The branch below already knows
2021        // this and converts alt *markers* through `old_base` → new base for exactly that
2022        // reason ("the primary scrollback below may rewrap and change length even when the alt
2023        // grid does not move"); the selection has no such conversion, so the geometry change
2024        // is the honest trigger.
2025        //
2026        // Rebasing instead of dropping is the better answer and is not done here: a grow has
2027        // no destroyed row, so both endpoints could shift by the base delta unambiguously —
2028        // but a *shrink* still needs the two-endpoint policy below, and shipping half of it
2029        // would leave the two axes behaving differently for no stated reason.
2030        //
2031        // The exact no-op is still not a resize: nothing reflows when neither axis moves, so
2032        // the base cannot shift, and a consumer that re-asserts its size every frame (a
2033        // `fit()` loop) must not make selecting on the alt screen impossible.
2034        if self.on_alt && (cols != old_cols || rows != old_rows) {
2035            self.selection = None;
2036        }
2037
2038        // Both screens are resized. Scrollback pairs with the PRIMARY screen
2039        // (whichever is active) — the alt screen has no history of its own.
2040        // `reflow: true` is the *primary* pane's setting and is deliberately a constant, **not**
2041        // `self.autowrap`. ghostty gates its equivalent on DECAWM — `.reflow =
2042        // self.modes.get(.wraparound)` (`terminal/Terminal.zig` `resize`) — and the reading that
2043        // makes that coherent is the one this file just accepted for the alt screen: an application
2044        // that turns autowrap off is placing lines itself, so its content is a layout rather than a
2045        // flow, and re-wrapping it changes what it drew.
2046        //
2047        // Not followed here, for three reasons, and they are recorded rather than filed because
2048        // nothing observable is known to break either way (measured: with DECAWM off, a full
2049        // 6-column row still re-splits into two rows at width 3 exactly as it does with DECAWM on;
2050        // the difference against ghostty is that ghostty truncates that row instead).
2051        //
2052        // - **The wrap flag is not a lie.** `Row::is_wrapped` means "this row continues into the
2053        //   next", which after a re-split is simply true. DECAWM governs the **write** path — where
2054        //   a glyph goes when the cursor is at the last column — not how stored content is laid out
2055        //   again later. Dropping the flag would make `"abcdef"` extract as `"abc\ndef"`.
2056        // - **The mode is global and momentary; the buffer is neither.** DECAWM is read at resize
2057        //   time and would decide the fate of history written under the opposite setting. A TUI that
2058        //   turns it off while drawing would, on a resize landing in that window, leave every
2059        //   properly wrapped line in scrollback un-reflowed.
2060        // - **It costs content.** Not reflowing truncates each row to the new width, so the tail of
2061        //   a long line leaves the grid. justerm keeps it.
2062        //
2063        // There is no per-row signal to be finer with: a row written under DECAWM off and a row that
2064        // merely ended early are both simply unwrapped. "Do not re-split an unwrapped logical line"
2065        // would break ordinary reflow, since a line that exactly fills its width carries no wrap
2066        // flag either.
2067        let dims = ReflowDims {
2068            old_cols,
2069            cols,
2070            rows,
2071            limit,
2072            reflow: true,
2073        };
2074        let scrollback = std::mem::take(&mut self.scrollback);
2075        if self.on_alt {
2076            // Active = alt (cursor, no scrollback); inactive = primary. No selection anchors
2077            // to track here — but *because the geometry change above dropped them* (#660),
2078            // not because there cannot be any. This comment used to read "selection is
2079            // primary-only and cleared on alt enter": the clearing is real
2080            // (`enter_alt_screen` / `leave_alt_screen`, "a selection cannot survive a screen
2081            // swap") and says nothing about a selection made *while* the alt screen is up,
2082            // which is the ordinary act of copying out of vim. A premise that held at one
2083            // instant was read as an invariant holding for the screen's lifetime.
2084            // Alt markers still ride this pane, but **not because it reflows** — since #567 it does
2085            // not, so a marker's content no longer moves under it and the old reason here ("justerm
2086            // column-reflows the alt grid, so a marker must follow its content") is retracted. What
2087            // they ride it for is the row *fit*: a shrink still drops rows off the top, and a marker
2088            // on one of those has left a screen with no history to hold it. Their stored line is
2089            // `base + alt_row` (base = primary scrollback len), so convert to alt-local rows here
2090            // and re-anchor on the new base afterward — the primary scrollback below may rewrap and
2091            // change length even when the alt grid does not move at all.
2092            let old_base = scrollback.len();
2093            let mut alt_pts: Vec<(usize, usize)> = self
2094                .alt_markers
2095                .iter()
2096                .map(|m| (m.line - old_base, m.col))
2097                .collect();
2098            // Alt-scoped tracked points are stored on the same `base + alt_row`
2099            // frame as the alt markers, so they convert and come back the same way
2100            // (#691).
2101            let alt_tracked_off = alt_pts.len();
2102            alt_pts.extend(
2103                self.alt_tracked
2104                    .iter()
2105                    .map(|p| (p.line.saturating_sub(old_base), p.col)),
2106            );
2107            let alt = self.grid.take_lines();
2108            let r_alt = reflow_pane(
2109                alt,
2110                VecDeque::new(),
2111                self.cursor.point(),
2112                &alt_pts,
2113                ReflowDims {
2114                    limit: 0,
2115                    reflow: false,
2116                    ..dims
2117                },
2118            );
2119            self.grid.set_screen(r_alt.screen, cols, rows);
2120            self.cursor.set_point(r_alt.cursor, rows, cols);
2121
2122            // Primary is inactive here, but markers anchor *primary* content, so
2123            // they reflow with it. There is no *primary* selection to carry alongside them —
2124            // `switch_to_alt` nulls it before the alt screen exists, so one cannot coexist
2125            // with `on_alt` — which is a different statement from the "cleared on alt enter"
2126            // this comment used to make, and the difference is #660: that clearing says
2127            // nothing about the *alt* selection the branch above now drops.
2128            // `(line, col)`, not `(line, 0)`: the column is what bounds OSC-133 command-text
2129            // extraction (#166), and discarding it here truncated the recorded command for any
2130            // resize taken while a full-screen app was up. The primary branch below has always
2131            // passed and restored both; this one is the sibling that did not.
2132            let mut marker_pts: Vec<(usize, usize)> = self
2133                .normal_markers
2134                .iter()
2135                .map(|m| (m.line, m.col))
2136                .collect();
2137            // Primary-scoped tracked points anchor primary content too, so they
2138            // reflow with this pane even though the alt screen is the active one
2139            // (#691) — the same reason the markers above do.
2140            let tracked_off = marker_pts.len();
2141            marker_pts.extend(self.normal_tracked.iter().map(|p| (p.line, p.col)));
2142            let primary = self.alt_grid.take_lines();
2143            let r = reflow_pane(
2144                primary,
2145                scrollback,
2146                self.saved_cursor.point(),
2147                &marker_pts,
2148                dims,
2149            );
2150            self.alt_grid.set_screen(r.screen, cols, rows);
2151            self.scrollback = r.scrollback;
2152            self.saved_cursor.set_point(r.cursor, rows, cols);
2153            for (i, m) in self.normal_markers.iter_mut().enumerate() {
2154                m.line = r.extras[i].0.saturating_sub(r.evicted);
2155                m.col = r.extras[i].1;
2156            }
2157            // Released rather than clamped when the reflow evicted its line — see
2158            // the primary-active branch for why the two loops differ (#691).
2159            let mut ti = 0;
2160            let evicted = r.evicted;
2161            let extras = &r.extras;
2162            self.normal_tracked.retain_mut(|p| {
2163                let (line, col) = extras[tracked_off + ti];
2164                ti += 1;
2165                match line.checked_sub(evicted) {
2166                    Some(line) => {
2167                        p.line = line;
2168                        p.col = col;
2169                        true
2170                    }
2171                    None => false,
2172                }
2173            });
2174            // The alt half lives in a different frame from the primary one above, and adding the
2175            // two was the defect: `extras` count from the top of the alt pane's own history, and
2176            // the alt screen **has** no history — every row the shrink pushed off the top is gone,
2177            // not archived. Passing the primary's scrollback limit made `reflow_pane` keep them,
2178            // so a rows-only resize (no reflow at all) reported a marker four lines past the end of
2179            // the buffer. The limit is `0` here because that is what an alt screen's history is.
2180            //
2181            // A marker whose row went with it is **disposed**, matching what the alt screen already
2182            // does when a row leaves by scrolling (`markers_rotate_region` fires `MarkerDisposed`
2183            // for the marker on the departing edge). Silently relocating it to row 0 would put a
2184            // decoration on content it was never attached to.
2185            let new_base = self.scrollback.len();
2186            let mut alt_disposed = Vec::new();
2187            let mut i = 0;
2188            self.alt_markers.retain_mut(|m| {
2189                let (line, col) = r_alt.extras[i];
2190                i += 1;
2191                match line.checked_sub(r_alt.evicted) {
2192                    Some(row) if row < rows => {
2193                        m.line = new_base + row;
2194                        // The column rides along for the same reason as the primary half, but
2195                        // **unpinned**: `add_marker` always passes column 0, and I could not get an
2196                        // OSC-133 mark (the only column-bearing kind) to appear in `alt_markers` at
2197                        // all. That is a gap in my knowledge, not evidence the column is
2198                        // structurally zero — `push_marker` takes a column and `markers_mut` routes
2199                        // by active buffer, so the field is reachable in principle. Carrying it
2200                        // keeps the two halves stating one invariant; if the alt path really is
2201                        // marker-column-free, this line is a no-op.
2202                        m.col = col;
2203                        true
2204                    }
2205                    _ => {
2206                        alt_disposed.push(m.id);
2207                        false
2208                    }
2209                }
2210            });
2211            for id in alt_disposed {
2212                self.events.push(TermEvent::MarkerDisposed(id));
2213            }
2214            // The alt half's tracked points, on the alt marker's rule: a row the
2215            // shrink pushed off an unarchived screen is gone, so the point is
2216            // released rather than relocated (#691).
2217            //
2218            // The `row < rows` half of that guard is **unproven, deliberately kept**.
2219            // A mutation dropping it stays green, and a sweep of 324 alt resizes
2220            // (rows 1..6 x cols {4,10,30}, both directions, a point on every alt row
2221            // at columns 0 / 1 / cols-1 / cols plus one past the pane) never reached
2222            // it — the alt fit runs with `reflow: false`, so a surviving row always
2223            // maps below the new row count. That is a measured *validity condition*,
2224            // not a proof of unreachability, and the marker loop above carries the
2225            // same bound: parity is the reason it stays.
2226            let mut ai = 0;
2227            let alt_extras = &r_alt.extras;
2228            let alt_evicted = r_alt.evicted;
2229            self.alt_tracked.retain_mut(|p| {
2230                let (line, col) = alt_extras[alt_tracked_off + ai];
2231                ai += 1;
2232                match line.checked_sub(alt_evicted) {
2233                    Some(row) if row < rows => {
2234                        p.line = new_base + row;
2235                        p.col = col;
2236                        true
2237                    }
2238                    _ => false,
2239                }
2240            });
2241        } else {
2242            // Active = primary (cursor, scrollback); inactive = alt. The selection
2243            // anchors (absolute) reflow alongside the cursor so they keep their
2244            // content across a column change.
2245            let sel_pts: Vec<(usize, usize)> = self
2246                .selection
2247                .as_ref()
2248                .map(|s| {
2249                    vec![
2250                        (s.anchor.point.line, s.anchor.point.col),
2251                        (s.focus.point.line, s.focus.point.col),
2252                    ]
2253                })
2254                .unwrap_or_default();
2255            // Markers reflow on the same pane by (line, col) — the column matters
2256            // for OSC-133 command marks, whose B/C columns bound the extracted
2257            // command text (#166). They ride after the selection points so each
2258            // reads its own reflowed slot back from `extras` (#118).
2259            let mut pts = sel_pts.clone();
2260            pts.extend(self.normal_markers.iter().map(|m| (m.line, m.col)));
2261            // Tracked points ride after the markers, reading their own slots back
2262            // by the same offset idiom (#691).
2263            let tracked_off = pts.len();
2264            pts.extend(self.normal_tracked.iter().map(|p| (p.line, p.col)));
2265
2266            let primary = self.grid.take_lines();
2267            let r = reflow_pane(primary, scrollback, self.cursor.point(), &pts, dims);
2268            self.grid.set_screen(r.screen, cols, rows);
2269            self.scrollback = r.scrollback;
2270            self.cursor.set_point(r.cursor, rows, cols);
2271            if let Some(sel) = &mut self.selection {
2272                // A selection endpoint is **UI** state, so its reading of `col == cols` (#562) is
2273                // neither the cursor's nor a mark's: it is clamped into the grid. UI state may not
2274                // move the application's content to make room for itself — the criterion that
2275                // decided this, and the one ghostty encodes by clamping every non-cursor pin before
2276                // it can widen a row (`terminal/PageList.zig:1576-1585` @ `e6e26e1`) while leaving
2277                // the cursor pin unclamped (`:1602-1606`).
2278                sel.anchor.point = BufferPoint {
2279                    line: r.extras[0].0.saturating_sub(r.evicted),
2280                    col: r.extras[0].1.min(cols - 1),
2281                };
2282                sel.focus.point = BufferPoint {
2283                    line: r.extras[1].0.saturating_sub(r.evicted),
2284                    col: r.extras[1].1.min(cols - 1),
2285                };
2286            }
2287            let marker_off = sel_pts.len();
2288            for (i, m) in self.normal_markers.iter_mut().enumerate() {
2289                m.line = r.extras[marker_off + i].0.saturating_sub(r.evicted);
2290                m.col = r.extras[marker_off + i].1;
2291            }
2292            // A point whose reflowed line fell inside the evicted prefix has left
2293            // the buffer, so it is released rather than clamped to line 0 (#691) —
2294            // deliberately unlike the marker loop above, which saturates. A marker
2295            // that lands on the wrong line still paints something the consumer can
2296            // see and correct; a tracked point is *asked* for a position, and
2297            // answering with content the caller never anchored to is the exact
2298            // failure this whole module exists to remove.
2299            let mut i = 0;
2300            let evicted = r.evicted;
2301            let extras = &r.extras;
2302            self.normal_tracked.retain_mut(|p| {
2303                let (line, col) = extras[tracked_off + i];
2304                i += 1;
2305                match line.checked_sub(evicted) {
2306                    Some(line) => {
2307                        p.line = line;
2308                        p.col = col;
2309                        true
2310                    }
2311                    None => false,
2312                }
2313            });
2314
2315            let alt = self.alt_grid.take_lines();
2316            let r = reflow_pane(
2317                alt,
2318                VecDeque::new(),
2319                (0, 0),
2320                &[],
2321                ReflowDims {
2322                    limit: 0,
2323                    reflow: false,
2324                    ..dims
2325                },
2326            );
2327            self.alt_grid.set_screen(r.screen, cols, rows);
2328        }
2329
2330        // Carry the deferred wrap across the resize — it is *cursor* state, and it used to be
2331        // reset here alongside the scroll margins. Losing it meant the next byte overwrote the
2332        // last glyph instead of wrapping past it, on a column resize *and* on a rows-only one
2333        // where no reflow runs at all.
2334        //
2335        // This comment also named the tab stops beside the margins, as state that "does
2336        // legitimately reset". That was wrong, and #849 measured it: the table is indexed by
2337        // *columns* and written by the *application*, so rebuilding it on a rows-only resize
2338        // destroyed it on an axis it does not name. The margins still reset; the table does not,
2339        // and the extension below is why.
2340        //
2341        // The flag means "the cursor is logically one past the column it sits on". Where the
2342        // reflow leaves it somewhere other than the last column that logical position **is**
2343        // representable, so the flag is cleared and the cursor takes it instead — ghostty's rule,
2344        // stated in its own words for the saved cursor: *"If we had pending wrap set and we're no
2345        // longer at the end of the line, we unset the pending wrap and move the cursor to reflect
2346        // the correct next position"* (`terminal/Screen.zig:2092-2098` @ `e6e26e1`). alacritty
2347        // reaches the same place from the other side, lifting the cursor outside the grid before
2348        // reflowing and clamping it back afterwards (`grid/resize.rs:113-116`, `:248-251`,
2349        // `:173-177` @ `852e971`); xterm.js needs no rule because `x === cols` is representable.
2350        //
2351        // `col + 1` cannot overflow the row: the branch requires `col != cols - 1`, and `col` is
2352        // already clamped below `cols` by `Cursor::set_point`.
2353        if self.cursor.pending_wrap && self.cursor.col != cols - 1 {
2354            self.cursor.pending_wrap = false;
2355            self.cursor.col += 1;
2356        }
2357        // The scroll region is a *range over the current screen*, so a geometry change discards
2358        // it: at a new row count the range names rows that are not the ones it was set for. Every
2359        // reference does the same, and the spec's own width change says so in an enumeration —
2360        // xterm's DECCOLM handler calls `resetMargins` under a `DEC 070, pp 5-71 to 5-72`
2361        // citation (`charproc.c:7446`, `:7463` @ `6380a3e`).
2362        //
2363        // What is **not** a geometry change is a resize to the size the terminal already has, and
2364        // until this gate the two were the same call. `resize` has no early return, so a consumer
2365        // re-asserting its size destroyed a region only the *application* could restore — and the
2366        // application is never told, so nothing restores it. Every reference is guarded against
2367        // that by an early return this function does not have (`alacritty_terminal/src/term/
2368        // mod.rs:662` @ `852e971`, `src/terminal/Terminal.zig:3753` @ `e6e26e1`,
2369        // `src/browser/CoreBrowserTerminal.ts:1055` @ `699f553`); the gate is the narrow form of
2370        // the same guard, and it is the predicate this function already uses twice above.
2371        if cols != old_cols || rows != old_rows {
2372            self.scroll_top = 0;
2373            self.scroll_bottom = rows - 1;
2374        }
2375        // Extend, never rebuild and never trim (#849). A resize changes the *grid*; the
2376        // tab-stop table is state the application wrote through the stream. The line this
2377        // replaces rebuilt it from defaults on every call, so a window dragged one row
2378        // taller — or a consumer merely re-asserting its size, which reaches here because
2379        // this function has no early return for unchanged geometry — silently replaced an
2380        // application's stops with multiples of eight, on an axis the table is not even
2381        // indexed by.
2382        //
2383        // New columns take the default ladder at their **absolute** index, which is why the
2384        // closure carries `index` instead of counting from zero: filling them with `false`
2385        // keeps the length right and loses every default stop past the old width.
2386        //
2387        // Nothing is trimmed, so `tabs.len()` is the widest this terminal has ever been and
2388        // the invariant the two walks need is `len() >= cols` rather than equality — every
2389        // index site is bounded by `cols` or by `cursor.col`. A stop pushed outside the grid
2390        // by a narrowing is unreachable while narrow and returns when the grid widens again.
2391        // The corpus splits 2-2 on that half: xterm keeps it structurally (MAX_TABS is 1024,
2392        // independent of the screen, `ptyx.h:3611` @ `6380a3e`) and xterm.js keeps it in a
2393        // sparse map, while alacritty truncates through `Vec::resize_with`
2394        // (`alacritty_terminal/src/term/mod.rs:2341` @ `852e971`) and ghostty rebuilds the
2395        // table outright when the column count moves (`src/terminal/Terminal.zig:3759` @
2396        // `e6e26e1`). No tie-breaker row covers the axis, so the call is the maintainer's,
2397        // recorded on #849.
2398        //
2399        // Only the *rebuild* is wrong here, not every reset: RIS still restores the default
2400        // ladder, because `full_reset` replaces the whole struct and takes the table from
2401        // the constructor — the right answer for state the application wrote and `ESC c`
2402        // resets.
2403        if cols > self.tabs.len() {
2404            let mut index = self.tabs.len();
2405            self.tabs.resize_with(cols, || {
2406                let is_stop = is_default_tab_stop(index);
2407                index += 1;
2408                is_stop
2409            });
2410        }
2411        self.display_offset = self.display_offset.min(self.scrollback.len());
2412
2413        // Damage tracking is sized to the screen; a resize repaints everything,
2414        // so drop any pending scroll op (it points at the old rows).
2415        self.line_damage = vec![LineBounds::undamaged(cols); rows];
2416        self.scroll = None;
2417        self.mark_fully_damaged();
2418    }
2419
2420    pub fn grid(&self) -> &Grid {
2421        &self.grid
2422    }
2423
2424    pub fn cursor(&self) -> &Cursor {
2425        &self.cursor
2426    }
2427
2428    /// Whether bracketed-paste mode (DEC ?2004) is enabled. The input encoder
2429    /// reads this to decide whether to wrap pasted text in markers.
2430    pub fn bracketed_paste(&self) -> bool {
2431        self.bracketed_paste
2432    }
2433
2434    // ---- input encoding (#11) ------------------------------------------------
2435
2436    /// Encode a key event to bytes using every mode that decides one: the active
2437    /// cursor-key mode (DECCKM), application keypad, the kitty keyboard-protocol
2438    /// flags and `modifyOtherKeys` level 2. `encode_key` consults all four,
2439    /// and asks kitty first.
2440    pub fn encode_key(&self, ev: KeyEvent) -> Option<Vec<u8>> {
2441        encode_key(
2442            &ev,
2443            self.app_cursor_keys,
2444            self.application_keypad,
2445            self.kitty_flags,
2446            self.modify_other_keys_2,
2447        )
2448    }
2449
2450    /// Encode a mouse event using the active tracking mode + encoding. `None`
2451    /// when reporting is off or the event is filtered by the mode.
2452    pub fn encode_mouse(&self, ev: MouseEvent) -> Option<Vec<u8>> {
2453        encode_mouse(&ev, self.mouse_protocol, self.mouse_encoding)
2454    }
2455
2456    /// Encode pasted text, wrapping it in bracketed-paste markers when ?2004 is
2457    /// on.
2458    pub fn encode_paste(&self, text: &str) -> Vec<u8> {
2459        encode_paste(text, self.bracketed_paste)
2460    }
2461
2462    /// Encode a focus change (`CSI I`/`CSI O`), or `None` when focus reporting
2463    /// (?1004) is off.
2464    pub fn encode_focus(&self, focused: bool) -> Option<Vec<u8>> {
2465        encode_focus(focused, self.focus_events)
2466    }
2467
2468    /// Take the consumer events queued since the last drain, emptying the queue.
2469    pub fn drain_events(&mut self) -> Vec<TermEvent> {
2470        std::mem::take(&mut self.events)
2471    }
2472
2473    /// Take the reply bytes queued since the last drain (DA/DSR/DECRQM answers),
2474    /// emptying the buffer. The consumer writes them back to the PTY.
2475    pub fn drain_replies(&mut self) -> Vec<u8> {
2476        std::mem::take(&mut self.replies)
2477    }
2478
2479    /// Device Status Report (CSI Ps n): 6 = cursor position, 5 = operating
2480    /// status. Queues the reply for `drain_replies` (#27).
2481    fn device_status_report(&mut self, param: u16) {
2482        match param {
2483            6 => {
2484                // CSI row;col R, 1-based — region-relative under origin mode
2485                // (the coordinate system the app is addressing in).
2486                let row = if self.origin_mode {
2487                    self.cursor.row.saturating_sub(self.scroll_top)
2488                } else {
2489                    self.cursor.row
2490                } + 1;
2491                let col = self.cursor.col + 1;
2492                self.replies
2493                    .extend_from_slice(format!("\x1b[{row};{col}R").as_bytes());
2494            }
2495            5 => self.replies.extend_from_slice(b"\x1b[0n"), // status: OK
2496            _ => {}
2497        }
2498    }
2499
2500    /// Kitty keyboard-protocol negotiation (#23). `lead` is the leading CSI
2501    /// intermediate: `?` query, `>` push, `=` set, `<` pop.
2502    fn kitty_dispatch(&mut self, lead: u8, params: &Params) {
2503        match lead {
2504            // Query → report the current flags as `CSI ? flags u` (#27 channel).
2505            b'?' => self
2506                .replies
2507                .extend_from_slice(format!("\x1b[?{}u", self.kitty_flags).as_bytes()),
2508            // Push: save the current flags, then set the new ones (default 0).
2509            b'>' => {
2510                const KITTY_STACK_CAP: usize = 16;
2511                if self.kitty_stack.len() >= KITTY_STACK_CAP {
2512                    self.kitty_stack.remove(0); // drop the oldest on overflow
2513                }
2514                self.kitty_stack.push(self.kitty_flags);
2515                self.kitty_flags = param_or(params, 0, 0) as u8;
2516            }
2517            // Pop `n` (default 1): restore from the stack, 0 once empty.
2518            b'<' => {
2519                for _ in 0..param_or(params, 0, 1) {
2520                    self.kitty_flags = self.kitty_stack.pop().unwrap_or(0);
2521                }
2522            }
2523            // Set in place (no push): mode 1 replace, 2 or-in, 3 and-not.
2524            b'=' => {
2525                let flags = param_or(params, 0, 0) as u8;
2526                self.kitty_flags = match param_or(params, 1, 1) {
2527                    1 => flags,
2528                    2 => self.kitty_flags | flags,
2529                    3 => self.kitty_flags & !flags,
2530                    _ => self.kitty_flags,
2531                };
2532            }
2533            _ => {}
2534        }
2535    }
2536
2537    /// DECRQM (CSI ? Ps $ p): report whether DEC private mode `Ps` is set —
2538    /// `CSI ? Ps ; val $ y` with val 1=set, 2=reset, 0=not recognized (#27).
2539    fn decrqm(&mut self, mode: u16) {
2540        let state = match mode {
2541            1 => Some(self.app_cursor_keys),
2542            // DECANM (#84): set = ANSI mode (the normal state), reset = VT52.
2543            2 => Some(!self.vt52_mode),
2544            6 => Some(self.origin_mode),
2545            // DECCOLM: derived from the actual width, never a tracked flag — a
2546            // flag would lie if the consumer ignored the resize request (#82).
2547            3 => Some(self.grid.cols() == 132),
2548            7 => Some(self.autowrap),
2549            45 => Some(self.reverse_wraparound),
2550            9 => Some(self.mouse_protocol == MouseProtocol::X10),
2551            66 => Some(self.application_keypad),
2552            12 => Some(self.cursor.blink),
2553            25 => Some(self.cursor.visible),
2554            // Mouse tracking is a single-state enum (the levels are mutually
2555            // exclusive — an app enables one), so querying ?1000 while ?1002 is
2556            // active reports "reset". Faithful to that model.
2557            1000 => Some(self.mouse_protocol == MouseProtocol::Normal),
2558            1002 => Some(self.mouse_protocol == MouseProtocol::ButtonEvent),
2559            1003 => Some(self.mouse_protocol == MouseProtocol::AnyEvent),
2560            1004 => Some(self.focus_events),
2561            1006 => Some(self.mouse_encoding == MouseEncoding::Sgr),
2562            1015 => Some(self.mouse_encoding == MouseEncoding::Urxvt),
2563            1005 => Some(self.mouse_encoding == MouseEncoding::Utf8),
2564            1016 => Some(self.mouse_encoding == MouseEncoding::SgrPixels),
2565            47 | 1047 | 1049 => Some(self.on_alt),
2566            2004 => Some(self.bracketed_paste),
2567            2026 => Some(self.synchronized_output),
2568            2027 => Some(self.grapheme_clustering),
2569            2031 => Some(self.color_scheme_updates),
2570            9001 => Some(self.win32_input_mode),
2571            _ => None,
2572        };
2573        let val = match state {
2574            Some(true) => 1,
2575            Some(false) => 2,
2576            None => 0,
2577        };
2578        self.replies
2579            .extend_from_slice(format!("\x1b[?{mode};{val}$y").as_bytes());
2580    }
2581
2582    // ---- cursor / scroll primitives ------------------------------------------
2583
2584    /// Move down one line. At the bottom margin, scroll the region instead;
2585    /// below the region, just descend (no scroll). Column is unchanged (raw LF;
2586    /// CR is what returns to column 0).
2587    /// An ordinary line feed — `LF`/`VT`/`FF`, `IND` and `NEL`. None of them serves a wrap.
2588    ///
2589    /// It clears the deferred wrap, as every acting positioner does — see
2590    /// [`Cursor::pending_wrap`]. The clear sits here rather than in
2591    /// [`Term::linefeed_inner`] because the wrap machinery drives that same
2592    /// primitive and *consumes* the flag rather than clearing it; folding the two
2593    /// together would put one property's arm and its clear in one statement.
2594    /// Leaving it armed made the print after an `LF` wrap a second time, landing a
2595    /// row further down and leaving the row the feed had reached blank (#848).
2596    fn linefeed(&mut self) {
2597        self.linefeed_inner(false);
2598        self.cursor.pending_wrap = false;
2599    }
2600
2601    /// A line feed, carrying the one fact the shift itself cannot see: whether the auto-wrap asked
2602    /// for it.
2603    ///
2604    /// `serves_wrap` is the **bottom** seam's exemption in `shift_region`, the mirror of
2605    /// `evicts_to_scrollback` for the top one. When `wrapline` drives this, the blank that lands at
2606    /// the region's bottom *is* where the wrapped text is about to go — so the row that #540 would
2607    /// call "the one that lost its continuation to the blank" is in fact the row whose continuation
2608    /// that blank **is**.
2609    ///
2610    /// xterm.js threads the identical fact through the identical seam, in the opposite direction:
2611    /// `BufferService.scroll(eraseAttr, isWrapped)` stamps the *destination* row
2612    /// (`common/services/BufferService.ts:68`/`:77` @ `699f553`), and exactly one of its four
2613    /// non-test callers passes `true` — the auto-wrap branch of `_print` (`InputHandler.ts:588`),
2614    /// not `lineFeed`, `index` or the ED-2 loop.
2615    fn linefeed_inner(&mut self, serves_wrap: bool) {
2616        // New-line mode (LNM ?20): a line feed also returns to column 0 (#71).
2617        if self.newline_mode {
2618            self.carriage_return();
2619        }
2620        if self.cursor.row == self.scroll_bottom {
2621            // A top-anchored primary-screen scroll pushes the evicted top line
2622            // into scrollback history.
2623            if self.scroll_top == 0 && !self.on_alt {
2624                // Scrollback accrues whenever the scroll is top-anchored on the
2625                // primary screen (`scroll_top == 0`) — but the O(1) ring handshake
2626                // only applies to a *full-screen* scroll (`scroll_bottom` at the
2627                // last row). A top-anchored *sub-region* (`[0..k]`, k < rows-1)
2628                // still accrues, yet must scroll only its region, so it keeps the
2629                // copy + region scroll. These are distinct predicates (ADR-0009).
2630                let evicted = if self.scroll_bottom == self.grid.rows() - 1 {
2631                    // Full-screen hot path: move the evicted top row out, install
2632                    // a recycled blank as the new bottom (zero-alloc steady state).
2633                    let blank = self
2634                        .recycled_row
2635                        .take()
2636                        .unwrap_or_else(|| Row::from_cells(Vec::with_capacity(self.grid.cols())));
2637                    let evicted = self.grid.scroll_up_recycle(blank);
2638                    // The one row-shifting path that does not route through `shift_region` (it
2639                    // needs the primitive that *returns* the evicted row), so it records its own
2640                    // scroll op — and owes no seam clear, which is now an argument rather than a
2641                    // coincidence: the top seam is exempt because this evicts into scrollback
2642                    // (adjacency is preserved one row back), and the bottom seam is exempt because
2643                    // this branch only runs at `scroll_bottom == rows - 1`, where a wrap on the
2644                    // last row is the state the scroll exists to serve.
2645                    self.record_scroll(self.scroll_top, self.scroll_bottom, 1);
2646                    evicted
2647                } else {
2648                    // Top-anchored sub-region: copy row 0, then region-scroll
2649                    // `[0..=scroll_bottom]` (rows below stay fixed).
2650                    //
2651                    // #449: the fixed rows keep their GRID position while
2652                    // scrollback grows, so their content's concatenated absolute
2653                    // index shifts +1 — re-anchor the content-tracking anchors
2654                    // (selection, markers; alacritty's swap-back of the fixed
2655                    // bottom lines is the screen-relative equivalent of this)
2656                    // and invalidate the query-derived highlights
2657                    // (drop-not-re-anchor policy, #108). In-region and
2658                    // scrollback content keeps stable indices — untouched.
2659                    let below = self.scrollback.len() + self.scroll_bottom + 1;
2660                    self.selection_shift_below_margin(below);
2661                    self.markers_shift_below_margin(below);
2662                    self.tracked_shift_below_margin(below);
2663                    self.invalidate_search_highlights();
2664                    let evicted = self.grid.row_owned(0);
2665                    self.shift_region(
2666                        self.scroll_top,
2667                        self.scroll_bottom,
2668                        false,
2669                        true,
2670                        serves_wrap,
2671                    );
2672                    evicted
2673                };
2674                self.scrollback.push_back(evicted);
2675                // Follow-bottom = stay: if the user is scrolled up, bump the
2676                // offset so the same lines stay in view instead of being yanked
2677                // to the bottom.
2678                if self.display_offset > 0 {
2679                    self.display_offset = (self.display_offset + 1).min(self.scrollback.len());
2680                }
2681                // Cap: evict the oldest line past the limit. The view is anchored
2682                // to history, so dropping the front shifts the offset down too
2683                // (xterm.js trims ybase and ydisp together) — also keeps the
2684                // offset within `[0, len]`. The evicted row is parked for reuse.
2685                if self.scrollback.len() > self.scrollback_limit {
2686                    self.recycled_row = self.scrollback.pop_front();
2687                    self.lines_left_the_front(1);
2688                    if self.display_offset > 0 {
2689                        // Scrolled up: evicting the oldest line advanced the
2690                        // viewport, so it must be repainted (the "frozen while
2691                        // scrolled" rule does not apply when the view itself moved).
2692                        self.display_offset -= 1;
2693                        self.mark_fully_damaged();
2694                    }
2695                }
2696            } else {
2697                // Region (top margin > 0) or alt-screen scroll: the evicted line
2698                // does NOT enter scrollback, so content moves *within* the screen
2699                // and absolute indices in the region shift. Rotate the selection
2700                // up so it follows; an endpoint on the dropped line clears it.
2701                let base = self.scrollback.len();
2702                self.selection_rotate_region(
2703                    base + self.scroll_top,
2704                    base + self.scroll_bottom,
2705                    true,
2706                );
2707                // Rotate the active buffer's markers with the content (#187):
2708                // per-buffer storage (#186) scopes them, so an alt scroll rotates
2709                // *alt* marks and leaves the frozen primary list untouched — no
2710                // guard needed. `markers_rotate_region` routes via `markers_mut`.
2711                self.markers_rotate_region(base + self.scroll_top, base + self.scroll_bottom, true);
2712                self.tracked_rotate_region(base + self.scroll_top, base + self.scroll_bottom, true);
2713                self.invalidate_search_highlights();
2714                self.shift_region(
2715                    self.scroll_top,
2716                    self.scroll_bottom,
2717                    false,
2718                    false,
2719                    serves_wrap,
2720                );
2721            }
2722        } else if self.cursor.row + 1 < self.grid.rows() {
2723            self.cursor.row += 1;
2724        }
2725    }
2726
2727    /// DECSTBM (CSI r): set the top/bottom scroll margins (1-based inclusive).
2728    /// An invalid region (top ≥ bottom) is ignored.
2729    fn set_scroll_region(&mut self, top: usize, bottom: usize) {
2730        let bottom = bottom.min(self.grid.rows());
2731        if top >= bottom {
2732            return;
2733        }
2734        self.scroll_top = top - 1;
2735        self.scroll_bottom = bottom - 1;
2736        self.goto(0, 0); // DECSTBM homes the cursor (absolute)
2737    }
2738
2739    // ---- alt screen (DEC 1049) -----------------------------------------------
2740
2741    /// Enter the alternate screen: save the cursor, swap in the other grid, and
2742    /// clear it.
2743    /// Save the cursor into the alt-screen slot — `?1048` set, and the first
2744    /// half of `?1049` enter (#72).
2745    fn save_alt_cursor(&mut self) {
2746        self.saved_cursor = self.cursor;
2747    }
2748
2749    /// Restore the cursor from the alt-screen slot — `?1048` reset, and the
2750    /// second half of `?1049` leave. DECTCEM visibility is a standalone mode, not
2751    /// part of the save, so preserve it across the restore (#38/#72).
2752    fn restore_alt_cursor(&mut self) {
2753        let visible = self.cursor.visible;
2754        self.cursor = self.saved_cursor;
2755        self.cursor.visible = visible;
2756        self.settle_restored_wrap();
2757    }
2758
2759    /// Switch to the (cleared) alternate buffer without touching the cursor —
2760    /// `?47`/`?1047` set, and the second half of `?1049` enter (#72).
2761    fn switch_to_alt(&mut self) {
2762        if self.on_alt {
2763            return;
2764        }
2765        // The pulled index reports the ACTIVE buffer, so a swap changes what the
2766        // consumer's held answer even describes — with no line having moved (#490).
2767        // Gated on a marker existing on either side, since an empty index is already
2768        // correct for both buffers.
2769        if !self.normal_markers.is_empty() || !self.alt_markers.is_empty() {
2770            self.bump_marker_epoch();
2771        }
2772        std::mem::swap(&mut self.grid, &mut self.alt_grid);
2773        self.grid.clear();
2774        self.swap_kitty_keyboard();
2775        self.on_alt = true;
2776        self.display_offset = 0; // the alt screen has no scrollback to view
2777        self.selection = None; // a selection cannot survive a screen swap
2778        self.invalidate_search_highlights(); // matches index the primary buffer
2779        self.mark_fully_damaged();
2780    }
2781
2782    /// Switch back to the primary buffer without touching the cursor —
2783    /// `?47`/`?1047` reset, and the first half of `?1049` leave (#72).
2784    fn switch_to_primary(&mut self) {
2785        if !self.on_alt {
2786            return;
2787        }
2788        // Dispose the alt buffer's markers on leave — xterm `activateNormalBuffer`
2789        // → `clearAllMarkers` (#177 S0). Empty while the alt guards stand, so this
2790        // fires nothing today; it's the seam the alt-marker slices (#187) build on.
2791        for m in self.alt_markers.drain(..) {
2792            self.events.push(TermEvent::MarkerDisposed(m.id));
2793        }
2794        // Same fate for alt-scoped tracked points, and for the same reason: the alt
2795        // buffer is not archived, so leaving it destroys what they named (#691).
2796        // No announcement — `tracked_point` answers `None` on the next ask.
2797        // The pulled index reports the ACTIVE buffer, so a swap changes what the
2798        // consumer's held answer even describes — with no line having moved (#490).
2799        // Only `normal_markers` is asked: the drain above just emptied `alt_markers`, so
2800        // an `|| !alt_markers.is_empty()` disjunct would be dead code carrying a comment
2801        // that claims it reads "either side". What was alt-scoped left through
2802        // `MarkerDisposed`; what can still be stale is the primary population.
2803        if !self.normal_markers.is_empty() {
2804            self.bump_marker_epoch();
2805        }
2806        self.alt_tracked.clear();
2807        std::mem::swap(&mut self.grid, &mut self.alt_grid);
2808        self.swap_kitty_keyboard();
2809        self.on_alt = false;
2810        self.display_offset = 0; // return to the primary at its bottom
2811        self.selection = None; // a selection cannot survive a screen swap
2812        self.invalidate_search_highlights(); // matches index the swapped-out buffer
2813        self.mark_fully_damaged();
2814    }
2815
2816    /// Exchange the active screen's kitty keyboard flags and stack with the other screen's.
2817    fn swap_kitty_keyboard(&mut self) {
2818        std::mem::swap(&mut self.kitty_flags, &mut self.kitty_flags_inactive);
2819        std::mem::swap(&mut self.kitty_stack, &mut self.kitty_stack_inactive);
2820    }
2821
2822    fn enter_alt_screen(&mut self) {
2823        if self.on_alt {
2824            return;
2825        }
2826        self.save_alt_cursor();
2827        self.switch_to_alt();
2828    }
2829
2830    /// Leave the alternate screen: swap the primary grid back in and restore the
2831    /// saved cursor.
2832    fn leave_alt_screen(&mut self) {
2833        if !self.on_alt {
2834            return;
2835        }
2836        self.switch_to_primary();
2837        self.restore_alt_cursor();
2838    }
2839
2840    /// RI (ESC M): move up one line. At the top margin, scroll the region down
2841    /// instead.
2842    fn reverse_index(&mut self) {
2843        if self.cursor.row == self.scroll_top {
2844            // RI never enters scrollback; the region scrolls down within the
2845            // screen, so absolute indices in it shift down. Rotate the selection.
2846            let base = self.scrollback.len();
2847            self.selection_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2848            // Rotate the active buffer's markers (#187) — alt-scoped on the alt
2849            // screen, so no guard (see `linefeed`).
2850            self.markers_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2851            self.tracked_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2852            self.invalidate_search_highlights();
2853            self.shift_region(self.scroll_top, self.scroll_bottom, true, false, false);
2854        } else if self.cursor.row > 0 {
2855            self.cursor.row -= 1;
2856        }
2857        // Cleared in both branches, because both are the verb acting: one moves the
2858        // cursor, the other scrolls the content under it. Branching on which would
2859        // reintroduce the per-verb special-casing [`Cursor::pending_wrap`] exists to
2860        // remove, and xterm and ghostty both clear unconditionally here (`cursor.c:284`
2861        // via `CursorUp`; `Terminal.zig:2174` in `index`, under a comment saying so).
2862        self.cursor.pending_wrap = false;
2863    }
2864
2865    // ---- cursor save/restore (DECSC / DECRC) ---------------------------------
2866
2867    /// DECSC (ESC 7): save the cursor position, pen, pending-wrap, and origin
2868    /// mode. Visibility is not saved (DECTCEM is separate).
2869    fn save_cursor(&mut self) {
2870        self.decsc = SavedCursor {
2871            row: self.cursor.row,
2872            col: self.cursor.col,
2873            pen: self.cursor.pen,
2874            pending_wrap: self.cursor.pending_wrap,
2875            origin_mode: self.origin_mode,
2876            charsets: self.charsets,
2877            gl: self.gl,
2878        };
2879    }
2880
2881    /// DECRC (ESC 8): restore what DECSC saved. Origin mode is restored (per
2882    /// ADR-0004); visibility is left as-is. The position is clamped to the
2883    /// current screen in case it shrank since the save.
2884    fn restore_cursor(&mut self) {
2885        let s = self.decsc;
2886        self.cursor.row = s.row.min(self.grid.rows() - 1);
2887        self.cursor.col = s.col.min(self.grid.cols() - 1);
2888        self.cursor.pen = s.pen;
2889        self.cursor.pending_wrap = s.pending_wrap;
2890        self.origin_mode = s.origin_mode;
2891        self.charsets = s.charsets;
2892        self.gl = s.gl;
2893        self.settle_restored_wrap();
2894    }
2895
2896    /// A restored deferred wrap is only meaningful at the last column; anywhere
2897    /// else the logical position is representable and the cursor takes it.
2898    ///
2899    /// The same translation `Term::resize` applies to the **live** cursor, applied
2900    /// to the two saved slots — which is where ghostty puts it, on the saved cursor
2901    /// it has just reflowed (`terminal/Screen.zig:2094`: *"If we had pending wrap
2902    /// set and we're no longer at the end of the line, we unset the pending wrap and
2903    /// move the cursor to reflect the correct next position"*).
2904    ///
2905    /// justerm's slots are not reflowed, so the repair belongs at the restore rather
2906    /// than at the resize: `decsc` is untouched by `Term::resize` and clamped here,
2907    /// and the alt slot is copied whole. Measured before the fix: 4 columns, `abcd`,
2908    /// `DECSC`, `resize(8, 3)`, `DECRC` left the flag armed at column 3 of an
2909    /// 8-column grid — five columns from the edge — and the next print wrapped
2910    /// instead of landing at column 4 (#848).
2911    fn settle_restored_wrap(&mut self) {
2912        if self.cursor.pending_wrap && self.cursor.col + 1 < self.grid.cols() {
2913            self.cursor.pending_wrap = false;
2914            self.cursor.col += 1;
2915        }
2916    }
2917
2918    /// Set the window title and tell the consumer, in one place.
2919    ///
2920    /// Both writers come through here — the OSC 0/2 path and the XTWINOPS pop
2921    /// path — so the retained string and the event a consumer sees cannot
2922    /// disagree. A pop deliberately fires the **same** `TermEvent::Title` an
2923    /// ordinary title change does: both implementations that carry the stack do
2924    /// exactly this (xterm.js's `setTitle` fires `_onTitleChange`, alacritty's
2925    /// `pop_title` routes through `set_title`), and a second event would ask
2926    /// every consumer to learn a distinction it has no use for. The consequence
2927    /// worth stating: `Title` now means *"the title is this"*, not *"the
2928    /// application just set this"*.
2929    fn set_window_title(&mut self, title: String) {
2930        self.window_title.clone_from(&title);
2931        self.events.push(TermEvent::Title(title));
2932    }
2933
2934    /// XTWINOPS (`CSI Ps ; Ps ; Ps t`) — window manipulation, of which this
2935    /// engine implements exactly two operations (#823).
2936    ///
2937    /// It owns no window, so most of the family is meaningless here: 14/16 ask
2938    /// about pixels the engine has no concept of, and the resize/move/iconify
2939    /// operations are requests about a window the consumer owns. 22 (push
2940    /// title) and 23 (pop title) are different — they are pure VT state, and
2941    /// they were the single most-emitted unimplemented sequence in the capture
2942    /// sweep that produced #823. **This does not make `CSI t` a handled final**;
2943    /// every other first parameter still falls through and is ignored.
2944    ///
2945    /// The second parameter selects the axis: absent or `0` both, `1` the icon
2946    /// name, `2` the window title, anything else no axis at all. It is honoured
2947    /// rather than assumed away because real applications use it — `vim` emits
2948    /// a fully nested `22;0;0t · 22;2t · 22;1t … 23;2t · 23;1t · 23;0;0t`,
2949    /// which a single shared stack gets wrong.
2950    ///
2951    /// **The optional third parameter is deliberately ignored**, and that is a
2952    /// divergence from the spec rather than a simplification of it:
2953    /// `ctlseqs.txt:1698` gives a value in 1..10 *direct access to the stack*,
2954    /// storing or retrieving without pushing or popping, and xterm implements
2955    /// it (`charproc.c:9272`). Measured reach of that form is zero on every
2956    /// axis checked — no occurrence across seven programs recorded under real
2957    /// ptys, no file under `/usr/bin` or `/usr/lib64` containing it, no
2958    /// terminfo capability that emits `CSI 22/23 t` at all under any candidate
2959    /// `TERM`, and no other implementation honouring it (xterm.js ignores it,
2960    /// alacritty's dispatch never reads past the first parameter, ghostty
2961    /// carries the index and then drops the command). It entered xterm in patch
2962    /// #385 (2023-10-01) for symmetry with XTPUSHCOLORS, not because an
2963    /// application asked. So `CSI 22;2;3t` is an ordinary push here.
2964    fn window_ops(&mut self, params: &Params) {
2965        // `param_or` folds an explicit 0 to the default, which is what both
2966        // axis and operation want: absent and `0` mean the same thing in each.
2967        let axis = param_or(params, 1, 0);
2968        let (window, icon) = (axis == 0 || axis == 2, axis == 0 || axis == 1);
2969        match param_or(params, 0, 0) {
2970            22 => {
2971                if window {
2972                    let title = self.window_title.clone();
2973                    push_title(&mut self.window_title_stack, title);
2974                }
2975                if icon {
2976                    let name = self.icon_name.clone();
2977                    push_title(&mut self.icon_name_stack, name);
2978                }
2979            }
2980            23 => {
2981                if window && let Some(title) = self.window_title_stack.pop() {
2982                    self.set_window_title(title);
2983                }
2984                // Restoring the icon name has no observable output: the engine
2985                // has no icon-name event. The stack is still popped so the two
2986                // axes stay aligned across mixed push/pop sequences.
2987                if icon && let Some(name) = self.icon_name_stack.pop() {
2988                    self.icon_name = name;
2989                }
2990            }
2991            _ => {}
2992        }
2993    }
2994
2995    /// RIS (ESC c) — full reset to the power-on state (#53). Reconstruct every
2996    /// screen/mode field to its construction default (preserving only the
2997    /// dimensions and the scrollback cap), but keep the consumer-bound output
2998    /// queues (`replies`/`events`) that accrued earlier in this `feed`, and
2999    /// signal a full repaint. The vte parser lives outside `Term`, so replacing
3000    /// `self` does not disturb in-progress parsing. Mirrors xterm.js fullReset.
3001    ///
3002    /// The XTWINOPS title stacks and the retained title/icon strings (#823) are
3003    /// **not** on the copy-back list and must not be: they are terminal state the
3004    /// application wrote, so *justerm's own* RIS invariant drops them, and the
3005    /// wholesale rebuild does that for free.
3006    ///
3007    /// Read "justerm's own" literally — this is a **minority position, 1–2**, and
3008    /// no spec text settles it. Only alacritty agrees (`title_stack =
3009    /// Vec::new()` in `reset_state`); xterm.js's `reset()` touches nothing but
3010    /// attribute data, and xterm's only bulk free of `saved_titles` is inside
3011    /// `VTDestroy`, i.e. widget teardown rather than `ESC c`. So the two
3012    /// references whose *model* this slice copied — two stacks, the bound, the
3013    /// drop-oldest rule — are the two that keep the stack across a reset. The
3014    /// grounds are the invariant note next door, which already records that no
3015    /// reference can be cited here because none of them holds the embedder's
3016    /// configuration in the object its reset replaces.
3017    ///
3018    /// **That "1–2" is right for the stacks and wrong for the retained strings**, and
3019    /// the sentence above ran the two together (corrected 2026-09-08, #835). Ghostty
3020    /// drops the title on a reset exactly as this does — `self.title` and `self.pwd`
3021    /// are both `clearRetainingCapacity()`d in `fullReset` (`Terminal.zig:4468-4469`)
3022    /// — so on the **retained string** the tally is 2–2, not 1–2, and justerm is not
3023    /// in a minority. It cannot be counted on the *stack* half at all, because it
3024    /// holds no title stack in `Terminal` to have an opinion about; there the 1–2
3025    /// stands. The correction matters because "minority position" is a standing
3026    /// invitation to revisit, and half of what it was pointing at is a tie.
3027    ///
3028    /// **Dropping the title is also not announced, and that too matches both
3029    /// references that drop it.** A consumer keeps the exited application's window
3030    /// title after `ESC c` — the same shape as the palette below, and *worse* on its
3031    /// face, since here the engine does hold the string and does discard it, so the
3032    /// two sides actually diverge. It is nonetheless not a defect by any available
3033    /// standard: alacritty clears `title`/`title_stack` in `reset_state` with no
3034    /// event on its proxy, and ghostty's `StreamHandler.fullReset` sends a mouse
3035    /// shape, a mode-2031 report and a progress clear — and nothing about the title.
3036    /// 2–0 among the references that face the question. Measured on #835 rather than
3037    /// assumed; if it is ever revisited, the first thing to re-measure is whether
3038    /// dropping at all is right, since xterm and xterm.js simply keep the title.
3039    ///
3040    /// **The palette is deliberately *not* announced here, and the silence is a
3041    /// decision rather than an omission (#835).** An application that redefined
3042    /// entries with `OSC 4`, or the foreground/background/cursor with
3043    /// `OSC 10`/`11`/`12`, keeps them across `ESC c`: the engine is theme-agnostic
3044    /// and holds no palette, so the consumer's copy is the only one, and this path
3045    /// pushes no `ResetPaletteColor` / `ResetForeground` / `ResetBackground` /
3046    /// `ResetCursorColor` onto the queue above. xterm is the one reference that
3047    /// resets its own palette on **both** strengths (`charproc.c:14366`, in the
3048    /// `if_OPT_ISO_COLORS` block *above* the `if (full)` split); three things
3049    /// decided against following it, none of them the head-count:
3050    ///
3051    /// * **The tie-breaker does not reach it.** ADR-0004 defers to xterm where
3052    ///   *the spec* mandates something alacritty merely omits. `OSC 4`/`104` are
3053    ///   xterm's own invention and DEC never defined a palette, so no spec text
3054    ///   says what `RIS` does to one — a genuine ambiguity, which ADR-0004 routes
3055    ///   to alacritty. ("The inventor owns the semantics", which settled
3056    ///   `XTREVWRAP` on [`Self::step_back`], does not transfer: that was the
3057    ///   invented sequence's *own* meaning, and this is what a **DEC** sequence
3058    ///   does to state the invented one left behind.)
3059    /// * **terminfo says the palette reset is not part of `RIS`.**
3060    ///   `xterm-256color` spells its reset string `rs1=\Ec\E]104\007` and `linux`
3061    ///   spells it `rs1=\Ec\E]R`: both append an explicit palette reset *after*
3062    ///   `RIS`, which xterm's own entry would not need if `\Ec` implied one. So
3063    ///   `tput reset` already emits `OSC 104` — measured, not read — and the
3064    ///   engine already relays it. The reachable case is covered without adding
3065    ///   anything, which is what `ris_then_osc104_is_the_reset_string_that_ships`
3066    ///   pins.
3067    /// * **The reference that shares this shape declines.** ghostty holds the
3068    ///   palette *and* announces every change across a consumer boundary, and its
3069    ///   override mask tells it exactly which entries are dirty — so a selective
3070    ///   announcement would be free there, and its `fullReset` still sends none.
3071    ///   justerm cannot even be selective: holding no palette, it would have to
3072    ///   fire unconditionally on every `ESC c`.
3073    ///
3074    /// What xterm resets on both strengths is **two** things, and the engine
3075    /// already does one: the pen is covered by this rebuild and by
3076    /// [`Self::soft_reset`]. The *dynamic* colours are restored by nobody, xterm
3077    /// included — `ReallyReset` never touches them. Rows in
3078    /// `docs/agents/reference-facts.md`; reversal criterion on #835.
3079    fn full_reset(&mut self) {
3080        let replies = std::mem::take(&mut self.replies);
3081        let mut events = std::mem::take(&mut self.events);
3082        // RIS wipes the buffer, so every marker's line is gone — announce each
3083        // disposal so the consumer drops its decorations (and isn't confused when
3084        // the reset id counter reissues the same ids). The events survive the
3085        // reset below (#118).
3086        events.extend(
3087            self.normal_markers
3088                .iter()
3089                .chain(&self.alt_markers)
3090                .map(|m| TermEvent::MarkerDisposed(m.id)),
3091        );
3092        let (cols, rows) = (self.grid.cols(), self.grid.rows());
3093        // The word-boundary set is consumer *policy* (ADR-0017), not terminal state, so
3094        // RIS does not own it — an application printing `reset` must not silently revert
3095        // a setting the embedder chose. It rides across with `replies`/`events` because
3096        // this reset rebuilds `Term` wholesale; the references never face the question,
3097        // holding the equivalent outside the object RIS clears (#545).
3098        let word_separators = std::mem::take(&mut self.word_separators);
3099        // Tracked points die here with everything else, but their *id counter*
3100        // rides across (#691). They have no disposal event — a holder learns its
3101        // point is gone by being told `None` — so a reissued id would answer a
3102        // stale ask with a *different* point's position, silently. The markers
3103        // above take the other route and announce; the counter is what a pull-only
3104        // handle has instead.
3105        let next_tracked_id = self.next_tracked_id;
3106        // The marker epoch rides across too, and then moves — for the reason one
3107        // paragraph up, now that a marker index is *pulled* as well as announced (#490).
3108        // A consumer re-pulls when the epoch differs; resetting it to 0 leaves it equal
3109        // to the value a quiet session already holds, so the one signal it watches would
3110        // not fire for the mutation that invalidates everything. `evicted_total`
3111        // legitimately restarts — the buffer it counted is gone — and the epoch change is
3112        // what stops the consumer rebasing against the old basis.
3113        let marker_epoch = self.marker_epoch;
3114        // Marker ids ride across for the *same* reason as `next_tracked_id`, which this
3115        // slice makes true of markers: a pulled handle outlives the announcement that
3116        // killed it, so a reissued id lets a stale `MarkerDisposed(7)` drop the live
3117        // post-RIS marker 7.
3118        let next_marker_id = self.next_marker_id;
3119        *self = Term::with_scrollback(cols, rows, self.scrollback_limit);
3120        self.replies = replies;
3121        self.events = events;
3122        self.word_separators = word_separators;
3123        self.next_tracked_id = next_tracked_id;
3124        self.next_marker_id = next_marker_id;
3125        self.marker_epoch = marker_epoch;
3126        self.bump_marker_epoch();
3127        self.mark_fully_damaged();
3128    }
3129
3130    /// DECSTR (CSI ! p) — soft reset (#53). Resets a defined subset of modes to
3131    /// their defaults *without* destroying screen content or scrollback, moving
3132    /// the active cursor, or touching the mouse/focus reporting subsystem. Per
3133    /// xterm.js softReset, autowrap returns to ON (the xterm default), not off.
3134    ///
3135    /// The pen returning to [`Pen::default`] is this path's half of xterm's
3136    /// `reset_SGR_Colors`, which runs on **both** reset strengths. The other half
3137    /// of that block — resetting the indexed palette — is deliberately not
3138    /// mirrored, here or in [`Self::full_reset`], where the grounds are written
3139    /// out.
3140    fn soft_reset(&mut self) {
3141        self.cursor.visible = true;
3142        self.cursor.pen = Pen::default();
3143        self.cursor.shape = None; // the application's caret shape and blink mode (#927)
3144        self.cursor.blink = false;
3145        self.scroll_top = 0;
3146        self.scroll_bottom = self.grid.rows() - 1;
3147        self.origin_mode = false;
3148        self.app_cursor_keys = false;
3149        self.bracketed_paste = false;
3150        self.modify_other_keys_2 = false; // xterm clears the modify resources on DECSTR too (#890)
3151        self.grapheme_clustering = false; // ?2027 back to the wcwidth-compat default (#295)
3152        self.autowrap = true; // xterm default is ON (not the VT100 "off")
3153        self.insert_mode = false;
3154        self.charsets = [Charset::Ascii; 4];
3155        self.gl = 0;
3156        self.decsc = SavedCursor::default();
3157    }
3158
3159    fn carriage_return(&mut self) {
3160        self.cursor.col = 0;
3161        self.cursor.pending_wrap = false;
3162    }
3163
3164    /// DECSCUSR (CSI Ps SP q): set the caret shape + blink (#89). 1/2 =
3165    /// blinking/steady block; 3/4 = blinking/steady underline; 5/6 =
3166    /// blinking/steady bar (odd = blink). 0 clears the shape to `None` — the
3167    /// consumer's default shape (#927) — and turns the blink mode off. An unknown
3168    /// param leaves the style unchanged.
3169    fn set_cursor_style(&mut self, param: u16) {
3170        let (shape, blink) = match param {
3171            0 => (None, false),
3172            1 => (Some(CursorShape::Block), true),
3173            2 => (Some(CursorShape::Block), false),
3174            3 => (Some(CursorShape::Underline), true),
3175            4 => (Some(CursorShape::Underline), false),
3176            5 => (Some(CursorShape::Bar), true),
3177            6 => (Some(CursorShape::Bar), false),
3178            _ => return,
3179        };
3180        self.cursor.shape = shape;
3181        self.cursor.blink = blink;
3182    }
3183
3184    /// Backspace (BS, 0x08): one step back.
3185    fn backspace(&mut self) {
3186        self.step_back();
3187    }
3188
3189    /// One step back, shared by `BS` and by `CSI D` (#873).
3190    ///
3191    /// **Both verbs take this step, and that is the decision rather than a convenience.**
3192    /// xterm reaches one `CursorBack` from `CASE_BS` (`charproc.c:3703`) and `CASE_CUB`
3193    /// (`:3933`) alike; ghostty's `backspace` is `cursorLeft(1)` (`Terminal.zig:1696`).
3194    /// xterm.js is the one reference that separates them, and does so **on purpose** —
3195    /// *"Our implementation deviates from xterm on purpose"*, one of whose four bullets is
3196    /// *"any cursor movement sequence keeps working as expected"* (`InputHandler.ts:810-818`).
3197    /// The tie was broken for xterm by ADR-0004 and by `XTREVWRAP` being xterm's own
3198    /// invention (`ctlseqs.txt:952`), with no DEC text above it to appeal to. Maintainer's
3199    /// call, 2026-09-08, and theirs to reverse; the tally and the reach measurement behind
3200    /// it are on `tests/reverse_wrap.rs::cursor_left_spends_a_park`.
3201    ///
3202    /// With reverse wraparound (?45) a step at column 0 of a *soft-wrapped* row moves back
3203    /// to the last column of the previous row — undoing one autowrap. Only soft wraps
3204    /// reverse (the previous row carries `WRAPLINE`), and that is xterm's rule too rather
3205    /// than an xterm.js import: its walk gives up on `!LineTstWrapped(ld)` (`cursor.c:178`).
3206    /// A hard CR/LF line does not reverse.
3207    fn step_back(&mut self) {
3208        // A parked cursor is logically one past the column it sits on, so under `?45`
3209        // the first step back lands *on* that column — which is where it already is.
3210        // The park is therefore **spent** as the first unit of the move rather than
3211        // cleared alongside it: clearing and decrementing discards the logical `+1` and
3212        // collapses the parked and unparked states onto the same landing (#80).
3213        //
3214        // **The gate needs autowrap as well as the mode**, and reading only the
3215        // conditional at xterm's spend site says otherwise — which is how a first
3216        // version of this got it backwards. `cursor.c:153` reads
3217        // `if ((rev || rev2) && screen->do_wrap) { --count; } else { --col; }`, but
3218        // `rev` is not the mode flag: `:123-127` define
3219        // `WRAP_MASK (REVERSEWRAP | WRAPAROUND)` and `rev = ((flags & WRAP_MASK) ==
3220        // WRAP_MASK)`, so `rev` means *`?45` **and** `?7h`* and the whole branch is dead
3221        // under `?7l`. ghostty gates the same way and earlier —
3222        // `if (!self.modes.get(.wraparound)) break :wrap_mode .none;`
3223        // (`Terminal.zig:1756`), returning through the plain decrement at `:1766-1769`
3224        // before it can reach the spend at `:1774`. xterm.js never reaches the state at
3225        // all, since its `?7l` print pins `x = cols - 1` (`InputHandler.ts:612`). So a
3226        // park taken under `?7l` is **spent by moving**, 3-0, and the park #869 arms
3227        // there is not this rule's to consume.
3228        if self.reverse_wraparound && self.autowrap && self.cursor.pending_wrap {
3229            self.cursor.pending_wrap = false;
3230            return;
3231        }
3232        self.cursor.pending_wrap = false;
3233        if self.cursor.col > 0 {
3234            self.cursor.col -= 1;
3235            return;
3236        }
3237        // **The walk needs autowrap too, and for the same reason the spend does.** xterm
3238        // reaches both arms through one `rev`, so `:165` is as dead under `?7l` as `:153`
3239        // is, and ghostty returns through the plain decrement at `:1766-1769` before it
3240        // can reach either. This engine gated only the spend, so `?45h` + `?7l` walked a
3241        // row where both references clamp — found while sharing this step with `CSI D`.
3242        if self.reverse_wraparound
3243            && self.autowrap
3244            && self.cursor.row > self.scroll_top
3245            && self.cursor.row <= self.scroll_bottom
3246        {
3247            let prev = self.cursor.row - 1;
3248            let last = self.grid.cols() - 1;
3249            if self.grid.row_ref(prev).is_wrapped() {
3250                // **The wrap link survives the walk.** Undoing the *cursor's* trip across
3251                // the boundary does not undo the boundary: the rows still hold one logical
3252                // line, and every public reader of that — logical lines, link detection,
3253                // command extraction, reflow — asks this flag. Clearing it (xterm.js's
3254                // `line.isWrapped = false`) made two buffers with identical visible content
3255                // answer differently depending on how the cursor got there, and a resize
3256                // kept them apart. xterm writes no wrap flag anywhere in `CursorBack`;
3257                // ghostty only *reads* `prev_row.wrap` (`Terminal.zig:1842-1843`).
3258                self.cursor.row = prev;
3259                self.cursor.col = last;
3260            }
3261        }
3262    }
3263
3264    /// Auto-wrap at end of line: line-feed then return to column 0.
3265    fn wrapline(&mut self) {
3266        self.linefeed_inner(true);
3267        self.cursor.col = 0;
3268        self.cursor.pending_wrap = false;
3269    }
3270
3271    // ---- tab stops (HT / HTS / TBC) ------------------------------------------
3272
3273    /// HT: advance to the next set tab stop, or the last column if none remain
3274    /// (no wrap).
3275    ///
3276    /// **The deferred wrap is cleared only when the walk actually moves** — see
3277    /// [`Cursor::pending_wrap`], which owns the rule. At the last column there is
3278    /// no stop to the right, so this verb changes nothing and must leave the flag
3279    /// armed; clearing it there discarded the parked position and let the next
3280    /// print overwrite the character already in that column (#848).
3281    ///
3282    /// Three of the four references keep it here, by three different mechanisms:
3283    /// xterm's `TabToNextStop` clamps to `LineMaxCol` and never touches `do_wrap`
3284    /// (`tabs.c:142-158`; the one `ResetWrap` on this path is in `TabNext`, gated
3285    /// on the `curses` resource at `tabs.c:113`, off by default), ghostty's loop
3286    /// condition `cursor.x < scrolling_region.right` is already false
3287    /// (`Terminal.zig:2111`), and xterm.js returns early on `x >= cols` because
3288    /// that *is* its parked state (`InputHandler.ts:850`). alacritty is the
3289    /// outlier and consumes the wrap instead (`term/mod.rs:1366`); it preserves
3290    /// the character too, and differs only on which row the next one lands in.
3291    fn put_tab(&mut self) {
3292        let cols = self.grid.cols();
3293        let mut col = self.cursor.col;
3294        while col + 1 < cols {
3295            col += 1;
3296            if self.tabs[col] {
3297                break;
3298            }
3299        }
3300        if col != self.cursor.col {
3301            self.cursor.col = col;
3302            self.cursor.pending_wrap = false;
3303        }
3304    }
3305
3306    /// CHT (CSI Ps I): [`Term::put_tab`] repeated `n` times, stopping at the first
3307    /// one that does not move — so the deferred-wrap rule is `put_tab`'s, and the
3308    /// work is bounded by the row rather than by the parameter (#898). The break
3309    /// is defensive only: a `put_tab` that did not move will not move on a repeat,
3310    /// so removing it changes no outcome and no test can redden it.
3311    fn put_forward_tabs(&mut self, n: usize) {
3312        for _ in 0..n {
3313            let col = self.cursor.col;
3314            self.put_tab();
3315            if self.cursor.col == col {
3316                break;
3317            }
3318        }
3319    }
3320
3321    /// CBT (CSI Ps Z): step back `n` tab stops, or to column one if fewer
3322    /// remain.
3323    ///
3324    /// The mirror of [`Term::put_tab`] over the *same* table, walked in the
3325    /// other direction. Writing it as a mirror rather than as arithmetic is
3326    /// what keeps the two from drifting: an application that moved a stop with
3327    /// HTS has moved it for both directions, and a modulo here would disagree
3328    /// with the forward walk the moment it did.
3329    ///
3330    /// The count repeats the walk — `n` stops, not one stop `n` columns away —
3331    /// which is what makes a multi-field jump land correctly when the stops are
3332    /// unevenly spaced.
3333    ///
3334    /// Backward tabulation is defined *within the line*: it clamps at column
3335    /// one and never wraps to the row above, which would make it a
3336    /// cursor-relocating operation across rows and give it interactions with
3337    /// the wrap state it should not have. The outer loop breaks at column zero
3338    /// for that reason and for a second one — it bounds the work by the grid
3339    /// rather than by the parameter, so a hostile `CSI 65535 Z` costs one walk
3340    /// of the row. That second reason is **defensive only**: `vte` saturates a
3341    /// parameter at `u16::MAX`, so the unbounded form would cost 65535 no-op
3342    /// iterations, and no test can redden the guard.
3343    ///
3344    /// The **count** converges 4/4 — every reference repeats the *walk* rather
3345    /// than computing a column: alacritty `term/mod.rs:1571-1585` @ `852e971`,
3346    /// ghostty `stream_terminal.zig:593-599` + `Terminal.zig:2124-2136` @
3347    /// `e6e26e1`, xterm `charproc.c:3745-3752` + `tabs.c:131-180` @ `6380a3e`,
3348    /// xterm.js `InputHandler.ts:1141-1151` + `Buffer.ts:599-603` @ `699f553`.
3349    ///
3350    /// The **clamp is 3/4**, and the outlier is alacritty — the one whose loop
3351    /// shape this most resembles, which is why the resemblance is worth
3352    /// distrusting. It seeds `col` with the cursor's current column and
3353    /// overwrites it only inside `if self.tabs[i]` (`term/mod.rs:1578-1583`),
3354    /// so with **no stop to the left it writes the cursor back unchanged** — a
3355    /// no-op where the walk below runs to column zero. Reachable after
3356    /// `CSI 3 g`, since clearing all stops clears column zero too in both
3357    /// engines. xterm (`TabPrev` returns 0), ghostty (returns at
3358    /// `x <= left_limit`) and xterm.js (`x < 0 ? 0 : x`) all clamp, and
3359    /// `back_tab_with_no_stops_lands_at_column_one` is the test that pins which
3360    /// side justerm is on.
3361    ///
3362    /// **Clearing the deferred wrap diverges from all four, deliberately.**
3363    /// None of them clears it here: alacritty's `move_backward_tabs` writes no
3364    /// `input_needs_wrap` though its own CUB does (`term/mod.rs:1253`);
3365    /// ghostty's `horizontalTabBack` calls the *screen*-level `cursorLeft`,
3366    /// which does not touch `pending_wrap`, where its terminal-level one does
3367    /// (`Terminal.zig:1768`); xterm's `TabToPrevStop` has no `ResetWrap` at all
3368    /// — the only one in `tabs.c` is in the *forward* walk and is gated on the
3369    /// `curses` resource (`tabs.c:113`), off by default; and xterm.js has no
3370    /// flag, representing the state as `x == cols` and returning early on it,
3371    /// so its CBT from the right margin moves *nothing*.
3372    ///
3373    /// ADR-0004's spec-first rule has nothing to award here, and the reason is
3374    /// stronger than "the spec is quiet". `ctlseqs.txt:755` is one line, but
3375    /// that file is xterm's documentation *of xterm* rather than the normative
3376    /// text; CBT's normative home is ECMA-48, which no pinned tree carries. The
3377    /// argument that does not depend on a document nobody here can open: the
3378    /// deferred wrap is an **implementation device** for "the cursor is parked
3379    /// at the last column", not an ECMA-48 concept, so no version of that spec
3380    /// can rule on it in principle.
3381    ///
3382    /// So the grounds are this engine's own coherence: **every horizontal-positioning
3383    /// verb here clears the flag** — `move_forward`, `move_back`, `set_col`,
3384    /// `set_row`, `goto`, `move_up`, `move_down`, `backspace`,
3385    /// `carriage_return`, `put_tab` — so a back-tab that did not would be the
3386    /// sole exception. Leaving it armed also reproduces the very bug #826
3387    /// exists to fix, from the other side: the next character would land on the
3388    /// *following row* rather than in the column the back-tab chose.
3389    ///
3390    /// The unanimity is over that population and not over every writer of
3391    /// `cursor.col`. `linefeed_inner` and `reverse_index` do **not** clear it,
3392    /// which is a separate and unsettled question — justerm is the outlier 3-1
3393    /// there — and deliberately outside this change. `put_tab` is in that
3394    /// population only where it moves: at the right edge of a full row it finds
3395    /// no stop and leaves the flag armed (#848), which is the same rule — clear
3396    /// where the verb moves the cursor — and CBT always moves. See
3397    /// [`docs/agents/reference-facts.md`](https://github.com/kihyun1998/justerm/blob/master/docs/agents/reference-facts.md).
3398    ///
3399    /// **What the divergence actually costs, stated as behaviour rather than as
3400    /// a flag.** On a full row, `CSI Z` then a print puts the character where
3401    /// the back-tab landed; every reference puts it on the *following row*,
3402    /// having in effect discarded the back-tab. Pinned by
3403    /// `back_tab_on_a_full_row_prints_where_it_landed_not_on_the_next_row`.
3404    ///
3405    /// **Cleared concern, with its validity condition.** xterm and ghostty
3406    /// clamp a back-tab to the **left margin** under origin mode
3407    /// (`tabs.c:171-175`; `Terminal.zig:2126`), not to column zero. That is
3408    /// inert here only because this engine implements no DECSLRM, so both
3409    /// reduce to zero. **If DECSLRM ever lands, this function is a site**, and
3410    /// nothing else in the tree points at it.
3411    fn put_back_tab(&mut self, n: usize) {
3412        let mut col = self.cursor.col;
3413        for _ in 0..n {
3414            if col == 0 {
3415                break;
3416            }
3417            while col > 0 {
3418                col -= 1;
3419                if self.tabs[col] {
3420                    break;
3421                }
3422            }
3423        }
3424        self.cursor.col = col;
3425        self.cursor.pending_wrap = false;
3426    }
3427
3428    /// HTS (ESC H): set a tab stop at the cursor column.
3429    fn set_tab_stop(&mut self) {
3430        let col = self.cursor.col;
3431        self.tabs[col] = true;
3432    }
3433
3434    /// TBC (CSI g): clear the tab stop at the cursor (mode 0) or all stops
3435    /// (mode 3).
3436    fn clear_tab_stop(&mut self, mode: u16) {
3437        match mode {
3438            0 => {
3439                let col = self.cursor.col;
3440                self.tabs[col] = false;
3441            }
3442            3 => self.tabs.iter_mut().for_each(|t| *t = false),
3443            _ => {}
3444        }
3445    }
3446
3447    // ---- printing ------------------------------------------------------------
3448
3449    /// The extended attributes the pen currently stamps onto a cell it writes: the open OSC 8
3450    /// hyperlink (#26/#46) and a non-default underline colour (SGR 58, #520).
3451    ///
3452    /// The colour is gated on the UNDERLINE attribute — an underline colour is meaningless on a
3453    /// cell that draws no underline, and xterm likewise does not persist it there
3454    /// (`AttributeData isEmpty()` ignores the colour; `InputHandler.test.ts:2084`). That keeps
3455    /// it off the wire for cells that never draw it (ADR-0020: no inert per-cell payload). SGR 58
3456    /// is the *underline* colour, so STRIKETHROUGH alone does not arm it.
3457    ///
3458    /// One place, because three sites take their extended attrs from the PEN — the glyph, its wide
3459    /// spacer, and the vacated wrap column — mirroring the pen half of `Row::ext_attrs_at`, so a
3460    /// later rider is added here rather than at each of them (#521/#528).
3461    ///
3462    /// **Five sites build a cell from the pen, not three, and the other two deliberately do not
3463    /// come here**: `promote_cluster_to_wide` and `relocate_cluster_wide` synthesise a pair's
3464    /// spacer, whose attrs are the LEAD's rather than the pen's (ADR-0025 D4), so they read
3465    /// `Row::ext_attrs_at` and the lead's underline style directly. Counting them in is how a
3466    /// rider gets added here and silently misses them — which is exactly what #829's underline
3467    /// style did until a refuting pass measured it.
3468    fn pen_ext_attrs(&self) -> ExtAttrs {
3469        let ucolor = self.cursor.pen.underline_color;
3470        let armed =
3471            ucolor != Color::Default && self.cursor.pen.flags.contains(CellFlags::UNDERLINE);
3472        ExtAttrs::from_pen(self.current_link.clone(), armed.then_some(ucolor))
3473    }
3474
3475    /// Free a cell that has stopped being part of a glyph — the *structural repair* every
3476    /// overwrite, erase and row-shift owes the no-orphan invariant when it destroys one half of
3477    /// a width-2 glyph, plus the spacer a mode-2027 demotion no longer needs.
3478    ///
3479    /// This is **not** an erase. The app asked for something at a *different* column; freeing
3480    /// this one is the engine keeping its own invariant. But it is still a mutation, so it
3481    /// **damages** — and that is the half every site used to forget, because each function
3482    /// damaged its own range and the repaired cell lies outside it by construction (that is what
3483    /// makes it a repair). A frame-mode consumer therefore kept painting the destroyed glyph.
3484    /// Bundling the reset with its damage is the point of this helper: a repair site added later
3485    /// cannot forget the half that has no compiler behind it (#530).
3486    ///
3487    /// The cell it leaves is a **blank carrying the current background** — the same rule
3488    /// `clear_cells` already applies to a BCE erase, extended to the repair, so one sentence
3489    /// covers both: *a blank cell carries the current background.* A bare `Cell::default()`
3490    /// would punch an uncoloured notch into a coloured run, which no reference implementation
3491    /// does.
3492    ///
3493    /// Deliberately the pen's **background only** — not its full attributes, and this is not a
3494    /// compromise between references: it is byte-for-byte xterm.js's `_eraseAttrData()`
3495    /// (`DEFAULT_ATTR_DATA` + `curAttr.bg & ~0xFC000000`, i.e. default everything plus the pen's
3496    /// background colour), which is what its `replaceCells` / `insertCells` / `deleteCells`
3497    /// repairs are handed — eight of the twelve sites here. Only xterm's *print* path uses the
3498    /// whole pen. Taking the whole pen
3499    /// (xterm.js `setCellFromCodepoint(x, 0, 1, curAttr)`) would plant the pen's hyperlink and,
3500    /// worse, its DECSCA protection onto a cell the app never wrote — a cell no later erase could
3501    /// clear. Taking the *cell's own* attributes (alacritty `clear_wide`, which keeps `extra`)
3502    /// would leave the destroyed glyph's hyperlink alive and clickable, the defect #529 is filed
3503    /// against. Both were considered and rejected; the maintainer chose this on 2026-07-24 and it
3504    /// is theirs to reverse (see #530 for what they were shown).
3505    ///
3506    /// What used to be recorded here as a known limitation is **resolved** (#538, ADR-0025 D1):
3507    /// `reset()` still clears the whole content word, but the soft-wrap link is no longer part of
3508    /// it. The live flag is on the `Row`, so freeing the last column — here or on the erase path —
3509    /// cannot break a wrap; `CellFlags::WRAPLINE` is wire-only, derived onto the last cell at
3510    /// encode time and never read back (`cell.rs`). Ending a wrap is now an explicit per-verb call
3511    /// (`end_wrap`), which is the shape both references already had and the reason the move was
3512    /// made: ghostty and xterm.js hold the flag on the row/line, and xterm.js takes `clearWrap` as
3513    /// an explicit argument on its erase helper (`_eraseInBufferLine`, `InputHandler.ts:1175`)
3514    /// rather than letting a cell clear decide it.
3515    ///
3516    /// Known cost, accepted rather than overlooked: with DECSCA the freed cell loses its
3517    /// protection. ghostty has the same hole and flags it in its own source; justerm does not
3518    /// implement DECSCA today, so revisit if it lands.
3519    fn free_cell(&mut self, row: usize, col: usize) {
3520        let bg = self.cursor.pen.bg;
3521        let cell = self.grid.cell_mut(row, col);
3522        cell.reset();
3523        cell.set_bg(bg);
3524        // As in `clear_cells`: the bits are gone, so release what they gated (#628).
3525        self.grid.row_mut(row).purge_side_maps(col..col + 1);
3526        self.damage_span(row, col, col);
3527    }
3528
3529    /// Will the next `wrapline()` actually reach another row?
3530    ///
3531    /// `wrapline` → `linefeed` advances in exactly two cases: the cursor sits at the scroll
3532    /// region's bottom (so the region scrolls under it), or it has a row below it on screen.
3533    /// Parked *below* a DECSTBM region on the last row it does neither — it silently stays put.
3534    ///
3535    /// Both wide-at-boundary paths must ask before they commit anything, because both destroy
3536    /// content on the assumption that the row is about to change: `write_glyph` blanks the column
3537    /// it is leaving, and `relocate_cluster_wide` writes its cluster to `(cursor.row, 0..=1)`
3538    /// *after* the wrap — which is the same row when nothing advanced, so it lands on live cells.
3539    /// Reasoning only about the vacated source column misses that second case entirely.
3540    ///
3541    /// This mirrors `linefeed`'s own condition; the two must be read together.
3542    fn wrapline_advances(&self) -> bool {
3543        self.cursor.row == self.scroll_bottom || self.cursor.row + 1 < self.grid.rows()
3544    }
3545
3546    /// Blank the last column as the soft-wrap artefact it is, when a width-2 glyph could not fit
3547    /// there (#528). Shared by the two paths that reach this state: `write_glyph`'s wide-at-boundary
3548    /// wrap and `relocate_cluster_wide`'s promoted cluster.
3549    ///
3550    /// The column is **written**, not merely flagged: a blank built from the current pen, exactly
3551    /// as every reference does it — xterm.js `setCellFromCodepoint(col, 0, 1, curAttr)`
3552    /// (`InputHandler.ts:609-611`; `BufferLine.ts:244-251` takes the pen's fg/bg *and* its
3553    /// `extended` link/colour), ghostty `printCell(0, .spacer_head)` (`Terminal.zig:1410-1412`,
3554    /// whose `printCell` stamps the cursor's hyperlink), alacritty `write_at_cursor(' ')` under a
3555    /// `LEADING_WIDE_CHAR_SPACER` template (`mod.rs:1108-1113`, assigning `extra` from it).
3556    ///
3557    /// Flagging in place instead left the previous occupant's glyph, hyperlink and underline colour
3558    /// alive in a cell every text reader skips — so a renderer drew a character that could not be
3559    /// copied, searched or announced (#528). Building from `Pen::cell` also clears the presence
3560    /// bits, so no stale side-map entry can be read back through the new cell.
3561    ///
3562    /// WRAPLINE marks the row a continuation rather than a hard line-end (search, logical lines
3563    /// #113 and reflow #7 all read it); the leading-spacer marker makes the text extractors skip
3564    /// the blank instead of joining `"ab한"` → `"ab 한"`.
3565    ///
3566    /// The marker is **alacritty's** `LEADING_WIDE_CHAR_SPACER` (`term/cell.rs`) — ghostty calls
3567    /// the same thing `.spacer_head`. It is *not* xterm's: xterm.js has no marker at all, writing
3568    /// a bare null cell and re-inferring the artefact at reflow time from "ends in null and the
3569    /// following line starts with a wide char". That difference has a consequence here — in
3570    /// xterm.js a lost marker degrades to an empty cell that trimming drops anyway, whereas
3571    /// justerm writes `' '`, so the marker is the *only* thing keeping this column out of the
3572    /// extracted text.
3573    fn vacate_for_wrap(&mut self, row: usize, col: usize) {
3574        // Writing this column makes the vacate an overwrite like any other, so it inherits the
3575        // no-orphan obligation every other overwrite site carries (`write_glyph`,
3576        // `promote_cluster_to_wide`, `insert_chars`, `delete_chars`, the erase path): if the
3577        // column was the *spacer* of a wide glyph, blanking it destroys the spacer marker and
3578        // strands the lead. That is unrecoverable rather than merely untidy — every repair path
3579        // keys off `is_wide_spacer()`, so once the marker is gone no later write, ECH, EL, ICH
3580        // or DCH can ever clear the orphan.
3581        if col > 0 && self.grid.cell(row, col).is_wide_spacer() {
3582            self.free_cell(row, col - 1);
3583        }
3584        let mut vacated = self.cursor.pen.cell(' ');
3585        vacated.set_leading_spacer();
3586        *self.grid.cell_mut(row, col) = vacated;
3587        self.begin_wrap(row);
3588        let ext = self.pen_ext_attrs();
3589        self.grid.row_mut(row).set_ext_attrs(col, ext);
3590        // The cell's contents changed, so a frame-mode consumer must be told or it keeps painting
3591        // the old glyph (ADR-0003: every mutation site records damage). The *repaired* lead above
3592        // is damaged by `free_cell`, which owns that pairing for all twelve repair sites — this
3593        // site used to hand-roll it, and keeping both left neither able to discriminate.
3594        self.damage_span(row, col, col);
3595    }
3596
3597    /// Place one already-charset-translated scalar: join it to the previous cluster
3598    /// under mode 2027, attach it as a combining mark, or write it as a new glyph.
3599    ///
3600    /// Split out of `print` so `REP` can re-enter the print path *below* two things it
3601    /// must not repeat (#825): the VT52 `ESC Y` coordinate intercept, and the GL
3602    /// character-set translation — `REP` reads its grapheme back off a cell, which
3603    /// holds the *translated* glyph, so passing it through `print` would map it twice.
3604    /// Neither is observable today (the intercept is unreachable because `ESC Y`
3605    /// disarms the repeat, and no glyph either implemented set produces is a key in
3606    /// its own table), so this is insurance, and its condition is worth stating: it
3607    /// stops being insurance the moment a set is added whose output overlaps its
3608    /// input.
3609    ///
3610    /// The three arms that place content are exactly the sites that arm
3611    /// [`Term::repeat_anchor`]. xterm reaches the same set from the other end — it arms
3612    /// on every printed scalar and then rejects the zero-width ones with a guard at
3613    /// `REP` (`charproc.c:6154`) — and the two are equivalent because the only scalars
3614    /// that reach here with no width are unreachable in the first place.
3615    fn place_grapheme(&mut self, c: char) {
3616        // Grapheme-cluster mode (DEC ?2027, #295): if `c` extends the previous cell's cluster,
3617        // join it there instead of placing a new cell. OFF → the per-char (wcwidth) path below.
3618        if self.grapheme_clustering
3619            && let Some(at) = self.try_grapheme_join(c)
3620        {
3621            self.repeat_anchor = Some(at);
3622            return;
3623        }
3624        match c.width() {
3625            // Zero-width (combining marks): a zero-width code point is a combining
3626            // mark — attach it to the previous base glyph rather than dropping it.
3627            Some(0) => self.repeat_anchor = Some(self.push_combining(c)),
3628            // Reachable, and the only scalar that reaches it: `vte` sends C0 and the
3629            // 8-bit C1 range to `execute`, but `ground_dispatch` matches only
3630            // `'\x00'..='\x1f' | '\u{80}'..='\u{9f}'` there (`vte-0.15.0/src/lib.rs:722`),
3631            // so `DEL` (0x7F) arrives here and `'\u{7f}'.width()` is `None`. It writes no
3632            // cell, so it clears the anchor — which is xterm's answer too, reached by its
3633            // own route (`lastchar` is set only by CASE_PRINT, and REP guards on positive
3634            // width, `charproc.c:6154`). An earlier version of this claimed the arm was
3635            // unreachable, citing a `0x7F => ()` line that belongs to `CsiIgnore`.
3636            None => self.repeat_anchor = None,
3637            // Coerced to a pair, because a pair is the only multi-column shape the cell model
3638            // has (`WIDE_CHAR` + exactly one `WIDE_CHAR_SPACER`) — see ADR-0025, which states
3639            // every clause over "a pair" and never over a wider run. `unicode-width` genuinely
3640            // returns 3 for at least one codepoint (U+17D8 KHMER SIGN BEYYAL, a ligature drawn
3641            // as three characters), and that value is not wrong — it is unrepresentable here.
3642            //
3643            // Left uncoerced, the width fell through *every* wide branch in `write_glyph`
3644            // (each gated on `width == 2`) while still driving the cursor advance, so the glyph
3645            // landed as a lone narrow cell followed by columns that no flag distinguished from
3646            // real blanks: search could not find the text on screen and word selection split
3647            // the run, handing the clipboard a space the buffer never held (#595).
3648            //
3649            // All three references bound it, and ghostty says why in the same words —
3650            // `unicode/props.zig:11-13`, *"We clamp to [0, 2] … i.e. 3-em dash becomes a 2-em
3651            // dash"*. Clamping *here* rather than inside `write_glyph` keeps the two jobs apart:
3652            // this is the policy for an out-of-range external value, and the invariant it
3653            // establishes is asserted at the site that depends on it.
3654            Some(width) => self.repeat_anchor = self.write_glyph(c, width.min(2)),
3655        }
3656    }
3657
3658    /// Write one glyph at the cursor, handling deferred wrap and the wide-char
3659    /// spacer, then advance the cursor (deferring the wrap if it hits the edge).
3660    /// Returns where the glyph landed — `(row, col)` of its lead cell — or `None` on
3661    /// the one path that writes nothing: a width-2 glyph that cannot fit the last column
3662    /// with autowrap off is dropped. [`Term::repeat_anchor`] is set from this, so a
3663    /// print that placed no cell must not arm the repeat.
3664    fn write_glyph(&mut self, c: char, width: usize) -> Option<(usize, usize)> {
3665        // Every wide branch below is gated on `width == 2`, and the four unguarded uses
3666        // (`insert_chars`, `col + width - 1` twice, the cursor advance) assume the same bound.
3667        // The caller coerces (#595); this states the assumption at the site that holds it, so a
3668        // future second caller fails a test rather than writing an unmarked run of blanks.
3669        // Ghostty pairs its own source-side clamp with the same assertion for the same reason
3670        // (`Terminal.zig`, *"it is possible to have a width of 3 … assert(width <= 2)"*).
3671        debug_assert!(
3672            width <= 2,
3673            "write_glyph({c:?}, {width}) — the cell model represents at most a pair"
3674        );
3675        let cols = self.grid.cols();
3676
3677        // Resolve a deferred last-column wrap before placing the next glyph.
3678        // The row being left soft-wrapped: mark its last cell so reflow (#7) can
3679        // tell it from a hard CR/LF line-end.
3680        if self.cursor.pending_wrap && !self.autowrap {
3681            // **This is where DECAWM is tested, and since #869 it is the only place.**
3682            // The arm is unconditional, so under `?7l` every row-filling print leaves a
3683            // park and this guard is what spends it in place instead of wrapping — not
3684            // the narrow "the mode was turned off after the flag was armed" repair it
3685            // began as. Deleting it does not merely regress an edge case; it wraps with
3686            // autowrap disabled. `decawm.rs::autowrap_off_overwrites_the_last_column` is
3687            // the guard on the guard.
3688            //
3689            // **All four references print in place here**: xterm clears `do_wrap` and
3690            // only then asks `WRAPAROUND` (`charproc.c:7059-7061`), xterm.js un-parks
3691            // with `x = cols - 1` in the else arm of its `wraparoundMode` branch
3692            // (`InputHandler.ts:612`), ghostty gates the whole consume
3693            // (`Terminal.zig:1368`) and alacritty's `wrapline` early-returns on
3694            // `!LINE_WRAP` (`term/mod.rs:962`). What they differ on is whether the flag
3695            // is left standing afterwards, and that is a separate axis — see
3696            // `docs/agents/reference-facts.md`, where #848's "2-2" is corrected as a
3697            // sampling artefact rather than a real split.
3698            //
3699            // Pre-existing, and #848 widened it: until that change `put_tab` cleared
3700            // the flag, so `abc` + `?7l` + `HT` + `X` printed in place by accident.
3701            self.cursor.pending_wrap = false;
3702        }
3703        if self.cursor.pending_wrap {
3704            let row = self.cursor.row;
3705            // Claim the wrap only if there will *be* a next row to continue into. Parked below a
3706            // DECSTBM region on the last row, `wrapline` → `linefeed` advances nothing and the
3707            // glyph overwrites this same row from column 0 — so the wrap never happened, and a
3708            // flag set here is permanently false: the cursor never leaves, nothing clears it, and
3709            // it survives into `backspace`'s reverse-wraparound, reflow, and every text reader.
3710            //
3711            // The predicate is not new and neither is its rationale: `wrapline_advances` was
3712            // written for exactly this state and is already asked by both wide-at-boundary paths.
3713            // This narrow path was the one caller that committed without asking. (Surfaced by the
3714            // #540 completeness pass, which found a row-shift verb inheriting the bogus flag and
3715            // merging two unrelated logical lines.)
3716            if self.wrapline_advances() {
3717                self.begin_wrap(row);
3718            }
3719            self.wrapline();
3720        }
3721
3722        // A width-2 glyph that cannot fit in the last column wraps first — unless
3723        // autowrap is off, in which case it is dropped (xterm.js `continue`), not
3724        // squeezed or wrapped.
3725        if width == 2 && self.cursor.col + 1 >= cols {
3726            if !self.autowrap {
3727                return None; // dropped: nothing written, so nothing to repeat
3728            }
3729            // …but only if the wrap actually happens: vacating for a wrap that never occurs
3730            // blanks a column holding a live glyph.
3731            if self.wrapline_advances() {
3732                self.vacate_for_wrap(self.cursor.row, cols - 1);
3733            }
3734            self.wrapline();
3735        }
3736
3737        // Insert mode (IRM): open a `width`-wide gap at the cursor first, shifting
3738        // the row's tail right (off-edge cells discarded, wide halves repaired),
3739        // then write into the gap — mirrors xterm.js's insertCells (#64).
3740        if self.insert_mode {
3741            self.insert_chars(width);
3742        }
3743
3744        let (row, col) = (self.cursor.row, self.cursor.col);
3745
3746        // Overwriting either half of a pair that wrapped from the row above ends that pair, so the
3747        // row above's artefact record is void (#534). The one exception is the in-place same-width
3748        // overwrite — a wide lead replaced by another wide lead at the same column — which is
3749        // ghostty's `if (cell.wide != wide)` escape and the reason this is asked *before* the
3750        // write rather than after it. Note IRM has already run its own check inside `insert_chars`
3751        // by the time this would fire, on the pre-shift state, which is the correct one.
3752        if col <= 1 && self.wrapped_pair_at_row_start(row) && !(col == 0 && width == 2) {
3753            self.void_wrap_artefact_above(row);
3754        }
3755
3756        // Overwriting one half of an existing wide glyph orphans the other —
3757        // clear it so no stray lead/spacer is left behind.
3758        let last = col + width - 1;
3759        if col > 0 && self.grid.cell(row, col).is_wide_spacer() {
3760            self.free_cell(row, col - 1);
3761        }
3762        if last + 1 < cols && self.grid.cell(row, last).is_wide() {
3763            self.free_cell(row, last + 1);
3764        }
3765
3766        let mut cell = self.cursor.pen.cell(c);
3767        if width == 2 {
3768            cell.insert_flags(CellFlags::WIDE_CHAR);
3769        }
3770        *self.grid.cell_mut(row, col) = cell;
3771        // Stamp the pen's extended attrs — the open hyperlink (#26/#46) and a non-default
3772        // underline colour (#520) — into the row's side maps.
3773        let ext = self.pen_ext_attrs();
3774        // `.clone()`: `ExtAttrs` stopped being `Copy` at #628 (the link rider is a shared
3775        // `Arc<str>`), and the spacer below stamps the same value — a refcount bump, not
3776        // a second string.
3777        self.grid.row_mut(row).set_ext_attrs(col, ext.clone());
3778
3779        // The trailing column of a wide glyph carries a distinct spacer marker —
3780        // and the same link + underline colour, so a hover/selection/underline over
3781        // either half agrees.
3782        if width == 2 && col + 1 < cols {
3783            let mut spacer = self.cursor.pen.cell(' ');
3784            spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
3785            *self.grid.cell_mut(row, col + 1) = spacer;
3786            self.grid.row_mut(row).set_ext_attrs(col + 1, ext);
3787        }
3788
3789        // Record damage for the cell(s) just written.
3790        self.damage_span(row, col, col + width - 1);
3791
3792        // Advance. Reaching/passing the last column sets pending-wrap instead of
3793        // wrapping eagerly — the cursor parks on the last column.
3794        let new_col = col + width;
3795        if new_col >= cols {
3796            self.cursor.col = cols - 1;
3797            // The park is taken whatever the mode says (#869): the cursor is logically
3798            // one past this column either way, and that is the whole of what this flag
3799            // means. With `?7l` the *consume* site above spends the park in place, so
3800            // the next glyph still overwrites the last column (#63) — but if the mode
3801            // is re-enabled before that print, the park is still there and it wraps,
3802            // which is what all four references do.
3803            self.cursor.pending_wrap = true;
3804        } else {
3805            self.cursor.col = new_col;
3806        }
3807        Some((row, col))
3808    }
3809
3810    /// The column, on the cursor's row, of the cluster the cursor last printed into —
3811    /// or `None` when nothing precedes it on this row. Shared by the combining-mark
3812    /// attach point and the mode-2027 join point, which each used to carry their own
3813    /// copy of it.
3814    ///
3815    /// # Two cases, and why the first one is trustworthy again (#865, #869)
3816    ///
3817    /// **Parked.** [`Cursor::pending_wrap`] says the cursor is logically one past the
3818    /// column it sits on, so the cluster is *at* the cursor — and once left over a
3819    /// `WIDE_CHAR_SPACER` to reach its lead.
3820    ///
3821    /// **This reading was wrong for a whole mode until #869, and the repair is not
3822    /// here.** The flag used to be armed as `pending_wrap = self.autowrap`, so with
3823    /// `?7l` a print that filled the last column pinned the cursor and armed nothing:
3824    /// a pin and a bare *advance onto* that column shared every cursor field, and a
3825    /// mark landed one column too far left. #865 worked around it here by consulting
3826    /// [`Term::repeat_anchor`]; #869 removed the cause instead — the arm is now
3827    /// unconditional and the mode is tested where it is consumed, which is what
3828    /// alacritty (`term/mod.rs:1136`), ghostty (`Terminal.zig:1434`) and xterm
3829    /// (`charproc.c:7152`) all do — that line is the *exact-fill* arm and is
3830    /// unconditional; xterm's *overflow* arm two branches up (`:7145`) is gated on
3831    /// `WRAPAROUND`, and the arm site here is only ever reached by the former. The
3832    /// workaround was then measured dead across the
3833    /// whole core suite, against a positive control that reproduced it under the old
3834    /// arming, and removed.
3835    ///
3836    /// **Not parked.** The cursor is merely *at* a cell, so the cluster is one column
3837    /// left, and once more left over a spacer. This is also the answer after a bare
3838    /// cursor move to the last column, which the four references answer four different
3839    /// ways: xterm attaches under the cursor (`char_was_written` is false after
3840    /// `ResetWrap`, so it falls back to `cur_col`), alacritty and ghostty attach one
3841    /// column left, and xterm.js attaches nowhere — its `precedingJoinState` is zeroed
3842    /// on every escape transition (`EscapeSequenceParser.ts:676`), so `shouldJoin` is
3843    /// false (`UnicodeV6.ts:134`) and the mark becomes its own zero-width cell. This
3844    /// engine keeps the answer it had, which is the plurality's; #865 deliberately did
3845    /// not reopen it, since nothing measured reaches the case.
3846    ///
3847    /// **`REP` still does not use this**, and the reason is on [`Term::repeat_anchor`]:
3848    /// it needs the position a print wrote even where the cursor has since moved, which
3849    /// is a different question from the one asked here.
3850    fn cursor_cluster_col(&self) -> Option<usize> {
3851        let row = self.cursor.row;
3852        // The pinned case. The wide arm is the same cell reached from its spacer: a
3853        // pair that fills the row leaves the cursor on the trailing spacer, one past
3854        // the anchored lead.
3855        let col = if self.cursor.pending_wrap {
3856            self.cursor.col
3857        } else if self.cursor.col == 0 {
3858            return None;
3859        } else {
3860            self.cursor.col - 1
3861        };
3862        Some(if self.grid.cell(row, col).is_wide_spacer() {
3863            col.saturating_sub(1)
3864        } else {
3865            col
3866        })
3867    }
3868
3869    /// The text the cell at `(row, col)` holds, in print order: its base glyph followed
3870    /// by any combining marks the row's side table carries for it. One grapheme cluster
3871    /// by construction — it is what a single print produced.
3872    ///
3873    /// A `String` and not a `Vec<char>`, because both callers want text: returning scalars
3874    /// and collecting cost a second allocation and measured 2.06x on the join path
3875    /// (11.66 ms -> 24.06 ms over 20k joins, release, best of 7). `REP` iterates
3876    /// `.chars()` instead, which costs it nothing.
3877    ///
3878    /// **It is no longer on the per-scalar path.** Until #867 the mode-2027 join called this for
3879    /// every printed scalar, which is what made a growing cluster quadratic; the join now consults
3880    /// it only when a width can actually change, so this is O(L) once per cluster rather than once
3881    /// per join.
3882    fn cluster_text(&self, row: usize, col: usize) -> String {
3883        let mut out = String::new();
3884        out.push(self.grid.cell(row, col).c());
3885        if let Some(marks) = self.grid.row_ref(row).combining_at(col) {
3886            out.extend(marks.iter().copied());
3887        }
3888        out
3889    }
3890
3891    /// REP (CSI Ps b): repeat the preceding grapheme `count` times (#825). The caller
3892    /// owns the armed check — see the `'b'` arm of `csi_dispatch`, which holds both
3893    /// halves of the ordering this sequence needs.
3894    ///
3895    /// **The repeat re-enters the print path**, which is the load-bearing decision:
3896    /// printing already owns pending-wrap, autowrap, wide-character pairing, cluster
3897    /// promotion under mode 2027, the pen, insert mode and the scroll region, so a
3898    /// cell-fill would have to re-derive every one of them and would drift from typed
3899    /// text the first time any of them changed. ghostty does the same
3900    /// (`src/terminal/Terminal.zig:452-456`), and so does xterm.js.
3901    ///
3902    /// **What is repeated is the cluster at [`Term::repeat_anchor`], read off the cell.**
3903    /// A retained copy would have to be kept in step with the cell by hand at three
3904    /// arming sites and nothing would catch it drifting; the anchor keeps the *position*
3905    /// exact, which is the half a read-back can get wrong.
3906    ///
3907    /// **That the unit is the cluster is a product judgement, not a derivation**, and
3908    /// the three references give three answers. xterm repeats *nothing* after a
3909    /// combining mark — its retained value is a single `IChar`, so the mark replaces
3910    /// the base and the positive-width guard (`charproc.c:6154`) then rejects it.
3911    /// ghostty repeats the base *without* its marks: its cluster-append branch returns
3912    /// before `previous_char = c` (`Terminal.zig:1355-1365`). xterm.js repeats the
3913    /// whole cluster, reading it off the cell (`InputHandler.ts:1649-1671`). ADR-0004
3914    /// makes xterm the tie-breaker for *the spec*, and xterm's answer here follows from
3915    /// a scalar `lastchar` rather than from a reading of it — while a cell in this
3916    /// engine holds a cluster. Decided by the maintainer on 2026-09-07 and theirs to
3917    /// reverse; a better derivation does not settle it.
3918    ///
3919    /// # Two bounds, and only one of them is the load-bearing one
3920    ///
3921    /// The count is untrusted and the repeat is the only place in this engine where one
3922    /// wire parameter buys unbounded work, so it is bounded twice — for different
3923    /// reasons, and the *order of importance is the reverse of the order they read in*.
3924    ///
3925    /// **The progress guard is what actually bounds the pathological case.** An
3926    /// iteration that places no new cell has not repeated anything: under mode 2027 a
3927    /// cluster ending in `ZWJ` re-joins the cluster it was read from, so each replay
3928    /// grows one cell's cluster instead of writing a second one, and `try_grapheme_join`
3929    /// **was** O(L) in that cluster's length. Measured before the guard: `?2027h`,
3930    /// `U+1F468`, `U+200D`, then the eight bytes `CSI 65535 b` cost **593 seconds** —
3931    /// nine minutes and fifty-three seconds of one consumer thread, for eight bytes of
3932    /// PTY output. The guard ends the loop the first time an iteration leaves the anchor
3933    /// where it found it, which is exactly the condition "nothing was repeated".
3934    ///
3935    /// **#867 removed that O(L), and it did not retire this guard.** The join is now
3936    /// constant in the cluster's length, so the amplification that produced 593 seconds
3937    /// no longer exists — but a no-progress iteration is still a no-progress iteration,
3938    /// and without the guard `CSI 65535 b` would still run 65 535 of them to place
3939    /// nothing. The guard bounds pointless work; it never bounded the cost of a join.
3940    ///
3941    /// **The count cap is defence in depth and would not have caught that case**, which
3942    /// is why it is stated second. The cap is a whole buffer's worth of
3943    /// cells, and at the default 10 000-line scrollback that is 801 920 on an 80x24
3944    /// grid — larger than the 65535 a `u16` parameter can carry, so it never binds
3945    /// there. It binds on a small buffer, where a count far past what the buffer can
3946    /// hold only rewrites what the repeat already wrote. Shipping the cap alone would
3947    /// have looked like a fix for the measurement above and been none.
3948    ///
3949    /// Both are divergences from every reference: xterm (`charproc.c:6156`), xterm.js
3950    /// (`InputHandler.ts:1655`) and ghostty (`Terminal.zig:452-456`) all loop uncapped,
3951    /// and alacritty does not implement the sequence. The asymmetry that justifies them
3952    /// is that all three *are* the terminal and own the thread they burn, while this is
3953    /// a library running on a consumer's.
3954    fn repeat_last(&mut self, count: usize) {
3955        let Some((row, col)) = self.repeat_anchor else {
3956            return;
3957        };
3958        // Snapshot once: the repeats move the anchor, so re-reading it per iteration
3959        // would repeat the growing run rather than the grapheme.
3960        let cluster = self.cluster_text(row, col);
3961        let cap = (self.scrollback_limit + self.grid.rows()).saturating_mul(self.grid.cols());
3962        for _ in 0..count.min(cap.max(1)) {
3963            let before = self.repeat_anchor;
3964            for c in cluster.chars() {
3965                self.place_grapheme(c);
3966            }
3967            if self.repeat_anchor == before {
3968                break; // placed no new cell — see "the progress guard" above
3969            }
3970        }
3971    }
3972
3973    /// Attach a combining mark (width-0 code point) to the grapheme it modifies —
3974    /// the cell the cursor just left. With pending-wrap the cursor still sits on
3975    /// the just-written last-column glyph, so attach in place (no back-up, no
3976    /// deferred wrap); otherwise step back one column, and once more over a
3977    /// wide-char spacer to reach its lead. Stored in the grapheme side-table.
3978    fn push_combining(&mut self, c: char) -> (usize, usize) {
3979        let row = self.cursor.row;
3980        // `unwrap_or(0)`: a mark that opens the stream has no base and attaches to
3981        // column 0, which is what the `saturating_sub` here did before #825. The join
3982        // path declines that case instead; the two differ deliberately.
3983        let col = self.cursor_cluster_col().unwrap_or(0);
3984        // Append the mark to the row's combining map at this column (setting the
3985        // cell's combining bit). No global pool — the cluster rides the row.
3986        self.grid.row_mut(row).push_combining(col, c);
3987        self.damage_span(row, col, col);
3988        (row, col)
3989    }
3990
3991    /// Mode 2027 (#295): if `c` **extends** the previous cell's grapheme cluster (UAX #29), append
3992    /// it to that cell's side-table — no new cell, no cursor advance — and return where it joined.
3993    /// Otherwise `None`, so `place_grapheme` takes the per-scalar path (a break starts a cell).
3994    ///
3995    /// The break state lives in the cell rather than being carried across calls, so cursor moves
3996    /// and CR/LF cannot corrupt it — but the cluster is **not** reconstructed to ask the question
3997    /// (#867): [`crate::grapheme::joins_cluster`] reads a bounded tail of the side table, and the
3998    /// width oracle is consulted only when [`crate::grapheme::width_may_change`] says the answer
3999    /// can have moved. Together those make the join O(1) in the cluster's length, where it used to
4000    /// be O(L) three times over.
4001    fn try_grapheme_join(&mut self, c: char) -> Option<(usize, usize)> {
4002        let row = self.cursor.row;
4003        // Locate the previous cluster's base cell; `None` means nothing precedes it on
4004        // this row, so there is nothing to extend.
4005        let Some(col) = self.cursor_cluster_col() else {
4006            return None; // nothing precedes on this row
4007        };
4008        // The cell is read, never rebuilt into a string (#867): both the break question and the
4009        // width question are answered from the base scalar, the side table's length, and — only
4010        // when the width can actually move — the cluster text itself.
4011        let base = self.grid.cell(row, col).c();
4012        let base_is_wide = self.grid.cell(row, col).is_wide();
4013        let (joins, first_join) = {
4014            let marks = self.grid.row_ref(row).combining_at(col).unwrap_or(&[]);
4015            (
4016                crate::grapheme::joins_cluster(base, marks, c),
4017                marks.is_empty(),
4018            )
4019        };
4020        if !joins {
4021            return None;
4022        }
4023        // Width promotion: a flag's second regional indicator, or a text-base + VS16, grows the
4024        // cluster to width 2. `UnicodeWidthStr` over the whole cluster remains the authority for
4025        // that; `width_may_change` only decides whether it has to be asked, so a cluster that
4026        // keeps growing stops paying for an answer that cannot have moved. Read BEFORE the push,
4027        // because `cluster_text` reads the side table this join is about to extend.
4028        let cluster_w = if crate::grapheme::width_may_change(c, first_join, base_is_wide) {
4029            let mut prev = self.cluster_text(row, col);
4030            prev.push(c);
4031            UnicodeWidthStr::width(prev.as_str())
4032        } else if base_is_wide {
4033            2
4034        } else {
4035            1
4036        };
4037        // Join: ride the side-table (no new cell).
4038        self.grid.row_mut(row).push_combining(col, c);
4039        // Where the cluster ends up, which is not always where it was joined: a promotion at
4040        // the last column relocates it to the next row (#303), and the anchor has to follow or
4041        // it names a column `vacate_for_wrap` just blanked.
4042        let mut at = (row, col);
4043        if cluster_w == 2 && !self.grid.cell(row, col).is_wide() {
4044            at = self.promote_cluster_to_wide(row, col);
4045        } else if cluster_w == 1 && self.grid.cell(row, col).is_wide() {
4046            // The mirror case: a default-wide emoji + VS15 (text selector) shrinks to width 1.
4047            self.demote_cluster_to_narrow(row, col);
4048        }
4049        self.damage_span(row, col, col);
4050        Some(at)
4051    }
4052
4053    /// Shrink a wide cluster cell back to a single-width cell (#295): a default-wide emoji joined by
4054    /// VS15 (U+FE0E, the text selector) requests text presentation → width 1. Remove `WIDE_CHAR`,
4055    /// free the spacer, and back the cursor up over it (the inverse of `promote_cluster_to_wide`).
4056    fn demote_cluster_to_narrow(&mut self, row: usize, col: usize) {
4057        let cols = self.grid.cols();
4058        self.grid
4059            .cell_mut(row, col)
4060            .remove_flags(CellFlags::WIDE_CHAR);
4061        if col + 1 < cols {
4062            self.free_cell(row, col + 1); // free the now-unused spacer
4063        }
4064        // The cluster shrank 2→1: the cursor sat just past the wide cell (col+2, or pending-wrap on
4065        // the last column); it now sits just past the single-width cell at col+1.
4066        self.cursor.pending_wrap = false;
4067        self.cursor.col = (col + 1).min(cols - 1);
4068        self.damage_span(row, col, (col + 1).min(cols - 1));
4069    }
4070
4071    /// Widen a narrow base cell to a double-width cluster in place (#295): set `WIDE_CHAR`, write
4072    /// its spacer, and step the cursor over it. Only reached when a joining scalar (flag's 2nd RI,
4073    /// VS16) promotes the cluster to width 2. A base pinned at the last column has no room for a
4074    /// spacer — relocation is a later step; until then it stays narrow (rare, renders single-width).
4075    fn promote_cluster_to_wide(&mut self, row: usize, col: usize) -> (usize, usize) {
4076        let cols = self.grid.cols();
4077        if col + 1 >= cols {
4078            // No spacer room at the last column: relocate the whole cluster to the next line as a
4079            // wide cell (the row soft-wraps), mirroring write_glyph's wide-at-boundary wrap (#303).
4080            return self.relocate_cluster_wide(row, col);
4081        }
4082        // Overwriting col+1 with the spacer can orphan the far half of a WIDE glyph standing there
4083        // (the cursor may have been repositioned before the joining scalar arrived). Reset that
4084        // orphan, exactly as write_glyph does (2462-2470), so no dangling spacer survives.
4085        if self.grid.cell(row, col + 1).is_wide() && col + 2 < cols {
4086            self.free_cell(row, col + 2);
4087        }
4088        self.grid
4089            .cell_mut(row, col)
4090            .insert_flags(CellFlags::WIDE_CHAR);
4091        // The spacer is the lead's second half, so it takes the LEAD's extended attrs — the
4092        // hyperlink and underline colour riding the row's side maps — exactly as write_glyph
4093        // stamps both halves of a wide write. `pen.cell(' ')` carries neither (and the pen may
4094        // have moved on since the base was printed), so they are re-attached here; a base with
4095        // none clears whatever the overwritten column held (#521).
4096        let ext = self.grid.row_ref(row).ext_attrs_at(col);
4097        // The underline STYLE needs the same treatment as those extended attrs and for the same
4098        // reason this comment already gives (#829): it is the LEAD's, and the pen may have moved
4099        // on. It rides the packed cell rather than a side map, so it is carried across directly
4100        // instead of through `set_ext_attrs`. ADR-0025 D4 — a path that *synthesises* one half of
4101        // a pair carries the whole pair; taking this from the pen curls the left half and leaves
4102        // the right half bare.
4103        let lead_style = self.grid.cell(row, col).underline_style();
4104        let mut spacer = self.cursor.pen.cell(' ');
4105        spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
4106        spacer.set_underline_style(lead_style);
4107        *self.grid.cell_mut(row, col + 1) = spacer;
4108        self.grid.row_mut(row).set_ext_attrs(col + 1, ext);
4109        // The cursor sat at col+1 (just past the narrow base); move it over the new spacer, applying
4110        // the same last-column pending-wrap rule as a wide write.
4111        let new_col = col + 2;
4112        if new_col >= cols {
4113            self.cursor.col = cols - 1;
4114            self.cursor.pending_wrap = true;
4115        } else {
4116            self.cursor.col = new_col;
4117        }
4118        self.damage_span(row, col, col + 1);
4119        // Promoted in place: the lead did not move.
4120        (row, col)
4121    }
4122
4123    /// Relocate a last-column narrow cluster to the next line as a wide cell (#303): its base +
4124    /// side-table marks move to `(next_row, 0..=1)` and the vacated last column becomes a soft-wrap
4125    /// (WRAPLINE + leading spacer), exactly as `write_glyph` wraps a wide glyph that can't fit. With
4126    /// autowrap off it stays narrow.
4127    ///
4128    /// The destination is an **overwrite**, so it owes the no-orphan repair every other overwrite
4129    /// site owes (#529, ADR-0025 D4) — see the comment at that site for why justerm restates it
4130    /// once per wide-writing path where the references get it structurally.
4131    ///
4132    /// The `cols < 2` arm is **unreachable since #547** —
4133    /// `MIN_COLUMNS = 2` is the floor on every path that sets a width — and is kept only as a
4134    /// bounds guard for the `col + 1` writes below, not as a described behaviour.
4135    fn relocate_cluster_wide(&mut self, row: usize, col: usize) -> (usize, usize) {
4136        let cols = self.grid.cols();
4137        if cols < 2 || !self.autowrap || !self.wrapline_advances() {
4138            // Nowhere to place a wide cell — leave it narrow. `!wrapline_advances()` joins the
4139            // other two for the same reason: with no next row, the relocation would write the
4140            // cluster over columns 0-1 of the *current* row and destroy whatever is there.
4141            return (row, col);
4142        }
4143        // Capture the base cell (glyph + attrs), its marks, and its extended attrs before
4144        // vacating. The extended attrs (hyperlink, underline colour) must be read HERE and not
4145        // after the move: they live in the *source row's* side maps, and `wrapline()` below may
4146        // scroll — after which that row is a different (or recycled) `Row` (#521).
4147        let base = *self.grid.cell(row, col);
4148        let marks: Vec<char> = self
4149            .combining_at(row, col)
4150            .map(<[char]>::to_vec)
4151            .unwrap_or_default();
4152        let ext = self.grid.row_ref(row).ext_attrs_at(col);
4153        // Vacate the last column as a soft-wrap artefact — the same step `write_glyph` takes for a
4154        // wide glyph that cannot fit, and now literally the same code, so the two cannot drift
4155        // apart again (#528; they held opposite behaviours until then).
4156        self.vacate_for_wrap(row, col);
4157        // Advance to the next line (scrolls if at the bottom); cursor lands at col 0.
4158        self.wrapline();
4159        let nr = self.cursor.row;
4160        // The destination is an overwrite like any other, so it owes the same no-orphan repair
4161        // `write_glyph` performs for its own trailing column (#529, D4): the spacer about to land
4162        // on `(nr, 1)` half-destroys a wide glyph standing there, stranding its far half at
4163        // `(nr, 2)` — a `WIDE_CHAR_SPACER` with no lead to its left, still carrying the destroyed
4164        // glyph's hyperlink and underline colour. Asked *before* the writes, on the pre-write
4165        // state, exactly as `write_glyph`'s `last + 1` check is.
4166        //
4167        // Two of the three references have this exact site, and both repair it without a rule of
4168        // their own, because they write a pair as two *separate* cell writes and the repair lives
4169        // in the write:
4170        //   - xterm.js names the case outright — *"Combining character widens 1 column to 2. Move
4171        //     old character to next line."* (`InputHandler.ts:583-611` @ 699f553,
4172        //     `copyCellsFrom(oldRow, oldCol, 0, oldWidth, false)` at `:605-607`). The relocation
4173        //     leaves `x == 2`, so its once-per-run right-edge repair (`:668-669`) lands on exactly
4174        //     the orphaned column.
4175        //   - ghostty relocates in `Terminal.zig:1188-1252` @ e6e26e1 and reaches the repair
4176        //     through `cursorRight(1); printCell(0, .spacer_tail)` (`:1251-1252`) — that second
4177        //     `printCell` runs the `cell.wide != wide` switch (`:1484`) whose `.wide` arm clears
4178        //     the neighbouring lead's tail (`:1489-1499`).
4179        //   - alacritty has **no** counterpart: a width-0 codepoint returns early through
4180        //     `push_zerowidth` (`term/mod.rs:1069-1085` @ 852e971), so a cluster never changes
4181        //     width and nothing is ever relocated. Its orphan repair (`:994-1008`) is still the
4182        //     mechanism reference, reached the same way — one repair per `write_at_cursor`.
4183        // justerm writes both halves in one step, so the repair is not structural here and each
4184        // wide-writing path restates it — this is the third (`write_glyph`,
4185        // `promote_cluster_to_wide`, and now the relocation).
4186        //
4187        // What justerm does **not** copy is ghostty's reach-back at this site: its `.wide` arm
4188        // also clears the previous row's `.spacer_head` (`:1504-1506`, gated `cursor.y > 0 and
4189        // cursor.x <= 1`) — the very marker this relocation set seven statements earlier
4190        // (`:1200`). Derived from source, not executed. Suppressing it here is #534's rule
4191        // verbatim: a repair keyed on a state predicate must not fire while that state is
4192        // mid-construction.
4193        //
4194        // The other two obligations `write_glyph` carries are N/A here, recorded because an
4195        // unexplained omission is what gets re-litigated:
4196        //   - the *left*-orphan repair asks `col > 0`, and the lead lands at column 0.
4197        //   - `void_wrap_artefact_above(nr)` would clear a record that `vacate_for_wrap` **just
4198        //     set**, in both the advance case (`nr == row + 1`, so its target `nr - 1` is `row`)
4199        //     and the scroll case (`nr == row`, the source rotated up to `row - 1`). Firing it
4200        //     would be self-clobbering, not merely redundant — the same shape as #534's
4201        //     mid-construction rule. Measured after a repairing relocation: `is_row_wrapped(0)`
4202        //     and `(0, cols-1).is_leading_spacer()` both hold.
4203        //
4204        // `2 < cols` is a live bound, not defence in depth. The print paths cannot leave a
4205        // `WIDE_CHAR` lead in the last column — `write_glyph` wraps rather than write one there
4206        // and `promote_cluster_to_wide` relocates rather than promote in place — but `Row::resize`
4207        // can: the alt screen resizes without reflowing (#567), so truncating a row through a pair
4208        // strands its lead in the final column. The relocation then meets `is_wide() == true` at
4209        // `cols == 2`, and without the bound reads `(nr, 2)` on a two-column grid — an
4210        // out-of-bounds panic in a library, inside a consumer's process, reachable by shrinking a
4211        // window over a CJK glyph. Pinned by `min_columns.rs::
4212        // a_relocation_beside_a_truncated_wide_lead_does_not_index_past_the_row`.
4213        if 2 < cols && self.grid.cell(nr, 1).is_wide() {
4214            self.free_cell(nr, 2);
4215        }
4216        // Re-place the base as a wide lead + spacer, re-attaching the marks fresh (drop the combining
4217        // bit so push_combining starts a clean cluster at the new column).
4218        let mut lead = base;
4219        lead.set_combined(false);
4220        lead.insert_flags(CellFlags::WIDE_CHAR);
4221        *self.grid.cell_mut(nr, 0) = lead;
4222        for m in marks {
4223            self.grid.row_mut(nr).push_combining(0, m);
4224        }
4225        // Re-attach the extended attrs to BOTH halves at the new row. `lead` copied the base's
4226        // presence bits but not its map entries, so without this the bit is set with nothing
4227        // behind it — the read is gated and silently returns the default, and the frame stops
4228        // round-tripping (the cell encodes as linked with no index).
4229        self.grid.row_mut(nr).set_ext_attrs(0, ext.clone());
4230        // …and the underline style from the relocated LEAD, for the reason the sibling site above
4231        // states (#829, ADR-0025 D4).
4232        let lead_style = self.grid.cell(nr, 0).underline_style();
4233        let mut spacer = self.cursor.pen.cell(' ');
4234        spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
4235        spacer.set_underline_style(lead_style);
4236        *self.grid.cell_mut(nr, 1) = spacer;
4237        self.grid.row_mut(nr).set_ext_attrs(1, ext);
4238        // Cursor just past the wide cell (pending-wrap if it fills a 2-column row).
4239        if cols <= 2 {
4240            self.cursor.col = cols - 1;
4241            self.cursor.pending_wrap = true;
4242        } else {
4243            self.cursor.col = 2;
4244            self.cursor.pending_wrap = false;
4245        }
4246        self.damage_span(nr, 0, 1);
4247        // The cluster's new home. Callers anchor on this, not on the vacated column.
4248        (nr, 0)
4249    }
4250
4251    // ---- cursor movement (CSI A/B/C/D/E/F/G/d/e/H/f) -------------------------
4252
4253    /// Up `n` rows — CUU, and through it VT52 `ESC A` and CPL — stopping at the top
4254    /// margin when the cursor is at or below it and at the screen top otherwise.
4255    fn move_up(&mut self, n: usize) {
4256        let floor = if self.cursor.row >= self.scroll_top {
4257            self.scroll_top
4258        } else {
4259            0
4260        };
4261        self.cursor.row = self.cursor.row.saturating_sub(n).max(floor);
4262        self.cursor.pending_wrap = false;
4263    }
4264
4265    /// Down `n` rows — CUD, and through it VT52 `ESC B` and CNL — stopping at the
4266    /// bottom margin when the cursor is at or above it and at the screen bottom otherwise.
4267    fn move_down(&mut self, n: usize) {
4268        let ceiling = if self.cursor.row <= self.scroll_bottom {
4269            self.scroll_bottom
4270        } else {
4271            self.grid.rows() - 1
4272        };
4273        self.cursor.row = (self.cursor.row + n).min(ceiling);
4274        self.cursor.pending_wrap = false;
4275    }
4276
4277    fn move_forward(&mut self, n: usize) {
4278        self.cursor.col = (self.cursor.col + n).min(self.grid.cols() - 1);
4279        self.cursor.pending_wrap = false;
4280    }
4281
4282    fn move_back(&mut self, n: usize) {
4283        // Under `?45` this is n applications of the step `BS` takes — xterm's shape
4284        // literally, where one `CursorBack` serves both verbs and its loop spends one unit
4285        // of the count per step (`cursor.c:160-190`), so `CSI 3 D` from a park moves two
4286        // and a walk at column 0 costs one of the three (#873). Off the mode there is
4287        // neither a walk nor a spend to distribute, so the whole move is one saturating
4288        // subtraction — the same landing without the loop.
4289        if self.reverse_wraparound {
4290            for _ in 0..n {
4291                self.step_back();
4292            }
4293        } else {
4294            self.cursor.col = self.cursor.col.saturating_sub(n);
4295        }
4296        // Both call sites pass at least 1 — `param_or` maps an explicit `CSI 0 D` to the
4297        // default — so the loop always runs and always puts the flag down. Cleared here
4298        // anyway rather than relied upon: a zero count must still clear, as every other
4299        // positioning verb does, and the loop is the only shape in this file where that
4300        // obligation can be skipped by arithmetic.
4301        self.cursor.pending_wrap = false;
4302    }
4303
4304    fn set_col(&mut self, col: usize) {
4305        self.cursor.col = col.min(self.grid.cols() - 1);
4306        self.cursor.pending_wrap = false;
4307    }
4308
4309    fn set_row(&mut self, row: usize) {
4310        self.cursor.row = row.min(self.grid.rows() - 1);
4311        self.cursor.pending_wrap = false;
4312    }
4313
4314    fn goto(&mut self, row: usize, col: usize) {
4315        let (offset, max_row) = self.addressable_rows();
4316        self.cursor.row = (row + offset).min(max_row);
4317        self.cursor.col = col.min(self.grid.cols() - 1);
4318        self.cursor.pending_wrap = false;
4319    }
4320
4321    /// The row addressing origin and the last addressable row. Origin mode
4322    /// addresses rows relative to the scroll region's top margin and clamps to its
4323    /// bottom; otherwise rows are absolute to the screen.
4324    fn addressable_rows(&self) -> (usize, usize) {
4325        if self.origin_mode {
4326            (self.scroll_top, self.scroll_bottom)
4327        } else {
4328            (0, self.grid.rows() - 1)
4329        }
4330    }
4331
4332    /// VPR (CSI Ps e): the current row plus `n`, positioned as CUP positions a row
4333    /// — bounded by the last addressable row, not by the scroll margin CUD stops at.
4334    fn vertical_position_relative(&mut self, n: usize) {
4335        let (_, max_row) = self.addressable_rows();
4336        self.cursor.row = (self.cursor.row + n).min(max_row);
4337        self.cursor.pending_wrap = false;
4338    }
4339
4340    // ---- erase (CSI J / K) ---------------------------------------------------
4341
4342    /// Clear cells `from..to` on `row`.
4343    ///
4344    /// Background Color Erase (BCE): erased cells carry the current SGR
4345    /// background only — fg and text attributes reset to default (matches
4346    /// xterm/alacritty, where the fill is `cursor.template.bg.into()`).
4347    ///
4348    /// **Cleared concern, with its validity condition — an empty range would break the pair
4349    /// invariant.** With `from == to` the first guard below still frees the lead at `from - 1`
4350    /// while the second is skipped (`to > from` is false) and the fill loop does nothing, so the
4351    /// spacer at `from` would survive its lead — an ADR-0025 D4 break, and the exact lead-less
4352    /// orphan the word walk must then treat as opaque. This is unreachable **as long as every
4353    /// caller passes a non-empty range**, which holds today: `ECH` clamps to
4354    /// `(col + n).min(cols)` with `n >= 1` (both `CSI X` and `CSI 0 X` erase one cell), and every
4355    /// `EL`/`ED` site passes `0..cols` or `0..=cursor`. A future caller that can pass an empty
4356    /// range must guard here first.
4357    fn clear_cells(&mut self, row: usize, from: usize, to: usize) {
4358        let cols = self.grid.cols();
4359        // Erasing either half of a pair that wrapped from the row above ends it, so that row's
4360        // artefact record is void (#534). `from <= 1` rather than `from == 0` because erasing from
4361        // column 1 destroys the spacer and the no-orphan repair below then frees the lead. ghostty
4362        // reaches the same row from its erase path — `Screen.splitCellBoundary`'s `x == 0 or x ==
4363        // 1` branch (`Screen.zig:1873` @ `e6e26e1`), called from `eraseChars` (`Terminal.zig:3159`).
4364        if from <= 1 && to > from && self.wrapped_pair_at_row_start(row) {
4365            self.void_wrap_artefact_above(row);
4366        }
4367        // Don't orphan a wide char straddling the erase boundary.
4368        if from > 0 && self.grid.cell(row, from).is_wide_spacer() {
4369            self.free_cell(row, from - 1);
4370        }
4371        if to > from && to < cols && self.grid.cell(row, to - 1).is_wide() {
4372            self.free_cell(row, to);
4373        }
4374
4375        let bg = self.cursor.pen.bg;
4376        for col in from..to {
4377            let cell = self.grid.cell_mut(row, col);
4378            cell.reset();
4379            cell.set_bg(bg);
4380        }
4381        // `reset` cleared the presence bits; this releases what they gated (#628).
4382        self.grid.row_mut(row).purge_side_maps(from..to);
4383        if to > from {
4384            self.damage_span(row, from, to - 1);
4385        }
4386    }
4387
4388    /// End `row`'s soft wrap, because something just destroyed the content that was continuing
4389    /// onto the next row.
4390    ///
4391    /// Which verbs owe this is **not** derivable from the erased range — it is a per-verb rule,
4392    /// and both references spell it out call site by call site rather than inferring it:
4393    ///
4394    /// | verb | ends the wrap? | xterm | ghostty |
4395    /// |---|---|---|---|
4396    /// | `EL 0` (erase right) | **yes**, at any column | `ClearRight` → `LineClrWrapped` unconditionally (`util.c:1871`) | `cursorResetWrap()` in `eraseLine(.right)` |
4397    /// | `ECH` | **yes**, at any column | same `ClearRight` (`util.c:1961`) | `cursorResetWrap()` in `eraseChars` |
4398    /// | `DCH` | **yes** | `screen.c` | `cursorResetWrap()` — *"Our row's soft-wrap is always reset"* |
4399    /// | `EL 1` (erase left) | no | `ClearLeft`, no clear | no |
4400    /// | `ICH` | no | no | no |
4401    /// | a reverse-wrap walk (`BS` / `CSI D` under `?45`) | **no** (#873) | `CursorBack` writes no wrap flag | only *reads* `prev_row.wrap` (`Terminal.zig:1842-1843`) |
4402    ///
4403    /// The last row is the one that was wrong. The walk **did** clear the flag — copied from
4404    /// xterm.js's `line.isWrapped = false` (`InputHandler.ts:823`), which neither other
4405    /// reference does — and it did so by writing the row directly, so it never appeared in
4406    /// this table and never took the damage obligation below. Three consequences, all
4407    /// measured: two buffers with identical cells read as different logical lines depending
4408    /// on how the cursor arrived, a reflow kept them apart instead of healing it, and the
4409    /// clear's whole damage was `Partial([])` where `EL 0` through this function reports
4410    /// `Partial([LineDamage { line: 0, left: 0, right: 2 }])`, so a frame-mode consumer was
4411    /// left joined where the engine had split.
4412    /// Undoing the *cursor's* trip across the boundary does not undo the boundary.
4413    ///
4414    /// The shape behind the three that do: each destroys content **from the cursor rightward**, so
4415    /// "this row continues past its last column" can no longer be asserted. Erasing leftward or
4416    /// inserting blanks leaves the tail — and whatever it flowed into — intact.
4417    ///
4418    /// **`EL 2` is a deliberate divergence.** justerm ends the wrap; xterm does not (`ClearLine`,
4419    /// `util.c:1905`, has no `LineClrWrapped`) and ghostty copies that with a comment naming it —
4420    /// *"it seems like complete should reset the soft-wrap state of the line but in xterm it does
4421    /// not."* justerm differs because it *joins* logical lines for `accessible_text` / `search` /
4422    /// selection text, so a blanked-but-still-wrapped row visibly merges two lines in copy — a
4423    /// consequence xterm does not carry. Recorded rather than silently matched or silently
4424    /// Mark `row` as soft-wrapping into the next one — and damage the cell the bit rides on.
4425    ///
4426    /// The exact mirror of [`Term::end_wrap`], and it exists for the mirror of that function's
4427    /// reason. The flag lives on the `Row` (#538) and reaches a consumer only as the last cell's
4428    /// `WRAPLINE`, derived at encode time. Every other cell-carried fact changes when that cell is
4429    /// written, so damage covers it for free; this one does not, and a `Partial` frame would never
4430    /// ship the bit — a frame-mode consumer rebuilding logical lines from cells then keeps the two
4431    /// rows *split* forever, the exact dual of the "joined forever" that `end_wrap` guards.
4432    ///
4433    /// `end_wrap` took that obligation in #540; the set side never did. It stayed invisible because
4434    /// a wrap normally moves the cursor to the next row, and `frame_damage` tops the frame up with
4435    /// the old cursor cell. When a **scroll serves the wrap** the cursor keeps its row index, so
4436    /// nothing tops it up — which is how #557 surfaced it.
4437    ///
4438    /// Damaging here rather than at each caller is what keeps this true for set sites added later,
4439    /// the same argument `end_wrap`'s comment makes.
4440    fn begin_wrap(&mut self, row: usize) {
4441        self.grid.row_mut(row).set_wrapped(true);
4442        let last = self.grid.cols() - 1;
4443        self.damage_span(row, last, last);
4444    }
4445
4446    /// diverged; see #538.
4447    fn end_wrap(&mut self, row: usize) {
4448        self.grid.row_mut(row).set_wrapped(false);
4449        // The flag is stored on the `Row` but rides the wire on the row's **last cell**, derived
4450        // at encode time. Every other cell-carried fact changes only when that cell is written,
4451        // so damage covers it for free; this one does not, and a `Partial` frame would never
4452        // re-ship the bit — leaving a frame-mode consumer with two rows joined forever. Damaging
4453        // here rather than at each caller is what keeps that true for call sites added later.
4454        let last = self.grid.cols() - 1;
4455        self.damage_span(row, last, last);
4456        // The wrap artefact goes with the wrap. The marker's claim is "the last column is the
4457        // blank a width-2 glyph vacated **because this row continues onto the next**", so a row
4458        // that stops continuing cannot hold one (ADR-0025 D3 — position is part of the test, and
4459        // so is the wrap it is positioned in). Coupling the two here is what makes the row-shift
4460        // seams and every wrap-ending erase a single rule instead of a clear per verb: ghostty
4461        // couples them in one function the same way — `Screen.cursorResetWrap`
4462        // (`terminal/Screen.zig:1524` @ `e6e26e1`, spacer-head clear at `:1539-1545`), reached from
4463        // `deleteChars` / `eraseChars` / `eraseLine`. It early-returns on `if (!page_row.wrap)`;
4464        // this one clears unconditionally, which is strictly safer.
4465        //
4466        // Most callers erase through this column anyway, so the clear is redundant for them; the
4467        // ones it is *not* redundant for are the row-shift seams (#540's `shift_region`, which
4468        // ends a wrap without touching a cell) and `delete_chars`, whose marker rides the shift.
4469        // The leftward erases are the mirror case — they blank this column while the wrap
4470        // legitimately survives — and go through `drop_artefact_if_erased` instead.
4471        //
4472        // One wrap-ending path deliberately does *not* reach here: `shift_region`'s `top == 0`
4473        // seam, whose row is in scrollback rather than the grid. It couples the same two clears
4474        // inline; see the comment there.
4475        self.grid.cell_mut(row, last).clear_leading_spacer();
4476    }
4477
4478    /// The pair that wrapped into `row` is about to be destroyed or moved, so the artefact record
4479    /// on the row **above** it is void — drop it. **Call before the mutation.**
4480    ///
4481    /// The marker makes a claim with two clauses: this row soft-wraps (owned by `end_wrap`), and
4482    /// its last column is the blank *that specific pair* vacated. This is the second clause, and
4483    /// the rule behind every call site is one sentence: **the record survives only an in-place
4484    /// same-width overwrite.** Anything else that reaches columns 0/1 of the continuation — a
4485    /// narrow write, an erase, a shift in either direction — ends the pair the record was about,
4486    /// and a wide lead that arrives afterwards by some other route did not *wrap* from anywhere.
4487    ///
4488    /// Both references gate on that, and both gate on the state **before** the write rather than
4489    /// after it:
4490    ///
4491    /// - ghostty `Terminal.zig:1484` @ `e6e26e1` — the whole wide-repair `switch` sits under
4492    ///   `if (cell.wide != wide)`, so a wide glyph overwritten by another wide glyph skips it; the
4493    ///   reach-back stanza then appears in the `.wide` (`:1501-1506`) and `.spacer_tail`
4494    ///   (`:1529-1532`) arms only.
4495    /// - alacritty `term/mod.rs:994` @ `852e971` — the reach-back at `:1004-1008` is inside
4496    ///   `if cursor_cell.flags.intersects(WIDE_CHAR | WIDE_CHAR_SPACER)`, but with no
4497    ///   width-unchanged escape, so it drops a record that is still true. Alacritty is the outlier
4498    ///   of the two and justerm follows ghostty.
4499    ///
4500    /// Asking *after* the mutation instead looks equivalent and is not: it answers "is some wide
4501    /// lead standing at column 0", which a `DCH` that pulls the *next* wide glyph left also
4502    /// satisfies, and which a two-step placement (a narrow base promoted to wide by VS16 under
4503    /// mode 2027, or IRM's insert-then-write) satisfies only at the end. Both were measured
4504    /// disagreeing with the rule above before this took its current form.
4505    ///
4506    /// The erase and intra-row-shift call sites are **ported, not derived**: ghostty's
4507    /// `Screen.splitCellBoundary` (`Screen.zig:1831`, the `x == 0 or x == 1` branch at `:1873`)
4508    /// reaches up one row and clears the previous row's spacer head, and it is called from
4509    /// `deleteChars` (`Terminal.zig:3107-3109`) and `eraseChars` (`:3159-3160`). Only justerm's
4510    /// `ICH` site has no counterpart — ghostty's `insertBlanks` (`:2988`) calls it nowhere.
4511    ///
4512    /// `row == 0` does not mean "no row above": on the primary screen the text readers walk
4513    /// `[scrollback ++ grid]` as one buffer (`abs_floor() == 0`), so the row above grid row 0 is
4514    /// the last **scrollback** row and it can carry the marker. Alacritty reaches the same row for
4515    /// the same reason — its `topmost_line()` is `Line(-history_size)` (`grid/mod.rs:504`), so
4516    /// `point.line - 1` indexes into history; ghostty is the one that stops at the viewport
4517    /// (`cursor.y > 0`). On the alt screen `abs_floor()` is the screen top, so no join crosses the
4518    /// boundary and there is nothing to repair.
4519    ///
4520    /// No damage is owed by either branch, and for a stronger reason than #540's: the marker is a
4521    /// `content` bit outside `CONTENT_MARKER_MASK`, so `Cell::flags()` never sees it and it does
4522    /// not cross the wire at all. The `damage_span` below is defensive, not load-bearing.
4523    fn void_wrap_artefact_above(&mut self, row: usize) {
4524        if row > 0 {
4525            let last = self.grid.cols() - 1;
4526            if self.grid.cell(row - 1, last).is_leading_spacer() {
4527                self.grid.cell_mut(row - 1, last).clear_leading_spacer();
4528                self.damage_span(row - 1, last, last);
4529            }
4530        } else if !self.on_alt
4531            && let Some(cell) = self.scrollback.back_mut().and_then(|r| r.last_mut())
4532        {
4533            cell.clear_leading_spacer();
4534        }
4535    }
4536
4537    /// Is a wide pair standing at columns 0..=1 of `row` — i.e. is there a record for
4538    /// `void_wrap_artefact_above` to void? A cheap pre-mutation test the four call sites share, so
4539    /// the rule lives in one place rather than being re-derived per verb (ADR-0025 D2).
4540    fn wrapped_pair_at_row_start(&self, row: usize) -> bool {
4541        self.grid.cell(row, 0).is_wide()
4542    }
4543
4544    /// Drop a wide-wrap artefact marker that has outlived the wrap it belonged to, without
4545    /// touching the wrap itself.
4546    ///
4547    /// The mirror of the marker clean-up inside `end_wrap`, for the verbs that erase *leftward*:
4548    /// `EL 1` and `ED 1` correctly leave the wrap alone (the row's tail still flows onward), but
4549    /// they can still clear the last column, and then the artefact's blank turns into visible
4550    /// text that a reflow bakes in permanently. Only the marker goes; the wrap is the caller's
4551    /// business.
4552    fn drop_artefact_if_erased(&mut self, row: usize, from: usize, to: usize) {
4553        let last = self.grid.cols() - 1;
4554        if from <= last && to > last {
4555            self.grid.cell_mut(row, last).clear_leading_spacer();
4556        }
4557    }
4558
4559    /// Shift `[top..=bottom]` by one line — up unless `down` — and end the wraps the shift
4560    /// falsified. Every row-shifting verb (IL/DL/SU/SD and the region paths in LF/RI) goes
4561    /// through here so the repair cannot be forgotten at a call site (ADR-0025 D2).
4562    ///
4563    /// The wrap flag claims "this row continues into the **next** row", so it is a statement about
4564    /// *adjacency*, and rotating whole `Row`s keeps it true for free: both halves of a pair inside
4565    /// the region move by the same line, so the claim still describes the same neighbour. Only the
4566    /// two seams falsify it, where a row's next neighbour changed underneath it:
4567    ///
4568    /// - **`top - 1`**, just outside the region. Its continuation rotated away (up-shift) or was
4569    ///   pushed down (down-shift), so whatever now sits at `top` is a stranger. This is the seam
4570    ///   that merges two unrelated logical lines in copy/search/accessible text (#540's repro).
4571    /// - **the row that lost its continuation to the blank** — `bottom - 1` after an up-shift (the
4572    ///   blank lands at `bottom`), `bottom` after a down-shift (its continuation rotated up to
4573    ///   `top` and was blanked there). The down-shift form is the one that reaches *outside* the
4574    ///   region: the stale claim points at `bottom + 1`, a row the verb never touched.
4575    ///
4576    /// Damaging matters as much as clearing, and `end_wrap` does both: `top - 1` is outside the
4577    /// region, so the scroll op the caller records does not cover it and a `Partial` frame would
4578    /// never re-ship the derived `WRAPLINE` bit.
4579    ///
4580    /// **Each seam has exactly one exemption, and both are facts about the caller that this
4581    /// function cannot see** — which is why they are parameters rather than tests:
4582    ///
4583    /// - `evicts_to_scrollback` exempts the **top** seam: a linefeed pushes row 0 into scrollback,
4584    ///   so the readers' `[scrollback ++ grid]` walk finds the continuation one row further back
4585    ///   and adjacency survives.
4586    /// - `serves_wrap` exempts the **bottom** seam: the shift was asked for by `wrapline`, so the
4587    ///   blank it exposes at `bottom` is not a stranger that displaced a continuation — it *is*
4588    ///   the continuation, about to be written into (#557).
4589    ///
4590    /// Both are one-sided on purpose. A wrap-serving scroll still falsifies the top seam, and a
4591    /// scrollback-evicting linefeed still falsifies the bottom one when no wrap asked for it.
4592    ///
4593    /// **No reference implements this rule**, so it is derived rather than ported — ADR-0004, the
4594    /// spec is the authority for VT semantics, above any implementation:
4595    ///
4596    /// - **ghostty** clears the wrap on *every* row a full-width IL/DL touches
4597    ///   (`terminal/Terminal.zig:2746-2752`, `:2906-2912` @ `e6e26e1`). The clear runs *before* the
4598    ///   row swap at `:2936-2939`, so both ends stay false: an interior pair is split, not
4599    ///   preserved. It still never reaches the row above the shifted range.
4600    /// - **alacritty** has no `WRAPLINE` clear on any scroll path (@ `852e971`).
4601    /// - **xterm.js** splices whole line objects and never touches `isWrapped`
4602    ///   (`common/InputHandler.ts:1345-1402` @ `699f553`). Its opposite polarity — "I continue the
4603    ///   *previous* row" (`common/buffer/Buffer.ts:566-570`) — moves the exposure to the mirrored
4604    ///   seam rather than removing it: a spliced-in line keeps a continuation claim about a
4605    ///   predecessor it never met.
4606    ///
4607    /// The seam row's wide-wrap *marker* is the same shift's other half, and it now rides along:
4608    /// `end_wrap` clears both (#534), and the `top == 0` branch below — the one seam whose row is
4609    /// not a grid row — couples them inline for the same reason.
4610    ///
4611    /// **Validity condition for clearing at the seams rather than everywhere.** ghostty clears the
4612    /// wrap and the spacer head on *every* row a full-width IL/DL touches, and its own comment
4613    /// gives two reasons: it splits interior pairs, **and** it supports left/right margins
4614    /// (DECSLRM), where a partial-row shift can break an interior pair without moving its
4615    /// neighbour. justerm rotates whole `Row`s and implements no DECSLRM, so an interior pair and
4616    /// its continuation always move together and seam-only is sound. If left/right margins ever
4617    /// land, this rule and #534's marker rule break at the same time — neither is safe under a
4618    /// shift that moves part of a row.
4619    fn shift_region(
4620        &mut self,
4621        top: usize,
4622        bottom: usize,
4623        down: bool,
4624        evicts_to_scrollback: bool,
4625        serves_wrap: bool,
4626    ) {
4627        if down {
4628            self.grid.scroll_down_region(top, bottom);
4629        } else {
4630            self.grid.scroll_up_region(top, bottom);
4631        }
4632        // Recording the scroll op is part of shifting, not a step a caller adds after: damage is
4633        // indexed by row position, so `record_scroll` rotates `line_damage` with the content. A
4634        // seam clear damaged *before* that rotation is carried to the wrong row — and on a
4635        // down-shift it lands on `top`, which `record_scroll` immediately overwrites with
4636        // `fully_damaged`. The clear then never reaches the wire at all: the model splits the
4637        // rows, a `Partial` frame does not say so, and the consumer keeps them joined forever.
4638        // Ordering it here is what makes that unrepeatable at a sixth call site.
4639        self.record_scroll(top, bottom, if down { -1 } else { 1 });
4640        if top > 0 {
4641            self.end_wrap(top - 1);
4642        } else if !evicts_to_scrollback && !self.on_alt {
4643            // `top == 0` does not mean "no row above": on the primary the text readers walk
4644            // `[scrollback ++ grid]` as one buffer (`abs_floor() == 0`), so the row above grid row
4645            // 0 is the last *scrollback* row and it can wrap into the screen. A full-screen SU /
4646            // DL / RI therefore leaves this issue's defect one row higher, outside the grid.
4647            //
4648            // `evicts_to_scrollback` is what keeps `linefeed` out: it pushes grid row 0 into
4649            // scrollback, so the continuation is re-attached one row further back and the claim
4650            // stays true — clearing there would split a line the scroll preserved. On the alt
4651            // screen `abs_floor()` is the screen top, so no join crosses the boundary at all.
4652            //
4653            // No damage is owed with the clear, unlike `end_wrap`'s grid form: a scrollback row
4654            // only reaches the wire while `display_offset > 0`, and there `damage()` returns an
4655            // empty `Partial` (`term.rs`, the frozen-viewport short-circuit) while any scroll that
4656            // *moves* the viewport marks full damage. Valid as long as that short-circuit holds.
4657            //
4658            // The artefact marker goes with the wrap here exactly as it does in `end_wrap`, and
4659            // this branch is the reason that coupling cannot simply live in `end_wrap`: it is the
4660            // one wrap-ending path whose row is not a grid row, so it does not call it. Leaving it
4661            // out left #534's defect alive one row above the grid — reachable from every
4662            // `scroll_region_lines` verb, since all of them pass `evicts_to_scrollback: false`,
4663            // and visible as a word selection one cell too wide plus a reflow that bakes the
4664            // stranded marker mid-row.
4665            if let Some(row) = self.scrollback.back_mut() {
4666                row.set_wrapped(false);
4667                if let Some(cell) = row.last_mut() {
4668                    cell.clear_leading_spacer();
4669                }
4670            }
4671        }
4672        // The blank lands at `bottom` going up and at `top` going down, so the row that lost its
4673        // continuation is the one just above it. Going down that is `top - 1`, already cleared
4674        // above; going up it is `bottom - 1`, which for a one-row region is that same row.
4675        //
4676        // The up-shift form needs the `bottom + 1` guard, and it is not defensive — without it the
4677        // clear destroys a **live** wrap. A row at the screen's bottom edge that wraps is the
4678        // ordinary soft-wrap-at-the-last-row state: `wrapline` sets the flag and the linefeed
4679        // scrolls precisely so the continuation has somewhere to land, which is the *next* row
4680        // after this shift. Its claim is about a row that does not exist yet, so the shift makes it
4681        // true rather than false.
4682        //
4683        // **The rest of that guard's original rationale was too narrow, and #557 is what it cost.**
4684        // It read: *"the link is only broken when there is a stationary row below the region
4685        // (`bottom + 1 < rows`): then the continuation stayed put while its lead moved up."* A
4686        // stationary row below is **necessary but not sufficient**. At a *region's* bottom the same
4687        // wrapline-asked-for scroll happens with `bottom + 1 < rows` perfectly true, and the clear
4688        // then split the logical line the scroll existed to continue. The geometry was never the
4689        // discriminator; **why the shift is happening** is — which is what `serves_wrap` carries.
4690        //
4691        // The guard stays anyway: it is the screen-bottom case of the same fact, and it also holds
4692        // for a *non*-wrap-serving linefeed at the screen edge.
4693        //
4694        // One invariant is still worth naming, because it was not true when this guard was first
4695        // written: **a row only claims a wrap if a next row will exist for it**. A row parked below
4696        // a DECSTBM region kept a permanent false claim, and this guard preserved it — the #540
4697        // completeness pass merged two unrelated logical lines through exactly that hole. The claim
4698        // is now gated at its set site (`write_glyph` asks `wrapline_advances`), so the guard's
4699        // premise holds. Valid as long as that gate stays.
4700        let orphaned = if serves_wrap {
4701            // The blank this shift just exposed is the continuation the wrap is waiting for, so
4702            // there is nothing to falsify — see the `serves_wrap` note on `linefeed_inner` (#557).
4703            None
4704        } else if down {
4705            Some(bottom)
4706        } else if bottom + 1 < self.grid.rows() {
4707            bottom.checked_sub(1)
4708        } else {
4709            None
4710        };
4711        if let Some(row) = orphaned {
4712            self.end_wrap(row);
4713        }
4714    }
4715
4716    fn erase_display(&mut self, mode: u16) {
4717        let (cols, rows) = (self.grid.cols(), self.grid.rows());
4718        let (cr, cc) = (self.cursor.row, self.cursor.col);
4719        match mode {
4720            0 => {
4721                // Erases this row's tail and every row below, so nothing can continue from here
4722                // — and the rows below cannot continue either.
4723                self.clear_cells(cr, cc, cols);
4724                self.end_wrap(cr);
4725                for row in (cr + 1)..rows {
4726                    self.clear_cells(row, 0, cols);
4727                    self.end_wrap(row);
4728                    self.dispose_markers_on_row(row);
4729                }
4730            }
4731            1 => {
4732                // Leftward: this row's tail survives, so its own wrap does. The rows *above* are
4733                // gone entirely.
4734                for row in 0..cr {
4735                    self.clear_cells(row, 0, cols);
4736                    self.end_wrap(row);
4737                    self.dispose_markers_on_row(row);
4738                }
4739                self.clear_cells(cr, 0, cc + 1);
4740                self.drop_artefact_if_erased(cr, 0, cc + 1);
4741                // Covering the whole row means nothing continues from it. xterm.js has a
4742                // dedicated arm for exactly this case, in its own words: *"Deleted entire
4743                // previous line. This next line can no longer be wrapped."*
4744                // (`InputHandler.ts:1248-1252` — under its continuation polarity that assignment
4745                // is this engine's `end_wrap(cr)`.) `EL 1` has no such arm there, and none here.
4746                if cc + 1 == cols {
4747                    self.end_wrap(cr);
4748                }
4749            }
4750            2 => {
4751                for row in 0..rows {
4752                    self.clear_cells(row, 0, cols);
4753                    self.end_wrap(row);
4754                    self.dispose_markers_on_row(row);
4755                }
4756            }
4757            // xterm's addition: erase saved lines. The screen and the cursor are untouched.
4758            3 => self.erase_history(),
4759            _ => {}
4760        }
4761    }
4762
4763    /// Drop every scrollback line — `ED 3`, and the first step of [`Term::clear`]
4764    /// (#936). The lines leave the *front* of the buffer, so every holder of an
4765    /// absolute line is repaired exactly as the scrollback cap repairs it, `n` lines
4766    /// at once: the selection clamps, markers and tracked points on the dropped lines
4767    /// go, search highlights are dropped, and `evicted_total` advances by `n`. The
4768    /// view returns to the bottom, since the history it was showing is gone.
4769    ///
4770    /// On the alt screen it drops the primary's history underneath, as xterm does;
4771    /// every alt line sits above the dropped ones, so its holders only shift.
4772    fn erase_history(&mut self) {
4773        let n = self.scrollback.len();
4774        if n == 0 {
4775            return;
4776        }
4777        self.scrollback.clear();
4778        self.lines_left_the_front(n);
4779        self.display_offset = 0;
4780        self.mark_fully_damaged();
4781    }
4782
4783    /// Repair every holder of an absolute line after `n` lines left the front of the
4784    /// buffer — the one funnel for the scrollback cap (one line per linefeed), `ED 3` and
4785    /// [`Term::clear`]. Every absolute index shifted by exactly `n`, which is what makes
4786    /// this class of movement expressible as a scalar (#490): `evicted_total` counts it
4787    /// here, because the fact is about the *buffer*, not about any one holder.
4788    ///
4789    /// **Scope, because the name `evicted_total` over-promises.** Reflow also drops lines
4790    /// off the front (`PaneReflow::evicted`, installed by replacing the deque) and does
4791    /// not come through here: it moves the survivors non-uniformly, so no delta repairs
4792    /// them and `marker_epoch` signals it instead. A holder rebasing off this number
4793    /// *without* also watching the epoch gets a wrong answer across every resize.
4794    ///
4795    /// The holders, each with its own answer: the selection re-anchors (its ends keep
4796    /// their content); query-derived search highlights cannot survive the shift and are
4797    /// dropped; markers shift and those on a dropped line are disposed and announced
4798    /// (#118); tracked points shift and those on a dropped line go (#691). The view is
4799    /// the caller's, because the cap keeps it on the same content and `ED 3` returns it.
4800    fn lines_left_the_front(&mut self, n: usize) {
4801        self.evicted_total += n as u64;
4802        self.selection_evict_oldest(n);
4803        self.invalidate_search_highlights();
4804        self.markers_evict_oldest(n);
4805        self.tracked_evict_oldest(n);
4806    }
4807
4808    /// Clear the primary screen and its scrollback, keeping the cursor's line
4809    /// — a terminal's Clear command, out of band: the parser and whatever it
4810    /// holds mid-sequence are untouched. The cursor's logical line, from its first
4811    /// row on screen down to the cursor's row, moves to the top with the cursor on it
4812    /// at the same column; the rows above it and all of history are dropped, and the
4813    /// rows below the cursor are blanked. The view returns to the bottom and the next
4814    /// frame is `Full`.
4815    ///
4816    /// The selection and the search highlights are cleared. A marker on a kept row
4817    /// stays on it; every other marker is disposed and announced. Tracked points on
4818    /// dropped lines go; the rest shift with the kept rows.
4819    ///
4820    /// Returns `false` and changes nothing on the alt screen.
4821    pub fn clear(&mut self) -> bool {
4822        if self.on_alt {
4823            return false;
4824        }
4825        let (cols, cursor) = (self.grid.cols(), self.cursor.row);
4826        // The kept line is the cursor's whole logical line up to the cursor, so a prompt
4827        // and a command that wrapped keep their start — as far up as the screen reaches.
4828        let mut top = cursor;
4829        while top > 0 && self.grid.is_row_wrapped(top - 1) {
4830            top -= 1;
4831        }
4832        // Move the rows above it into history, so dropping history takes them too: their
4833        // absolute lines are unchanged by the move, so no holder moves.
4834        for _ in 0..top {
4835            let row = self.grid.scroll_up_recycle(Row::blank(cols));
4836            self.scrollback.push_back(row);
4837        }
4838        let last = cursor - top;
4839        self.cursor.row = last;
4840        self.erase_history();
4841        self.selection = None;
4842        self.invalidate_search_highlights();
4843        for row in last + 1..self.grid.rows() {
4844            let blanked = self.grid.row_mut(row);
4845            blanked.blank_in_place();
4846            blanked.purge_side_maps(0..cols);
4847            self.dispose_markers_on_row(row);
4848        }
4849        // Nothing follows the cursor's row now, so it cannot continue onto the next row.
4850        self.end_wrap(last);
4851        // `REP` reads back the cell the last print wrote. That cell is on the cursor's row
4852        // whenever the anchor is armed, so it moves up with it; anywhere else it is gone.
4853        self.repeat_anchor = self
4854            .repeat_anchor
4855            .filter(|&(row, _)| row == cursor)
4856            .map(|(_, col)| (last, col));
4857        self.scroll = None;
4858        self.mark_fully_damaged();
4859        true
4860    }
4861
4862    /// Erase in line (EL): 0 = cursor→end, 1 = start→cursor, 2 = whole line.
4863    fn erase_line(&mut self, mode: u16) {
4864        let cols = self.grid.cols();
4865        let (cr, cc) = (self.cursor.row, self.cursor.col);
4866        match mode {
4867            // Erase right — ends the wrap at any column (xterm's `ClearRight`).
4868            0 => {
4869                self.clear_cells(cr, cc, cols);
4870                self.end_wrap(cr);
4871            }
4872            // Erase left — the tail survives, so the wrap does. The artefact marker does not:
4873            // if the erase reached the last column it just blanked the cell the marker described.
4874            1 => {
4875                self.clear_cells(cr, 0, cc + 1);
4876                self.drop_artefact_if_erased(cr, 0, cc + 1);
4877            }
4878            // Erase the whole line — see `end_wrap`: a deliberate divergence from xterm.
4879            2 => {
4880                self.clear_cells(cr, 0, cols);
4881                self.end_wrap(cr);
4882            }
4883            _ => {}
4884        }
4885    }
4886
4887    // ---- intra-line editing (ICH / DCH / ECH) --------------------------------
4888
4889    /// ECH (CSI Pn X): erase `n` cells in place from the cursor — no shift.
4890    /// BCE-filled (via `clear_cells`); pending-wrap is left untouched.
4891    fn erase_chars(&mut self, n: usize) {
4892        let cols = self.grid.cols();
4893        let (row, col) = (self.cursor.row, self.cursor.col);
4894        let to = (col + n).min(cols);
4895        self.clear_cells(row, col, to);
4896        // Destroys content from the cursor rightward, so the row can no longer be continuing —
4897        // unconditionally, at any column and for any `n`. Both references do exactly this (see
4898        // `end_wrap`): xterm routes ECH through the same `ClearRight` as `EL 0`, ghostty calls
4899        // `cursorResetWrap()` in `eraseChars`.
4900        self.end_wrap(row);
4901    }
4902
4903    /// ICH (CSI Pn @): insert `n` blanks at the cursor, shifting the rest of the
4904    /// line right; cells pushed past the right edge are lost. The opened gap is
4905    /// BCE-filled; pending-wrap is left untouched.
4906    fn insert_chars(&mut self, n: usize) {
4907        let cols = self.grid.cols();
4908        let (r, col) = (self.cursor.row, self.cursor.col);
4909        let n = n.min(cols - col);
4910        if n == 0 {
4911            return;
4912        }
4913        // Shifting a wrapped pair out of columns 0/1 ends it, so the row above's artefact record
4914        // is void (#534). Asked **before** the shift, which is what keeps IRM correct: `write_glyph`
4915        // routes its wide-at-boundary insert through here *after* `vacate_for_wrap` has just set
4916        // the marker on the row above, and a post-shift test would see the freshly blanked gap and
4917        // clear the marker inside its own SET site's critical section. Pre-shift the question is
4918        // about the pair that was actually there, which is the one the record is about.
4919        if col <= 1 && self.wrapped_pair_at_row_start(r) {
4920            self.void_wrap_artefact_above(r);
4921        }
4922        let bg = self.cursor.pen.bg;
4923        let row = self.grid.row_mut(r);
4924        // Shift [col .. cols-n) right by n; the tail falls off the edge. The
4925        // combining map follows the moved cells (the bit travels with the raw
4926        // copy, the cluster data must too).
4927        row.copy_within(col..cols - n, col + n);
4928        row.move_maps(col..cols - n, col + n);
4929        for cell in &mut row[col..col + n] {
4930            cell.reset();
4931            cell.set_bg(bg);
4932        }
4933        // Repair wide-char halves split at the seams (no-orphan invariant):
4934        // a lead just before the gap lost its spacer; the first shifted cell may
4935        // be a spacer whose lead did not move.
4936        if col > 0 && self.grid.cell(r, col - 1).is_wide() {
4937            self.free_cell(r, col - 1);
4938        }
4939        if col + n < cols && self.grid.cell(r, col + n).is_wide_spacer() {
4940            self.free_cell(r, col + n);
4941        }
4942        // A lead shifted to the last column lost its spacer off the edge.
4943        if self.grid.cell(r, cols - 1).is_wide() {
4944            self.free_cell(r, cols - 1);
4945        }
4946        // Note ICH needs no repair to *this* row's marker: a right shift always pushes the last
4947        // column off the edge, so it discards a marker rather than carrying one inward —
4948        // measured, and pinned by `ich_discards_the_marker_off_the_edge`.
4949        self.damage_span(r, col, cols - 1);
4950    }
4951
4952    /// DCH (CSI Pn P): delete `n` cells at the cursor, shifting the tail left; the
4953    /// vacated cells at the right are BCE-blanked. Pending-wrap is left untouched.
4954    fn delete_chars(&mut self, n: usize) {
4955        let cols = self.grid.cols();
4956        let (r, col) = (self.cursor.row, self.cursor.col);
4957        let n = n.min(cols - col);
4958        if n == 0 {
4959            return;
4960        }
4961        // The shift pulls the tail left and blanks the far end, so the row stops continuing —
4962        // ghostty says it outright (*"Our row's soft-wrap is always reset"* in `deleteChars`,
4963        // `Terminal.zig:3133` @ `e6e26e1`).
4964        //
4965        // **Before the shift, not after** (#534): `end_wrap` clears the artefact marker at the
4966        // *last* column, and the marker is a cell bit that the shift carries inward with every
4967        // other cell. Ending the wrap afterwards would clear a column the marker has already left,
4968        // stranding it mid-row where it describes nothing (ADR-0025 D3) and silently swallows the
4969        // blank between two runs in copy, search and accessible text. Same shape as #540's
4970        // `record_scroll` ordering: the clear has to happen where the state still is.
4971        self.end_wrap(r);
4972        // Deleting a wrapped pair out of columns 0/1 ends it, so the row above's artefact record
4973        // is void — and this is where the "ask before, not after" rule earns its keep twice over:
4974        // a `DCH` can pull the *next* wide glyph left into column 0, which a post-shift "is a wide
4975        // lead standing here?" test happily accepts even though the pair the record was about has
4976        // been deleted. ghostty asks the same question at the same point:
4977        // `Screen.splitCellBoundary(cursor.x)` from `deleteChars` (`Terminal.zig:3107` @ `e6e26e1`),
4978        // whose `x == 0 or x == 1` branch reaches up a row and clears the spacer head.
4979        if col <= 1 && self.wrapped_pair_at_row_start(r) {
4980            self.void_wrap_artefact_above(r);
4981        }
4982        let bg = self.cursor.pen.bg;
4983        let row = self.grid.row_mut(r);
4984        // Shift [col+n .. cols) left to [col ..); BCE-fill the vacated tail. The
4985        // combining map follows the moved cells.
4986        row.copy_within(col + n..cols, col);
4987        row.move_maps(col + n..cols, col);
4988        for cell in &mut row[cols - n..cols] {
4989            cell.reset();
4990            cell.set_bg(bg);
4991        }
4992        // Repair wide-char halves split by the deletion (no-orphan invariant):
4993        // a lead just before the cut lost its spacer; the cell now at the cursor
4994        // may be a spacer whose lead was deleted.
4995        if col > 0 && self.grid.cell(r, col - 1).is_wide() {
4996            self.free_cell(r, col - 1);
4997        }
4998        if self.grid.cell(r, col).is_wide_spacer() {
4999            self.free_cell(r, col);
5000        }
5001        self.damage_span(r, col, cols - 1);
5002    }
5003
5004    // ---- line/region editing (IL / DL / SU / SD) -----------------------------
5005
5006    /// Scroll rows `[top..=bottom]` by `n` lines, BCE-filling the exposed lines.
5007    /// `down` inserts blanks at the top (content moves down); otherwise content
5008    /// moves up and blanks appear at the bottom. Reuses the one-line region scroll
5009    /// primitives (so damage + scroll-op accumulation come for free), then fills
5010    /// the exposed lines with the current SGR background.
5011    fn scroll_region_lines(&mut self, top: usize, bottom: usize, n: usize, down: bool) {
5012        let height = bottom - top + 1;
5013        let n = n.min(height);
5014        if n == 0 {
5015            return;
5016        }
5017        // Anchors (selection #3, markers #118/#158) live at absolute buffer lines;
5018        // SU/SD/IL/DL don't accrue scrollback, so `base` is stable across the loop.
5019        let base = self.scrollback.len();
5020        for _ in 0..n {
5021            self.shift_region(top, bottom, down, false, false);
5022            // Rotate anchors with the content, like `linefeed`/`reverse_index`
5023            // (#162). `up` = content moved up = the non-`down` case. Markers rotate
5024            // with the active buffer (#187) — alt-scoped on the alt screen, so no
5025            // guard. **The selection is unguarded here for the same reason, not because
5026            // "it is cleared on alt enter"** — that was this comment's claim until #660 and
5027            // it is false: a selection made while the alt screen is up is ordinary, it does
5028            // reach this line, and rotating it is correct, because the content really did
5029            // move under it. The code was right; only its stated reason was wrong.
5030            self.selection_rotate_region(base + top, base + bottom, !down);
5031            self.markers_rotate_region(base + top, base + bottom, !down);
5032            self.tracked_rotate_region(base + top, base + bottom, !down);
5033        }
5034        self.invalidate_search_highlights();
5035        // BCE-fill the n exposed lines (the primitives blank to default).
5036        let bg = self.cursor.pen.bg;
5037        let (fill_top, fill_end) = if down {
5038            (top, top + n)
5039        } else {
5040            (bottom + 1 - n, bottom + 1)
5041        };
5042        let cols = self.grid.cols();
5043        for r in fill_top..fill_end {
5044            for c in 0..cols {
5045                let cell = self.grid.cell_mut(r, c);
5046                cell.reset();
5047                cell.set_bg(bg);
5048            }
5049        }
5050    }
5051
5052    /// SU (CSI Pn S): scroll the scroll region up by `n`.
5053    fn scroll_up_lines(&mut self, n: usize) {
5054        self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, false);
5055    }
5056
5057    /// SD (CSI Pn T): scroll the scroll region down by `n`.
5058    fn scroll_down_lines(&mut self, n: usize) {
5059        self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, true);
5060    }
5061
5062    /// IL (CSI Pn L): insert `n` blank lines at the cursor, scrolling
5063    /// `[cursor..=scroll_bottom]` down. A no-op when the cursor is outside the
5064    /// scroll region.
5065    fn insert_lines(&mut self, n: usize) {
5066        let cur = self.cursor.row;
5067        if cur < self.scroll_top || cur > self.scroll_bottom {
5068            return;
5069        }
5070        // 3-1 for clearing, and the odd one out is the row-shift family's usual
5071        // outlier: xterm `util.c:1295`, ghostty `Terminal.zig:2691` (*"Always unset
5072        // pending wrap"*), and xterm.js structurally — `insertLines` opens with
5073        // `_restrictCursor()`, whose `Math.min(cols - 1, …)` un-parks the column
5074        // (`InputHandler.ts:1346`, `:890`). Only alacritty leaves it.
5075        //
5076        // `SU`/`SD` deliberately do **not** join them: ghostty saves and restores the
5077        // flag around those two on purpose (`Terminal.zig:2390`), so this is a
5078        // per-verb answer and not "row-shift verbs clear" (#848).
5079        self.cursor.pending_wrap = false;
5080        self.scroll_region_lines(cur, self.scroll_bottom, n, true);
5081    }
5082
5083    /// DL (CSI Pn M): delete `n` lines at the cursor, scrolling
5084    /// `[cursor..=scroll_bottom]` up. A no-op when the cursor is outside the
5085    /// scroll region.
5086    fn delete_lines(&mut self, n: usize) {
5087        let cur = self.cursor.row;
5088        if cur < self.scroll_top || cur > self.scroll_bottom {
5089            return;
5090        }
5091        // Same 3-1 as `Term::insert_lines`; xterm `util.c:1388`, ghostty
5092        // `Terminal.zig:2856`, xterm.js `InputHandler.ts:1380` via `_restrictCursor`.
5093        self.cursor.pending_wrap = false;
5094        self.scroll_region_lines(cur, self.scroll_bottom, n, false);
5095    }
5096
5097    // ---- SGR (CSI m) ---------------------------------------------------------
5098
5099    fn sgr(&mut self, params: &Params) {
5100        let pen = &mut self.cursor.pen;
5101        let mut iter = params.iter();
5102        while let Some(param) = iter.next() {
5103            let code = param.first().copied().unwrap_or(0);
5104            match code {
5105                0 => pen.reset(),
5106                1 => pen.flags.insert(CellFlags::BOLD),
5107                2 => pen.flags.insert(CellFlags::DIM),
5108                3 => pen.flags.insert(CellFlags::ITALIC),
5109                // SGR 4 and its colon sub-parameter form (#829). The sub-parameter is already
5110                // here — `params.iter()` yields the whole `&[u16]` and every other arm reads only
5111                // `first()` — so `4:3` has been arriving as `[4, 3]` and being truncated to a
5112                // plain underline. `4:0` is an explicit off in every reference that implements
5113                // the form. An unrecognised sub-style stays a single underline: three of the four
5114                // references degrade that way (xterm is the outlier and swallows the whole
5115                // parameter), and losing an underline entirely is a worse failure than drawing the
5116                // wrong kind — the application asked for emphasis and would get nothing, with no way
5117                // to tell. #830 confirmed that rule against the corpus rather than changing it.
5118                //
5119                // Every value is stored **and every value is now drawn** (#830). #829 stored all six
5120                // while the shader branched on `Curly` alone, because storing and drawing are not
5121                // symmetric in cost: storing 2/4/5 was three arms and no pixel, while NOT storing
5122                // them was a loss #830 could not have repaired — a cell written `4:5m` and scrolled
5123                // into history would have recorded `Single` forever.
5124                4 => {
5125                    let style = match param.get(1) {
5126                        None | Some(1) => UnderlineStyle::Single,
5127                        Some(0) => UnderlineStyle::None,
5128                        Some(2) => UnderlineStyle::Double,
5129                        Some(3) => UnderlineStyle::Curly,
5130                        Some(4) => UnderlineStyle::Dotted,
5131                        Some(5) => UnderlineStyle::Dashed,
5132                        Some(_) => UnderlineStyle::Single,
5133                    };
5134                    pen.flags.set_underline_style(style);
5135                }
5136                5 => pen.flags.insert(CellFlags::BLINK),
5137                7 => pen.flags.insert(CellFlags::INVERSE),
5138                8 => pen.flags.insert(CellFlags::HIDDEN),
5139                9 => pen.flags.insert(CellFlags::STRIKETHROUGH),
5140                // The legacy double underline (#830), which predates the sub-parameter form above.
5141                // It lands on the same field, so `24` clears both spellings — ghostty gets that by
5142                // construction (4, 4:x, 21 and 24 all reduce to one variant on one arm,
5143                // `Screen.zig:2269-2271`) where xterm leaves two independent bits set and lets each
5144                // consumer resolve them (`html.c:208-216` against `svg.c:271`).
5145                //
5146                // **Decided by the spec, not by a head count**, because the corpus is not
5147                // unanimous: `vte` — the crate this engine's own parser is built on — reads `[21]`
5148                // as `CancelBold` (`vte-0.15.0/src/ansi.rs:1849`), so alacritty produces no double
5149                // underline from it at all. `ctlseqs.txt:1200` reads *"Doubly-underlined, ECMA-48
5150                // 3rd"*, and the VT tie-breaker puts the spec above any implementation including
5151                // ours; xterm (`charproc.c:4407-4409`), ghostty (`sgr.zig:301`) and xterm.js
5152                // (`InputHandler.ts:2653-2655`) all agree. A reference that *contradicts* rather
5153                // than omits is the third case ADR-0004's text does not classify — #824 settled
5154                // that routing for DA2 and it applies unchanged here.
5155                //
5156                // The consequence, pinned rather than left to a bug report: an application sending
5157                // `CSI 1m` then `CSI 21m` **meaning "stop bold"** gets a double underline and keeps
5158                // its bold. That is what `22` is for, and this arm deliberately does not touch it.
5159                21 => pen.flags.set_underline_style(UnderlineStyle::Double),
5160                22 => pen.flags.remove(CellFlags::BOLD | CellFlags::DIM),
5161                23 => pen.flags.remove(CellFlags::ITALIC),
5162                // Clears the style, not just the derived flag (#829) — removing `UNDERLINE` alone
5163                // would leave a styled-but-not-underlined pen, the disagreement this model exists
5164                // to make unrepresentable.
5165                24 => pen.flags.set_underline_style(UnderlineStyle::None),
5166                25 => pen.flags.remove(CellFlags::BLINK),
5167                27 => pen.flags.remove(CellFlags::INVERSE),
5168                28 => pen.flags.remove(CellFlags::HIDDEN),
5169                29 => pen.flags.remove(CellFlags::STRIKETHROUGH),
5170                30..=37 => pen.fg = Color::Indexed((code - 30) as u8),
5171                38 => {
5172                    if let Some(c) = parse_extended_color(param, &mut iter) {
5173                        pen.fg = c;
5174                    }
5175                }
5176                39 => pen.fg = Color::Default,
5177                40..=47 => pen.bg = Color::Indexed((code - 40) as u8),
5178                48 => {
5179                    if let Some(c) = parse_extended_color(param, &mut iter) {
5180                        pen.bg = c;
5181                    }
5182                }
5183                49 => pen.bg = Color::Default,
5184                // Underline colour (SGR 58 / 59, #520) — same extended-colour grammar
5185                // as 38/48 (colon `58:2:r:g:b` / `58:5:n`, or legacy semicolon), so it
5186                // reuses `parse_extended_color` verbatim. 59 returns to "follow the fg".
5187                58 => {
5188                    if let Some(c) = parse_extended_color(param, &mut iter) {
5189                        pen.underline_color = c;
5190                    }
5191                }
5192                59 => pen.underline_color = Color::Default,
5193                // bright foreground/background (aixterm) → palette 8..=15.
5194                90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),
5195                100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),
5196                _ => {}
5197            }
5198        }
5199    }
5200}
5201
5202/// Cap a recorded scroll to what a consumer can act on **and** what the wire can
5203/// carry (#661) — two bounds for two different reasons, see [`Term::scroll_delta`].
5204///
5205/// A free function so the second bound is provable without building the grid that
5206/// reaches it: a region taller than `i16::MAX` means a screen taller than 32 767
5207/// rows, and every scroll of it rotates a `line_damage` of that length, so driving
5208/// the engine to that corner costs ~10⁹ element moves (measured: 16 s in a debug
5209/// build, for one assertion). The engine-level tests in `tests/damage.rs` prove
5210/// `scroll_delta` applies this at ordinary sizes; the wire-level one in
5211/// `tests/serialize.rs` proves a count at the bound survives `encode`.
5212fn cap_scroll(op: ScrollOp) -> ScrollOp {
5213    let height = op.bottom.saturating_sub(op.top).saturating_add(1) as isize;
5214    let bound = height.min(MAX_SCROLL_COUNT);
5215    ScrollOp {
5216        count: op.count.clamp(-bound, bound),
5217        ..op
5218    }
5219}
5220
5221/// Parse `38`/`48`/`58` extended colour (foreground / background / underline colour, #520), in
5222/// either form:
5223/// - sub-parameter (colon) form inline in `param`: `38:5:n`, `38:2:r:g:b`
5224///   (optionally `38:2:cs:r:g:b` with a colorspace id), or
5225/// - legacy (semicolon) form: pull the following top-level params from `iter`.
5226///
5227/// The colon RGB form is **count-based** (`off = if param.len() >= 6 { 3 } else { 2 }`): a 5-param
5228/// `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
5229/// `38:2::r:g:b` with an *empty* cs, the form kitty/nvim actually emit — skips the colorspace slot.
5230/// The short 5-param form is **non-conformant to T.416 / ISO-8613-6** (the de-jure standard always
5231/// carries a colorspace field), but tolerating it is the **ecosystem-dominant** behaviour, verified
5232/// against real source (2026-07, #520): VTE (`src/sgr.hh`, branches on `n > 4`), foot (`csi.c`,
5233/// `sub.idx >= 5`) and alacritty (`ansi.rs`, `params.len() > 4`) all count the sub-parameters and
5234/// decode the short form as RGB(r,g,b), exactly as here. VTE's own comment calls it a "common
5235/// misinterpretation of the standard" (foot: "bastard version") that it supports anyway; **only
5236/// xterm.js is strict** (always consumes a colorspace slot, so it misreads the short form). So a
5237/// difference from xterm here is deliberate leniency shared with the non-xterm ecosystem, not a
5238/// defect — the ADR-0004 spec-faithfulness is about not *omitting* behaviour, not about rejecting a
5239/// widely-emitted non-standard input.
5240fn parse_extended_color<'a, I>(param: &[u16], iter: &mut I) -> Option<Color>
5241where
5242    I: Iterator<Item = &'a [u16]>,
5243{
5244    if param.len() > 1 {
5245        // Colon sub-parameter form: kind is param[1].
5246        match param[1] {
5247            2 => {
5248                // 38:2:r:g:b (len 5) or 38:2:cs:r:g:b (len 6, colorspace skipped).
5249                let off = if param.len() >= 6 { 3 } else { 2 };
5250                let r = *param.get(off)? as u8;
5251                let g = *param.get(off + 1)? as u8;
5252                let b = *param.get(off + 2)? as u8;
5253                Some(Color::Rgb(r, g, b))
5254            }
5255            5 => Some(Color::Indexed(*param.get(2)? as u8)),
5256            _ => None,
5257        }
5258    } else {
5259        // Legacy semicolon form: kind, then its operands, are separate params.
5260        match iter.next()?.first().copied()? {
5261            2 => {
5262                let r = iter.next()?.first().copied()? as u8;
5263                let g = iter.next()?.first().copied()? as u8;
5264                let b = iter.next()?.first().copied()? as u8;
5265                Some(Color::Rgb(r, g, b))
5266            }
5267            5 => Some(Color::Indexed(iter.next()?.first().copied()? as u8)),
5268            _ => None,
5269        }
5270    }
5271}
5272
5273/// Reflow one screen (joined with its `scrollback`) to `cols` x `rows`, tracking
5274/// `point` (a cursor in screen coordinates). Returns the new screen rows, the new
5275/// scrollback (capped to `limit`), and the new point. The alt screen passes an
5276/// empty scrollback and discards the returned one.
5277/// The fixed dimensions a resize reflows toward.
5278#[derive(Clone, Copy)]
5279struct ReflowDims {
5280    old_cols: usize,
5281    cols: usize,
5282    rows: usize,
5283    limit: usize,
5284    /// Whether a column change may **re-split** this pane's content, or only re-fit its rows.
5285    ///
5286    /// False for the alt screen (#567). Reflow re-splits a long line so history stays readable at
5287    /// the new width — it assumes the content is text that *flows*. The alt screen has no history,
5288    /// its content is a **layout** rather than a paragraph (re-wrapping htop's columns means
5289    /// nothing), and the application already knows the new size and repaints. All three references
5290    /// take the same position with the same shape — one flag on the same resize function:
5291    /// ghostty `alt.resize(.{ .reflow = false })`, alacritty `grid.resize(!is_alt, …)`, xterm.js
5292    /// gating on `_hasScrollback` with the alt buffer built as `new Buffer(false, …)`.
5293    ///
5294    /// It is not merely wasted work: measured on a real `htop` recording taken across a live
5295    /// `SIGWINCH`, re-splitting leaves debris in the cells htop does not overwrite, because htop
5296    /// repaints **without** clearing. `vim` hides it by erasing first.
5297    reflow: bool,
5298}
5299
5300/// The result of reflowing one pane.
5301struct PaneReflow {
5302    screen: Vec<Row>,
5303    scrollback: VecDeque<Row>,
5304    /// The cursor's new screen-relative position.
5305    cursor: (usize, usize),
5306    /// Each tracked extra point's new position **in this pane's own `[history ++ screen]` frame**,
5307    /// index-aligned with the `extra_abs` argument — *before* any history the caller discards.
5308    ///
5309    /// Reported raw, with `evicted` beside it, because the two callers translate differently and
5310    /// doing it here silently picked the primary's answer for both: the primary keeps its history,
5311    /// so an extra's absolute line only moves by what the cap threw away, while the alt pane has no
5312    /// history at all and everything above the screen is *gone*. Adding the alt result to the
5313    /// primary's scrollback length then produced a line the buffer does not have — reachable
5314    /// without any reflow, on a rows-only resize.
5315    extras: Vec<(usize, usize)>,
5316    /// Rows that left the buffer entirely off the front of this pane's history. For the primary
5317    /// that is the scrollback cap's eviction; for the alt pane, whose limit is `0` because it has
5318    /// no history, it is every row the shrink pushed off the top. An extra whose raw line is below
5319    /// this **is not in the buffer any more** — the caller decides what that means for its kind.
5320    evicted: usize,
5321}
5322
5323/// Reflow one pane (its `scrollback` joined with `screen`) to `dims`, tracking
5324/// the screen-relative cursor `point` plus any `extra_abs` points given in
5325/// **absolute** `[scrollback ++ screen]` coordinates (selection anchors).
5326fn reflow_pane(
5327    screen: Vec<Row>,
5328    scrollback: VecDeque<Row>,
5329    point: (usize, usize),
5330    extra_abs: &[(usize, usize)],
5331    dims: ReflowDims,
5332) -> PaneReflow {
5333    let scroll_len = scrollback.len();
5334    let mut all: Vec<Row> = scrollback.into();
5335    all.extend(screen);
5336
5337    // The cursor is screen-relative; lift it to absolute, then track it together
5338    // with the already-absolute extras.
5339    let mut pts: Vec<(usize, usize)> = Vec::with_capacity(1 + extra_abs.len());
5340    pts.push((scroll_len + point.0, point.1));
5341    pts.extend_from_slice(extra_abs);
5342
5343    let pts = if dims.reflow && dims.cols != dims.old_cols {
5344        let (reflowed, np) = crate::grid::reflow(all, dims.cols, &pts);
5345        all = reflowed;
5346        np
5347    } else {
5348        pts
5349    };
5350
5351    // The cursor can land one row past everything the reflow emitted — "just after the content"
5352    // when the content ends on a full row (#562). That row is real, and while the pane is shorter
5353    // than the screen the caller's fit supplies it for free. When the content already fills the
5354    // pane it has to be bought, and the price is one row of history: the pane **scrolls**, which is
5355    // what a terminal does when content grows past the bottom. Without it the cursor was pulled
5356    // back onto the last glyph and the next byte destroyed a character — the ordinary shell shape,
5357    // a prompt at the bottom of a full screen.
5358    //
5359    // Five earlier designs made `reflow` itself materialise the row and were rejected on
5360    // measurements (a cursor at column 59 resized to width 4 emptied the buffer; a blank-line
5361    // exemption turned 22 alt lines into 21). `reflow` cannot see this pane's budget, so it spent
5362    // what it did not have. Here the budget is in scope, and it is the gate: a pane with no history
5363    // cannot pay — the displaced row would be destroyed rather than archived — so it keeps clamping.
5364    //
5365    // `limit > 0`, deliberately, and not "is this the alt screen": since #567 the alt panes pass
5366    // `limit: 0` because that is what an alt screen's history is, so they are excluded by the budget
5367    // rather than by a branch. That branch is what the design carrying this rule was rejected for
5368    // needing.
5369    //
5370    // This **amends** ADR-0025 rather than reading it narrowly: `reflow` does not create rows; the
5371    // seam may, when the pane can pay. What that record measured is that materialising
5372    // *unconditionally* destroys content.
5373    let cursor_abs = pts[0].0 + usize::from(pts[0].1 == dims.cols);
5374    if dims.limit > 0 {
5375        while all.len() <= cursor_abs {
5376            all.push(Row::blank(dims.cols));
5377        }
5378    }
5379
5380    let split = all.len().saturating_sub(dims.rows);
5381    let history: Vec<Row> = all.drain(0..split).collect();
5382    let mut sb: VecDeque<Row> = history.into();
5383    let mut dropped = 0usize;
5384    while sb.len() > dims.limit {
5385        sb.pop_front();
5386        dropped += 1;
5387    }
5388
5389    // `reflow` may answer `col == cols` — "just after the last cell", which is a real place in the
5390    // logical line and no place in the grid (#562). The **cursor's** reading of it is the next
5391    // *write* position, so a full row means the start of the row after; the caller's row fit
5392    // provides that row (`Grid::set_screen` pads at the bottom). A mark reads the same value the
5393    // opposite way and keeps it verbatim — see `Term::resize`.
5394    let cursor_row = pts[0].0.saturating_sub(split);
5395    let cursor = if pts[0].1 == dims.cols {
5396        (cursor_row + 1, 0)
5397    } else {
5398        (cursor_row, pts[0].1)
5399    };
5400
5401    // The bound on a tracked line belongs **here**, not inside `reflow`: this is where the final
5402    // geometry is known. The screen is padded to `dims.rows` whatever `reflow` emitted, so this
5403    // pane's last addressable line is `split + dims.rows - 1`. Bounding against `reflow`'s own row
5404    // count instead clamped away rows the fit was about to create (#562), while still being the
5405    // only thing standing between an out-of-range anchor and a panic in the consumer's process —
5406    // selection anchors and marks are written back raw, unlike the cursor (`Cursor::set_point`).
5407    // Expressed in this pane's own frame, so it is the same frame `extras` and `evicted` are in.
5408    let max_line = split + dims.rows - 1;
5409
5410    // The cursor returns to screen-relative (its absolute index minus the history split). The
5411    // extras stay in this pane's frame — see the field docs for why they are not shifted here.
5412    PaneReflow {
5413        cursor,
5414        extras: pts[1..]
5415            .iter()
5416            .map(|&(l, c)| (l.min(max_line), c))
5417            .collect(),
5418        evicted: dropped,
5419        screen: all,
5420        scrollback: sb,
5421    }
5422}
5423
5424/// Default tab stops: one every 8 columns (incl. column 0), matching xterm.
5425fn default_tabs(cols: usize) -> Vec<bool> {
5426    (0..cols).map(is_default_tab_stop).collect()
5427}
5428
5429/// Whether `col` carries a stop in the default ladder.
5430///
5431/// Shared by the table the constructor builds and the extension [`Term::resize`]
5432/// performs, which fills at the *absolute* column index. The two must not drift,
5433/// and a second literal `8` is exactly how they would.
5434fn is_default_tab_stop(col: usize) -> bool {
5435    col.is_multiple_of(8)
5436}
5437
5438/// First sub-parameter of CSI param `idx`, or `default` when absent or zero
5439/// (a zero/omitted numeric param means "1" for cursor movement and "0" for
5440/// erase — callers pass the right default).
5441/// Push onto an XTWINOPS title stack, bounded at [`TITLE_STACK_DEPTH`] (#823).
5442///
5443/// At the bound the **oldest** entry goes and the push still succeeds, which is
5444/// what both implementations carrying this feature do — xterm.js `shift()`s its
5445/// array, alacritty `remove(0)`s its `Vec`, and xterm wraps a fixed-size one.
5446/// The alternative (refuse the push) is worse in the case that actually occurs:
5447/// it breaks the pairing for the innermost nesting levels, and those are the
5448/// ones a user unwinds first.
5449fn push_title(stack: &mut Vec<String>, value: String) {
5450    if stack.len() >= TITLE_STACK_DEPTH {
5451        stack.remove(0);
5452    }
5453    stack.push(value);
5454}
5455
5456fn param_or(params: &Params, idx: usize, default: u16) -> u16 {
5457    match params.iter().nth(idx).and_then(|p| p.first().copied()) {
5458        Some(v) if v != 0 => v,
5459        _ => default,
5460    }
5461}
5462
5463/// The `Pv` field of the secondary device-attributes report (#824), derived
5464/// from the crate version so a release cannot ship a report that disagrees
5465/// with what was published.
5466///
5467/// Semver components are padded base-100, so a higher version always reports a
5468/// higher number. That is alacritty's scheme, and it is a *reinterpretation* of
5469/// the spec rather than a divergence from it: `ctlseqs.txt` calls `Pv` "the
5470/// firmware version" and fixes no encoding for it.
5471///
5472/// **The encoding has a functional floor, and it is not cosmetic.** Measured on
5473/// a real pty by sweeping this field alone (RHEL 9.2, vim 8.2), vim picks its
5474/// mouse protocol off `Pv`:
5475///
5476/// ```text
5477/// Pv < 95     ->  ttymouse=xterm    (no upgrade at all)
5478/// Pv = 95     ->  ttymouse=sgr      (vim special-cases the exact >1;95;0c
5479///                                    signature that macOS Terminal sends)
5480/// 95..276     ->  ttymouse=xterm2
5481/// Pv >= 277   ->  ttymouse=sgr
5482/// ```
5483///
5484/// The mouse rows above reproduced across two independent runs, 11 arms each,
5485/// with no-reply controls bracketing both.
5486///
5487/// A further gate sits on the same field: vim's XTGETTCAP key-code
5488/// interrogation, which its `term.txt` (*xterm-codes*) documents as needing a
5489/// response indicating "patchlevel 141 or higher". Measured here only to the
5490/// extent of bracketing — present at 276 and 1500, absent at 1, 94 and 95 — so
5491/// the doc's 141 is consistent but not independently pinned. The number
5492/// therefore gates upgrades at three separate thresholds rather than one, and
5493/// 1500 clears all three.
5494///
5495/// justerm at 0.15.0 maps to 1500 and clears it comfortably. A `0.2.x` would map
5496/// to 200 and silently cost every consumer the SGR mouse encoding — so the
5497/// base-100 scheme is load-bearing for a reason that has nothing to do with
5498/// monotonicity, and lowering the base would be a behavioural change.
5499///
5500/// Three edges, all deliberate. A component of 100 or more carries into the
5501/// next place — justerm is far from that, and widening the base would change
5502/// every number already reported for no measured gain. The pre-release suffix
5503/// is cut at the **first** hyphen, which is where semver says it begins;
5504/// alacritty cuts at the last, which mis-parses a two-part suffix like
5505/// `-rc.1-dev`. And the monotonicity above holds only below `u16::MAX`: a CSI
5506/// parameter is a `u16` in `vte` (saturating), and ghostty types the field
5507/// `firmware_version: u16`, so a `Pv` past 65535 — major version 7 — reaches a
5508/// receiver saturated. alacritty carries the same latent property; the corpus
5509/// prescribes no wider encoding, so this is recorded rather than designed
5510/// around.
5511const fn version_number(version: &str) -> u32 {
5512    let bytes = version.as_bytes();
5513    let mut parts = [0u32; 3];
5514    let mut part = 0usize;
5515    let mut i = 0usize;
5516    while i < bytes.len() {
5517        let b = bytes[i];
5518        if b == b'-' || b == b'+' {
5519            break;
5520        } else if b == b'.' {
5521            part += 1;
5522            if part >= 3 {
5523                break;
5524            }
5525        } else if b.is_ascii_digit() {
5526            parts[part] = parts[part] * 10 + (b - b'0') as u32;
5527        }
5528        i += 1;
5529    }
5530    parts[0] * 10_000 + parts[1] * 100 + parts[2]
5531}
5532
5533/// `Pp` of the secondary DA report: 1 = VT220, from the spec's closed table
5534/// (`ctlseqs.txt:825`). It names the same terminal DA1 already advertises —
5535/// `CSI ? 62 ; 22 c`, where 62 *is* VT220 (`ctlseqs.txt:778`).
5536///
5537/// The two are not forced to agree in general: DA1's first parameter is an
5538/// operating **level** and `Pp` a device **type**, and xterm can decouple them
5539/// through DECTID. justerm implements neither DECTID nor DECSCL, so nothing
5540/// here can decouple them — which is why one terminal identity is the only
5541/// coherent answer, not because disagreeing would be malformed.
5542///
5543/// ghostty is the convergence check: it derives both from one device type
5544/// (`src/terminal/device_attributes.zig:82`, `:161` @ `e6e26e1`) and pairs a
5545/// level-62 DA1 with `Pp = 1` (`src/termio/stream_handler.zig:843`). Its DA1 is
5546/// *not* byte-identical to justerm's at its default — `clipboard-write` is
5547/// `.allow` (`src/config/Config.zig:2380`), which appends `;52`; the identical
5548/// string is its deny branch. alacritty pairs `?6c` (VT102) with `Pp = 0` and
5549/// xterm.js `?1;2c` (VT100) with `Pp = 0`, both of which are what this same
5550/// rule produces at those levels — so neither is a counterexample, and no
5551/// reference in the corpus pairs a level-62 DA1 with a `Pp` other than 1.
5552const DA2_TERMINAL_TYPE: u32 = 1;
5553
5554/// `Pc` of the secondary DA report: 0.
5555///
5556/// The ground is first-principles and needs no reference: `Pc` is a **ROM
5557/// cartridge registration number**, justerm has no cartridge, and 0 is the
5558/// absence value. That is how both implementations that comment the field read
5559/// it — xterm writes it as `/* options (none) */` (`charproc.c:4267`) and
5560/// ghostty as *"Always 0 for emulators"* (`src/terminal/device_attributes.zig:88`
5561/// @ `e6e26e1`). xterm.js sends 0 in all three of its branches; alacritty alone
5562/// sends 1 (`alacritty_terminal/src/term/mod.rs:1267` @ `852e971`).
5563///
5564/// **What does *not* carry this choice, stated because it looks like it should.**
5565/// `ctlseqs.txt:839` says `Pc` *"is always zero"* only of a **DEC terminal** —
5566/// a description of hardware in xterm's own documentation, not a requirement on
5567/// emulators. And ADR-0004 tie-breaks the spec against alacritty where alacritty
5568/// *"merely omits or under-implements"*; here alacritty **contradicts**, which
5569/// is neither of its branches, and its other branch (genuine ambiguity → follow
5570/// alacritty) would give 1. So the value rests on the argument above and on the
5571/// 3-1 head count, not on a spec mandate that does not exist.
5572const DA2_ROM_CARTRIDGE: u32 = 0;
5573
5574/// The `Pv` this build reports, folded at compile time so the doc-comment above
5575/// is literally true and the query path does no arithmetic.
5576const DA2_VERSION: u32 = version_number(env!("CARGO_PKG_VERSION"));
5577
5578impl Term {
5579    /// Apply one DEC private mode set (`'h'`) or reset (`'l'`). DECSET/DECRST
5580    /// carry a list of modes, so `csi_dispatch` folds this over every parameter
5581    /// (#56); each mode is an independent toggle, not a stack.
5582    fn set_dec_private_mode(&mut self, action: char, mode: u16) {
5583        match (action, mode) {
5584            ('h', 1049) => self.enter_alt_screen(),
5585            ('l', 1049) => self.leave_alt_screen(),
5586            // Legacy alt-screen variants (#72): ?47/?1047 switch the buffer
5587            // without saving the cursor; ?1048 saves/restores the cursor without
5588            // switching. ?1049 is the two combined.
5589            ('h', 47) | ('h', 1047) => self.switch_to_alt(),
5590            ('l', 47) | ('l', 1047) => self.switch_to_primary(),
5591            ('h', 1048) => self.save_alt_cursor(),
5592            ('l', 1048) => self.restore_alt_cursor(),
5593            ('h', 6) => {
5594                // DECOM: set homes the cursor to the region top.
5595                self.origin_mode = true;
5596                self.goto(0, 0);
5597            }
5598            ('l', 6) => self.origin_mode = false, // unset leaves the cursor put
5599            ('h', 7) => self.autowrap = true,     // DECAWM
5600            ('l', 7) => self.autowrap = false,
5601            ('h', 45) => self.reverse_wraparound = true, // reverse wraparound (#80)
5602            ('l', 45) => self.reverse_wraparound = false,
5603            // DECCOLM (#82): the engine is dimension-free, so emit a request the
5604            // consumer may honor by resizing — no screen/cursor/margin change here.
5605            ('h', 3) => self.events.push(TermEvent::ColumnMode { cols: 132 }),
5606            ('l', 3) => self.events.push(TermEvent::ColumnMode { cols: 80 }),
5607            ('h', 25) => self.cursor.visible = true, // DECTCEM show
5608            ('l', 25) => self.cursor.visible = false, // DECTCEM hide
5609            ('h', 12) => self.cursor.blink = true,   // att610 cursor blink (#81)
5610            ('l', 12) => self.cursor.blink = false,
5611            ('h', 2004) => self.bracketed_paste = true,
5612            ('l', 2004) => self.bracketed_paste = false,
5613            ('h', 2026) => self.synchronized_output = true, // synchronized output (#73)
5614            ('l', 2026) => self.synchronized_output = false,
5615            ('h', 2027) => self.grapheme_clustering = true, // grapheme-cluster mode (#295)
5616            ('l', 2027) => self.grapheme_clustering = false,
5617            ('h', 2031) => self.color_scheme_updates = true, // color-scheme notifications (#85)
5618            ('l', 2031) => self.color_scheme_updates = false,
5619            ('h', 9001) => self.win32_input_mode = true, // win32-input-mode (#86)
5620            ('l', 9001) => self.win32_input_mode = false,
5621
5622            // Input-encoding modes (#11): DECCKM, mouse tracking + encoding,
5623            // focus reporting. Each set assigns the level; each reset clears
5624            // it (apps enable/disable the same mode, not a stack).
5625            ('h', 1) => self.app_cursor_keys = true, // DECCKM
5626            ('l', 1) => self.app_cursor_keys = false,
5627            ('h', 66) => self.application_keypad = true, // DECNKM (#74)
5628            ('l', 66) => self.application_keypad = false,
5629            // DECANM (#84): set = ANSI (the normal state); reset enters VT52. Only
5630            // the reset is meaningful — `?2h` is a no-op (already ANSI).
5631            ('l', 2) => self.vt52_mode = true,
5632            ('h', 9) => self.mouse_protocol = MouseProtocol::X10, // X10 mouse (#70)
5633            ('h', 1000) => self.mouse_protocol = MouseProtocol::Normal,
5634            ('h', 1002) => self.mouse_protocol = MouseProtocol::ButtonEvent,
5635            ('h', 1003) => self.mouse_protocol = MouseProtocol::AnyEvent,
5636            ('l', 9) | ('l', 1000) | ('l', 1002) | ('l', 1003) => {
5637                self.mouse_protocol = MouseProtocol::Off
5638            }
5639            ('h', 1006) => self.mouse_encoding = MouseEncoding::Sgr,
5640            ('l', 1006) => self.mouse_encoding = MouseEncoding::Default,
5641            ('h', 1015) => self.mouse_encoding = MouseEncoding::Urxvt,
5642            ('l', 1015) => self.mouse_encoding = MouseEncoding::Default,
5643            ('h', 1005) => self.mouse_encoding = MouseEncoding::Utf8,
5644            ('l', 1005) => self.mouse_encoding = MouseEncoding::Default,
5645            ('h', 1016) => self.mouse_encoding = MouseEncoding::SgrPixels,
5646            ('l', 1016) => self.mouse_encoding = MouseEncoding::Default,
5647            ('h', 1004) => self.focus_events = true,
5648            ('l', 1004) => self.focus_events = false,
5649
5650            _ => {} // other DEC modes are later slices
5651        }
5652    }
5653
5654    /// Dispatch one VT52 escape sequence (`ESC <final>`), reached only while
5655    /// `vt52_mode` is set (#84). VT52 is a pre-ANSI dialect: the cursor/erase
5656    /// finals map to the same `Term` primitives the ANSI path uses. `ESC <`
5657    /// returns to ANSI. Unknown finals are ignored.
5658    fn vt52_dispatch(&mut self, byte: u8) {
5659        match byte {
5660            b'A' => self.move_up(1),         // cursor up
5661            b'B' => self.move_down(1),       // cursor down
5662            b'C' => self.move_forward(1),    // cursor right
5663            b'D' => self.move_back(1),       // cursor left
5664            b'H' => self.goto(0, 0),         // cursor home
5665            b'I' => self.reverse_index(),    // reverse line feed
5666            b'J' => self.erase_display(0),   // erase cursor → end of screen
5667            b'K' => self.erase_line(0),      // erase cursor → end of line
5668            b'Y' => self.vt52_y_pending = 2, // direct address: two coord bytes follow
5669            // Identify (DECID): reply `ESC / Z` — "I am a VT52".
5670            b'Z' => self.replies.extend_from_slice(b"\x1b/Z"),
5671            b'=' => self.application_keypad = true, // enter alternate keypad
5672            b'>' => self.application_keypad = false, // exit alternate keypad
5673            b'<' => self.vt52_mode = false,         // exit VT52, return to ANSI
5674            // RIS (`ESC c`) is honored even here: it is a hard "recover from any
5675            // state" reset, and `full_reset` rebuilds `Term` with `vt52_mode`
5676            // cleared, so RIS always escapes VT52 back to ANSI. VT52 defines no
5677            // other meaning for `ESC c`.
5678            b'c' => self.full_reset(),
5679            // Graphics mode (`ESC F`/`ESC G`) is a documented non-goal: the VT52
5680            // graphics glyph set differs from DEC Special Graphics, so reusing that
5681            // charset would render the wrong glyphs. No-op rather than approximate.
5682            b'F' | b'G' => {}
5683            _ => {} // unknown VT52 finals are ignored
5684        }
5685    }
5686
5687    /// Consume one `ESC Y` coordinate byte (#84). The first byte is the row, the
5688    /// second the column; each decodes as `value - 0x20`. On the second byte the
5689    /// cursor is addressed (`goto` clamps out-of-range coordinates). Reached only
5690    /// from `print` while `vt52_y_pending > 0`.
5691    fn vt52_take_coord(&mut self, c: char) {
5692        let coord = (c as usize).saturating_sub(0x20);
5693        if self.vt52_y_pending == 2 {
5694            self.vt52_y_row = coord;
5695            self.vt52_y_pending = 1;
5696        } else {
5697            self.vt52_y_pending = 0;
5698            self.goto(self.vt52_y_row, coord);
5699        }
5700    }
5701
5702    /// The allocation an OSC 8 `id=` names: the live one if that id already named a link
5703    /// with this same URI, else a fresh one recorded under the key (#635).
5704    ///
5705    /// Keyed on **id and URI together**, mirroring xterm.js's `_getEntryIdKey`
5706    /// (`` `${id};;${uri}` ``, `OscLinkService.ts:87`). Keying on the id alone would follow
5707    /// a reused id to a stale target — an application saying "same link" about two
5708    /// different destinations has not said anything the engine should honour.
5709    fn link_for_id(&mut self, id: &str, uri: &str) -> std::sync::Arc<str> {
5710        let key = format!("{id};;{uri}");
5711        // A key whose link has left the buffer is *absent*, not stale — the group it named
5712        // is gone, so this open starts a new one. That is xterm.js's behaviour too, reached
5713        // by deleting the entry rather than by letting a reference die.
5714        if let Some(live) = self.link_ids.get(&key).and_then(std::sync::Weak::upgrade) {
5715            return live;
5716        }
5717        // Amortised sweep before inserting, so dangling keys stay O(live) rather than
5718        // O(ids ever declared). Doubling the threshold keeps it O(1) per open.
5719        if self.link_ids.len() >= self.link_ids_sweep_at {
5720            self.link_ids.retain(|_, weak| weak.strong_count() > 0);
5721            self.link_ids_sweep_at = (self.link_ids.len() * 2).max(LINK_IDS_FIRST_SWEEP);
5722        }
5723        let fresh: std::sync::Arc<str> = std::sync::Arc::from(uri);
5724        self.link_ids.insert(key, std::sync::Arc::downgrade(&fresh));
5725        fresh
5726    }
5727}
5728
5729impl Perform for Term {
5730    fn print(&mut self, c: char) {
5731        // VT52 `ESC Y` direct addressing (#84): vte delivers the two coordinate
5732        // bytes here (it returned to ground after the `Y` final), so intercept
5733        // them before they would be written as glyphs.
5734        if self.vt52_y_pending > 0 {
5735            self.vt52_take_coord(c);
5736            return;
5737        }
5738        // Translate through the active (GL) character set first (#62): under DEC
5739        // Special Graphics a printable byte becomes a line-drawing glyph.
5740        let c = self.charsets[self.gl].map(c);
5741        self.place_grapheme(c);
5742    }
5743
5744    /// A DCS is terminated: not a print, so the repeat is disarmed. This method
5745    /// exists for that alone — the payload is otherwise unhandled — and it is reachable
5746    /// in ordinary use: with DA2 answered, `vim` follows up with XTGETTCAP
5747    /// (`DCS + q <hex> ST`) queries this engine does not answer. Both halves of that
5748    /// are pinned on recorded bytes rather than asserted — `tests/closed_loop_capture.rs`.
5749    ///
5750    /// The end of the DCS and not its start, which is both xterm's rule (its gate fires
5751    /// when the parser returns to the ground state) and the only half that can be shown
5752    /// to matter: no CSI can arrive between `hook` and here, so a disarm in `hook` is a
5753    /// guard no mutation can redden.
5754    ///
5755    /// Which DCS terminator is fed decides whether this line is load-bearing at all,
5756    /// measured by deleting it (`-`, a DCS, then `CSI 3 b`, counting dashes):
5757    ///
5758    /// | terminator | dashes without this line |
5759    /// |---|---|
5760    /// | `ESC \` (7-bit ST) | 1 — `esc_dispatch` disarms on the `\` |
5761    /// | `0x9C` (8-bit ST, which DCS accepts where OSC refuses it) | 4 |
5762    /// | none; aborted by the `ESC` of the next sequence | 4 |
5763    ///
5764    /// So a test that feeds only `ESC \` proves nothing here, which is what the first
5765    /// version of `rep_after_a_dcs_repeats_nothing` did.
5766    ///
5767    /// The last row is a **deliberate divergence from xterm**, in the safe direction:
5768    /// an unterminated DCS never returns xterm's parser to the ground state, so xterm
5769    /// would still repeat, while `vte` calls this on the abort and this engine does
5770    /// not. Disarming too eagerly can only turn `REP` into a no-op.
5771    fn unhook(&mut self) {
5772        self.repeat_anchor = None;
5773    }
5774
5775    fn execute(&mut self, byte: u8) {
5776        // Not a print, so the repeat is disarmed (#825, [`Term::repeat_anchor`]). This
5777        // is also where `CAN` and `SUB` land, which abort a sequence in every state.
5778        self.repeat_anchor = None;
5779        match byte {
5780            // LF, VT, FF all line-feed.
5781            b'\n' | 0x0b | 0x0c => self.linefeed(),
5782            b'\r' => self.carriage_return(),
5783            0x08 => self.backspace(),
5784            b'\t' => self.put_tab(),
5785            0x07 => self.events.push(TermEvent::Bell), // BEL (#12)
5786            0x0e => self.gl = 1,                       // SO (LS1): GL = G1 (#62)
5787            0x0f => self.gl = 0,                       // SI (LS0): GL = G0
5788            _ => {}
5789        }
5790    }
5791
5792    fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
5793        // Every completed CSI disarms `REP` (#825, [`Term::repeat_anchor`]). It is
5794        // *taken* here rather than cleared on the way out because this function has
5795        // six early returns and a clear at the end would miss all of them; `REP` is
5796        // the one arm that needs the value, and it puts it back.
5797        let repeat_anchor = self.repeat_anchor.take();
5798        // Kitty keyboard-protocol negotiation: CSI > / = / < / ? ... u. The
5799        // leading intermediate distinguishes it from plain `CSI u` (SCORC) (#23).
5800        if action == 'u'
5801            && let Some(&lead) = intermediates.first()
5802            && matches!(lead, b'>' | b'<' | b'=' | b'?')
5803        {
5804            self.kitty_dispatch(lead, params);
5805            return;
5806        }
5807        // DEC private modes arrive with a '?' intermediate.
5808        if intermediates.first() == Some(&b'?') {
5809            // DECRQM (CSI ? Ps $ p) — report whether mode Ps is set. The '$'
5810            // intermediate distinguishes it from a plain `?...p`. It queries a
5811            // single mode, so it keys off the first parameter only.
5812            if action == 'p' && intermediates.contains(&b'$') {
5813                self.decrqm(param_or(params, 0, 0));
5814                return;
5815            }
5816            // Private DSR (CSI ? Ps n): ?996 = color-scheme query (#85). The
5817            // theme-agnostic engine relays it as an event for the consumer.
5818            if action == 'n' {
5819                if param_or(params, 0, 0) == 996 {
5820                    self.events.push(TermEvent::ColorSchemeQuery);
5821                }
5822                return;
5823            }
5824            // DECSET/DECRST carry a *list* of modes; apply set/reset to EVERY
5825            // parameter, not just the first — htop batches `?1006;1000h` into one
5826            // CSI, so folding only params[0] dropped the 1000 (#56).
5827            for mode in params.iter().filter_map(|p| p.first().copied()) {
5828                self.set_dec_private_mode(action, mode);
5829            }
5830            return;
5831        }
5832        // DECSTR soft reset: CSI ! p (#53).
5833        if intermediates.first() == Some(&b'!') && action == 'p' {
5834            self.soft_reset();
5835            return;
5836        }
5837        // DECSCUSR set cursor style: CSI Ps SP q (space intermediate) (#89). The raw
5838        // value is read, since `param_or` folds 0 to its default and 0 is the reset.
5839        // `CSI SP q` arrives from vte as an explicit 0; `unwrap_or(1)` covers a
5840        // params list with no entry at all.
5841        if intermediates.first() == Some(&b' ') && action == 'q' {
5842            let param = params.iter().next().and_then(|p| p.first().copied());
5843            self.set_cursor_style(param.unwrap_or(1));
5844            return;
5845        }
5846        // DA2 (secondary device attributes, CSI > c) — the query vim uses to
5847        // fill `v:termresponse` and identify what it is talking to (#824).
5848        //
5849        // What answering it actually buys, measured as a control pair on a real
5850        // pty (RHEL 9.2, vim 8.2, TERM=xterm-256color, 24x80; every other query
5851        // answered identically in both arms, controls run before and after):
5852        //
5853        //     no reply           ->  ttymouse=xterm
5854        //     ESC[>1;1500;0c     ->  ttymouse=sgr
5855        //
5856        // The mouse protocol is what the version number buys *directly*: legacy
5857        // `xterm` encoding cannot report a column past 223 and cannot report a
5858        // release, `sgr` has neither limit.
5859        //
5860        // It is not the only effect, and the second one is the larger. Answering
5861        // also makes vim **ask ten more questions** — `DCS + q <hex> ST`
5862        // (XTGETTCAP) for `Co`, `ku`, `kd`, `kl`, `kr`, `k1`, `#2`, `#4`, `%i`
5863        // and `*7`: the colour count and the arrow / function / shifted key
5864        // codes. vim's own `term.txt` gates that on the reply indicating
5865        // "patchlevel 141 or higher", and its point is that a terminal produces
5866        // different key codes in different modes, so it asks instead of
5867        // guessing. Measured rather than taken from the doc: the requests appear
5868        // at `Pv` 276 and 1500 and are absent at 1, 94 and 95, which brackets the
5869        // gate to (95, 276] and is consistent with 141 without pinning it. What
5870        // is stable across runs is whether they appear at all; *how many* arrive
5871        // is not — one arm sent each capability once where every other sent it
5872        // twice. **justerm answers none of those today** — they fall to the
5873        // same intermediate catch-all this block sits above — so the capability
5874        // is unlocked and then unanswered. That is the honest state, and it is
5875        // #47 tail rather than this slice.
5876        //
5877        // modifyOtherKeys is *not* gated on any of it: vim emits `CSI > 4 ; 2 m`
5878        // about 180 bytes before it asks.
5879        //
5880        // `>` reaches us as an *intermediate*, so DA2 was not "unhandled" but
5881        // unreachable: the catch-all below returns before the final is ever
5882        // examined. This opened exactly one route through it — the `>` prefix
5883        // alone, with the `c` final. **A second one is open now**: the `m`
5884        // final, for XTMODKEYS (#890), in the block immediately below this one.
5885        //
5886        // It is one route out of ten `>` finals xterm routes, and the choice is
5887        // reach, not completeness: across this repo's capture corpus `CSI > c`
5888        // occurs 5 times and XTMODKEYS `CSI > m` 10 (re-measured 2026-09-11 after
5889        // #891 added a twentieth fixture; they read 4 and 7 when #890 chose on
5890        // them, and the order the choice turned on is unchanged) — the latter is
5891        // the highest-reach `>` sequence justerm did not route, which is why it
5892        // was the next one taken (#890) rather than a later one — it no longer falls
5893        // through here. XTVERSION `CSI > q` occurs **once**, in `tmux_clipboard.raw`.
5894        //
5895        // Both numbers moved after this paragraph was written, and the second one
5896        // changed sign: it said `> q` occurred *zero* times, which was true on
5897        // 2026-09-01 and false on 2026-09-02, when #842 checked in a tmux capture
5898        // that contains one (re-measured 2026-09-11, and a fresh tmux attach
5899        // recorded the same day emits it too — tmux asks unconditionally). A count
5900        // taken from a corpus is only ever true of one revision of it.
5901        //
5902        // And it is a floor rather than a measurement of reach, because all but one
5903        // capture is **open-loop** — recorded under `script(1)` or a bare `expect`,
5904        // both of which answer nothing, so no sequence an application only sends *after* a reply can
5905        // appear in it. The signature is in the corpus: answering DA2 makes vim ask
5906        // ten `DCS + q` XTGETTCAP questions, and `DCS + q` occurs **zero** times
5907        // across every open-loop fixture, four of which ask DA2. The exception is
5908        // `vim_closed_loop.raw`, which holds all ten (#891) — what it cost to record
5909        // one, and why its bytes are a function of a consumer policy as well as of
5910        // vim, is in `docs/map/territory/vt-interpretation.md`.
5911        //
5912        // The match is on the whole slice rather than `.first()`, so
5913        // `CSI > $ c` is not DA2. That is 3-1: xterm drops it
5914        // (`VTPrsTbl.c:4747`, `$` is CASE_CSI_IGNORE inside `dec2_table`),
5915        // ghostty drops it (`src/terminal/stream.zig:1612`, `else => null`) and
5916        // xterm.js drops it (its handler key packs prefix *and* intermediates,
5917        // `InputHandler.ts:233`), while alacritty **would answer** it
5918        // (`vte-0.15.0/src/ansi.rs:1572` passes `intermediates.first()`). No
5919        // producer of that form exists in any pinned corpus, so this is a
5920        // divergence with no measured reach — pinned by a test regardless,
5921        // because the predicate is otherwise unfalsifiable.
5922        if intermediates == [b'>'] && action == 'c' {
5923            // Only `Ps = 0` or omitted is a request; a qualifier we do not
5924            // recognise is answered with silence rather than with a report that
5925            // does not address it. xterm (`charproc.c:4220`), xterm.js
5926            // (`InputHandler.ts:1738`) and alacritty (`ansi.rs:1572`) all gate
5927            // this way; ghostty reads no parameter at all and has no test that
5928            // would notice.
5929            if param_or(params, 0, 0) == 0 {
5930                self.replies.extend_from_slice(
5931                    format!("\x1b[>{DA2_TERMINAL_TYPE};{DA2_VERSION};{DA2_ROM_CARTRIDGE}c")
5932                        .as_bytes(),
5933                );
5934            }
5935            return;
5936        }
5937        // XTMODKEYS (`CSI > Pp ; Pv m`) — the second route through the `>` guard, and
5938        // the highest-reach one: 10 occurrences across this repo's captures against
5939        // DA2's 5, all of them `Pp = 4` (modifyOtherKeys). `vim` sets it at startup
5940        // and clears it on exit, and the clear is the more frequent of the two.
5941        //
5942        // **Only `Pp = 4` is routed**, of the eight resources xterm keys off this one final;
5943        // `CSI > m` is deliberately not honoured, because an omitted `Pp` is measurably
5944        // indistinguishable from one aimed at another resource. **`Pv >= 2`, not `== 2`**:
5945        // level 2 is what separates `Ctrl+I` from `Tab`, and 3 asks for more than 2 rather
5946        // than for nothing. Both, with the reference sites, are in
5947        // `docs/agents/reference-facts.md` (#890).
5948        if intermediates == [b'>'] && action == 'm' {
5949            if param_or(params, 0, 0) == 4 {
5950                self.modify_other_keys_2 = param_or(params, 1, 0) >= 2;
5951            }
5952            return;
5953        }
5954        // Other private/intermediate sequences are later slices; ignore them
5955        // rather than misinterpret.
5956        if !intermediates.is_empty() {
5957            return;
5958        }
5959        match action {
5960            'A' => self.move_up(param_or(params, 0, 1) as usize),
5961            'B' => self.move_down(param_or(params, 0, 1) as usize),
5962            'e' => self.vertical_position_relative(param_or(params, 0, 1) as usize),
5963            // CNL / CPL (CSI Ps E / F): CUD / CUU, then CR (#898).
5964            'E' => {
5965                self.move_down(param_or(params, 0, 1) as usize);
5966                self.carriage_return();
5967            }
5968            'F' => {
5969                self.move_up(param_or(params, 0, 1) as usize);
5970                self.carriage_return();
5971            }
5972            'C' | 'a' => self.move_forward(param_or(params, 0, 1) as usize),
5973            'D' => self.move_back(param_or(params, 0, 1) as usize),
5974            // CBT (CSI Ps Z): back-tab, the mirror of HT over the tab-stop
5975            // table. Cursor motion only — it writes no cell (#826).
5976            'Z' => self.put_back_tab(param_or(params, 0, 1) as usize),
5977            // CHT (CSI Ps I): forward tab, the counted HT (#898).
5978            'I' => self.put_forward_tabs(param_or(params, 0, 1) as usize),
5979            // REP (CSI Ps b): repeat the preceding grapheme. `param_or` folds an
5980            // absent parameter and an explicit zero to one, as everywhere else here.
5981            'b' => {
5982                // The three lines are one rule and their order is the rule: put back
5983                // what this dispatch took, repeat, then clear what the repeats re-armed
5984                // through the print path. That is xterm's lifecycle — the byte
5985                // completing `CSI b` returns the parser to the ground state with nothing
5986                // printed, so a second `CSI b` repeats nothing. ghostty is the outlier
5987                // and re-arms (`printRepeat` calls `print`); pinned by
5988                // `rep_does_not_rearm_itself` (#825).
5989                self.repeat_anchor = repeat_anchor;
5990                self.repeat_last(param_or(params, 0, 1) as usize);
5991                self.repeat_anchor = None;
5992            }
5993            'G' | '`' => self.set_col(param_or(params, 0, 1) as usize - 1),
5994            'd' => self.set_row(param_or(params, 0, 1) as usize - 1),
5995            'H' | 'f' => {
5996                let row = param_or(params, 0, 1) as usize - 1;
5997                let col = param_or(params, 1, 1) as usize - 1;
5998                self.goto(row, col);
5999            }
6000            'J' => self.erase_display(param_or(params, 0, 0)),
6001            'K' => self.erase_line(param_or(params, 0, 0)),
6002            'X' => self.erase_chars(param_or(params, 0, 1) as usize),
6003            '@' => self.insert_chars(param_or(params, 0, 1) as usize),
6004            'P' => self.delete_chars(param_or(params, 0, 1) as usize),
6005            'S' => self.scroll_up_lines(param_or(params, 0, 1) as usize),
6006            'T' => self.scroll_down_lines(param_or(params, 0, 1) as usize),
6007            'L' => self.insert_lines(param_or(params, 0, 1) as usize),
6008            'M' => self.delete_lines(param_or(params, 0, 1) as usize),
6009            'g' => self.clear_tab_stop(param_or(params, 0, 0)),
6010            'r' => {
6011                let rows = self.grid.rows() as u16;
6012                let top = param_or(params, 0, 1) as usize;
6013                let bottom = param_or(params, 1, rows) as usize;
6014                self.set_scroll_region(top, bottom);
6015            }
6016            'm' => self.sgr(params),
6017            's' => self.save_cursor(), // SCOSC (CSI s) — alias of DECSC
6018            't' => self.window_ops(params), // XTWINOPS — only 22/23 (#823)
6019            'u' => self.restore_cursor(), // SCORC (CSI u) — alias of DECRC
6020            // DA1 (primary device attributes, CSI c): advertise VT220 + ANSI
6021            // colour — the levels justerm actually implements (#27).
6022            'c' => self.replies.extend_from_slice(b"\x1b[?62;22c"),
6023            'n' => self.device_status_report(param_or(params, 0, 0)),
6024            // Non-private SM/RM. Folded over every parameter (modes can batch,
6025            // like the private path #56). IRM (4) and LNM (20) so far.
6026            'h' => {
6027                for m in params.iter().filter_map(|p| p.first().copied()) {
6028                    match m {
6029                        4 => self.insert_mode = true,
6030                        20 => self.newline_mode = true,
6031                        _ => {}
6032                    }
6033                }
6034            }
6035            'l' => {
6036                for m in params.iter().filter_map(|p| p.first().copied()) {
6037                    match m {
6038                        4 => self.insert_mode = false,
6039                        20 => self.newline_mode = false,
6040                        _ => {}
6041                    }
6042                }
6043            }
6044            _ => {}
6045        }
6046    }
6047
6048    fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
6049        // Not a print: the repeat is disarmed (#825, [`Term::repeat_anchor`]).
6050        self.repeat_anchor = None;
6051        // VT52 mode (#84): the pre-ANSI dialect reuses the same `ESC <final>`
6052        // tokens vte already produces, but with different meanings, so it is a
6053        // mode-gated branch here rather than a separate parser. All VT52 sequences
6054        // are intermediate-free; anything with an intermediate is not VT52.
6055        if self.vt52_mode && intermediates.is_empty() {
6056            self.vt52_dispatch(byte);
6057            return;
6058        }
6059        if let Some(&i) = intermediates.first() {
6060            // SCS: designate a charset to G0 (`ESC ( F`) or G1 (`ESC ) F`) (#62).
6061            if matches!(i, b'(' | b')') {
6062                let set = match byte {
6063                    b'0' => Charset::DecSpecialGraphics,
6064                    b'A' => Charset::Uk,
6065                    b'B' => Charset::Ascii,
6066                    _ => return, // other sets are later slices
6067                };
6068                self.charsets[if i == b'(' { 0 } else { 1 }] = set;
6069            }
6070            // Other intermediates (G2/G3 designators, etc.) are later slices.
6071            return;
6072        }
6073        match byte {
6074            b'D' => self.linefeed(), // IND (line-feed without CR)
6075            b'E' => {
6076                // NEL (next line): carriage return + line-feed.
6077                self.carriage_return();
6078                self.linefeed();
6079            }
6080            b'H' => self.set_tab_stop(),             // HTS
6081            b'M' => self.reverse_index(),            // RI
6082            b'7' => self.save_cursor(),              // DECSC
6083            b'8' => self.restore_cursor(),           // DECRC
6084            b'c' => self.full_reset(),               // RIS (#53)
6085            b'=' => self.application_keypad = true,  // DECKPAM (#74)
6086            b'>' => self.application_keypad = false, // DECKPNM
6087            _ => {}
6088        }
6089    }
6090
6091    /// OSC dispatch (the event surface): title (0/2), cwd (7). OSC 8 hyperlink
6092    /// is per-cell state, handled in its own slice, not here.
6093    fn osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool) {
6094        // Not a print: the repeat is disarmed (#825, [`Term::repeat_anchor`]).
6095        self.repeat_anchor = None;
6096        // Which byte ended the sequence decides which byte ends its reply, and
6097        // this is the only place it is observable — vte hands it over per
6098        // dispatch and keeps nothing (#836). It rides outward on the query
6099        // events rather than being remembered: `drain_events` is a batch, so
6100        // two queries can be outstanding at once and one stored scalar could
6101        // not say which exchange it belonged to.
6102        let terminator = if bell_terminated {
6103            Terminator::Bel
6104        } else {
6105            Terminator::St
6106        };
6107        // params[0] is the OSC number; params[1..] the payload fields.
6108        let Some(&number) = params.first() else {
6109            return;
6110        };
6111        match number {
6112            // OSC 0 = icon + window title, OSC 2 = window title. Both set title.
6113            // Since #823 the string is also *retained*, because a title pop has
6114            // nothing to restore otherwise. OSC 0 writes both axes and OSC 2
6115            // only the window one — the distinction was invisible while the
6116            // engine merely forwarded, and becomes observable the moment an
6117            // axis-limited push/pop pair (which `vim` emits) is answered.
6118            //
6119            // The payload is `params[1..]` **rejoined**, not `params[1]`: `vte` splits on
6120            // every `;` and a title may legally contain one, so reading a single field cut
6121            // `make -j8; ./run` down to `make -j8` and announced the short string as the
6122            // real one (#880). Same read-site rule #650 established for `OSC 8`. The guard
6123            // is the *slice* being non-empty, not the string: a fieldless `OSC 2` must stay
6124            // ignored where `OSC 2 ;` clears the title, and `params.get(1..)` answers
6125            // `Some(&[])` for the first, whose join is indistinguishable from the second.
6126            //
6127            // The rejoin recovers only what `vte` hands over, which is at most 16 fields: a
6128            // title with 15 or more `;` still arrives cut, and cannot be told from a complete
6129            // one here (#840, closed not planned; see the VT interpretation map note).
6130            b"0" | b"2" => {
6131                if let Some(fields) = params.get(1..).filter(|f| !f.is_empty()) {
6132                    let title = String::from_utf8_lossy(&fields.join(&b';')).into_owned();
6133                    if number == b"0" {
6134                        self.icon_name.clone_from(&title);
6135                    }
6136                    self.set_window_title(title);
6137                }
6138            }
6139            // OSC 7 = current working directory (a file:// URI). Rejoined for the reason
6140            // on the title arm above — `;` is a legal byte in a path and in a URI, and the
6141            // engine hands the value over as declared (#880, ADR-0017), up to the same
6142            // 16-field bound as the title.
6143            b"7" => {
6144                if let Some(fields) = params.get(1..).filter(|f| !f.is_empty()) {
6145                    let cwd = String::from_utf8_lossy(&fields.join(&b';')).into_owned();
6146                    self.events.push(TermEvent::Cwd(cwd));
6147                }
6148            }
6149            // OSC 133 = FinalTerm/iTerm2 shell-integration command marks (#158):
6150            // `A` prompt start, `B` command start, `C` output start, `D[;exit]`
6151            // command finished. Each anchors a kinded marker at the cursor line;
6152            // pairing + navigation is consumer policy (#160). Unknown subcommands
6153            // (or none) are ignored. `D`'s exit field parses to `i32`, else None.
6154            b"133" => match params.get(1).copied() {
6155                Some(b"A") => self.add_command_mark(MarkerKind::PromptStart),
6156                Some(b"B") => self.add_command_mark(MarkerKind::CommandStart),
6157                Some(b"C") => self.add_command_mark(MarkerKind::OutputStart),
6158                Some(b"D") => {
6159                    let exit = params
6160                        .get(2)
6161                        .and_then(|p| core::str::from_utf8(p).ok())
6162                        .and_then(|s| s.parse::<i32>().ok());
6163                    self.add_command_mark(MarkerKind::CommandFinished(exit));
6164                }
6165                _ => {}
6166            },
6167            // OSC 8 = hyperlink: `OSC 8 ; params ; URI`. A non-empty URI opens a
6168            // link (made current); an empty URI closes it. `params` carries the
6169            // optional `id=` that groups runs into one link (#635).
6170            b"8" => {
6171                // One allocation per *open*, shared by that open's cells and dropped
6172                // with the last row holding it (#628 — there is no pool). Two opens of
6173                // an identical URI stay two links, deliberately: merging them would
6174                // override a distinction the application controls through `id=`. That
6175                // parameter is the *only* dedup performed, which is one rule and not
6176                // two — xterm.js states it as "links with no id will only ever be
6177                // registered a single time" beside a lookup keyed on id-plus-uri
6178                // (`OscLinkService.ts:34`, `:49-54`).
6179                // The URI is `params[2..]` **rejoined**, not `params[2]` (#650). vte splits the
6180                // OSC payload on `;`, so a URI carrying an unencoded `;` arrives in pieces and
6181                // reading only the first dropped the rest — silently, with no error. Measured,
6182                // `]8;;https://x/a;b=c` arrives as `["8", "", "https://x/a", "b=c"]`. xterm.js
6183                // special-cases the same thing from the other side, splitting on the *first* `;`
6184                // only and taking all the rest as the URI, *"to support unencoded semi-colons in
6185                // the URIs"* (`InputHandler.ts:3106-3112`). `?a=1;b=2` is a legal query string.
6186                //
6187                // Nothing is lost at the parser **up to 16 fields**, and past that the tail is
6188                // gone before this arm runs: `vte` records at most 16 field boundaries, so a URI
6189                // with 14 or more `;` resolves to a shorter one that is indistinguishable from a
6190                // complete link (#840, closed not planned; see the VT interpretation map note).
6191                //
6192                // The close survives this: `]8;;` arrives as `["8", "", ""]`, whose rejoin is
6193                // empty, and an empty URI still closes. Never decoded — a `%3B` stays `%3B`,
6194                // because the engine hands the target over exactly as declared (ADR-0017).
6195                let uri: Vec<u8> = params.get(2..).unwrap_or_default().join(&b';');
6196                self.current_link = if uri.is_empty() {
6197                    None
6198                } else {
6199                    let uri = String::from_utf8_lossy(&uri);
6200                    Some(match osc8_link_id(params.get(1).copied().unwrap_or(b"")) {
6201                        // No id declared: fresh per open, the reference-correct default.
6202                        None => std::sync::Arc::from(&*uri),
6203                        Some(id) => self.link_for_id(&String::from_utf8_lossy(id), &uri),
6204                    })
6205                };
6206            }
6207            // OSC 4 = set/query an ANSI palette entry: `OSC 4 ; index ; spec`
6208            // (#122). The engine forwards index + raw spec; the consumer applies
6209            // it to its palette (theme-agnostic — the cell keeps `Indexed`).
6210            //
6211            // **An empty spec relays nothing, and drops only its own pair** (#834).
6212            // The engine cannot tell a *malformed* colour from a good one — it
6213            // never parses one, which is its identity and not a gap — but "this
6214            // field is empty" needs no parser, and forwarding `""` hands the
6215            // consumer a value it must invent a policy for.
6216            //
6217            // Dropping *the rest of the sequence* was the alternative, and it is
6218            // what xterm does: `ChangeOneAnsiColor` returns negative and that hits
6219            // `/* stop on any error */ break` (`misc.c:3013-3016`, pair loop at
6220            // `:2993`; chain `AllocateAnsiColor` → `xtermAllocColor` → `-1` at
6221            // `:2918`).
6222            //
6223            // **This is a deliberate divergence from the ADR-0004 tie-breaker, not
6224            // a case the tie-breaker fails to reach.** An earlier draft of this
6225            // comment claimed the latter and was wrong, which is worth stating
6226            // because the wrong version is the intuitive one: xterm makes the
6227            // *same* observation this engine makes — `strlen(spec) == 0`, no
6228            // parser, `misc.c:3105-3107` — and `XParseColor` at `:3111` is in the
6229            // `else if`, so it is never reached for an empty spec. The trigger IS
6230            // available here and "follow xterm" IS well defined. It is declined
6231            // because reading a blank field as evidence that structurally
6232            // well-formed pairs are corrupt is an inference about *application
6233            // intent*, which ADR-0017 puts on the consumer's side, and because it
6234            // would discard a value the application explicitly sent. That call is
6235            // the maintainer's, recorded on #834 with its grounds, and theirs to
6236            // reverse.
6237            //
6238            // **The references are 2–2 on this input, not 1–1**, and the two that
6239            // answer as this engine does are the two that keep walking: xterm.js
6240            // (`InputHandler.ts:3073`, loop `:3064`) and alacritty via `vte`
6241            // (`vte-0.15.0/src/ansi.rs:1372-1389` — a failed `xparse_color` falls
6242            // to `unhandled` with no `break`). ghostty relays nothing here, by a
6243            // *third* mechanism rather than by agreeing: `tokenizeScalar` drops
6244            // the empty token (`color.zig:130`) so the pairing re-aligns, and then
6245            // `RGB.parse("2")` fails into `catch return result` (`:210`), yielding
6246            // the accumulated — empty — list. Its re-alignment only becomes
6247            // *visible* where the re-aligned pair parses: `OSC 4 ; 1 ; ; #fff`
6248            // sets index 1 there and nothing anywhere else.
6249            //
6250            // The guard tests **emptiness only**, deliberately (#834 Out of
6251            // Scope). A space-only spec is relayed verbatim; TAB and NUL are C0
6252            // bytes `vte` drops inside an OSC string, so those fields arrive
6253            // genuinely empty and *are* dropped. Both are pinned by tests, so a
6254            // later "align with the reference" widening to whitespace reddens.
6255            b"4" => {
6256                // One event per `index ; spec` pair (xterm's `while slots > 1`).
6257                // The walk advances two fields whether or not a pair produces an
6258                // event, so dropping one cannot misalign the pairs after it.
6259                let mut rest = &params[1..];
6260                while let [idx, spec, tail @ ..] = rest {
6261                    rest = tail;
6262                    if let Ok(index) = String::from_utf8_lossy(idx).parse::<u8>() {
6263                        if *spec == b"?" {
6264                            self.events
6265                                .push(TermEvent::QueryPaletteColor { index, terminator });
6266                        } else if !spec.is_empty() {
6267                            self.events.push(TermEvent::SetPaletteColor {
6268                                index,
6269                                spec: String::from_utf8_lossy(spec).into_owned(),
6270                            });
6271                        }
6272                    }
6273                }
6274            }
6275            // OSC 104 = reset palette entries (#122): an **empty payload** resets
6276            // the whole table, else one event per named index.
6277            //
6278            // Empty means both `OSC 104` and `OSC 104 ;` (#832). vte hands those
6279            // over as `["104"]` and `["104", ""]`, and testing only the first left
6280            // the second falling into the index loop, where `"".parse::<u8>()`
6281            // fails and the reset evaporated silently. xterm tests the payload
6282            // string rather than the field count — `if (*buf != '\0')`
6283            // (`misc.c:3057`), whose else-branch is *"resetting all colors"*
6284            // (`misc.c:3077`) — and xterm.js gates on the same emptiness
6285            // (`InputHandler.ts:3223-3224`, a slot-less RESTORE).
6286            //
6287            // The test is `params[1]` being the *whole* payload, not "every field
6288            // is empty": for `OSC 104 ; ;` xterm's buf is `";"`, which is not
6289            // empty, so that form takes the index path. (ghostty differs here —
6290            // `tokenizeScalar` drops both separators and it resets everything —
6291            // but it agrees on the form that matters, `misc.c` and xterm.js do
6292            // not, and ADR-0004 puts the spec proxy on top.)
6293            b"104" => {
6294                if params.len() <= 1 || (params.len() == 2 && params[1].is_empty()) {
6295                    self.events.push(TermEvent::ResetPaletteColor(None));
6296                } else {
6297                    for &idx in &params[1..] {
6298                        if let Ok(index) = String::from_utf8_lossy(idx).parse::<u8>() {
6299                            self.events.push(TermEvent::ResetPaletteColor(Some(index)));
6300                        }
6301                    }
6302                }
6303            }
6304            // OSC 10/11/12 = set/query the default foreground/background/cursor
6305            // colour, stacking specs across the [fg, bg, cursor] slots (#122,
6306            // #137, #832). Each code names the slot the stack starts at. The
6307            // engine forwards raw specs (theme-agnostic).
6308            b"10" => self.special_color(params, 0, terminator),
6309            b"11" => self.special_color(params, 1, terminator),
6310            b"12" => self.special_color(params, 2, terminator),
6311            // OSC 52 = manipulate selection data (#828): `OSC 52 ; Pc ; Pd`.
6312            // The engine decodes `Pd` and relays the request; the *clipboard* is
6313            // the consumer's, and so is every policy about it.
6314            b"52" => self.clipboard(params, terminator),
6315            // OSC 110 / 111 / 112 = reset the default foreground / background /
6316            // cursor colour (#122, #832). One slot each, never a stack — xterm's
6317            // reset path resolves a single index from the code itself and walks
6318            // nothing (`misc.c:3729`).
6319            b"110" => self.events.push(TermEvent::ResetForeground),
6320            b"111" => self.events.push(TermEvent::ResetBackground),
6321            b"112" => self.events.push(TermEvent::ResetCursorColor),
6322            _ => {} // other OSCs are later slices
6323        }
6324    }
6325}
6326
6327#[cfg(test)]
6328mod tests {
6329    use super::cap_scroll;
6330    use super::version_number;
6331    use crate::Engine;
6332    use crate::damage::ScrollOp;
6333    use crate::serialize::MAX_SCROLL_COUNT;
6334
6335    /// #824 — the `Pv` mapping, in-crate because the seam cannot reach it.
6336    ///
6337    /// `tests/reply.rs` asserts the reply carries the number *this* crate
6338    /// version maps to, which is the assertion that fires the moment a release
6339    /// drifts from its report. What it cannot observe is the mapping itself:
6340    /// there is only ever one crate version at run time, so a hand-written
6341    /// literal equal to today's number is byte-identical to the derivation at
6342    /// that seam. Measured, by replacing the call site with `1500`:
6343    /// `cargo test --workspace` stayed green, and `cargo clippy --workspace
6344    /// --all-targets -- -D warnings` **failed** — `version_number` loses its
6345    /// only non-test caller and `dead_code` is an error under the gate. So the
6346    /// test suite alone cannot see it and the gate can; these cases are what
6347    /// make the mapping itself falsifiable rather than assumed.
6348    #[test]
6349    fn version_number_pads_semver_base_100() {
6350        // Each place is two decimal digits wide, so a higher version always
6351        // reports a higher number — which is the only property `Pv` promises.
6352        assert_eq!(version_number("0.0.1"), 1);
6353        assert_eq!(version_number("0.15.0"), 15_00);
6354        assert_eq!(version_number("1.2.3"), 1_02_03);
6355        assert_eq!(version_number("999.99.99"), 9_99_99_99);
6356        assert!(version_number("0.16.0") > version_number("0.15.9"));
6357        assert!(version_number("1.0.0") > version_number("0.99.99"));
6358    }
6359
6360    #[test]
6361    fn version_number_strips_a_pre_release_suffix() {
6362        // Semver starts the pre-release at the FIRST hyphen, so a two-part
6363        // suffix strips whole. alacritty cuts at the last and mis-parses this.
6364        assert_eq!(version_number("0.15.0-dev"), 15_00);
6365        assert_eq!(version_number("1.2.3-rc.1-dev"), 1_02_03);
6366        assert_eq!(version_number("1.2.3+build.5"), 1_02_03);
6367        // The cases above cannot observe the strip at all: their suffixes carry
6368        // no digits, so removing the `-` break leaves the number unchanged —
6369        // measured, by doing exactly that and watching them stay green. A digit
6370        // *inside* the suffix is what the strip is for.
6371        assert_eq!(version_number("0.15.0-rc2"), 15_00);
6372        assert_eq!(version_number("1.2.3-4"), 1_02_03);
6373    }
6374
6375    #[test]
6376    fn version_number_tolerates_a_short_or_odd_version() {
6377        // A missing component reads as zero rather than panicking, and a fourth
6378        // component is ignored — the report must not be able to fail.
6379        assert_eq!(version_number("2"), 2_00_00);
6380        assert_eq!(version_number("2.7"), 2_07_00);
6381        assert_eq!(version_number("1.2.3.4"), 1_02_03);
6382        assert_eq!(version_number(""), 0);
6383    }
6384
6385    /// #661 — the wire's `i16` bound, not the region-height one.
6386    ///
6387    /// In-crate on purpose, and the reason is cost rather than visibility: reaching
6388    /// this through `Engine` needs a screen taller than 32 767 rows *and* 32 768
6389    /// scrolls of it, each rotating a `line_damage` of that length. Measured at
6390    /// 15.8 s in a debug build for a single assertion — the whole `serialize` suite
6391    /// is 0.2 s without it. See `cap_scroll`'s note for how the coverage is split.
6392    #[test]
6393    fn a_region_taller_than_the_wire_field_truncates_rather_than_wraps() {
6394        // 40 000 rows: over i16::MAX, under MAX_ROWS (u16::MAX), so the region
6395        // height alone would let 35 000 through — and 35 000 as i16 is -30 536.
6396        let up = cap_scroll(ScrollOp {
6397            top: 0,
6398            bottom: 40_000,
6399            count: 35_000,
6400        });
6401        assert_eq!(up.count, MAX_SCROLL_COUNT, "capped, and still an up-scroll");
6402
6403        let down = cap_scroll(ScrollOp {
6404            top: 0,
6405            bottom: 40_000,
6406            count: -35_000,
6407        });
6408        assert_eq!(down.count, -MAX_SCROLL_COUNT, "sign survives the cap");
6409    }
6410
6411    /// The cap is a ceiling, not a rewrite: a count inside both bounds is reported
6412    /// exactly, and the region it names is untouched.
6413    #[test]
6414    fn a_scroll_inside_both_bounds_passes_through_unchanged() {
6415        let op = ScrollOp {
6416            top: 4,
6417            bottom: 9,
6418            count: -2,
6419        };
6420        assert_eq!(cap_scroll(op), op);
6421    }
6422
6423    /// #628 — a hyperlink's storage is released once no live row references it.
6424    ///
6425    /// In-crate on purpose: this defect has **no public observable**, which is why it
6426    /// survived from #46 until #621's completeness pass went looking. Pool indices never
6427    /// cross the wire (`Term::frame` remaps them to frame-local `link_table` positions),
6428    /// and the one public reader takes an index the caller already holds — so from
6429    /// outside the crate a pool of 5 entries and a pool of 50 000 are indistinguishable.
6430    /// The assertion has to stand where the storage does.
6431    ///
6432    /// The fixture is a buffer that cannot hold what it is fed: 2 rows plus 2 lines of
6433    /// scrollback is four lines total, so by the end all but the last four opens have
6434    /// been evicted and nothing on screen or in history refers to them.
6435    #[test]
6436    fn a_link_evicted_from_the_buffer_stops_being_stored() {
6437        let mut e = Engine::with_scrollback(20, 2, 2);
6438        for i in 0..50 {
6439            e.feed(format!("\x1b]8;;https://example.com/{i}\x07L{i}\x1b]8;;\x07\r\n").as_bytes());
6440        }
6441
6442        // The observable had to move with the storage — there is no pool left to count.
6443        // A `Weak` is the stronger form of the same claim anyway: a bounded count can be
6444        // bounded and still wrong, while a dead `Weak` says *this exact allocation* was
6445        // released.
6446        //
6447        // **`e2` must outlive the assertion, and that is the whole test.** The first
6448        // version of this scoped the engine to the block that built the `Weak`, so the
6449        // engine was dropped before the check and the `Weak` died for that reason
6450        // instead. Measured: with a deliberate leak reintroduced (a `Vec<Arc<str>>` on
6451        // `Term`, retaining every open), that version stayed **green** — a tautological
6452        // proof, confirming only that dropping an `Engine` frees its own memory. Keeping
6453        // the engine alive is what makes the assertion about reclamation.
6454        let mut e2 = Engine::with_scrollback(20, 2, 2);
6455        e2.feed(b"\x1b]8;;https://example.com/first\x07L\x1b]8;;\x07\r\n");
6456        let weak = {
6457            let arc = e2
6458                .term
6459                .grid
6460                .row_ref(0)
6461                .link_at(0)
6462                .expect("on screen")
6463                .clone();
6464            std::sync::Arc::downgrade(&arc)
6465        };
6466        // The live half first: a fix that simply never stored the URI would satisfy the
6467        // dead-`Weak` assertion below for the wrong reason.
6468        assert!(
6469            weak.upgrade().is_some(),
6470            "the URI must be alive while its cell is on screen",
6471        );
6472        for i in 0..50 {
6473            e2.feed(format!("filler {i}\r\n").as_bytes());
6474        }
6475        assert!(
6476            weak.upgrade().is_none(),
6477            "the first link scrolled out of a 4-line buffer and nothing should still \
6478             hold its URI — before #628 every OSC 8 open lived for the life of the Term",
6479        );
6480
6481        // The whole-buffer form of the same claim: 50 distinct opens through a buffer
6482        // that holds four lines leaves at most four entries *owned*.
6483        //
6484        // `owned_link_count` and not `link_at`, and that distinction is the test. The
6485        // first version summed the gated reader, which counts **linked cells** — measured
6486        // on an erased screen it read 0 while every URI was still allocated, so it could
6487        // not fail for the property this test exists to assert.
6488        // Deduped by allocation: one open covering three cells is three map entries and
6489        // one URI, so counting entries would fail at 9 for a buffer holding four links.
6490        let owned: std::collections::HashSet<*const u8> = e
6491            .term
6492            .scrollback
6493            .iter()
6494            .chain((0..2).map(|r| e.term.grid.row_ref(r)))
6495            .flat_map(|r| r.owned_links())
6496            .map(|u| std::sync::Arc::as_ptr(u) as *const u8)
6497            .collect();
6498        assert!(
6499            owned.len() <= 4,
6500            "a 4-line buffer cannot own more than 4 distinct URIs, found {}",
6501            owned.len(),
6502        );
6503    }
6504
6505    /// #628 — erasing a cell in place releases its URI, not just its presence bit.
6506    ///
6507    /// The sibling of the eviction test above, and the case that one structurally cannot
6508    /// see: `clear_cells` / `free_cell` blank a cell **without dropping its row**, so no
6509    /// row-lifetime event fires. Under `row-keyed-side-maps` rule 3 leaving the map entry
6510    /// is sanctioned — *"a write that clears the cell owes the bit, not the map"* — and
6511    /// that was exactly right while the value was a 4-byte index: a stale entry is
6512    /// unreadable through the gate and costs nothing.
6513    ///
6514    /// #628 changed what the entry *is*. The map now owns a heap string, so the same
6515    /// sanctioned line retains one. Rule 3 still holds as stated — purging is not the
6516    /// correctness step, and missing a site costs bounded retention rather than a wrong
6517    /// answer — but the optimisation it calls optional became worth taking here.
6518    /// All three references release at this point: alacritty's `Cell::reset` drops the
6519    /// `Option<Arc<CellExtra>>` outright, ghostty's ref-counted set frees at zero, and
6520    /// xterm.js's `_resetBufferLine` clears `_extendedAttrs` and disposes the line's
6521    /// markers so `OscLinkService` deletes the entry.
6522    #[test]
6523    fn an_erased_cell_releases_its_uri_not_only_its_bit() {
6524        let mut e = Engine::new(80, 24);
6525        e.feed(b"]8;;https://example.com/erasedL]8;;");
6526        let weak = {
6527            let a = e
6528                .term
6529                .grid
6530                .row_ref(0)
6531                .link_at(0)
6532                .expect("on screen")
6533                .clone();
6534            std::sync::Arc::downgrade(&a)
6535        };
6536        assert!(weak.upgrade().is_some(), "alive while on screen");
6537
6538        e.feed(b""); // ED 2 — erases in place; no row is dropped or reused
6539
6540        // The gated reader already says "no link", and so does the frame. Neither can
6541        // see the retention, which is why this assertion holds the `Weak` instead:
6542        // measured before the purge, both public views read 0 while the URI lived.
6543        assert!(e.link_at(0, 0).is_none(), "the presence bit is cleared");
6544        assert!(
6545            weak.upgrade().is_none(),
6546            "and the URI itself is released — before the purge the map kept owning it,              so an erased screen retained every link it had shown",
6547        );
6548    }
6549
6550    /// `Arc`, not `Rc`, and this is what makes that a fact rather than a comment.
6551    ///
6552    /// #628 chose `Arc<str>` for the row's link map on the stated ground that `Engine` is
6553    /// `Send + Sync`; `Rc` would have removed both **silently** — no signature changes
6554    /// here, and a downstream `Mutex<Engine>` failing to compile instead. The claim was
6555    /// load-bearing and unpinned: a repo-wide grep for it found only prose.
6556    #[test]
6557    fn the_engine_stays_send_and_sync() {
6558        fn assert_send_sync<T: Send + Sync>() {}
6559        assert_send_sync::<Engine>();
6560    }
6561
6562    /// Two OSC 8 opens of an identical URI are **two links**, not one.
6563    ///
6564    /// Deliberate, and the reason is #635: merging them would override the grouping the
6565    /// application controls through `id=`, which is the one dedup xterm.js performs.
6566    /// Asserted by allocation identity through the in-crate observer rather than through
6567    /// a public accessor — the behaviour is real now, a consumer asking about it is not.
6568    #[test]
6569    fn two_opens_of_one_uri_are_two_links() {
6570        let mut e = Engine::new(40, 2);
6571        // One open covering two cells, then a *separate* open of the very same URI.
6572        e.feed(b"]8;;https://example.com/xAB]8;;");
6573        e.feed(b"]8;;https://example.com/xC]8;;");
6574
6575        let row = e.term.grid.row_ref(0);
6576        let ptr = |c: usize| std::sync::Arc::as_ptr(row.link_at(c).expect("linked")) as *const u8;
6577        assert_eq!(
6578            e.link_at(0, 0).map(|h| h.uri().to_owned()),
6579            e.link_at(0, 2).map(|h| h.uri().to_owned()),
6580            "the text is the same",
6581        );
6582        assert_eq!(ptr(0), ptr(1), "A and B are one open, so one allocation");
6583        assert_ne!(
6584            ptr(0),
6585            ptr(2),
6586            "…but C is a second open — merging the two would override the distinction              `id=` exists to express (#635)",
6587        );
6588    }
6589
6590    /// The other half of the rule above: an `id=` the application declared **does** group
6591    /// (#635). One rule, not two — "never merge on URI alone, always merge on a declared
6592    /// id" is how xterm.js states it (`OscLinkService.ts:34`, `:51` at the pinned SHA), and
6593    /// justerm shipped the first half only because #26 ported `registerLink`'s id-minting
6594    /// and not its lookup.
6595    ///
6596    /// Grouping is asserted as **allocation identity**, which is not an implementation
6597    /// detail leaking into a test: since #628 the `Arc`'s address *is* link identity —
6598    /// `Term::frame` interns `link_table` by `Arc::as_ptr`, so one allocation is what makes
6599    /// two runs one link index on the wire, and that index is what a consumer groups by.
6600    #[test]
6601    fn the_same_id_and_uri_group_into_one_link() {
6602        let mut e = Engine::new(40, 2);
6603        // Two separate opens, same `id=` and same URI, on two different lines — the case
6604        // the parameter exists for (a link that cannot be one contiguous run).
6605        e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07A\x1b]8;;\x07\r\n");
6606        e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07B\x1b]8;;\x07");
6607
6608        let ptr = |r: usize, c: usize| {
6609            std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
6610        };
6611        assert_eq!(
6612            ptr(0, 0),
6613            ptr(1, 0),
6614            "the application said these two runs are one link, so they share one allocation",
6615        );
6616
6617        // And the wire agrees, which is the half a consumer can actually see: one entry in
6618        // `link_table`, referenced by both spans. Two entries is the defect.
6619        let f = e.frame();
6620        assert_eq!(f.link_table.len(), 1, "one link ships once");
6621    }
6622
6623    /// Keyed on `id` **and** URI, not on `id` alone — xterm.js's `_getEntryIdKey` is
6624    /// `` `${id};;${uri}` `` (`OscLinkService.ts:87`). An application reusing an id for a
6625    /// different target has not said "same link"; treating it as one would follow a stale
6626    /// declaration to the wrong URI.
6627    #[test]
6628    fn the_same_id_with_a_different_uri_stays_two_links() {
6629        let mut e = Engine::new(40, 2);
6630        e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07A\x1b]8;;\x07\r\n");
6631        e.feed(b"\x1b]8;id=xyz;https://example.com/b\x07B\x1b]8;;\x07");
6632
6633        let ptr = |r: usize, c: usize| {
6634            std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
6635        };
6636        assert_ne!(
6637            ptr(0, 0),
6638            ptr(1, 0),
6639            "same id, different target — two links"
6640        );
6641        assert_eq!(e.frame().link_table.len(), 2, "and both ship");
6642    }
6643
6644    /// `id=` with an **empty value** is no id at all, so the no-id rule applies and each
6645    /// open is its own link. xterm.js reaches this by `parsedParams[i].slice(3) || undefined`
6646    /// (`InputHandler.ts:3130`) — the `||` is the whole behaviour, and reading `slice(3)`
6647    /// alone gives the opposite answer.
6648    ///
6649    /// Worth a test rather than a comment because the empty-string key is the one that
6650    /// would group *every* `id=`-with-no-value link in a session into one, across unrelated
6651    /// URIs — a wrong answer that grows with uptime.
6652    #[test]
6653    fn an_empty_id_value_is_no_id_at_all() {
6654        let mut e = Engine::new(40, 2);
6655        e.feed(b"\x1b]8;id=;https://example.com/a\x07A\x1b]8;;\x07\r\n");
6656        e.feed(b"\x1b]8;id=;https://example.com/a\x07B\x1b]8;;\x07");
6657
6658        let ptr = |r: usize, c: usize| {
6659            std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
6660        };
6661        assert_ne!(
6662            ptr(0, 0),
6663            ptr(1, 0),
6664            "no id declared, so the reference-correct fresh-per-open rule still holds",
6665        );
6666    }
6667
6668    /// `params` is a **`:`-separated** key=value list (`id=xyz123:foo=bar:baz=quux`), and
6669    /// `id` may sit anywhere in it — xterm.js scans with `findIndex(e =>
6670    /// e.startsWith('id='))` (`InputHandler.ts:3129`). Testing only a leading `id=` would
6671    /// pass with a `starts_with` on the whole field, which is the wrong parse.
6672    #[test]
6673    fn the_id_param_is_found_among_other_params() {
6674        let mut e = Engine::new(40, 2);
6675        e.feed(b"\x1b]8;foo=bar:id=xyz:baz=quux;https://example.com/a\x07A\x1b]8;;\x07\r\n");
6676        e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07B\x1b]8;;\x07");
6677
6678        let ptr = |r: usize, c: usize| {
6679            std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
6680        };
6681        assert_eq!(
6682            ptr(0, 0),
6683            ptr(1, 0),
6684            "the id is the same whatever else rides beside it",
6685        );
6686    }
6687
6688    /// The grouping registry must not become the pool #628 deleted.
6689    ///
6690    /// Whatever maps an `id=` to its link has to hold it **weakly**: a strong reference
6691    /// would make every id'd link immortal for the life of the `Term` — the exact defect
6692    /// #628 removed, re-entering through the door #635 opens. xterm.js's equivalent map is
6693    /// reclaimed rather than weak (`_entriesWithId.delete` when the entry's last line
6694    /// marker is disposed, `OscLinkService.ts:98-100`); justerm has no disposal hook by
6695    /// design, so `Weak` is how the same lifetime is expressed here.
6696    ///
6697    /// This is the test that discriminates the two, and nothing public can: both spellings
6698    /// group correctly, and they differ only in what stays alive afterwards.
6699    #[test]
6700    fn the_id_registry_does_not_keep_a_link_alive() {
6701        let mut e = Engine::new(80, 24);
6702        e.feed(b"\x1b]8;id=xyz;https://example.com/grouped\x07L\x1b]8;;\x07");
6703        let weak = {
6704            let a = e
6705                .term
6706                .grid
6707                .row_ref(0)
6708                .link_at(0)
6709                .expect("on screen")
6710                .clone();
6711            std::sync::Arc::downgrade(&a)
6712        };
6713        assert!(weak.upgrade().is_some(), "alive while on screen");
6714
6715        e.feed(b"\x1b[2J"); // ED 2 — the in-place erase that releases the row's side maps
6716
6717        assert!(
6718            weak.upgrade().is_none(),
6719            "the id registry must hold a Weak — a strong entry would outlive the screen and              rebuild #628's leak one id at a time",
6720        );
6721    }
6722}