summaryrefslogtreecommitdiff
path: root/tests.zig
blob: 6080aa3b6eb92264639344692abd88cd5194d466 (plain) (blame)
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
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
const std = @import("std");
const h = @import("e2e_harness.zig");
const Harness = h.Harness;
const config = @import("config");
const dump = @import("dump.zig");

const ls_ready = "tests.zig";

// E2E tests for pardes. The harness forks the app in a pty and parses its
// output with a ghostty terminal emulator, so all assertions read the 2D screen
// GRID (plainString) — not raw byte streams. This is decoupled from the app's
// rendering (vaxis diff-rendering, repaints, escape sequences): the grid is
// always current after pump/waitFor.
//
// Input is sent as raw bytes (keystrokes, SGR mouse sequences) that the app's
// vaxis parses. The harness terminal only reads output.
//
// Mouse events use SGR pixel mode: \x1b[<button;col;row M (press) / m (release).
// Cols/rows are 1-based screen coords. Button 0=left, 1=middle, 2=right.
// A drag is press at start + release at end (with different coords).

// Row index (0-based) of the first screen line containing `needle`, or null.
fn rowOf(text: []const u8, needle: []const u8) ?usize {
    var it = std.mem.splitScalar(u8, text, '\n');
    var i: usize = 0;
    while (it.next()) |line| : (i += 1) {
        if (std.mem.indexOf(u8, line, needle) != null) return i;
    }
    return null;
}

// Column (0-based byte offset within its line) of the first `needle` on screen.
// The grid is monospace ASCII here, so the byte offset is the screen column.
fn colOf(text: []const u8, needle: []const u8) ?usize {
    var it = std.mem.splitScalar(u8, text, '\n');
    while (it.next()) |line| {
        if (std.mem.indexOf(u8, line, needle)) |off| return off;
    }
    return null;
}

fn clearRightColumnTerminal(hs: *Harness) !void {
    try hs.send("\x1b[<0;60;20M\x1b[<0;60;20m"); // focus the right-column terminal
    try hs.pump(300);
    try hs.send("\x02"); // normal -> tty
    try hs.pump(400);
    try hs.send("clear\r");
    try hs.pump(800);
    try hs.send("\x02"); // tty -> normal
    try hs.pump(400);
}

fn writeReplayFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
    const file_content =
        "const std = @import(\"std\");\n" ++
        "\n" ++
        "// utf8: \xe2\x80\x94\n";
    const content_b64 = try dump.encodeBytes(gpa, file_content);
    defer gpa.free(content_b64);
    const panes = [_]dump.Pane{.{
        .kind = .file,
        .tag = "nm /tmp/replay_fixture.zig Save Del",
        .body = "",
        .cols = 78,
        .rows = 22,
        .file = .{
            .path = "/tmp/replay_fixture.zig",
            .content = file_content,
            .content_b64 = content_b64,
        },
    }};
    const col_panes = [_]usize{0};
    const columns = [_]dump.Column{.{ .weight = 1, .panes = &col_panes }};
    try dump.writeFile(io, gpa, path, .{
        .screen = .{ .cols = 80, .rows = 24 },
        .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
        .theme = "dark",
        .columns = &columns,
        .panes = &panes,
    });
}

fn writeReplayScrollFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
    var file_content: std.ArrayList(u8) = .empty;
    defer file_content.deinit(gpa);
    var line_no: usize = 1;
    while (line_no <= 80) : (line_no += 1) {
        const line = try std.fmt.allocPrint(gpa, "replay scroll line {d}\n", .{line_no});
        defer gpa.free(line);
        try file_content.appendSlice(gpa, line);
    }
    const content_b64 = try dump.encodeBytes(gpa, file_content.items);
    defer gpa.free(content_b64);
    const panes = [_]dump.Pane{.{
        .kind = .file,
        .tag = "nm /tmp/replay_scroll_fixture.zig Save Del",
        .body = "",
        .cols = 78,
        .rows = 22,
        .file = .{
            .path = "/tmp/replay_scroll_fixture.zig",
            .content = file_content.items,
            .content_b64 = content_b64,
        },
    }};
    const col_panes = [_]usize{0};
    const columns = [_]dump.Column{.{ .weight = 1, .panes = &col_panes }};
    try dump.writeFile(io, gpa, path, .{
        .screen = .{ .cols = 80, .rows = 24 },
        .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
        .theme = "dark",
        .columns = &columns,
        .panes = &panes,
    });
}

fn writeReplayHoverFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
    const panes = [_]dump.Pane{
        .{
            .kind = .file,
            .tag = "nm /tmp/replay_left.txt Save Del",
            .body = "",
            .cols = 48,
            .rows = 22,
            .file = .{
                .path = "/tmp/replay_left.txt",
                .content = "left\n",
            },
        },
        .{
            .kind = .file,
            .tag = "nm /tmp/replay_right.txt Save Del",
            .body = "",
            .cols = 48,
            .rows = 22,
            .file = .{
                .path = "/tmp/replay_right.txt",
                .content = "right\n",
            },
        },
    };
    const left_col = [_]usize{0};
    const right_col = [_]usize{1};
    const columns = [_]dump.Column{
        .{ .weight = 1, .panes = &left_col },
        .{ .weight = 1, .panes = &right_col },
    };
    try dump.writeFile(io, gpa, path, .{
        .screen = .{ .cols = 100, .rows = 24 },
        .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
        .theme = "dark",
        .columns = &columns,
        .panes = &panes,
    });
}

fn writeReplayImageFixture(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !void {
    const png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
    const panes = [_]dump.Pane{.{
        .kind = .image,
        .tag = "img /tmp/replay.png Petscii Del",
        .body = "",
        .cols = 78,
        .rows = 22,
        .image = .{
            .path = "/tmp/replay.png",
            .bytes_b64 = png_b64,
        },
    }};
    const col_panes = [_]usize{0};
    const columns = [_]dump.Column{.{ .weight = 1, .panes = &col_panes }};
    try dump.writeFile(io, gpa, path, .{
        .screen = .{ .cols = 80, .rows = 24 },
        .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
        .theme = "dark",
        .columns = &columns,
        .panes = &panes,
    });
}

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    const io = init.io;
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    if (args.len != 3) {
        std.debug.print("usage: {s} <pardes-exe> <pardes-replay-exe>\n", .{args[0]});
        return error.BadArgs;
    }
    const exe = args[1];
    const replay_exe = args[2];

    var hs = try Harness.init(gpa, exe.ptr, 24, 80);
    hs.trace = config.trace;
    defer hs.deinit();

    // 0. The stats overlay is off by default; turn it on via the "Debug" command in
    //    the global tagbar (top row). "Debug" sits at cols 18-22, so SGR col ~20,
    //    row 1. This also exercises the global tagbar + the Debug toggle. Give the
    //    app a moment to come up and render the bar first.
    try hs.pump(800);
    try hs.send("\x1b[<1;20;1M\x1b[<1;20;1m"); // toggle the Debug overlay on

    // 1. The app posts an initial winsize on startup. Its winsize handler bumps
    //    the resize counter to 1 and repaints, so "resizes=1" shows in the debug
    //    overlay. Confirms the app is up and handled its first size.
    try hs.expectWaitFor("resizes=1", 6000, "app never handled its initial winsize (no 'resizes=1')");
    try hs.expectWaitFor(ls_ready, 8000, "new terminals did not auto-ls their directory on open");

    // 1b. Dump is a global-tagbar builtin. It writes a backend-independent ZON
    //     snapshot with compact pane ids, layout columns, rendered pane text, and
    //     pane backing contents.
    std.Io.Dir.deleteFileAbsolute(io, dump.default_path) catch {};
    try hs.send("\x1b[<1;43;1M\x1b[<1;43;1m"); // middle-click "Dump" in the topbar
    try hs.pump(800);
    {
        const dumped = dump.readFile(io, gpa, dump.default_path) catch {
            hs.dump("Dump did not write a parseable /tmp/pardes-dump.zon");
            return error.ExpectFailed;
        };
        defer dump.free(gpa, dumped);
        if (dumped.panes.len == 0 or dumped.columns.len == 0 or dumped.active >= dumped.panes.len) {
            hs.dump("Dump wrote an invalid empty layout");
            return error.ExpectFailed;
        }
        if (std.mem.indexOf(u8, dumped.topbar, "Dump") == null) {
            hs.dump("Dump snapshot did not include the main tagline");
            return error.ExpectFailed;
        }
        if (!std.mem.eql(u8, dumped.theme, "dark")) {
            hs.dump("Dump snapshot did not include the active theme");
            return error.ExpectFailed;
        }
        if (dumped.panes[0].kind != .terminal or dumped.panes[0].terminal == null) {
            hs.dump("Dump snapshot did not capture the terminal pane stream");
            return error.ExpectFailed;
        }
        if (dumped.panes[0].terminal.?.stream_b64.len == 0) {
            hs.dump("Dump snapshot did not include base64 terminal bytes");
            return error.ExpectFailed;
        }
        const tty_bytes = try dump.decodeBytes(gpa, dumped.panes[0].terminal.?.stream_b64);
        defer gpa.free(tty_bytes);
        if (std.mem.indexOf(u8, tty_bytes, ls_ready) == null) {
            hs.dump("Dump terminal byte stream does not replay the visible ls output");
            return error.ExpectFailed;
        }
    }
    {
        var replay_h = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, dump.default_path);
        replay_h.trace = config.trace;
        defer replay_h.deinit();
        try replay_h.pump(1200);
        try replay_h.expectContains("Kill Newcol", "replay did not render the dump topbar");
        try replay_h.expectContains(ls_ready, "replay did not rebuild the terminal from the dumped byte stream");
        try replay_h.expectNotContains("\xc2\xaa", "replay rendered corrupt prefix bytes");
    }
    {
        const fixture_path = "/tmp/pardes-replay-fixture.zon";
        try writeReplayFixture(io, gpa, fixture_path);
        var replay_file = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, fixture_path);
        replay_file.trace = config.trace;
        defer replay_file.deinit();
        try replay_file.pump(1200);
        try replay_file.expectContains("   1 const std", "replay file pane line numbers are broken");
        try replay_file.expectNotContains("\xc2\xaa", "replay file line numbers used invalid stack bytes");
    }
    {
        const fixture_path = "/tmp/pardes-replay-hover.zon";
        try writeReplayHoverFixture(io, gpa, fixture_path);
        var replay_hover = try Harness.initArgs(gpa, replay_exe.ptr, 24, 100, fixture_path);
        replay_hover.trace = config.trace;
        defer replay_hover.deinit();
        try replay_hover.pump(1200);
        try replay_hover.send("\x1b[<35;50;10M");
        try replay_hover.pump(400);
        try replay_hover.expectContains("╎", "replay hover did not overlay the dashed resize handle");
    }
    {
        const fixture_path = "/tmp/pardes-replay-scroll-fixture.zon";
        try writeReplayScrollFixture(io, gpa, fixture_path);
        var replay_scroll = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, fixture_path);
        replay_scroll.trace = config.trace;
        defer replay_scroll.deinit();
        try replay_scroll.pump(1200);
        try replay_scroll.expectContains("   1 replay scroll line 1", "replay scrollbar fixture did not start at the top");
        try replay_scroll.send("\x1b[<2;1;23M\x1b[<2;1;23m");
        try replay_scroll.pump(300);
        try replay_scroll.expectContains("  21 replay scroll line 21", "replay right-click scrollbar did not scroll down");
        try replay_scroll.expectNotContains("   1 replay scroll line 1", "replay right-click scrollbar left the top line visible");
        try replay_scroll.send("\x1b[<0;1;23M\x1b[<0;1;23m");
        try replay_scroll.pump(300);
        try replay_scroll.expectContains("   1 replay scroll line 1", "replay left-click scrollbar did not scroll back up");
    }
    {
        const fixture_path = "/tmp/pardes-replay-image.zon";
        try writeReplayImageFixture(io, gpa, fixture_path);
        var replay_image = try Harness.initArgs(gpa, replay_exe.ptr, 24, 80, fixture_path);
        replay_image.trace = config.trace;
        defer replay_image.deinit();
        try replay_image.pump(1200);
        try replay_image.expectContains("/tmp/replay.png", "replay image pane did not render from dumped bytes");
    }

    // 2. Regression: grow the pty -> SIGWINCH -> app re-resizes. Counter bumps
    //    to 2 only if the resize handler is installed.
    try hs.resize(30, 100);
    try hs.expectWaitFor("resizes=2", 8000, "SIGWINCH was not turned into a resize (counter stuck at 1)");

    // 2b. RECLAIMED GAP + HOVER HANDLE: no dead gap column between panes now — the
    //     resize handle is the LEFT pane's last column, and hovering it overlays a
    //     dash (keeping the cell's colors). 2 equal columns at 100 wide -> left width
    //     50, last col = 49 (SGR 50). A no-button motion there paints the "╎".
    try hs.send("\x1b[<35;50;10M"); // motion (no button) over the left pane's last col
    try hs.pump(400);
    try hs.expectContains("╎", "hovering the column boundary did not overlay the dashed resize handle");

    // 3. Default is NORMAL mode: the tag shows "nm" (helix-style block cursor).
    try hs.expectContains("nm /", "default tag is not 'nm' (normal mode not default)");

    // 3b. ls-on-open: a fresh shell greets with its directory listing. The e2e app
    //     boots in the project dir, so a known file shows up as command output
    //     (visible even in normal mode — only prompt rows are hidden).
    try hs.expectWaitFor(ls_ready, 8000, "new terminals did not auto-ls their directory on open");

    // 3c. Normal-mode TAB = acme middle-click "execute" the word under the cursor.
    //     Drop a one-line executable, echo its path onto body row 0 (clear homes the
    //     cursor first), left-click the path to enter normal + pin the cursor on it,
    //     then TAB. It runs the word in place (no new pane) -> the script's marker.
    try hs.send("\x02"); // normal -> tty (Ctrl-b)
    try hs.pump(600);
    try hs.send("printf '#!/bin/sh\\necho ZZTABRAN\\n' > /tmp/zztabcmd; chmod +x /tmp/zztabcmd; clear; echo /tmp/zztabcmd\r");
    try hs.pump(1200);
    try hs.send("\x02"); // tty -> normal (Ctrl-b): the mouse no longer exits tty mode
    try hs.pump(600);
    try hs.send("\x1b[<0;8;3M\x1b[<0;8;3m"); // left-click the path on body row 0 -> pin the cursor on it (+1 row: global tagbar)
    try hs.pump(600);
    try hs.send("\t"); // TAB: execute "/tmp/zztabcmd"
    try hs.expectWaitFor("ZZTABRAN", 8000, "normal-mode Tab did not execute the word under the cursor");
    try hs.pump(400);

    // 3c-bis. CENTRAL DISPATCH: a builtin executes from a PANE BODY, not only the
    //     tagbar. Echo "Debug" as output (visible in normal mode), click it, TAB it:
    //     the same .debug action runs through the one dispatcher and toggles the
    //     overlay OFF (step 0 turned it on) -> "resizes=" disappears. Proves onSelect
    //     matches builtins wherever the word appears (tty here, not the tagbar).
    try hs.send("\x02"); // normal -> tty
    try hs.pump(400);
    try hs.send("clear; echo Debug\r");
    try hs.pump(900);
    try hs.send("\x02"); // tty -> normal
    try hs.pump(400);
    try hs.send("\x1b[<0;5;3M\x1b[<0;5;3m"); // left-click "Debug" on body row 0 (col 3=body 0; GUTTER 2)
    try hs.pump(400);
    try hs.send("\t"); // TAB: execute the builtin "Debug"
    try hs.pump(600);
    try hs.expectNotContains("resizes=", "executing 'Debug' from a pane body did not toggle the overlay (central dispatch broken)");

    // 3d. OUTPUT PADDING: a command composed (typed as an overlay) far below the
    //     prompt runs BELOW where it was typed, not back up at the prompt. The app
    //     pads the shell down with newlines to max(last-edit row, prompt row); the
    //     padding prompts are hidden in text mode, so the output flows under the
    //     composed line. Clear the pane (prompt -> row 0), type a command on a low
    //     row whose OUTPUT marker (PAD13Z) differs from its source (echo PAD...),
    //     run it, and assert the output row is BELOW the typed row. (Screen is 30x100
    //     from step 2; terms[0] is the ~15-row top-left pane.)
    try hs.send("\x02"); // normal -> tty
    try hs.pump(400);
    try hs.send("clear\r"); // prompt back to row 0
    try hs.pump(900);
    try hs.send("\x02"); // tty -> normal
    try hs.pump(400);
    try hs.send("\x1b[<0;5;11M\x1b[<0;5;11m"); // left-click a low blank row -> normal + pin (+1 row: global tagbar)
    try hs.pump(400);
    try hs.send("i"); // insert
    try hs.pump(400);
    try hs.send("echo PAD$((6+7))Z"); // overlay; output is "PAD13Z"
    try hs.pump(600);
    try hs.send("\x1b"); // -> normal
    try hs.pump(400);
    try hs.send("\x1b[<1;5;11M\x1b[<33;21;11M\x1b[<1;21;11m"); // middle-drag-select the line -> run (padded)
    try hs.expectWaitFor("PAD13Z", 8000, "padded command did not run");
    try hs.pump(600);
    {
        const text = try hs.screenText();
        defer gpa.free(text);
        const typed = rowOf(text, "echo PAD") orelse {
            hs.dump("composed line vanished before the padding check");
            return error.ExpectFailed;
        };
        const outp = rowOf(text, "PAD13Z") orelse {
            hs.dump("padded output not found on screen");
            return error.ExpectFailed;
        };
        if (outp <= typed) {
            hs.dump("output did not land below the composed command (padding failed)");
            return error.ExpectFailed;
        }
        // and it should land RIGHT under the composed line (tight alignment), not
        // somewhere far below — the hidden padding prompts fill the gap above it.
        if (outp - typed > 2) {
            hs.dump("padded output landed too far below the composed line (misaligned)");
            return error.ExpectFailed;
        }
    }
    // tidy up: drop the lingering overlay so it doesn't ride the scrollback into
    // later steps (cursor is on the typed row; x selects it, d clears the run).
    try hs.send("x");
    try hs.pump(300);
    try hs.send("d");
    try hs.pump(300);

    // 4. Ctrl-b toggles raw TTY mode from normal and forwards keystrokes to the
    //    shell. tty is a hidden toggle (Ctrl-b), off the normal editing workflow.
    const marker = "PARDES_E2E";
    try hs.send("\x02"); // normal -> tty (Ctrl-b)
    try hs.pump(600);
    try hs.send("echo " ++ marker ++ "\r");
    try hs.expectWaitFor(marker, 8000, "tty mode did not forward the marker to the shell");
    try hs.expectContains("sy /", "no 'sy' indicator after Ctrl-b (tty mode)");

    // 4a. The mouse stays in tty mode: a body left-click (press+release) must NOT
    //     drop to normal — only Ctrl-b exits tty now. The tag stays "sy".
    try hs.send("\x1b[<0;6;4M\x1b[<0;6;4m"); // left-click + release in the body
    try hs.pump(500);
    try hs.expectContains("sy /", "a left-click in tty mode wrongly returned to normal");

    // 4b. Ctrl-b toggles back: tty -> normal. Send Ctrl-b again and confirm the
    //    tag flips off "sy" (raw tty) back to "nm" (normal).
    try hs.send("\x02"); // tty -> normal (Ctrl-b)
    try hs.pump(600);
    try hs.expectContains("nm /", "Ctrl-b did not toggle tty back to normal");
    try hs.send("\x02"); // normal -> tty again for the prompt-hiding step
    try hs.pump(600);

    // 5. Prompt hiding in TEXT mode. tty shows the real terminal (command line +
    //    output). BOTH normal and insert hide the prompt/command rows (the acme
    //    compose surface) while keeping output. Run a command in tty, Ctrl-b to
    //    NORMAL, and check: output present, the command's prompt row hidden.
    try hs.send("echo CMDHIDEZZ\r");
    try hs.expectWaitFor("CMDHIDEZZ", 8000, "tty mode did not run the command");
    try hs.pump(800); // let tty-mode frames settle
    try hs.send("\x02"); // tty -> normal (Ctrl-b): the mouse no longer exits tty mode
    try hs.pump(600);
    try hs.send("\x1b[<0;6;4M\x1b[<0;6;4m"); // left-click body to pin the cursor (already normal) (+1 row: global tagbar)
    try hs.pump(600);
    try hs.expectContains("CMDHIDEZZ", "command output not shown in normal mode");
    // The prompt row (with the typed command) is hidden in normal mode now too. We
    // check "echo CMDHIDEZZ" (this pane's prompt row) rather than a shell prompt (e.g. "user@host") since a
    // raw-tty pane elsewhere could show its own prompt.
    try hs.expectNotContains("echo CMDHIDEZZ", "command line still visible in normal mode (prompt row not hidden)");

    // 6. Insert-mode editing + middle-click execute. Click a blank row to pin the
    //    cursor at text col 0, type a computed echo, then middle-select those 15
    //    cells and release. "Q7Z" appears only if the SELECTED typed text ran —
    //    proving selection reads the rendered screen.
    try hs.send("\x1b[<0;3;15M\x1b[<0;3;15m"); // click blank row, col 0 -> normal + pin (+1 row: global tagbar)
    try hs.pump(400);
    try hs.send("i"); // normal -> insert so typing composes the command
    try hs.pump(400);
    try hs.send("echo Q$((3+4))Z");
    try hs.pump(600); // let the run build
    try hs.send("\x1b[<1;3;15M\x1b[<33;17;15M\x1b[<1;17;15m"); // middle-select it (+1 row: global tagbar)
    try hs.expectWaitFor("Q7Z", 8000, "middle-click did not execute the selected typed text");
    // leave insert -> normal so the next step can reach tty via Ctrl-b
    try hs.send("\x1b");
    try hs.pump(400);

    // 6b. FOLLOW OUTPUT: new tty output scrolls the pane to the bottom, so a result
    //     shows even after you've scrolled up. Fill the pane, wheel UP off the
    //     bottom (LN40 disappears), then run a command — the pane snaps back and the
    //     new marker is visible. Done in tty mode so the active pane follows too.
    try hs.send("\x02"); // normal -> tty
    try hs.pump(400);
    try hs.send("clear; for i in $(seq 1 40); do echo LN$i; done\r");
    try hs.expectWaitFor("LN40", 8000, "filler output never appeared");
    try hs.pump(500);
    {
        var w: usize = 0;
        while (w < 30) : (w += 1) try hs.send("\x1b[<64;5;6M"); // wheel UP over the pane (+1 row: global tagbar)
    }
    try hs.pump(500);
    try hs.expectNotContains("LN40", "wheel-up did not scroll the pane off the bottom");
    try hs.send("echo ZZFOLLOW\r");
    try hs.expectWaitFor("ZZFOLLOW", 8000, "new output did not scroll the pane back to the bottom");
    try hs.send("\x02"); // tty -> normal (step 7 re-enters via Ctrl-b)
    try hs.pump(400);

    // 6c. NORMAL-MODE MOTION SCROLLS THE TTY like a file pane: the pane is at the
    //     bottom (ZZFOLLOW visible). `gg` rides the cursor to the top of the
    //     scrollback and the viewport follows, so the bottom marker scrolls off;
    //     `G` rides it back down and the marker returns.
    try hs.send("gg");
    try hs.pump(500);
    try hs.expectNotContains("ZZFOLLOW", "normal-mode gg did not scroll the tty up off the bottom");
    try hs.send("G");
    try hs.pump(500);
    try hs.expectContains("ZZFOLLOW", "normal-mode G did not scroll the tty back to the bottom");

    // 6d. YANK MIRRORS TO THE SYSTEM CLIPBOARD (OSC 52): select the line with `x`
    //     and yank with `y`; the app writes an OSC 52 clipboard-copy to the host.
    //     (The paste direction needs a host that answers OSC 52 reads, which this
    //     emulator can't, so it isn't covered here.)
    try hs.send("xy");
    try hs.pump(400);
    try hs.expectRawContains("\x1b]52;c;", "yank did not emit an OSC 52 system-clipboard copy");

    // 7. Right-click a directory opens (acme "look") a terminal there and ls's
    //    it. Make a dir with a unique entry, then `clear` and echo its path so the
    //    path lands as contiguous output on body row 0. Right-click (no drag) on
    //    it: the new terminal lists the dir, so the unique entry appears only if
    //    the directory was actually opened.
    try hs.send("\x02"); // normal -> tty (Ctrl-b)
    try hs.pump(700);
    try hs.send("rm -rf /tmp/pardesE2E; mkdir -p /tmp/pardesE2E/ZZUNIQDIR; clear; echo /tmp/pardesE2E\r");
    try hs.pump(2000); // let it run + settle
    try hs.send("\x1b[<2;5;3M\x1b[<2;5;3m"); // right-click the path (+1 row: global tagbar)
    try hs.expectWaitFor("ZZUNIQDIR", 20000, "right-click on a directory did not open+ls it");

    // 8. Right-click a "path:line" opens a line-numbered FILE pane SCROLLED to
    //    that line. Build a tall file whose deep marker (line 150) is only
    //    reachable by scrolling, type the path:line on a blank row of the right
    //    column, then right-click it. The file pane opens, scrolls to line 150 ->
    //    the marker shows.
    {
        var fbuf: std.ArrayList(u8) = .empty;
        defer fbuf.deinit(gpa);
        var ln: usize = 1;
        while (ln <= 200) : (ln += 1)
            try fbuf.appendSlice(gpa, if (ln == 150) "ZZDEEPMARK\n" else "filler\n");
        try h.writeFile("/tmp/pardesE2Efile.txt", fbuf.items);
    }
    try hs.resize(30, 100); // clean geometry (right column starts ~col 51)
    try hs.pump(700); // settle the reflow
    try clearRightColumnTerminal(&hs);
    try hs.send("\x1b[<0;60;26M\x1b[<0;60;26m"); // left-click a blank row well below the ls output, right column -> normal + pin (+1 row: global tagbar)
    try hs.pump(400);
    try hs.send("i"); // normal -> insert so typing composes the path:line
    try hs.pump(400);
    try hs.send("/tmp/pardesE2Efile.txt:150");
    try hs.pump(800); // build the run
    try hs.send("\x1b[<2;70;26M\x1b[<2;70;26m"); // right-click on the typed path (+1 row: global tagbar)
    try hs.expectWaitFor("ZZDEEPMARK", 20000, "right-click 'path:150' did not open the file scrolled to line 150");
    try hs.expectContains("Save Del", "file pane tag did not include the default Save command");
    try hs.expectNotContains("Delcol", "Delcol is still shown in the default pane tag");

    // 9. Modal modes on the active FILE pane (left open & focused by step 8):
    //    `i` -> INSERT (tag shows "in"), Esc -> NORMAL ("nm"). Validates the
    //    3-mode model end to end via the tag indicator. No content changes here.
    try hs.send("i"); // normal -> insert
    try hs.pump(600);
    try hs.expectContains("in /", "no 'in' indicator after i (insert mode)");
    try hs.send("\x1b"); // insert -> normal
    try hs.pump(600);
    try hs.expectContains("nm /", "no 'nm' indicator after Esc (back to normal)");

    // 10. Helix-style file editing on that same file pane (still focused, normal,
    //     cursor on the ZZDEEPMARK line). `o` opens a line below and enters insert;
    //     type a unique marker, Esc to normal, then `x` (select line) + `d` (delete
    //     selection) removes it. Assert the marker appears then is gone, while the
    //     surrounding file content survives — proving real content mutation.
    try hs.send("o"); // open line below -> insert
    try hs.pump(600);
    try hs.send("ZZMODALNEW");
    try hs.pump(600);
    try hs.send("\x1b"); // back to normal
    try hs.pump(600);
    try hs.expectContains("ZZMODALNEW", "'o' did not open a line / typed text not shown");
    // cursor sits on the ZZMODALNEW line: `x` selects it, `d` deletes it.
    try hs.send("x");
    try hs.pump(400);
    try hs.send("d");
    try hs.pump(800);
    try hs.expectNotContains("ZZMODALNEW", "ZZMODALNEW still present after x+d (line delete failed)");
    // sanity: the deep marker (line 150, above the opened/deleted line) survives.
    try hs.expectContains("ZZDEEPMARK", "ZZDEEPMARK vanished — file pane content was clobbered");

    // 10b. Char-wise select (`v`) + delete, then undo/redo. Open a line, type a
    //      marker, `0` to the line start, `v` to start a char selection, `$` to
    //      extend to the line end, `d` to delete the range (gone), `u` to undo it
    //      (back), `U` to redo (gone again). Proves v-select, real char-range
    //      deletion, and the dumb undo/redo stack on a file pane.
    try hs.send("o"); // open line below -> insert
    try hs.pump(500);
    try hs.send("ZZVSELMARK");
    try hs.pump(500);
    try hs.send("\x1b"); // -> normal, cursor on the new line
    try hs.pump(500);
    try hs.expectContains("ZZVSELMARK", "char-select setup: typed marker not shown");
    try hs.send("0"); // line start
    try hs.pump(300);
    try hs.send("v"); // start char selection
    try hs.pump(300);
    try hs.send("$"); // extend the selection to the line end
    try hs.pump(300);
    try hs.send("d"); // delete the selected char range
    try hs.pump(800);
    try hs.expectNotContains("ZZVSELMARK", "v + $ + d did not delete the char selection");
    try hs.send("u"); // undo the delete
    try hs.pump(800);
    try hs.expectContains("ZZVSELMARK", "u (undo) did not restore the deleted text");
    try hs.send("U"); // redo the delete
    try hs.pump(800);
    try hs.expectNotContains("ZZVSELMARK", "U (redo) did not re-apply the delete");

    // 10c. Save writes the current file pane back to disk. Add a durable marker,
    //      middle-click the default "Save" command in the file tag, then read the
    //      real file from /tmp and confirm the edit landed there.
    try hs.send("o"); // open a new line below -> insert
    try hs.pump(500);
    try hs.send("ZZSAVEDISK");
    try hs.pump(500);
    try hs.send("\x1b"); // -> normal
    try hs.pump(500);
    try hs.expectContains("ZZSAVEDISK", "save setup: marker not visible in the file pane");
    try hs.send("\x1b[<1;31;2M\x1b[<1;31;2m"); // middle-click "Save" in the file tag
    try hs.pump(1000);
    const saved = try std.Io.Dir.cwd().readFileAlloc(io, "/tmp/pardesE2Efile.txt", gpa, .limited(1 << 20));
    defer gpa.free(saved);
    if (std.mem.indexOf(u8, saved, "ZZSAVEDISK") == null) {
        hs.dump("Save command did not write the edited file content to disk");
        return error.SaveDidNotWriteDisk;
    }

    // 10d. Focused save regression: saving a shorter edited buffer must replace the
    //      file exactly, not merely leave a marker somewhere on disk. This catches
    //      missed Save dispatch and non-truncating writes.
    {
        var hs_save = try Harness.init(gpa, exe.ptr, 24, 80);
        hs_save.trace = config.trace;
        defer hs_save.deinit();
        try h.writeFile("/tmp/pardesE2Esave.txt", "ZZORIG_SAVE_SHOULD_DISAPPEAR_AND_TAIL\n");
        try hs_save.expectWaitFor(ls_ready, 8000, "save regression app never auto-ls'd");
        try hs_save.resize(30, 100);
        try hs_save.pump(700);
        try clearRightColumnTerminal(&hs_save);
        try hs_save.send("\x1b[<0;60;20M\x1b[<0;60;20m"); // pin a blank row, right column -> normal
        try hs_save.pump(400);
        try hs_save.send("i");
        try hs_save.pump(300);
        try hs_save.send("/tmp/pardesE2Esave.txt");
        try hs_save.pump(500);
        try hs_save.send("\x1b"); // -> normal
        try hs_save.pump(400);
        try hs_save.send("\x1b[<2;70;20M\x1b[<2;70;20m"); // right-click the path -> open file
        try hs_save.expectWaitFor("ZZORIG_SAVE_SHOULD_DISAPPEAR", 20000, "save regression file did not open");
        try hs_save.send("x"); // select the original line
        try hs_save.pump(300);
        try hs_save.send("c"); // clear it and enter insert
        try hs_save.pump(300);
        try hs_save.send("ZZSAVED_EXACT");
        try hs_save.pump(500);
        try hs_save.send("\x1b"); // -> normal
        try hs_save.pump(500);
        try hs_save.expectContains("ZZSAVED_EXACT", "save regression edit is not visible before saving");
        try hs_save.send("\x1b[<1;31;2M\x1b[<1;31;2m"); // middle-click "Save" in the file tag
        try hs_save.pump(1000);
        const exact = try std.Io.Dir.cwd().readFileAlloc(io, "/tmp/pardesE2Esave.txt", gpa, .limited(1 << 20));
        defer gpa.free(exact);
        if (!std.mem.eql(u8, exact, "ZZSAVED_EXACT\n")) {
            hs_save.dump("Save command did not replace the file with the exact edited bytes");
            std.debug.print("saved file bytes: {any}\n", .{exact});
            return error.SaveDidNotWriteExactDisk;
        }
    }

    // 11. The Tutor builtin now lives in the GLOBAL tagbar (top row, full width):
    //     "Kill Newcol Tutor Debug". Clicking "Tutor" there spawns the tutor as a
    //     file pane. The tutor is embedded in the binary (@embedFile), so its
    //     content (the PARDES TUTOR header + the acme mouse intro) appears. "Tutor"
    //     sits at cols 12-16 (0-based) -> SGR col ~14, the tagbar is screen row 1.
    try hs.send("\x1b[<1;14;1M\x1b[<1;14;1m"); // middle-click "Tutor" in the global tagbar
    try hs.expectWaitFor("PARDES TUTOR", 10000, "global-tagbar 'Tutor' did not spawn the tutor pane");
    try hs.expectContains("tmux + vi + acme", "tutor pane content is wrong (no tagline)");

    // Tell the app to quit (ctrl-c is the app's own exit key).
    try hs.send("\x03");

    // 12. The --tty flag: start tmux-style as a single shell pane already in tty
    //     mode. Confirm the (only) pane's tag shows "sy" (tty) and that keystrokes
    //     go straight to the shell. A fresh app instance with the flag.
    {
        var hs2 = try Harness.initArgs(gpa, exe.ptr, 24, 80, "--tty");
        defer hs2.deinit();
        try hs2.expectWaitFor("sy /", 6000, "--tty did not start in tty mode (no 'sy' tag)");
        try hs2.send("echo ZZTTYFLAG\r");
        try hs2.expectWaitFor("ZZTTYFLAG", 8000, "--tty single shell did not forward input to the shell");
    }

    // 12b. -h/--help print usage and exit before entering the TUI.
    {
        var hsh = try Harness.initArgs(gpa, exe.ptr, 12, 80, "-h");
        defer hsh.deinit();
        try hsh.expectWaitFor("Usage: pardes", 4000, "-h did not print usage");
        try hsh.expectContains("--tty-toggle", "-h help did not mention --tty-toggle");
    }
    {
        var hsh2 = try Harness.initArgs(gpa, exe.ptr, 12, 80, "--help");
        defer hsh2.deinit();
        try hsh2.expectWaitFor("Usage: pardes", 4000, "--help did not print usage");
    }

    // 12c. The tty toggle can be moved off Ctrl-b. With --tty-toggle=g, Ctrl-b
    //      should no longer enter tty; Ctrl-g should.
    {
        var hsg = try Harness.initArgs(gpa, exe.ptr, 24, 80, "--tty-toggle=g");
        defer hsg.deinit();
        try hsg.expectWaitFor("nm /", 6000, "--tty-toggle=g app did not start in normal mode");
        try hsg.send("\x02"); // Ctrl-b: now just a normal-mode key, not the tty toggle
        try hsg.pump(600);
        try hsg.expectNotContains("sy /", "Ctrl-b still toggled tty after --tty-toggle=g");
        try hsg.send("\x07"); // Ctrl-g: configured tty toggle
        try hsg.expectWaitFor("sy /", 6000, "Ctrl-g did not toggle tty after --tty-toggle=g");
        try hsg.send("\x07");
        try hsg.expectWaitFor("nm /", 6000, "Ctrl-g did not toggle tty back to normal");
    }

    // 13. Terminal char-mode editing end to end (fresh instance for a clean pane):
    //     insert an overlay run, backspace part of it, Ctrl-b to tty (overlay hidden)
    //     and back (overlay returns), then u (undo the whole insert) / U (redo).
    {
        var hs3 = try Harness.init(gpa, exe.ptr, 24, 80);
        defer hs3.deinit();
        try hs3.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
        // pin the cursor on the top-left pane and type a marker (insert mode).
        try hs3.send("\x1b[<0;5;8M\x1b[<0;5;8m"); // left-click -> normal + pin (+1 row: global tagbar)
        try hs3.pump(400);
        try hs3.send("i");
        try hs3.pump(300);
        try hs3.send("ZZTERMINSXYZ");
        try hs3.pump(500);
        try hs3.expectContains("ZZTERMINSXYZ", "terminal insert did not show the typed overlay");
        // insert-mode backspace erases within the run: drop the trailing "XYZ".
        try hs3.send("\x7f\x7f\x7f");
        try hs3.pump(500);
        try hs3.expectNotContains("ZZTERMINSXYZ", "terminal insert-mode backspace did not erase typed text");
        try hs3.expectContains("ZZTERMINS", "backspace erased too much of the run");
        try hs3.send("\x1b"); // insert -> normal
        try hs3.pump(400);
        try hs3.send("\x02"); // normal -> tty: the overlay is hidden
        try hs3.pump(700);
        try hs3.expectNotContains("ZZTERMINS", "tty mode did not hide the inserted-text overlay");
        try hs3.send("\x02"); // tty -> normal: the overlay returns
        try hs3.pump(700);
        try hs3.expectContains("ZZTERMINS", "overlay did not return after leaving tty mode");
        try hs3.send("u"); // undo the whole insert session
        try hs3.pump(600);
        try hs3.expectNotContains("ZZTERMINS", "u (undo) did not remove the terminal insertion");
        try hs3.send("U"); // redo
        try hs3.pump(600);
        try hs3.expectContains("ZZTERMINS", "U (redo) did not restore the terminal insertion");
    }

    // 14. Normal-mode Tab/Enter act on the SELECTION when one is active (not the
    //     word under the cursor). Clear a fresh pane, type a computed command as an
    //     overlay, `x` to select the whole line, Tab -> the SELECTION runs. The
    //     output marker (ZZSEL5X) differs from the source ($((2+3))) so it only
    //     matches if the selection actually executed.
    {
        var hs4 = try Harness.init(gpa, exe.ptr, 24, 80);
        defer hs4.deinit();
        try hs4.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
        try hs4.send("\x02"); // normal -> tty
        try hs4.pump(500);
        try hs4.send("clear\r"); // blank the pane, prompt to row 0
        try hs4.pump(900);
        try hs4.send("\x02"); // tty -> normal
        try hs4.pump(400);
        try hs4.send("\x1b[<0;2;6M\x1b[<0;2;6m"); // pin a blank low row near col 0
        try hs4.pump(400);
        try hs4.send("i");
        try hs4.pump(300);
        try hs4.send("echo ZZSEL$((2+3))X"); // overlay; output is "ZZSEL5X"
        try hs4.pump(500);
        try hs4.send("\x1b"); // -> normal
        try hs4.pump(400);
        try hs4.send("x"); // select the whole typed line
        try hs4.pump(400);
        try hs4.send("\t"); // Tab: execute the SELECTION (not the word under cursor)
        try hs4.expectWaitFor("ZZSEL5X", 8000, "Tab on a selection did not execute the selected text");
    }

    // 15. Image panes open even without kitty graphics, rendering via the PETSCII
    //     fallback, and carry a "Petscii" toggle in their tagline. Write a small PPM,
    //     open it with an acme right-click, confirm the image pane + toggle appear,
    //     and pump frames (smoke-testing the glyph draw doesn't crash).
    {
        var hs5 = try Harness.init(gpa, exe.ptr, 24, 80);
        defer hs5.deinit();
        try hs5.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
        {
            var ppm: std.ArrayList(u8) = .empty;
            defer ppm.deinit(gpa);
            try ppm.appendSlice(gpa, "P6\n64 64\n255\n"); // diagonal split -> per-cell glyphs
            var y: usize = 0;
            while (y < 64) : (y += 1) {
                var x: usize = 0;
                while (x < 64) : (x += 1)
                    try ppm.appendSlice(gpa, if (x + y < 64) &[_]u8{ 255, 255, 255 } else &[_]u8{ 0, 0, 255 });
            }
            try h.writeFile("/tmp/pardesE2Epet.ppm", ppm.items);
        }
        try hs5.send("\x02"); // normal -> tty
        try hs5.pump(500);
        try hs5.send("clear\r"); // blank the pane
        try hs5.pump(900);
        try hs5.send("\x02"); // tty -> normal
        try hs5.pump(400);
        try hs5.send("\x1b[<0;5;8M\x1b[<0;5;8m"); // pin a blank row near col 0
        try hs5.pump(400);
        try hs5.send("i");
        try hs5.pump(300);
        try hs5.send("/tmp/pardesE2Epet.ppm");
        try hs5.pump(500);
        try hs5.send("\x1b"); // -> normal
        try hs5.pump(400);
        try hs5.send("\x1b[<2;10;8M\x1b[<2;10;8m"); // right-click the typed path -> open image pane
        try hs5.expectWaitFor("Petscii", 16000, "right-click on an image path did not open an image pane with the Petscii toggle");
        try hs5.pump(800); // let the petscii grid render across frames
        // the diagonal split renders as real block glyphs — assert the full block
        // shows (a blank body here would mean the grid didn't blit, e.g. a dangling
        // grapheme slice into a stack-local cell).
        try hs5.expectContains("█", "petscii image body is blank — no block glyphs rendered");
    }

    // 16. Opening a SECOND file SPLITS the existing file pane (acme-style minimal
    //     reflow) instead of stacking at the top of the column: the new file lands
    //     directly BELOW the source file, so A is above B. The OLD stack-at-top
    //     behavior would put B ABOVE A — so asserting rowOf(A) < rowOf(B) is a
    //     regression test for the split.
    {
        var hs6 = try Harness.init(gpa, exe.ptr, 24, 80);
        defer hs6.deinit();
        try hs6.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
        try h.writeFile("/tmp/pardesE2Efa.txt", "ZZFILEA\nfa1\nfa2\nfa3\nfa4\nfa5\nfa6\nfa7\nfa8\nfa9\n");
        try h.writeFile("/tmp/pardesE2Efb.txt", "ZZFILEB\nfb1\nfb2\nfb3\nfb4\nfb5\nfb6\nfb7\nfb8\nfb9\n");
        try hs6.resize(30, 100); // clean geometry: right column starts ~col 51
        try hs6.pump(700);
        try clearRightColumnTerminal(&hs6);
        // open file A from the right-column terminal: the FIRST doc pane, so it
        // takes the stack-at-top fallback (no existing doc pane to split).
        try hs6.send("\x1b[<0;60;20M\x1b[<0;60;20m"); // pin a blank row, right column -> normal
        try hs6.pump(400);
        try hs6.send("i");
        try hs6.pump(300);
        try hs6.send("/tmp/pardesE2Efa.txt");
        try hs6.pump(500);
        try hs6.send("\x1b"); // -> normal
        try hs6.pump(400);
        try hs6.send("\x1b[<2;70;20M\x1b[<2;70;20m"); // right-click the path -> open file A
        try hs6.expectWaitFor("ZZFILEA", 20000, "right-click did not open file A");
        // open file B likewise: a doc pane now exists, so B SPLITS it (lands below A).
        try hs6.send("\x1b[<0;60;22M\x1b[<0;60;22m"); // pin another blank row, right column
        try hs6.pump(400);
        try hs6.send("i");
        try hs6.pump(300);
        try hs6.send("/tmp/pardesE2Efb.txt");
        try hs6.pump(500);
        try hs6.send("\x1b");
        try hs6.pump(400);
        try hs6.send("\x1b[<2;70;22M\x1b[<2;70;22m"); // right-click the path -> open file B
        try hs6.expectWaitFor("ZZFILEB", 20000, "right-click did not open file B");
        // both markers sit on each file's TOP line (scroll 0). The split puts B
        // under A -> A's row < B's row. Stack-at-top would invert this.
        const text = try hs6.screenText();
        defer gpa.free(text);
        const ra = rowOf(text, "ZZFILEA") orelse return error.NoFileAOnScreen;
        const rb = rowOf(text, "ZZFILEB") orelse return error.NoFileBOnScreen;
        if (!(ra < rb)) {
            hs6.dump("second file did not split BELOW the first (B not under A)");
            return error.FileSplitOrderWrong;
        }
    }

    // 17. Deleting a pane that EMPTIES its column hands the column's width to a
    //     neighbor, so the OTHER columns keep their widths (only the neighbor
    //     grows). Regression for the extra horizontal movement: without the
    //     col-weight handoff, wsum drops and every surviving column reflows
    //     sideways. Build 3 columns, put a marker in the rightmost, delete the
    //     MIDDLE column's shell (exit), and assert the marker's screen column is
    //     unchanged (the middle column's width flowed LEFT, not to the right one).
    {
        var hs7 = try Harness.init(gpa, exe.ptr, 24, 80);
        defer hs7.deinit();
        try hs7.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
        try hs7.resize(30, 120); // 3 columns of ~40 each
        try hs7.pump(700);
        // add a 3rd column via the global-tagbar "Newcol" (cols 5-10, 0-based).
        try hs7.send("\x1b[<1;8;1M\x1b[<1;8;1m"); // middle-click "Newcol"
        try hs7.pump(2500); // let the new shell come up
        // mark the rightmost column (now the active pane) with a unique token.
        try hs7.send("\x02"); // normal -> tty
        try hs7.pump(500);
        try hs7.send("clear\r");
        try hs7.pump(800);
        // split the token so it appears ONLY in the OUTPUT (at the pane's left edge),
        // never in the command text — else colOf would match the prompt-relative echo.
        try hs7.send("echo ZZFAR''COL\r");
        try hs7.expectWaitFor("ZZFARCOL", 12000, "new column's shell did not echo the marker");
        const before = try hs7.screenText();
        const x1 = colOf(before, "ZZFARCOL") orelse {
            gpa.free(before);
            return error.NoFarColBefore;
        };
        gpa.free(before);
        // focus the MIDDLE column (x 40..80) and exit its shell -> the column
        // empties and collapses. pty_eof deletes the pane and reflows.
        try hs7.send("\x1b[<0;58;12M\x1b[<0;58;12m"); // left-click inside the middle column
        try hs7.pump(500);
        try hs7.send("\x02"); // -> tty
        try hs7.pump(400);
        try hs7.send("exit\r"); // shell EOF -> pane removed -> middle column collapses
        try hs7.pump(2000); // let pty_eof + the reflow settle
        try hs7.expectContains("ZZFARCOL", "marker vanished after collapsing the middle column");
        const after = try hs7.screenText();
        defer gpa.free(after);
        const x2 = colOf(after, "ZZFARCOL") orelse return error.NoFarColAfter;
        // with the width handoff the rightmost column stays put (x2 ~= x1); without
        // it, every column reflows and x2 shrinks by ~one column width.
        if (x2 + 2 < x1) {
            hs7.dump("rightmost column moved when the middle column collapsed (no width handoff)");
            std.debug.print("ZZFARCOL col: before={d} after={d}\n", .{ x1, x2 });
            return error.ColumnCollapseReflow;
        }
    }

    // 18. DEVICE-QUERY REPLIES: full-screen apps (nushell/reedline, helix, fzf — all
    //     crossterm) send a DSR cursor-position report (ESC[6n) at startup and BLOCK
    //     until the terminal answers. pardes must reply into the pty; without the
    //     write_pty effect the reply is dropped and the app spins forever ("nushell
    //     never opens"). Mode-independent. Run a command that (in raw mode, like a
    //     real app) emits ESC[6n and only echoes its marker AFTER it reads the reply
    //     — the marker appears only if pardes answered the query.
    {
        var hs8 = try Harness.init(gpa, exe.ptr, 24, 80);
        defer hs8.deinit();
        try hs8.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
        try hs8.send("\x02"); // normal -> tty
        try hs8.pump(600);
        // -icanon so the reply's bytes reach `read` without a newline; read until the
        // reply terminator 'R' (ESC[<row>;<col>R) with a timeout, and print the marker
        // ONLY if the read SUCCEEDS (a reply arrived). The marker is split with ''
        // so it appears solely in the command's OUTPUT, never in the echoed command
        // line — otherwise the on-screen command text would false-match it.
        try hs8.send("stty -icanon -echo min 1 time 0; printf '\\033[6n'; read -r -d R -t 3 _c && printf 'ZZCPR''OK\\n'; stty sane\r");
        try hs8.expectWaitFor("ZZCPROK", 8000, "app blocked on its ESC[6n query — pardes dropped the reply (device queries not answered)");
    }

    // 19. PRIMARY-DA TERMINATOR (helix regression). crossterm probes capabilities with
    //     `ESC[?u ESC[c` (kitty-keyboard query + primary DA) and waits on the DA reply
    //     `ESC[?...c` as the TERMINATOR of the whole probe. Answering ESC[6n alone (test
    //     18) is not enough: without primary DA, helix enters its alt screen and then
    //     hangs on a BLANK pane. Send the real probe, read until the DA reply's 'c',
    //     and print the split marker only on success.
    {
        var hs9 = try Harness.init(gpa, exe.ptr, 24, 80);
        defer hs9.deinit();
        try hs9.expectWaitFor(ls_ready, 8000, "fresh terminal never auto-ls'd");
        try hs9.send("\x02"); // normal -> tty
        try hs9.pump(600);
        // read until 'c' (primary DA ends in 'c'); the kitty reply ESC[?0u has no 'c',
        // so the read only completes once primary DA is answered.
        try hs9.send("stty -icanon -echo min 1 time 0; printf '\\033[?u\\033[c'; read -r -d c -t 3 _r && printf 'ZZDA''OK\\n'; stty sane\r");
        try hs9.expectWaitFor("ZZDAOK", 8000, "app blocked on its ESC[c device-attributes probe — pardes never answered primary DA (helix hangs on a blank pane)");
    }

    // 20. KEPT LEFT SELECTION + MIDDLE+LEFT CHORD. A left-drag selection now PERSISTS
    //     after release (it clears only on a plain left click or a new left drag). The
    //     middle+left chord: press middle (on a command word), press left BEFORE
    //     releasing middle -> the KEPT left selection's text is passed as the command's
    //     argument. Here a script echoes CHORDARG:"$1"; left-select ZZCHORDARG, then
    //     chord the script -> output "CHORDARG:ZZCHORDARG" only if the kept selection
    //     fed the arg. Top-left pane body row 0 = SGR row 3; body col 0 = SGR col 3.
    {
        var hsc = try Harness.init(gpa, exe.ptr, 24, 80);
        defer hsc.deinit();
        try hsc.expectWaitFor(ls_ready, 8000, "chord test: fresh terminal never auto-ls'd");
        try h.writeFile("/tmp/pardesE2Echord.sh", "#!/bin/sh\necho CHORDARG:\"$1\"\n");
        try hsc.send("\x02"); // normal -> tty
        try hsc.pump(500);
        try hsc.send("chmod +x /tmp/pardesE2Echord.sh; clear; printf 'ZZCHORDARG\\n/tmp/pardesE2Echord.sh\\n'\r");
        try hsc.expectWaitFor("/tmp/pardesE2Echord.sh", 8000, "chord test: command path not rendered");
        // left-drag-select "ZZCHORDARG" (10 chars, body cols 0-9 = SGR cols 3-12) on
        // body row 0 (SGR row 3) -> kept (.done) after release.
        try hsc.send("\x1b[<0;3;3M\x1b[<32;12;3M\x1b[<0;12;3m");
        try hsc.pump(400);
        // middle+left chord: middle-press on the script path (body row 1 = SGR row 4),
        // left-press (captures the kept selection as the arg), middle-release (runs it).
        try hsc.send("\x1b[<1;8;4M\x1b[<0;8;4M\x1b[<1;8;4m");
        try hsc.expectWaitFor("CHORDARG:ZZCHORDARG", 8000, "middle+left chord did not pass the kept left selection as the command argument");
    }

    // 21. A plain LEFT CLICK (no drag) clears the kept left selection, so a later
    //     middle+left chord passes NO argument. Left-select ZZCLRARG, left-click a
    //     blank cell (clears), then chord the script -> output "CHORDARG:" (empty),
    //     with no ZZCLRARG after it.
    {
        var hscl = try Harness.init(gpa, exe.ptr, 24, 80);
        defer hscl.deinit();
        try hscl.expectWaitFor(ls_ready, 8000, "clear test: fresh terminal never auto-ls'd");
        try h.writeFile("/tmp/pardesE2Echord.sh", "#!/bin/sh\necho CHORDARG:\"$1\"\n");
        try hscl.send("\x02");
        try hscl.pump(500);
        try hscl.send("chmod +x /tmp/pardesE2Echord.sh; clear; printf 'ZZCLRARG\\n/tmp/pardesE2Echord.sh\\n'\r");
        try hscl.expectWaitFor("/tmp/pardesE2Echord.sh", 8000, "clear test: command path not rendered");
        // left-drag-select "ZZCLRARG" (8 chars, body cols 0-7 = SGR cols 3-10) -> kept.
        try hscl.send("\x1b[<0;3;3M\x1b[<32;10;3M\x1b[<0;10;3m");
        try hscl.pump(400);
        // plain left click on a blank row (body row 3 = SGR row 6), no drag -> clears.
        try hscl.send("\x1b[<0;3;6M\x1b[<0;3;6m");
        try hscl.pump(400);
        // chord the script; the kept selection is gone -> no arg -> "CHORDARG:" (empty).
        try hscl.send("\x1b[<1;8;4M\x1b[<0;8;4M\x1b[<1;8;4m");
        try hscl.pump(900);
        try hscl.expectContains("CHORDARG:", "clear test: chord did not run the script");
        try hscl.expectNotContains("CHORDARG:ZZCLRARG", "a plain left click did not clear the kept left selection (chord still passed it)");
    }

    // 22. A NEW left drag REPLACES the previous kept left selection. Left-select
    //     ZZTXTONE, then left-select ZZTXTTWO (replaces), then chord the script ->
    //     output "CHORDARG:ZZTXTTWO" (the NEW selection), NOT "CHORDARG:ZZTXTONE".
    {
        var hsr = try Harness.init(gpa, exe.ptr, 24, 80);
        defer hsr.deinit();
        try hsr.expectWaitFor(ls_ready, 8000, "replace test: fresh terminal never auto-ls'd");
        try h.writeFile("/tmp/pardesE2Echord.sh", "#!/bin/sh\necho CHORDARG:\"$1\"\n");
        try hsr.send("\x02");
        try hsr.pump(500);
        try hsr.send("chmod +x /tmp/pardesE2Echord.sh; clear; printf 'ZZTXTONE\\nZZTXTTWO\\n/tmp/pardesE2Echord.sh\\n'\r");
        try hsr.expectWaitFor("/tmp/pardesE2Echord.sh", 8000, "replace test: command path not rendered");
        // left-drag-select "ZZTXTONE" on body row 0 (SGR row 3; 8 chars -> SGR cols 3-10).
        try hsr.send("\x1b[<0;3;3M\x1b[<32;10;3M\x1b[<0;10;3m");
        try hsr.pump(400);
        // left-drag-select "ZZTXTTWO" on body row 1 (SGR row 4) -> REPLACES the kept one.
        try hsr.send("\x1b[<0;3;4M\x1b[<32;10;4M\x1b[<0;10;4m");
        try hsr.pump(400);
        // chord the script on body row 2 (SGR row 5) -> arg is the NEW selection ZZTXTTWO.
        try hsr.send("\x1b[<1;8;5M\x1b[<0;8;5M\x1b[<1;8;5m");
        try hsr.expectWaitFor("CHORDARG:ZZTXTTWO", 8000, "a new left drag did not replace the old kept selection (chord used the wrong arg)");
        try hsr.expectNotContains("CHORDARG:ZZTXTONE", "the old kept selection leaked into the chord arg (not replaced)");
    }

    // 23. A BUILTIN with a chord arg ignores the arg (each builtin decides; none use
    //     it yet). Left-select some text, then chord middle-click "Debug" in the body
    //     -> the Debug overlay toggles on (arg ignored), so "resizes=" appears.
    {
        var hsb = try Harness.init(gpa, exe.ptr, 24, 80);
        defer hsb.deinit();
        try hsb.expectWaitFor(ls_ready, 8000, "builtin-chord test: fresh terminal never auto-ls'd");
        try hsb.send("\x02");
        try hsb.pump(500);
        try hsb.send("clear; printf 'ZZBUILTARG\\nDebug\\n'\r");
        try hsb.expectWaitFor("Debug", 8000, "builtin-chord test: 'Debug' word not rendered");
        // left-select "ZZBUILTARG" (10 chars, SGR cols 3-12) on body row 0 -> kept.
        try hsb.send("\x1b[<0;3;3M\x1b[<32;12;3M\x1b[<0;12;3m");
        try hsb.pump(400);
        // chord middle-click "Debug" on body row 1 (SGR row 4, body col 2 = SGR col 5).
        try hsb.send("\x1b[<1;5;4M\x1b[<0;5;4M\x1b[<1;5;4m");
        try hsb.pump(900);
        try hsb.expectContains("resizes=", "builtin with a chord arg did not toggle (arg broke builtin dispatch)");
    }
}