justerm-core 0.17.0

A pure terminal engine: VT byte stream to grid + scrollback + damage. No I/O, no rendering, theme-agnostic.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
//! Consumer event surface (#12): point-in-time notifications the engine
//! accumulates while parsing, for the consumer to drain.
//!
//! Pull, not push — the engine queues events during `feed` and the consumer
//! takes them with `drain_events`, mirroring the rest of the pull cadence
//! (`damage` / `frame` / `reset_damage`). No callback is injected across the
//! boundary, so the engine stays decoupled from the consumer's event loop
//! (unlike alacritty's `EventListener`, whose push model would couple them).
//!
//! OSC 8 hyperlinks are deliberately absent — a hyperlink is per-cell state
//! (which cells are links), not a point-in-time event, so it is modelled like
//! graphemes in its own slice (#26), not here.

use crate::serialize::{MarkerId, MarkerKind};

/// Which byte ended an OSC sequence — and therefore which one ends its reply.
///
/// The engine relays this rather than choosing: a query event carries the
/// terminator the request arrived with, and the consumer hands it back to the
/// matching `report_*`. Under ADR-0017 the parse-time fact is a *mechanism* only
/// the engine can observe, while *which* terminator to send is policy — and a
/// consumer cannot exercise a policy on a fact it was never given, which is what
/// #836 measured: `bell_terminated` was discarded at the parser boundary before
/// any event was queued.
///
/// **The spec settles the direction, not just the reference tally.**
/// `ctlseqs.txt:2020` — *"XTerm accepts either BEL or ST for terminating OSC
/// sequences, and when returning information, uses the same terminator used in a
/// query. While the latter is preferred, the former is supported for legacy
/// applications."* Under ADR-0004 that outranks every implementation, this one
/// included. **On the colour path** all three implementations that echo carry the
/// terminator *outward with the request* rather than remembering it: alacritty
/// binds it into the reply formatter it sends its consumer
/// (`alacritty_terminal/src/term/mod.rs:1678`), ghostty makes it a field on the
/// parsed command (`src/terminal/osc.zig:87`, written at
/// `src/termio/stream_handler.zig:1497`), and xterm threads it as a parameter
/// (`misc.c:3567`, emitted at `:3593`). xterm.js is the one that always sends ST
/// (`CoreBrowserTerminal.ts:239`) — which is what this engine used to do.
///
/// **The colour qualifier is load-bearing, because `OSC 52` has a counterexample.**
/// xterm *does* store the terminator for the clipboard: `int base64_final;` on the
/// screen (`ptyx.h:2637`), written when the request is parsed (`misc.c:3389`) and
/// read a file away when the paste finally arrives (`button.c:2218`), because its
/// X selection retrieval is asynchronous. That is shape (b) — the one this engine
/// weighed and did not take — on the exact sequence `report_clipboard` answers.
///
/// It is recorded here because it **strengthens** the choice rather than undoing
/// it. xterm stores precisely because the reply is detached from the request, and
/// its store is a *single scalar*, so two overlapping paste requests would collide
/// exactly as one stored terminator does here. Detachment is the condition
/// [`crate::Engine::drain_events`] creates for every family at once, not just one —
/// so carrying answers it where storing only postpones it.
///
/// **Carrying beats remembering for a reason that is this channel's.**
/// [`crate::Engine::drain_events`] hands over a batch, so a consumer can hold two
/// colour queries at once and answer them in either order; one remembered scalar
/// could not say which exchange it belonged to. An occurrence's payload is
/// detached from its instant by the queue — ADR-0029 D4 records the same shape
/// for coordinates — so there is no re-ask and the fact must ride the event.
///
/// **Exhaustive on purpose (#843's rule).** The space is closed at exactly two,
/// with a date for each (`ctlseqs.txt:2024-2028`), so there is no member a later
/// slice may name and nothing for `#[non_exhaustive]` to preserve.
///
/// ⚠ **The closure rests on the input space, not on the spec alone (#847).** ECMA-48
/// gives `ST` a third encoding — the 8-bit C1 `0x9C` — and it is absent here because
/// [`crate::Engine::feed`] does not treat a lone `0x80..=0x9F` byte as a control at
/// all, not because the spec stops at two. A reader who finds `0x9C` in `ctlseqs.txt`
/// and concludes this enum is missing a member has the reasoning backwards: were that
/// contract ever revisited, the member would follow, and it is the contract that is
/// load-bearing.
/// ghostty reaches the same shape independently — `src/terminal/osc.zig:252` is a
/// two-member `{ st, bel }` with no trailing `_`, which is Zig's marker for an
/// open enum and is used at 23 other sites in that tree. Convergence on both the
/// partition and the closure is the non-arbitrariness signal.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Terminator {
    /// `ESC \` (ST), the terminator ECMA-48 documents and xterm prefers.
    ///
    /// The default, and what an OSC ended by **any other byte that ends one**
    /// resolves to. Those streams are real rather than theoretical: `vte` ends a
    /// string on three byte classes — `BEL`, the cancel pair `CAN`/`SUB` (`0x18` /
    /// `0x1a`), and a bare `ESC` opening the next sequence — and only the first is
    /// reported as bell-terminated. So a cancelled query is still relayed, and
    /// answered ST.
    ///
    /// **Read "any other byte that ends one" strictly: the 8-bit C1 `ST` (`0x9C`) is
    /// not a fourth class (#847).** It does not end the string, so there is no event
    /// to carry a terminator and nothing resolves to this variant — the OSC stays
    /// open instead. See [`crate::Engine::feed`] for why that is a contract.
    ///
    /// That is the right answer, not a fallback: xterm hardcodes ST on exactly this
    /// shape (`charproc.c:8964`, *"should be ST"*) and ghostty's `Terminator.init`
    /// returns `.st` for a missing byte (`src/terminal/osc.zig:263`).
    #[default]
    St,
    /// `BEL` (`0x07`), supported for legacy applications.
    ///
    /// Real applications still emit it: `nvim` 0.8.0 asks `ESC ] 11 ; ? BEL` in
    /// this crate's own `cursor_color_nvim.raw` fixture. And the shell idiom
    /// `printf '\e]11;?\a'; read -d $'\a'` reads *until* BEL, so an ST answer to
    /// a BEL question blocks that `read` until it times out.
    Bel,
}

impl Terminator {
    /// The bytes that end a reply carrying this terminator.
    pub(crate) fn bytes(self) -> &'static [u8] {
        match self {
            Terminator::St => b"\x1b\\",
            Terminator::Bel => b"\x07",
        }
    }
}

/// Which selection an `OSC 52` clipboard request names (#828).
///
/// A *value*, never the protocol byte, so a consumer never parses the sequence —
/// the same reason [`TermEvent::SetPaletteColor`] carries a `u8` index rather
/// than the field it was written in.
///
/// **Three members, and `p` is kept apart from `s` deliberately.** The
/// sequence's target field admits `c`, `p`, `q`, `s` and the eight cut buffers
/// (`ctlseqs.txt:2156`); justerm models the three a consumer can act on and
/// ignores the rest rather than folding them onto a neighbour, since mapping `q`
/// onto a selection would be the engine inventing an equivalence the application
/// did not ask for. ghostty folds every unrecognised kind onto the clipboard
/// (`src/termio/stream_handler.zig:1013`); alacritty ignores them
/// (`alacritty_terminal/src/term/mod.rs:1714`), and so does this. Read
/// ghostty's from the `switch` and not from the comment four lines above it,
/// which says *"we ignore the 'kind' field and always use the standard
/// clipboard"* and is contradicted by the code under it — only the `else` arm
/// goes to `.standard`.
///
/// **The first draft collapsed `p` and `s` into one member, and that was wrong
/// on the wire.** alacritty collapses them
/// (`alacritty_terminal/src/term/mod.rs:1713`) but replies with the byte the
/// application sent (`:1744`), so the collapse never reaches a client. This
/// engine hands the consumer a value and gets it back at `report_clipboard`, so
/// a collapse here would answer `ESC ] 52 ; s ; ?` naming `p` — a selector the
/// application never wrote, in the one field a client could pair a reply on. The
/// spec lists the two separately (`ctlseqs.txt:2157`), xterm binds them to
/// different atoms (`misc.c:3327`) and echoes the recognised list back
/// (`misc.c:3384`), and ghostty keeps three locations apart in both directions
/// (`src/Surface.zig:5954`, `src/terminal/c/terminal.zig:2942`). Splitting the
/// member is what lets the value round-trip without the engine remembering
/// anything.
///
/// **`#[non_exhaustive]` (#843).** The set is open by the paragraph above: `q` and
/// the eight cut buffers are in the sequence and unmodelled here, so a later slice
/// may name one. A consumer meeting a member it does not know can decline the
/// request, which is already how it refuses any of them.
///
/// **ghostty reaches the same shape independently**, which #843 had recorded as
/// impossible — its issue says Zig "has no such construct", and Zig does: a
/// trailing `_` marks a non-exhaustive enum, used at 23 sites in that tree. The
/// one that matters here is `src/terminal/clipboard.zig:2`, whose `Location` is
/// `{ standard, selection, primary, _ }` — the same three members this type
/// carries, and open for the same reason. Convergence on both the partition and
/// the openness is the non-arbitrariness signal; it was invisible while the
/// corpus was recorded as having no vote.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ClipboardTarget {
    /// The system clipboard — the `c` field, **and the empty field**.
    ///
    /// The empty field is the common form in the wild rather than an edge case:
    /// it is what `tmux` 3.2a was measured emitting for both an ordinary
    /// copy-mode copy and `set-buffer -w` (#828). Reading it as "unrecognised"
    /// would drop the only emission this project has observed.
    Clipboard,
    /// The primary selection — the `p` field. On a platform with no primary
    /// selection a consumer may treat it as [`Clipboard`](Self::Clipboard); the
    /// engine does not make that choice for it.
    Primary,
    /// The `s` field — *"the configurable primary/clipboard selection"*
    /// (`ctlseqs.txt:2161`), which is to say: whichever of the two the user has
    /// configured.
    ///
    /// **Relayed rather than resolved, and that is the boundary working.** The
    /// thing that decides what `s` means is a setting: xterm resolves `SELECT`
    /// through `DefaultSelection`, which is the `selectToClipboard` resource
    /// (`button.c:2081`), and under ADR-0017 a setting is the consumer's. So the
    /// application's choice is carried through unchanged and the consumer
    /// resolves it against the configuration it owns.
    ///
    /// **A consumer with no such setting should treat this as
    /// [`Primary`](Self::Primary), because that is what xterm-as-shipped does**
    /// — `selectToClipboard` defaults to false. Worth stating rather than left
    /// to taste, since the alternative reading sends the copy somewhere the
    /// reference would not.
    ///
    /// And the setting is not purely out of reach: **DECSET 1041 sets the same
    /// resource from the stream** (`ctlseqs.txt:1008`), so an engine that
    /// tracked that mode could resolve `s` itself. justerm does not model 1041,
    /// which is a *declined* capability rather than an impossible one — the
    /// honest form of the claim, and the mode is unimplemented here like the
    /// rest of the tail (#47).
    Selection,
}

/// A consumer-facing event emitted while parsing the VT stream.
///
/// **`#[non_exhaustive]`, so a consumer must carry a `_` arm and a new variant
/// never breaks one.** Decided 2026-09-02, by the maintainer, while #828 was
/// adding two — and what decided it was neither this slice nor any consumer we
/// can see.
///
/// **What decided it is `CLAUDE.md`'s own identity statement**: *"`justerm-core`
/// is not penterm-only — it is a reusable, independent crate."* That sentence
/// says there are consumers we cannot edit, which is precisely what this
/// attribute defends; a crate whose identity were "internal, used by penterm"
/// would want the opposite, because there a broken build is the compiler doing
/// us a favour. So this follows from a call already made rather than from a
/// preference, and it reverses only if that identity does.
///
/// Three measurements, so the next reader does not have to retake them:
///
/// - **crates.io reverse dependencies: zero** (the single row the API returns is
///   this crate itself), across 248 downloads split over 11 versions — i.e. no
///   external consumer exists *today*. That is why the identity statement had to
///   decide it: there was nothing to observe.
/// - **Cost in this workspace: zero.** `cargo test --workspace` (87 suites) and
///   `clippy -D warnings` both stay green. A same-crate `match` may still be
///   exhaustive, `justerm-wasm-decode` and `justerm-renderer` never name
///   `TermEvent`, and `justerm-web`'s `events.ts` is a deliberately narrower
///   union (title / bell / cwd).
/// - **The window closes at `1.0.0`.** Adding this is free while the crate is
///   `0.x` and is *itself* a breaking change afterwards, while an enum without
///   it turns every future variant into a major bump. Conformance here is
///   cumulative by design (#47 is a perpetual tail) and the two slices before
///   this one added three variants and two, so that rate is measured rather than
///   assumed.
///
/// **The argument that lost, recorded because it is a real cost.** An exhaustive
/// match is a *feature* for a consumer: the compiler tells them a new event
/// exists and makes them decide about it. penterm's `route_event` is the worked
/// example — its `ColumnMode` and `ColorSchemeQuery` arms carry a comment
/// explaining why each is dropped, written by someone the compiler had just
/// informed. That signal is given up here, and it now has to come from release
/// notes. It loses because this is a *notification* channel where ignoring an
/// unknown event is documented as safe, so the guarantee belongs in prose rather
/// than in the type — but a consumer who wanted the old behaviour is not
/// imagining the loss.
///
/// (penterm was the evidence that an outside exhaustive matcher can exist — its
/// five-variant `match` predates nine minor versions of this enum — and not the
/// reason. It is being reimplemented, which is exactly why it could not be.)
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TermEvent {
    /// The window title is now this string.
    ///
    /// Read the tense carefully: since #823 this is **not** only "the
    /// application set a title". Two paths emit it — `OSC 0`/`OSC 2`, and an
    /// XTWINOPS title *pop* (`CSI 23 t`) restoring what an earlier `CSI 22 t`
    /// saved. A consumer that treats it as "the title is now this" is correct
    /// for both; one that treats it as "the application just chose this" is
    /// wrong for the second, which is why there is no separate pop event.
    ///
    /// A pop can legitimately restore the **empty** string — every application
    /// measured pushes at startup, before setting a title of its own — and that
    /// means "go back to whatever you would show by default", not "show a blank
    /// title".
    Title(String),
    /// The terminal bell rang (BEL, `0x07`).
    Bell,
    /// The working directory was reported (OSC 7), e.g. `file://host/path`.
    Cwd(String),
    /// The app requested 80/132-column mode (DECCOLM `?3`). justerm is
    /// dimension-free, so this is a *request* — the consumer may honor it by
    /// calling `resize(cols, rows)`, or ignore it. `cols` is 80 or 132 (#82).
    ColumnMode { cols: usize },
    /// The app queried the light/dark color scheme (DSR `CSI ? 996 n`). justerm
    /// is theme-agnostic, so the consumer (which knows the scheme) answers by
    /// calling `Engine::report_color_scheme` (#85).
    ColorSchemeQuery,
    /// The app set ANSI palette entry `index` to `spec` (OSC 4). One event per
    /// `index ; spec` pair in the sequence. The cell still references
    /// `Indexed(index)` — only the consumer's `palette[index]` changes, so the
    /// engine stays theme-agnostic (#122).
    SetPaletteColor { index: u8, spec: String },
    /// The app set the default foreground colour (OSC 10). Raw spec, forwarded
    /// for the consumer to apply — theme-agnostic, like [`SetBackground`](Self::SetBackground) (#122).
    SetForeground(String),
    /// The app set the default background colour (OSC 11). The engine is
    /// theme-agnostic, so it forwards the raw spec string (`rgb:…`/`#…`) for the
    /// consumer to parse and apply to its palette — it never holds hex (#122).
    SetBackground(String),
    /// The app reset palette entries to the theme default (OSC 104). `None` =
    /// the whole table (no argument); `Some(index)` = one entry, one event per
    /// index given. The consumer restores its palette (#122).
    ResetPaletteColor(Option<u8>),
    /// The app queried ANSI palette entry `index` (OSC 4 with `?` for that pair);
    /// the consumer answers with `report_palette_color` (#122).
    QueryPaletteColor {
        index: u8,
        /// The terminator `report_palette_color` must answer with (#836).
        terminator: Terminator,
    },
    /// The app set the cursor colour (OSC 12, #832). The third slot of the same
    /// dynamic-colour sequence `SetForeground` and `SetBackground` ride, and
    /// theme-agnostic for the same reason: the raw spec is forwarded and the
    /// consumer — which owns the palette *and* the cursor's contrast guard —
    /// applies it.
    SetCursorColor(String),
    /// The app queried the cursor colour (OSC 12 with `?`); the consumer answers
    /// with `report_cursor_color` (#832).
    QueryCursorColor {
        /// The terminator `report_cursor_color` must answer with (#836).
        terminator: Terminator,
    },
    /// The app reset the cursor colour to the theme default (OSC 112, #832). The
    /// third member of the 110/111/112 reset family, and the one real
    /// applications emit most: `nvim` sends it on startup, on every alt-screen
    /// transition and on exit.
    ResetCursorColor,
    /// The app reset the default foreground to the theme default (OSC 110, #122).
    ResetForeground,
    /// The app reset the default background to the theme default (OSC 111, #122).
    ResetBackground,
    /// The app queried the default foreground colour (OSC 10 with `?`); the
    /// consumer answers with `report_foreground` (#122).
    QueryForeground {
        /// The terminator `report_foreground` must answer with (#836).
        terminator: Terminator,
    },
    /// The app queried the default background colour (OSC 11 with `?`). The
    /// theme-agnostic engine relays it; the consumer answers with
    /// `report_background` (#122), mirroring `ColorSchemeQuery`.
    QueryBackground {
        /// The terminator `report_background` must answer with (#836).
        terminator: Terminator,
    },
    /// The app asked for `text` to be put on `target` (`OSC 52` with a payload,
    /// #828). The engine has already base64-decoded it, and holds no clipboard
    /// of its own.
    ///
    /// **This is a request, not a fact.** Whether the copy happens is the
    /// consumer's: it owns the platform clipboard, any permission model and any
    /// prompt, and a consumer that drops this event has refused the copy. The
    /// engine carries no allow/deny knob, which is where it parts company with
    /// alacritty — alacritty gates the same sequence behind a four-state config
    /// (`alacritty_terminal/src/term/mod.rs:1706`) because alacritty *is* the
    /// consumer. Under ADR-0017 that gate lives one layer out.
    ///
    /// **An empty `text` means "clear it".** `ESC ] 52 ; c ; ESC \` carries a
    /// payload that decodes to nothing, and both the spec and xterm end that
    /// exchange with an empty selection — the spec because `Pd` *"becomes the
    /// new selection"* whatever it is (`ctlseqs.txt:2166`), xterm because it
    /// clears the buffer before appending anything (`misc.c:3410`). Note this is
    /// **not** the spec's *"neither a base64 string nor `?`"* clause at
    /// `ctlseqs.txt:2174`, which the engine diverges from: an empty payload is a
    /// perfectly well-formed encoding of no bytes, so it never reaches that
    /// sentence. Citing `:2174` here, as an earlier draft did, would have the
    /// same line standing as authority followed and as authority departed from.
    /// ghostty pins the same input under a test named *"clear clipboard"*
    /// (`src/terminal/osc/parsers/clipboard_operation.zig:93`). It needs no rule
    /// of its own here, which is the argument for having none: an empty payload
    /// *is* a well-formed encoding of no bytes, so it reaches the consumer
    /// through the ordinary path.
    ClipboardStore {
        target: ClipboardTarget,
        text: String,
    },
    /// The app asked what is on `target` (`OSC 52` with a `?` payload, #828).
    /// The consumer answers by calling `report_clipboard`, which encodes the
    /// reply — or declines, which is how a clipboard *read* is refused
    /// independently of a write.
    ///
    /// The engine cannot answer this itself and deliberately holds nothing that
    /// would let it: a query is answered from the consumer's clipboard or not at
    /// all, so there is no engine state here for a hostile application to read
    /// back. Same `Query…` + `report_…` shape as `OSC 4`/`10`/`11`/`12`.
    QueryClipboard {
        target: ClipboardTarget,
        /// The terminator `report_clipboard` must answer with (#836).
        terminator: Terminator,
    },
    /// A decoration marker's line left the buffer — evicted past the scrollback
    /// cap, or scrolled out of an in-screen region (#118). The handle is now
    /// dead; the consumer drops the decoration bound to it. This is the
    /// frame-mode equivalent of xterm's `IMarker.onDispose` — disposal is a
    /// point-in-time fact (a marker absent from a frame may merely be scrolled
    /// off-screen), so it rides the event queue, not the frame overlay.
    MarkerDisposed(MarkerId),
    /// A marker was created (#490) — by `add_marker`, or by the *stream* through an
    /// OSC 133 command mark, which the consumer never called for.
    ///
    /// The mirror of [`TermEvent::MarkerDisposed`], and it exists for the same reason
    /// ADR-0020 R1 gives: an appearance is an occurrence, not state, so it rides this
    /// queue rather than a frame field. Without it a consumer that pulled a marker
    /// index (`Engine::marker_index`) has no way to learn of a marker born after its
    /// pull — the population would only ever shrink.
    ///
    /// `line` is absolute at the moment of creation, and `evicted_total` / `epoch` are the
    /// instant it is absolute at — the same triple [`crate::MarkerIndex`] carries, because
    /// this event is that pull's incremental mirror. The consumer appends the entry with
    /// the basis it arrived on and rebases it exactly like a pulled one.
    ///
    /// **The two are one fact and neither is usable alone (#737).** A single `feed` can
    /// create a marker and then evict, so by the end of the batch the buffer's origin has
    /// moved out from under the line this event already carries. `Frame::evicted_total` is
    /// the basis at the *end* of that batch, so reading `line` against it misplaces the
    /// marker by however much the batch evicted after the birth — measured at three lines,
    /// with the event line, both frame bases, the epoch and `Frame::marker_count` all
    /// identical to the batch that evicted *first* and needs no adjustment at all.
    ///
    /// **And a basis dates only a uniform move (#741).** Eviction shifts every marker by
    /// the same amount, which is what one scalar can say; a reflow or a region rotate
    /// moves them *individually*, which is what `epoch` is for. A birth still queued when
    /// the epoch moves describes a buffer that no longer exists, and carrying only the
    /// basis leaves that indistinguishable from a birth in the current generation —
    /// measured, a mark at absolute 3 reflowed to 5 with the basis unmoved at 0.
    ///
    /// Deliberately not an epoch bump: a bump costs a whole re-pull, and creation is
    /// `O(1)` information.
    MarkerCreated {
        id: MarkerId,
        line: u32,
        kind: MarkerKind,
        /// Lines evicted since RIS at the moment of creation — the basis `line` is
        /// absolute at. Carried rather than inferred so that placement does not depend on
        /// whether the consumer drains this queue before or after it reads the frame,
        /// which nothing in the API specifies.
        ///
        /// It is the same quantity `Frame::evicted_total` reports, so a consumer whose
        /// transport crosses a language boundary owes it the same treatment: the wasm
        /// frame getter hands its `u64` over as an `f64` deliberately (exact to 2^53),
        /// because a `BigInt` on one side of a subtraction and a `number` on the other is
        /// a `TypeError`, not a rounding question.
        evicted_total: u64,
        /// The marker generation this line belongs to — [`crate::MarkerIndex::epoch`] at
        /// the moment of creation (#741). Two lines dated with different epochs are
        /// answers about different buffers and nothing rebases one onto the other, so a
        /// consumer adopts this entry only into the generation it names and lets the
        /// re-pull that the bump already forces supply it otherwise.
        ///
        /// Compare it for **equality**, never for order: the counter is
        /// `wrapping_add`, so `<` is meaningless across a wrap while `==` is exact.
        epoch: u32,
    },
}