justerm-core 0.15.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
444
445
446
447
//! #750 — a command mark is repaired when the buffer *moves* and by nothing when its
//! row's **content** dies in place, so `Engine::command_lines` answers with commands
//! that are not there and with text that belongs to something else.
//!
//! Two defects, and they do not fold together — which is the whole reason this file
//! pins both halves separately.
//!
//! **The text half — captured, not re-read.** `command_lines` used to re-extract each
//! command from *current* cells, clipped to `[b_col, c_col)`. Measured, four different
//! verbs make that clip name somebody else's content: a plain overwrite, ICH, DCH and
//! an erase. Only the last is a verb a disposal rule could ever reach, so no lifetime
//! rule closes this — the text is frozen at OSC-133 `C`, which is the instant it is
//! complete and on screen, and read back verbatim afterwards. That is where the fact is
//! first true, and it is what the one system that solves this at all does (VSCode's
//! shell integration extracts at its command-executed handler and stores the string).
//!
//! **The lifetime half — ED only, deliberately.** A mark whose row was blanked still
//! reports a *document line*, and revealing to it lands on an empty row. ED's whole-row
//! arms therefore dispose the marks on those lines, firing `MarkerDisposed` so the
//! consumer's cleanup stays one path. **EL and ECH deliberately do not**, and that is
//! not an omission: both references retire a mark on a whole-row reset and on nothing
//! else (xterm.js `Buffer.clearMarkers` is called only from `_resetBufferLine`, reached
//! from `eraseInDisplay`; ghostty's whole-struct `row.* = .{ .cells = … }` in
//! `Screen.clearRows` resets `semantic_prompt` while `Screen.clearCells`, which
//! `eraseLine`/`eraseChars` use, never touches it). There is also a reference-free
//! reason, and it is the decisive one: `\r ESC[K` is how a line editor redraws the input
//! line on **every keystroke**, and `B` was emitted before the user began typing. An EL
//! that retired marks would delete the `CommandStart` of the command being typed, so no
//! command would ever be reported at all.
//!
//! **Why the exit code is resolved in the stream.** Disposing a row's marks used to
//! re-parent the *next* exit code onto the *previous* command, because the pairing was
//! positional over survivors (`out.last_mut()`), and only the from-the-oldest ordering
//! of eviction had been hiding it. Measured before the fix:
//! `[(0,"a0",Some(1)),(1,"a1",Some(2)),(2,"a2",Some(3))]` → dispose everything on
//! absolute line 1 → `[(0,"a0",Some(2)),(2,"a2",Some(3))]`. The exit is now written
//! onto the `OutputStart` mark when `D` arrives, so the pairing is decided where it is
//! unambiguous and a disposal can only ever *drop* an answer, never move one.

use justerm_core::{Engine, MarkerKind, TermEvent};

/// Four complete OSC-133 groups on their own rows: `$ cN` typed, `o` printed.
fn four_commands() -> Engine {
    let mut e = Engine::with_scrollback(16, 6, 40);
    for i in 0..4 {
        e.feed(
            format!("\x1b]133;A\x07$ \x1b]133;B\x07c{i}\x1b]133;C\x07o\r\n\x1b]133;D;0\x07")
                .as_bytes(),
        );
    }
    e.drain_events();
    e
}

fn lines(e: &Engine) -> Vec<(usize, String, Option<i32>)> {
    e.command_lines()
        .into_iter()
        .map(|c| (c.line, c.command, c.exit))
        .collect()
}

fn disposed(e: &mut Engine) -> usize {
    e.drain_events()
        .into_iter()
        .filter(|ev| matches!(ev, TermEvent::MarkerDisposed(_)))
        .count()
}

/// The control every other test is read against.
#[test]
fn the_commands_are_reported_before_anything_is_erased() {
    let e = four_commands();
    assert_eq!(
        lines(&e),
        vec![
            (0, "c0".into(), Some(0)),
            (1, "c1".into(), Some(0)),
            (2, "c2".into(), Some(0)),
            (3, "c3".into(), Some(0)),
        ]
    );
}

// ---- the lifetime half -----------------------------------------------------------

/// `clear` — the dominant path. Every mark on the screen goes, through the channel the
/// consumer already handles.
#[test]
fn ed_2_disposes_the_marks_on_every_row_it_blanks() {
    let mut e = four_commands();
    e.feed(b"\x1b[H\x1b[2J");

    assert_eq!(lines(&e), vec![], "no command survives a cleared screen");
    assert!(e.command_marks().is_empty(), "and no mark does either");
    assert_eq!(disposed(&mut e), 16, "each of the four groups' four marks");
}

/// The failure the phantom actually produced: after a `clear` the shell redraws its
/// prompt onto the columns the dead marks bound, so every later command was reported
/// twice — once as itself and once through a corpse.
#[test]
fn a_command_run_after_a_clear_is_reported_exactly_once() {
    let mut e = four_commands();
    e.feed(b"\x1b[H\x1b[2J");
    e.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07new\x1b]133;C\x07n\r\n\x1b]133;D;0\x07");

    assert_eq!(lines(&e), vec![(0, "new".into(), Some(0))]);
}

/// ED 0 and ED 1 blank whole rows too, and only those.
///
/// **The cursor's own row is the known edge, pinned here rather than fixed.** Both
/// references route it through the *partial* helper and so keep its marks even when the
/// erase covers the full width — xterm.js's ED 1 arm erases `[0, x+1)` with
/// `x + 1 == cols` through `_eraseInBufferLine` and disposes nothing. Followed rather
/// than widened because two independent references converge *including* the edge, and
/// the only argument for widening is symmetry — the tell ADR-0019's retracted first
/// amendment was caught by (a rule with no user-facing benefit anyone could name).
///
/// **The size of the residue, measured — the first version of this note understated
/// it.** It said *"`ESC[H ESC[0J` leaves one phantom on row 0"*, which is true and is
/// the smaller half:
///
/// | input | `command_lines()` | marks |
/// |---|---|---|
/// | `ESC[H ESC[0J` (and `ESC[H ESC[J`, the `tput ed` form) | 1 phantom | 16 → 3 |
/// | `ESC[1;16H ESC[1J` at 16 columns | **all 4 phantoms, 0 disposals** | 16 → 16 |
///
/// The second is worse and is what the sentence missed: with the cursor on row 0 there
/// is no whole row *above* it, so the disposal loop does not run at all while the row is
/// blanked to its full width. Pinned by the sibling test below.
///
/// The first case's phantom also names an **empty document** — `accessible_text()` is
/// `""` there, and `AccessibleView` builds its line elements from `text.split("\n")`,
/// which yields one element for `""`. So `reveal(0)` succeeds, focus lands on a blank
/// row and the nav announces the command anyway. (Code-read, not driven in a browser.)
///
/// **Reachability, measured three times and zero every time.** ED 1 does not appear in
/// any of the 16 recorded captures; ED 0 appears once (htop, not from home). A
/// purpose-built hunt — an inline `curses.filter()` program and `whiptail` run under a
/// live OSC-133 prompt — found both going to the **alt screen** (`?1049h`), where
/// command marks do not exist at all. The one program that does reach the verb is
/// **`fzf --height`**, measured on the VM: it stays on the primary screen (`?1049h` 0)
/// and emits `ESC[J` four times with five marks live — as `ESC[9A ESC[J`, cursor up then
/// erase-to-bottom, which is why a `home + ED0` grep reports zero and is the wrong
/// pattern to look for. Replaying that recording produces **no phantom in any engine
/// state** (before the fix, after it, and with either half disabled): `marks` stays 33,
/// because fzf erases the inline region it drew *below* the prompt and never covers a
/// row carrying a mark.
///
/// So the shape that would reach this is narrower than "a program that clears to
/// bottom": it has to stay on the primary screen **and** erase upward over the prompt.
/// Nothing measured does both.
#[test]
fn ed_0_disposes_the_rows_below_and_keeps_the_cursor_row_marks() {
    let mut e = four_commands();
    e.feed(b"\x1b[2;1H\x1b[0J"); // cursor on row 1, erase below

    assert_eq!(
        lines(&e),
        vec![(0, "c0".into(), Some(0)), (1, "c1".into(), Some(0))],
        "rows 2..5 are blanked whole and retire; row 0 is untouched and row 1 is the \
         cursor row"
    );
}

#[test]
fn ed_1_disposes_the_rows_above_and_keeps_the_cursor_row_marks() {
    let mut e = four_commands();
    e.feed(b"\x1b[3;1H\x1b[1J"); // cursor on row 2, erase above

    assert_eq!(
        lines(&e),
        vec![(2, "c2".into(), Some(0)), (3, "c3".into(), Some(0))],
        "rows 0 and 1 retire; row 2 is the cursor row and keeps c2. The document lines \
         do NOT renumber — a blanked row is still a hard-ended row, so `doc_line_of` \
         still counts it. `line` stays derived rather than frozen precisely because it \
         is the half the anchor fixups do maintain"
    );
}

/// The residue at its widest, pinned because the note above had described only its
/// narrow half.
///
/// With the cursor on row 0 an `ED 1` erases `[0, cursor_col]` of that row and nothing
/// above it — so at the last column the row is blanked to its full width and the
/// disposal loop, which walks `0..cursor_row`, does not execute once. Every mark on the
/// screen survives over a row that no longer holds what it describes.
///
/// This is xterm.js's own inconsistency ported deliberately, not an oversight here: its
/// `eraseInDisplay` case 1 calls `_eraseInBufferLine(j, 0, x + 1, …)` and only then, in
/// a separate arm, handles the `x + 1 >= cols` case — for `isWrapped` alone, never for
/// markers. Measured reachability is zero (see the note above); if that ever changes,
/// this test is the one to flip.
#[test]
fn ed_1_at_the_last_column_blanks_the_row_and_retires_nothing() {
    let mut e = four_commands();
    e.feed(b"\x1b[1;16H\x1b[1J"); // cursor row 0, last column of a 16-column screen

    assert_eq!(e.command_marks().len(), 16, "no whole row is above row 0");
    assert_eq!(disposed(&mut e), 0);
    assert_eq!(
        lines(&e),
        vec![
            (0, "c0".into(), Some(0)),
            (1, "c1".into(), Some(0)),
            (2, "c2".into(), Some(0)),
            (3, "c3".into(), Some(0)),
        ],
        "all four still reported, and c0's row is blank — the widest form of the \
         cursor-row residue"
    );
    assert!(
        e.accessible_text().starts_with('\n'),
        "row 0 really is blank: {:?}",
        e.accessible_text()
    );
}

/// The deliberate divergence, pinned so it cannot be "fixed" by accident. Both
/// references retire a mark on a whole-row *reset* and on no other erase, and EL is
/// what a prompt redraw uses.
#[test]
fn el_2_does_not_dispose_the_mark_on_the_row_it_blanks() {
    let mut e = four_commands();
    e.feed(b"\x1b[1;1H\x1b[2K");

    assert_eq!(e.command_marks().len(), 16, "EL retires nothing");
    assert_eq!(disposed(&mut e), 0);
    assert_eq!(
        lines(&e)[0],
        (0, "c0".into(), Some(0)),
        "and because the text is captured, the surviving mark still answers the \
         command that ran rather than the blanks now under it"
    );
}

/// Scrollback is not the screen: ED blanks grid rows, so a command that has already
/// scrolled off keeps both its marks and its place.
#[test]
fn ed_2_leaves_the_marks_that_have_scrolled_into_scrollback() {
    let mut e = Engine::with_scrollback(16, 3, 40);
    for i in 0..4 {
        e.feed(
            format!("\x1b]133;A\x07$ \x1b]133;B\x07c{i}\x1b]133;C\x07o\r\n\x1b]133;D;0\x07")
                .as_bytes(),
        );
    }
    e.drain_events();
    let before = lines(&e);
    assert!(before.len() >= 2, "fixture must push some rows off-screen");

    e.feed(b"\x1b[H\x1b[2J");

    let after = lines(&e);
    assert!(
        !after.is_empty() && after.len() < before.len(),
        "the scrolled-off commands survive and the on-screen ones do not: \
         before {before:?}, after {after:?}"
    );
    assert_eq!(after, before[..after.len()].to_vec());
}

/// The alt screen has its own marker population, and OSC-133 marks are primary-only.
/// A `vim` starting up must not delete the shell's command history.
#[test]
fn an_erase_on_the_alt_screen_does_not_dispose_primary_command_marks() {
    let mut e = four_commands();
    let before = lines(&e);

    e.feed(b"\x1b[?1049h"); // enter alt
    e.feed(b"\x1b[H\x1b[2J");
    assert_eq!(lines(&e), before, "while on alt");

    e.feed(b"\x1b[?1049l"); // leave
    assert_eq!(lines(&e), before, "and after leaving");
}

// ---- the exit-code pairing -------------------------------------------------------

/// Three commands with distinct codes, the fixture both exit-code tests read against.
/// A group's `A`/`B`/`C` land on its own row and its `D` on the next, so absolute line
/// `n` holds command `n-1`'s `D` together with the whole of command `n`.
fn three_coded_commands() -> Engine {
    let mut e = Engine::with_scrollback(16, 6, 40);
    for (i, code) in [1, 2, 3].iter().enumerate() {
        e.feed(
            format!("\x1b]133;A\x07$ \x1b]133;B\x07a{i}\x1b]133;C\x07o\r\n\x1b]133;D;{code}\x07")
                .as_bytes(),
        );
    }
    e.drain_events();
    e
}

/// An erase that takes a `D` must not cost a command that fully survives its code.
///
/// This is the reachable half: ED retires a contiguous run of rows, so it can strip the
/// `D` off the end of a command whose text and start are safely above it. Before the
/// exit moved into the stream this answered `(1, "a1", None)`.
#[test]
fn a_command_keeps_its_exit_when_an_erase_takes_only_its_finished_mark() {
    let mut e = three_coded_commands();
    e.feed(b"\x1b[2;1H\x1b[0J"); // rows 2..5 retire — a1's D is on line 2

    assert_eq!(
        lines(&e),
        vec![(0, "a0".into(), Some(1)), (1, "a1".into(), Some(2))],
        "a1 survives whole and keeps its own code, though the mark that carried it is \
         gone"
    );
}

/// And a disposal that leaves a *hole* must not slide a code onto the wrong command.
///
/// ED cannot produce a hole — it retires a prefix or a suffix — but `remove_marker` is
/// public and does, so the pairing is pinned against the shape rather than against the
/// verb. Under the old query-time pairing this answered `[(0,"a0",Some(2)), …]`: `a0`
/// wearing `a1`'s exit code.
#[test]
fn a_hole_in_the_marks_does_not_move_an_exit_code_onto_another_command() {
    let mut e = three_coded_commands();
    let victims: Vec<_> = e
        .command_marks()
        .into_iter()
        .filter(|(_, line, _)| *line == 1)
        .map(|(id, _, _)| id)
        .collect();
    assert_eq!(victims.len(), 4, "a0's D plus the whole of a1");
    for id in victims {
        e.remove_marker(id);
    }

    assert_eq!(
        lines(&e),
        vec![(0, "a0".into(), Some(1)), (2, "a2".into(), Some(3))],
        "a0 keeps its own code and a1 is simply absent — a2 stays at its own document          line, since disposing a mark moves no content"
    );
}

/// The exit is resolved when `D` arrives, so losing the `D` mark afterwards cannot cost
/// a command that fully survives its code.
#[test]
fn a_surviving_command_keeps_its_exit_when_only_its_finished_mark_is_disposed() {
    let mut e = four_commands();
    let d_ids: Vec<_> = e
        .command_marks()
        .into_iter()
        .filter(|(_, _, k)| matches!(k, MarkerKind::CommandFinished(_)))
        .map(|(id, _, _)| id)
        .collect();
    assert_eq!(d_ids.len(), 4);

    for id in d_ids {
        e.remove_marker(id);
    }

    assert_eq!(
        lines(&e),
        vec![
            (0, "c0".into(), Some(0)),
            (1, "c1".into(), Some(0)),
            (2, "c2".into(), Some(0)),
            (3, "c3".into(), Some(0)),
        ]
    );
}

// ---- the text half ---------------------------------------------------------------

/// The producer no disposal rule reaches: a plain write over the command's columns,
/// with no erase verb anywhere.
#[test]
fn an_overwrite_of_the_command_row_does_not_re_borrow_its_cells() {
    let mut e = four_commands();
    e.feed(b"\x1b[1;3HZZ");

    assert_eq!(
        lines(&e)[0],
        (0, "c0".into(), Some(0)),
        "the command that ran, not the cells now standing where it was"
    );
}

/// ICH and DCH move the command's cells out from under the recorded columns without
/// blanking the row, which is the same class one verb over.
#[test]
fn an_in_line_shift_does_not_re_borrow_the_command_row() {
    let mut e = four_commands();
    e.feed(b"\x1b[1;3H\x1b[1P"); // DCH 1 inside the command text
    assert_eq!(lines(&e)[0].1, "c0");

    let mut e = four_commands();
    e.feed(b"\x1b[1;3H\x1b[3@"); // ICH 3 before it
    assert_eq!(lines(&e)[0].1, "c0");
}

/// A command that spans rows is captured whole, and the answer does not change when the
/// rows underneath it do.
#[test]
fn a_wrapped_command_is_captured_across_its_rows() {
    let mut e = Engine::with_scrollback(8, 6, 40);
    e.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07abcdefghij\x1b]133;C\x07o\r\n\x1b]133;D;0\x07");
    e.drain_events();
    assert_eq!(lines(&e)[0].1, "abcdefghij");

    e.feed(b"\x1b[1;1H\x1b[2K");
    assert_eq!(lines(&e)[0].1, "abcdefghij", "still the captured text");
}

/// A command still being typed — `B` with no `C` — has no bound and no capture, so it
/// stays out of the answer exactly as before.
#[test]
fn a_command_with_no_output_start_is_still_omitted() {
    let mut e = four_commands();
    e.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07typing");

    assert_eq!(
        lines(&e).len(),
        4,
        "the four finished ones, and not the fifth"
    );
}

/// The capture is bounded, for the reason `MAX_MARKERS` is: the *stream* allocates it,
/// so a stream that emits `B`, dumps a screenful and then `C` decides the size.
#[test]
fn a_capture_is_bounded_by_max_command_text() {
    let mut e = Engine::with_scrollback(64, 40, 4000);
    e.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07");
    for _ in 0..400 {
        e.feed(b"0123456789012345678901234567890123456789012345678901234567890123");
    }
    e.feed(b"\x1b]133;C\x07o\r\n\x1b]133;D;0\x07");

    let captured = &lines(&e)[0].1;
    assert!(
        captured.chars().count() <= justerm_core::MAX_COMMAND_TEXT,
        "captured {} chars, cap is {}",
        captured.chars().count(),
        justerm_core::MAX_COMMAND_TEXT
    );
    assert!(
        !captured.is_empty(),
        "truncation keeps the announceable prefix rather than dropping the answer"
    );
}