retach 0.10.0

Persistent terminal sessions with native scrollback passthrough
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
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
//! Tests for rendering correctness when output exceeds the visible grid area.
//!
//! Covers:
//! - Scrollback accumulation from rapid bulk output
//! - Incremental render after full-screen scrolls
//! - Dirty tracking across bulk content replacement
//! - Render with scrollback for large pending queues
//! - Scrollback limit enforcement under burst output
//! - Render stability after many scroll cycles

use super::test_helpers::*;
use super::*;
use render::AnsiRenderer;

/// Write N labeled lines ("L001\r\n", ..., "LNNN") to the screen.
fn write_many_lines(screen: &mut Screen, count: usize) {
    for i in 1..=count {
        if i < count {
            screen.process(format!("L{:03}\r\n", i).as_bytes());
        } else {
            screen.process(format!("L{:03}", i).as_bytes());
        }
    }
}

// ─── Scrollback accumulation from large output ──────────────────────────────

#[test]
fn bulk_output_scrollback_count() {
    // 100 lines on a 5-row screen → 95 in scrollback
    let mut screen = Screen::new(20, 5, 1000);
    write_many_lines(&mut screen, 100);
    let _ = screen.take_pending_scrollback();

    let hist = history_texts(&screen);
    assert_eq!(
        hist.len(),
        95,
        "expected 95 scrollback lines, got {}",
        hist.len()
    );

    // Visible grid should have the last 5 lines
    let visible = screen_lines(&screen);
    assert!(
        visible[0].contains("L096"),
        "row 0 should be L096, got: '{}'",
        visible[0]
    );
    assert!(
        visible[4].contains("L100"),
        "row 4 should be L100, got: '{}'",
        visible[4]
    );
}

#[test]
fn bulk_output_scrollback_ordering() {
    let mut screen = Screen::new(20, 3, 5000);
    write_many_lines(&mut screen, 500);
    let _ = screen.take_pending_scrollback();

    let hist = history_texts(&screen);
    assert_eq!(hist.len(), 497);

    // Every line should be in order
    for (i, line) in hist.iter().enumerate() {
        let expected = format!("L{:03}", i + 1);
        assert!(
            line.contains(&expected),
            "history line {} should contain '{}', got: '{}'",
            i,
            expected,
            line
        );
    }
}

#[test]
fn bulk_output_pending_scrollback_matches_total() {
    let mut screen = Screen::new(20, 4, 1000);
    write_many_lines(&mut screen, 50);

    // pending_scrollback should have all lines that scrolled off
    let pending_rows = screen.take_pending_scrollback();
    let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 46, "50 lines - 4 visible = 46 pending");

    // After drain, pending is empty but get_history still works
    let pending2_rows = screen.take_pending_scrollback();
    let pending2 = AnsiRenderer::new().render_rows(&screen, &pending2_rows);
    assert!(pending2.is_empty());
    assert_eq!(screen.get_history().len(), 46);
}

// ─── Scrollback limit enforcement ───────────────────────────────────────────

#[test]
fn scrollback_limit_caps_history() {
    let limit = 20;
    let mut screen = Screen::new(20, 5, limit);
    write_many_lines(&mut screen, 100);
    let _ = screen.take_pending_scrollback();

    let hist = history_texts(&screen);
    assert_eq!(
        hist.len(),
        limit,
        "scrollback should be capped at limit {}, got {}",
        limit,
        hist.len()
    );

    // The oldest lines should be evicted; history starts from L076
    assert!(
        hist[0].contains("L076"),
        "first history line should be L076 (oldest kept), got: '{}'",
        hist[0]
    );
    assert!(
        hist[limit - 1].contains("L095"),
        "last history line should be L095, got: '{}'",
        hist[limit - 1]
    );
}

#[test]
fn scrollback_limit_pending_also_capped() {
    let limit = 10;
    let mut screen = Screen::new(20, 3, limit);
    write_many_lines(&mut screen, 50);

    let pending_rows = screen.take_pending_scrollback();
    let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
    assert_eq!(
        pending.len(),
        limit,
        "pending scrollback should be capped at limit {}, got {}",
        limit,
        pending.len()
    );
}

#[test]
fn scrollback_limit_zero_no_history() {
    let mut screen = Screen::new(20, 5, 0);
    write_many_lines(&mut screen, 50);

    let hist = screen.get_history();
    assert!(
        hist.is_empty(),
        "zero scrollback limit should produce no history"
    );

    let pending_rows = screen.take_pending_scrollback();
    let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
    assert!(
        pending.is_empty(),
        "zero scrollback limit should produce no pending"
    );
}

// ─── Render correctness after bulk scrolling ────────────────────────────────

#[test]
fn full_render_after_bulk_scroll_shows_last_rows() {
    let mut screen = Screen::new(20, 5, 100);
    write_many_lines(&mut screen, 50);

    let mut cache = AnsiRenderer::new();
    let output = cache.render(&screen, true);
    let text = String::from_utf8_lossy(&output);

    // Full render should contain the last 5 lines
    assert!(text.contains("L046"), "render should contain L046");
    assert!(text.contains("L050"), "render should contain L050");

    // Should NOT contain scrolled-off lines
    assert!(
        !text.contains("L001"),
        "render should not contain scrolled-off L001"
    );
    assert!(
        !text.contains("L045"),
        "render should not contain scrolled-off L045"
    );
}

#[test]
fn incremental_render_after_bulk_scroll() {
    let mut screen = Screen::new(20, 5, 100);
    let mut cache = AnsiRenderer::new();

    // Initial content
    screen.process(b"AAAA\r\nBBBB\r\nCCCC\r\nDDDD\r\nEEEE");
    let _ = cache.render(&screen, false);

    // Bulk scroll: 50 new lines push everything off
    write_many_lines(&mut screen, 50);

    let output = cache.render(&screen, false);
    let text = String::from_utf8_lossy(&output);

    // All rows changed, so all should be redrawn even in incremental mode
    assert!(text.contains("\x1b[1;1H"), "row 1 should be redrawn");
    assert!(text.contains("\x1b[2;1H"), "row 2 should be redrawn");
    assert!(text.contains("\x1b[3;1H"), "row 3 should be redrawn");
    assert!(text.contains("\x1b[4;1H"), "row 4 should be redrawn");
    assert!(text.contains("\x1b[5;1H"), "row 5 should be redrawn");
}

#[test]
fn incremental_render_no_redraw_when_content_unchanged_after_scroll() {
    let mut screen = Screen::new(10, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Write 6 lines → screen shows L04, L05, L06
    write_many_lines(&mut screen, 6);
    let _ = cache.render(&screen, false);

    // No changes → incremental render should skip all rows
    let output = cache.render(&screen, false);
    let text = String::from_utf8_lossy(&output);

    // Row positioning for content should not appear (only cursor)
    assert!(!text.contains("\x1b[1;1H"), "no row redraws when unchanged");
    assert!(!text.contains("\x1b[2;1H"), "no row redraws when unchanged");
}

#[test]
fn render_with_large_pending_scrollback() {
    let mut screen = Screen::new(20, 5, 200);
    write_many_lines(&mut screen, 100);

    let pending_rows = screen.take_pending_scrollback();
    let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 95);

    let mut cache = AnsiRenderer::new();
    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // Should end with sync end (screen redraw is inside sync block)
    assert!(text.ends_with("\x1b[?2026l"));

    // Scrollback lines should appear before sync block (outside it)
    let pos_l001 = text.find("L001").expect("L001 should be in scrollback");
    let sync_begin = text
        .find("\x1b[?2026h")
        .expect("sync begin should be present");
    assert!(
        pos_l001 < sync_begin,
        "scrollback should precede sync block"
    );

    // Scrollback lines should appear before screen clear
    let pos_clear = text
        .find("\x1b[2J")
        .expect("screen clear should be present");
    assert!(
        pos_l001 < pos_clear,
        "scrollback should precede screen clear"
    );

    // Screen content after screen clear
    let after_clear = &text[pos_clear..];
    assert!(
        after_clear.contains("L096"),
        "visible L096 should be after screen clear"
    );
    assert!(
        after_clear.contains("L100"),
        "visible L100 should be after screen clear"
    );

    // No scrollback lines in the screen portion
    assert!(
        !after_clear.contains("L001"),
        "L001 should not be in screen portion"
    );
    assert!(
        !after_clear.contains("L050"),
        "L050 should not be in screen portion"
    );
}

// ─── Multiple render cycles with bulk updates ───────────────────────────────

#[test]
fn multiple_bulk_updates_dirty_tracking() {
    let mut screen = Screen::new(20, 4, 100);
    let mut cache = AnsiRenderer::new();

    // First bulk: 20 lines
    write_many_lines(&mut screen, 20);
    let r1 = cache.render(&screen, false);
    let t1 = String::from_utf8_lossy(&r1);
    // All rows drawn on first render
    assert!(t1.contains("\x1b[1;1H"));
    assert!(t1.contains("\x1b[4;1H"));

    // Second bulk: 20 more lines (complete content replacement)
    for i in 21..=40 {
        screen.process(format!("M{:03}\r\n", i).as_bytes());
    }
    screen.process(b"M041");

    let r2 = cache.render(&screen, false);
    let t2 = String::from_utf8_lossy(&r2);
    // All rows changed, all should be redrawn
    assert!(
        t2.contains("\x1b[1;1H"),
        "all rows should redraw after second bulk"
    );
    assert!(
        t2.contains("M038") || t2.contains("M039") || t2.contains("M040") || t2.contains("M041"),
        "new content should appear in render"
    );

    // Third render without changes → no row redraws
    let r3 = cache.render(&screen, false);
    let t3 = String::from_utf8_lossy(&r3);
    assert!(
        !t3.contains("\x1b[1;1H"),
        "no redraws on third render without changes"
    );
}

#[test]
fn alternating_bulk_and_single_line_updates() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Bulk: 10 lines
    write_many_lines(&mut screen, 10);
    let _ = cache.render(&screen, false);

    // Single line addition (scrolls by 1)
    screen.process(b"\r\nSINGLE");
    let r = cache.render(&screen, false);
    let t = String::from_utf8_lossy(&r);

    // All rows shifted, all should be redrawn
    assert!(t.contains("\x1b[1;1H"), "row 1 should redraw after scroll");
    assert!(t.contains("SINGLE"), "new content should be visible");
}

// ─── Cursor position after large scrolls ────────────────────────────────────

#[test]
fn cursor_position_after_bulk_output() {
    let mut screen = Screen::new(20, 5, 100);
    write_many_lines(&mut screen, 100);

    // Cursor should be at column 4 (after "L100"), row 4 (last row, 0-indexed)
    assert_eq!(
        screen.grid.cursor_y(),
        4,
        "cursor_y should be at bottom row"
    );
    assert_eq!(screen.grid.cursor_x(), 4, "cursor_x should be after 'L100'");

    let mut cache = AnsiRenderer::new();
    let output = cache.render(&screen, true);
    let text = String::from_utf8_lossy(&output);
    // 1-indexed: row 5, col 5
    assert!(
        text.contains("\x1b[5;5H"),
        "cursor should be at row 5, col 5 (1-indexed)"
    );
}

#[test]
fn cursor_stays_on_bottom_row_during_continuous_scroll() {
    let mut screen = Screen::new(20, 3, 100);
    // Fill the screen first so cursor reaches the bottom
    screen.process(b"a\r\nb\r\nc");
    assert_eq!(screen.grid.cursor_y(), 2);

    // Now every \r\n should scroll, keeping cursor at bottom row
    for i in 1..=50 {
        screen.process(format!("\r\nline{}", i).as_bytes());
        assert_eq!(
            screen.grid.cursor_y(),
            2,
            "cursor_y should stay at bottom row (2) after scroll, iteration {}",
            i
        );
    }
}

// ─── Reattach after large output ────────────────────────────────────────────

#[test]
fn reattach_after_1000_lines() {
    let mut screen = Screen::new(20, 5, 500);
    write_many_lines(&mut screen, 1000);
    let _ = screen.take_pending_scrollback();

    let hist = screen.get_history();
    // Capped at scrollback_limit
    assert_eq!(hist.len(), 500, "history should be capped at 500");

    // Reattach render
    let mut cache = AnsiRenderer::new();
    let output = cache.render_with_scrollback(&screen, &hist);
    let text = String::from_utf8_lossy(&output);

    // Should have screen clear
    assert!(text.contains("\x1b[2J"));

    // Screen portion should have last 5 lines
    let pos_clear = text.find("\x1b[2J").unwrap();
    let after_clear = &text[pos_clear..];
    assert!(after_clear.contains("L996"), "screen should show L996");
    assert!(after_clear.contains("L1000"), "screen should show L1000");
}

#[test]
fn reattach_render_no_standalone_bell_after_bulk() {
    let mut screen = Screen::new(20, 5, 100);
    // Set a title via OSC so BEL bytes will actually appear in the render
    screen.process(b"\x1b]2;Bulk Test Title\x07");
    write_many_lines(&mut screen, 200);
    let _ = screen.take_pending_scrollback();

    let hist = screen.get_history();
    let mut cache = AnsiRenderer::new();
    let output = cache.render_with_scrollback(&screen, &hist);

    // BEL bytes should exist (from the title) but only inside OSC sequences
    let bell_count = output.iter().filter(|&&b| b == 0x07).count();
    assert!(
        bell_count > 0,
        "title should produce at least one BEL byte in render"
    );

    for (i, &byte) in output.iter().enumerate() {
        if byte == 0x07 {
            let prefix = &output[..i];
            let osc_start = prefix.windows(2).rposition(|w| w == b"\x1b]");
            assert!(
                osc_start.is_some(),
                "BEL at offset {} is standalone after bulk output reattach",
                i
            );
        }
    }
}

// ─── Edge cases ─────────────────────────────────────────────────────────────

#[test]
fn output_exactly_fills_screen_no_scroll() {
    let mut screen = Screen::new(20, 5, 100);
    // Write exactly 5 lines (fills screen, no scroll)
    write_many_lines(&mut screen, 5);

    let hist = screen.get_history();
    assert!(
        hist.is_empty(),
        "no scrollback when output exactly fills screen"
    );

    let visible = screen_lines(&screen);
    assert!(visible[0].contains("L001"), "row 0 should be L001");
    assert!(visible[4].contains("L005"), "row 4 should be L005");
}

#[test]
fn output_one_more_than_screen_scrolls_once() {
    let mut screen = Screen::new(20, 5, 100);
    write_many_lines(&mut screen, 6);

    let hist = history_texts(&screen);
    assert_eq!(hist.len(), 1, "one line should scroll off");
    assert!(hist[0].contains("L001"), "scrolled line should be L001");

    let visible = screen_lines(&screen);
    assert!(visible[0].contains("L002"), "row 0 should be L002");
    assert!(visible[4].contains("L006"), "row 4 should be L006");
}

#[test]
fn rapid_output_then_partial_overwrite() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Bulk output: 30 lines
    write_many_lines(&mut screen, 30);
    let _ = cache.render(&screen, false);

    // Now overwrite just the current line (cursor is at end of L030)
    screen.process(b"\rOVERWRITTEN");
    let output = cache.render(&screen, false);
    let text = String::from_utf8_lossy(&output);

    // Only the bottom row should be redrawn
    assert!(text.contains("\x1b[3;1H"), "bottom row should be redrawn");
    assert!(
        text.contains("OVERWRITTEN"),
        "overwritten content should appear"
    );
    // Other rows unchanged
    assert!(!text.contains("\x1b[1;1H"), "row 1 should not be redrawn");
    assert!(!text.contains("\x1b[2;1H"), "row 2 should not be redrawn");
}

#[test]
fn bulk_output_with_scroll_region() {
    // Scroll region set to rows 2-4 of a 5-row screen
    let mut screen = Screen::new(20, 5, 100);
    screen.process(b"\x1b[2;4r"); // DECSTBM: rows 2-4 (1-indexed)
    screen.process(b"\x1b[2;1H"); // Move to row 2

    // Write 10 lines inside the scroll region
    for i in 1..=10 {
        if i < 10 {
            screen.process(format!("R{:02}\r\n", i).as_bytes());
        } else {
            screen.process(format!("R{:02}", i).as_bytes());
        }
    }

    // Row 1 (outside scroll region, top) should be untouched
    assert_eq!(
        screen.grid.visible_row(0)[0].c,
        ' ',
        "row 0 should be blank (outside scroll region)"
    );
    // Row 5 (outside scroll region, bottom) should be untouched
    assert_eq!(
        screen.grid.visible_row(4)[0].c,
        ' ',
        "row 4 should be blank (outside scroll region)"
    );

    // Scroll region rows should have the last 3 of the 10 lines:
    // 10 lines written in region of 3 rows → 7 scrolled off, R08/R09/R10 remain
    let visible = screen_lines(&screen);
    assert!(
        visible[1].contains("R08"),
        "scroll region row 1 should be R08, got: '{}'",
        visible[1]
    );
    assert!(
        visible[2].contains("R09"),
        "scroll region row 2 should be R09, got: '{}'",
        visible[2]
    );
    assert!(
        visible[3].contains("R10"),
        "scroll region row 3 should be R10, got: '{}'",
        visible[3]
    );

    // Scrollback should NOT capture lines scrolled within non-top scroll region
    let hist = screen.get_history();
    assert!(
        hist.is_empty(),
        "scroll region not starting at top should not generate scrollback, got {} lines",
        hist.len()
    );
}

#[test]
fn bulk_output_with_styles_renders_correctly() {
    let mut screen = Screen::new(30, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Write styled lines that scroll off
    for i in 1..=10 {
        let color = 31 + (i % 7); // cycle through colors
        screen.process(format!("\x1b[{}mLine{:02}\x1b[0m\r\n", color, i).as_bytes());
    }
    screen.process(b"\x1b[1;33mLastLine\x1b[0m");

    let output = cache.render(&screen, true);
    let text = String::from_utf8_lossy(&output);

    // Should contain the styled content
    assert!(text.contains("LastLine"), "last line should be visible");

    // History should preserve styles
    let hist = screen.get_history();
    assert!(!hist.is_empty(), "styled lines should be in scrollback");
    let first_hist = String::from_utf8_lossy(&hist[0]);
    assert!(
        first_hist.contains("\x1b["),
        "scrollback should preserve SGR codes"
    );
}

#[test]
fn cache_invalidate_mid_bulk_produces_correct_render() {
    let mut screen = Screen::new(20, 4, 100);
    let mut cache = AnsiRenderer::new();

    // First render
    write_many_lines(&mut screen, 20);
    let _ = cache.render(&screen, false);

    // Invalidate cache manually (simulates reconnect scenario)
    cache.invalidate();

    // Render should redraw all rows
    let output = cache.render(&screen, false);
    let text = String::from_utf8_lossy(&output);
    assert!(text.contains("\x1b[1;1H"), "row 1 redrawn after invalidate");
    assert!(text.contains("\x1b[2;1H"), "row 2 redrawn after invalidate");
    assert!(text.contains("\x1b[3;1H"), "row 3 redrawn after invalidate");
    assert!(text.contains("\x1b[4;1H"), "row 4 redrawn after invalidate");
}

#[test]
fn sync_block_wraps_large_render() {
    let mut screen = Screen::new(20, 5, 100);
    write_many_lines(&mut screen, 200);

    let mut cache = AnsiRenderer::new();
    let output = cache.render(&screen, true);
    let text = String::from_utf8_lossy(&output);

    assert!(
        text.starts_with("\x1b[?2026h"),
        "should start with sync begin"
    );
    assert!(text.ends_with("\x1b[?2026l"), "should end with sync end");
}