a73x

cb63edb8

feat: predicted echo — the keystroke paints before the RTT

a73x   2026-08-08 15:34

Commit message
feat: predicted echo — the keystroke paints before the RTT

The overlay meets the replica. A printable byte typed in a context the pty
has vouched for is drawn underlined at the predicted cell immediately, the
cursor advances past it, and the bytes go to the daemon unchanged — the
shell receives exactly what it always did. When the answering frame lands
the prediction is judged against the replica and the daemon's own row
content replaces the guess.

The read that makes it work is prev_ch, and it comes from the PREDICTED
cursor rather than the replica's. Mid-burst those are different cells, and
reading the wrong one hands reconcile a prev_ch belonging to a cell nobody
predicted: the next frame then looks like a contradiction instead of
silence, and the burst flushes itself. That is the standing debt from Task
3 discharged — a real engine, fed real VT bytes, with the cursor parked on
one character and a different one beside it, which is the only shape in
which the two reads disagree. The mutation fails it twice over: at the
prev_ch value, and again at the verdict.

Predictions are re-laid after every authoritative paint, because a delta
rewrites whole rows and would otherwise wipe a glyph whose prediction is
still outstanding — the burst would flicker away one frame after being
drawn. That repaint asks the same visibility question the keystroke path
does, so an unearned prediction cannot appear through the back door.

Flushed on snapshot, resize, scroll entry and reconnect; expired off the
idle path, which is the only thing that can retire a prediction an
application answered by going quiet. MUX_PREDICT_STATS=1 prints one
greppable line, its format pinned by a test rather than by whatever the
process happened to emit.

Verified live, not merely by unit test: typing into a real session paints
`\e[1;2H\e[4mi\e[0m\e[1;3H` before the daemon's own `hi` arrives, and the
counters read made=1 displayed=1 confirmed=1 contradicted=0. The first
keystroke of that run was suppressed — it was typed before the pty_mode
frame arrived, which is the safe default doing its job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

build.zig
Old New
@@ -144,6 +144,9 @@ pub fn build(b: *std.Build) void {
144 client_mod.addImport("protocol", protocol_mod); 144 client_mod.addImport("protocol", protocol_mod);
145 client_mod.addImport("testtmp", testtmp_mod); 145 client_mod.addImport("testtmp", testtmp_mod);
146 client_mod.addImport("quic_client", quic_client_mod); 146 client_mod.addImport("quic_client", quic_client_mod);
147 // The client is the only thing that predicts: the overlay is a local
148 // display decision and never becomes state anybody else can see.
149 client_mod.addImport("predict", predict_mod);
147 150
148 const mux_mod = b.createModule(.{ 151 const mux_mod = b.createModule(.{
149 .root_source_file = b.path("src/mux_main.zig"), 152 .root_source_file = b.path("src/mux_main.zig"),
src/client.zig
Old New
@@ -10,6 +10,7 @@ const Engine = @import("engine").Engine;
10 const proto = @import("protocol"); 10 const proto = @import("protocol");
11 const TmpDir = @import("testtmp").TmpDir; 11 const TmpDir = @import("testtmp").TmpDir;
12 const quic_client = @import("quic_client"); 12 const quic_client = @import("quic_client");
13 const predict = @import("predict");
13 14
14 var winch_flag = std.atomic.Value(bool).init(false); 15 var winch_flag = std.atomic.Value(bool).init(false);
15 16
@@ -457,6 +458,16 @@ fn session(
457 var exit_msg: ?[]const u8 = null; 458 var exit_msg: ?[]const u8 = null;
458 defer if (exit_msg) |m| std.debug.print("{s}\n", .{m}); 459 defer if (exit_msg) |m| std.debug.print("{s}\n", .{m});
459 460
461 // Speculative echo. Born `.never` and stays there until a daemon tells
462 // it otherwise, so an old daemon that has never heard of pty_mode gets a
463 // client that predicts nothing at all.
464 //
465 // Registered here so the stats line lands after the terminal has been
466 // put back and on the normal screen, like every other message.
467 var overlay = predict.Overlay.init(alloc, size.cols, size.rows);
468 defer overlay.deinit();
469 defer dumpPredictStats(overlay.counters);
470
460 // Raw mode when we own a terminal. The alternate screen is NOT entered 471 // Raw mode when we own a terminal. The alternate screen is NOT entered
461 // here — see the first-frame gate in the loop below. 472 // here — see the first-frame gate in the loop below.
462 var orig_termios: ?std.posix.termios = null; 473 var orig_termios: ?std.posix.termios = null;
@@ -591,6 +602,10 @@ fn session(
591 reconnect_grace_until = std.time.milliTimestamp() + reconnect_grace_ms; 602 reconnect_grace_until = std.time.milliTimestamp() + reconnect_grace_ms;
592 } 603 }
593 repaint_after_resync = true; 604 repaint_after_resync = true;
605 // Whatever was outstanding was predicted against a connection
606 // that no longer exists. Dropping it is not an accusation, so
607 // the counters stay where they are.
608 overlay.flush();
594 continue; 609 continue;
595 } 610 }
596 if (winch_flag.swap(false, .acq_rel)) { 611 if (winch_flag.swap(false, .acq_rel)) {
@@ -609,6 +624,9 @@ fn session(
609 needs_reconnect = true; 624 needs_reconnect = true;
610 continue; 625 continue;
611 }; 626 };
627 // The grid this would be painted on is about to stop
628 // existing; cleared when the answering snapshot lands.
629 overlay.setResizePending(true);
612 } 630 }
613 } 631 }
614 } 632 }
@@ -620,6 +638,14 @@ fn session(
620 _ = try std.posix.poll(&fds, transport.timeoutMs(100)); 638 _ = try std.posix.poll(&fds, transport.timeoutMs(100));
621 transport.service(); 639 transport.service();
622 640
641 // The idle path, and the only thing that can retire a prediction the
642 // application answered by going quiet: no frame is coming, so
643 // reconcile will never run again and the glyph would otherwise stay
644 // on screen for the rest of the session.
645 if (overlay.expire(std.time.milliTimestamp()) == .contradicted and scroll_pages == 0) {
646 try renderClipped(alloc, replica, size, stdout_fd);
647 }
648
623 // Labelled, because "no whole frame yet" must leave the REST of this 649 // Labelled, because "no whole frame yet" must leave the REST of this
624 // iteration running. Everything below — the detach chord, keystrokes, 650 // iteration running. Everything below — the detach chord, keystrokes,
625 // resize — lives in the same pass, so jumping straight back to the 651 // resize — lives in the same pass, so jumping straight back to the
@@ -677,6 +703,15 @@ fn session(
677 // scrolled, so leaving scroll mode paints current state. 703 // scrolled, so leaving scroll mode paints current state.
678 replica.reset(); 704 replica.reset();
679 replica.feed(frame.payload[proto.snapshot_prefix_len..]); 705 replica.feed(frame.payload[proto.snapshot_prefix_len..]);
706 // A snapshot answers a resize, ends a reconnect, and
707 // rebuilds the screen under anything outstanding. None
708 // of that says a prediction was wrong — it says we can
709 // no longer find out, so the queue goes and the counters
710 // do not move.
711 overlay.setGrid(grid.cols, grid.rows);
712 overlay.setResizePending(false);
713 overlay.flush();
714 overlay.noteSeq(prefix.seq);
680 if (scroll_pages == 0) { 715 if (scroll_pages == 0) {
681 try renderClipped(alloc, replica, size, stdout_fd); 716 try renderClipped(alloc, replica, size, stdout_fd);
682 repaint_after_resync = false; // banner painted over 717 repaint_after_resync = false; // banner painted over
@@ -708,19 +743,52 @@ fn session(
708 history_rows = composed.header.history_rows; 743 history_rows = composed.header.history_rows;
709 last_seq = composed.header.seq; 744 last_seq = composed.header.seq;
710 replica.feed(composed.bytes); 745 replica.feed(composed.bytes);
746 // Judged against the replica the frame has just been fed
747 // into, which is the only authority there is.
748 const verdict = reconcileOverlay(
749 alloc,
750 &overlay,
751 replica,
752 last_seq,
753 std.time.milliTimestamp(),
754 );
711 // While scrolled the replica still tracks live output; the 755 // While scrolled the replica still tracks live output; the
712 // repaint on scroll exit comes from it. 756 // repaint on scroll exit comes from it.
713 if (scroll_pages == 0) { 757 if (scroll_pages == 0) {
714 if (repaint_after_resync) { 758 if (repaint_after_resync or verdict == .contradicted) {
715 // First frame back after a reconnect. The daemon 759 // First frame back after a reconnect. The daemon
716 // sent only what changed, which is correct — but 760 // sent only what changed, which is correct — but
717 // the screen still carries the banner, so repaint 761 // the screen still carries the banner, so repaint
718 // the whole thing from the replica instead. 762 // the whole thing from the replica instead.
763 //
764 // A contradiction (or an expiry) takes the same
765 // route: the whole queue has just been abandoned,
766 // and repainting everything from the replica is
767 // the simplest rollback that is certainly right.
768 // It is affordable precisely because reconcile v2
769 // made contradictions rare — a burst outrunning
770 // the round trip is no longer one.
719 try renderClipped(alloc, replica, size, stdout_fd); 771 try renderClipped(alloc, replica, size, stdout_fd);
720 repaint_after_resync = false; 772 repaint_after_resync = false;
721 } else { 773 } else {
722 try paintDeltaClipped(alloc, frame.payload, size, stdout_fd); 774 try paintDeltaClipped(alloc, frame.payload, size, stdout_fd);
723 } 775 }
776 // Last, and after either paint: the rows the daemon
777 // just sent have overwritten anything drawn on them,
778 // including predictions that are still outstanding.
779 paintOverlay(alloc, &overlay, replica.cursorPos(), size, stdout_fd);
780 }
781 },
782 .pty_mode => {
783 const flags = proto.decodePtyMode(frame.payload) catch continue;
784 // Mode churn is ordinary — readline hands the terminal
785 // back and forth around every command — so the repaint
786 // is spent only when the flush actually took something
787 // off the screen.
788 const had_pending = overlay.pendingCount() > 0;
789 overlay.setMode(flags);
790 if (had_pending and overlay.pendingCount() == 0 and scroll_pages == 0) {
791 try renderClipped(alloc, replica, size, stdout_fd);
724 } 792 }
725 }, 793 },
726 .scrollback_chunk => { 794 .scrollback_chunk => {
@@ -775,6 +843,10 @@ fn session(
775 if (history_rows > 0) { 843 if (history_rows > 0) {
776 const max_pages: u32 = (history_rows + size.rows - 1) / size.rows; 844 const max_pages: u32 = (history_rows + size.rows - 1) / size.rows;
777 if (scroll_pages < max_pages) scroll_pages += 1; 845 if (scroll_pages < max_pages) scroll_pages += 1;
846 // The cursor is no longer where the user is looking,
847 // so a prediction painted at it would land in the
848 // middle of history.
849 overlay.setScrollMode(true);
778 requestScrollPage(transport, scroll_pages, history_rows, size) catch { 850 requestScrollPage(transport, scroll_pages, history_rows, size) catch {
779 needs_reconnect = true; 851 needs_reconnect = true;
780 continue; 852 continue;
@@ -783,6 +855,7 @@ fn session(
783 } else if (std.mem.eql(u8, buf[0..n], scroll_dn)) { 855 } else if (std.mem.eql(u8, buf[0..n], scroll_dn)) {
784 if (scroll_pages > 0) scroll_pages -= 1; 856 if (scroll_pages > 0) scroll_pages -= 1;
785 if (scroll_pages == 0) { 857 if (scroll_pages == 0) {
858 overlay.setScrollMode(false);
786 try renderClipped(alloc, replica, size, stdout_fd); 859 try renderClipped(alloc, replica, size, stdout_fd);
787 } else { 860 } else {
788 requestScrollPage(transport, scroll_pages, history_rows, size) catch { 861 requestScrollPage(transport, scroll_pages, history_rows, size) catch {
@@ -793,8 +866,13 @@ fn session(
793 } else if (scroll_pages > 0) { 866 } else if (scroll_pages > 0) {
794 // Any other key exits scroll mode (swallowed, not forwarded). 867 // Any other key exits scroll mode (swallowed, not forwarded).
795 scroll_pages = 0; 868 scroll_pages = 0;
869 overlay.setScrollMode(false);
796 try renderClipped(alloc, replica, size, stdout_fd); 870 try renderClipped(alloc, replica, size, stdout_fd);
797 } else { 871 } else {
872 // Speculate before sending, so the glyph is on screen
873 // while the keystroke is still in flight. The bytes that
874 // go out are unchanged either way.
875 offerKeystroke(alloc, &overlay, replica, buf[0..n], size, stdout_fd);
798 transport.writeFrame(.input, buf[0..n]) catch { 876 transport.writeFrame(.input, buf[0..n]) catch {
799 // These keystrokes are lost with the transport, by 877 // These keystrokes are lost with the transport, by
800 // the same policy that drops what is typed while 878 // the same policy that drops what is typed while
@@ -875,6 +953,159 @@ fn paintDeltaClipped(alloc: std.mem.Allocator, payload: []const u8, tty: proto.S
875 try proto.writeAllFd(out_fd, paint.items); 953 try proto.writeAllFd(out_fd, paint.items);
876 } 954 }
877 955
956 // ---- prediction --------------------------------------------------------
957 //
958 // The overlay is a display decision and nothing else. It never writes to
959 // the replica, so the replica keeps meaning exactly "what the daemon said"
960 // and stays comparable to `muxd dump` at every instant. Everything below
961 // either reads the replica or paints on top of it.
962
963 /// What the replica shows at one cell — the `prev_ch` a prediction is
964 /// judged against later.
965 ///
966 /// Read at the PREDICTED cursor, not the replica's own: mid-burst those are
967 /// different cells, and reading the wrong one hands reconcile a `prev_ch`
968 /// that belongs to somebody else's cell, which turns "the frame has not
969 /// answered yet" into "we were contradicted" and flushes the queue. That is
970 /// the failure the real-Engine test below exists to catch.
971 fn replicaCellChar(alloc: std.mem.Allocator, replica: *Engine, at: predict.CursorPos) u8 {
972 const plain = replica.dumpPlain(alloc) catch return ' ';
973 defer alloc.free(plain);
974 const grid: predict.PlainGrid = .{ .text = plain, .cols = @intCast(replica.term.cols) };
975 return grid.cellChar(at.y, at.x) orelse ' ';
976 }
977
978 /// Judge the overlay against the replica as it now stands. Call after the
979 /// replica has taken the frame, never before: the whole question is what
980 /// the authoritative state says now.
981 fn reconcileOverlay(
982 alloc: std.mem.Allocator,
983 overlay: *predict.Overlay,
984 replica: *Engine,
985 seq: u64,
986 now_ms: i64,
987 ) predict.Verdict {
988 const plain = replica.dumpPlain(alloc) catch return .none;
989 defer alloc.free(plain);
990 return overlay.reconcile(
991 predict.PlainGrid{ .text = plain, .cols = @intCast(replica.term.cols) },
992 seq,
993 now_ms,
994 );
995 }
996
997 /// Paint every pending prediction on top of whatever is on screen, and
998 /// leave the cursor where the typist believes it is.
999 ///
1000 /// Idempotent and called after every authoritative paint as well as on each
1001 /// keystroke, because a delta repaints whole rows: the row content the
1002 /// daemon sent would otherwise wipe an underlined glyph whose prediction is
1003 /// still outstanding, and the burst would flicker away one frame after it
1004 /// was drawn.
1005 fn paintOverlay(
1006 alloc: std.mem.Allocator,
1007 overlay: *predict.Overlay,
1008 base: Engine.CursorPos,
1009 tty: proto.Size,
1010 out_fd: std.posix.fd_t,
1011 ) void {
1012 if (!overlay.confident or overlay.pendingCount() == 0) return;
1013 var paint: std.ArrayList(u8) = .empty;
1014 defer paint.deinit(alloc);
1015 paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l") catch return;
1016
1017 var i: usize = 0;
1018 while (i < overlay.pendingCount()) : (i += 1) {
1019 const cell = overlay.pendingAt(i).cell;
1020 if (cell.row >= tty.rows or cell.col >= tty.cols) continue;
1021 var b: [32]u8 = undefined;
1022 // Underlined, so a prediction is visibly a prediction until the
1023 // daemon's own row content replaces it.
1024 const s = std.fmt.bufPrint(&b, "\x1b[{d};{d}H\x1b[4m{c}\x1b[0m", .{
1025 cell.row + 1,
1026 cell.col + 1,
1027 cell.ch,
1028 }) catch continue;
1029 paint.appendSlice(alloc, s) catch return;
1030 }
1031
1032 const pc = overlay.predictedCursor(.{ .x = base.x, .y = base.y });
1033 const cur = clampCursor(.{ .x = pc.x, .y = pc.y }, tty);
1034 var cbuf: [32]u8 = undefined;
1035 const tail = std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H\x1b[?25h\x1b[?2026l", .{
1036 cur.y + 1,
1037 cur.x + 1,
1038 }) catch return;
1039 paint.appendSlice(alloc, tail) catch return;
1040 proto.writeAllFd(out_fd, paint.items) catch {};
1041 }
1042
1043 /// Offer one chunk of typed bytes to the overlay. The chunk goes to the
1044 /// daemon unchanged whatever happens here — prediction never alters what
1045 /// the shell receives, only what the screen shows before it answers.
1046 fn offerKeystroke(
1047 alloc: std.mem.Allocator,
1048 overlay: *predict.Overlay,
1049 replica: *Engine,
1050 chunk: []const u8,
1051 tty: proto.Size,
1052 out_fd: std.posix.fd_t,
1053 ) void {
1054 if (chunk.len != 1) {
1055 // An escape sequence, a multi-byte character, or a paste. None is
1056 // one cell's worth of change and M9 speculates about none of them.
1057 // Offering the lead byte records the refusal in the same counter as
1058 // every other refusal, which covers the two shapes that actually
1059 // arrive this way — both have a non-printable lead byte.
1060 if (chunk[0] < 0x20 or chunk[0] >= 0x7f) {
1061 _ = overlay.predictAt(.{
1062 .cursor = .{},
1063 .ch = chunk[0],
1064 .prev_ch = 0,
1065 .now_ms = std.time.milliTimestamp(),
1066 });
1067 }
1068 return;
1069 }
1070
1071 const base = replica.cursorPos();
1072 const at = overlay.predictedCursor(.{ .x = base.x, .y = base.y });
1073 const out = overlay.predictAt(.{
1074 .cursor = at,
1075 .ch = chunk[0],
1076 .prev_ch = replicaCellChar(alloc, replica, at),
1077 .now_ms = std.time.milliTimestamp(),
1078 });
1079 switch (out) {
1080 .display => paintOverlay(alloc, overlay, base, tty, out_fd),
1081 // Queued but unearned, or refused outright: either way nothing is
1082 // drawn, which is the entire safety property.
1083 .hidden, .suppressed => {},
1084 }
1085 }
1086
1087 /// The one machine-readable line `MUX_PREDICT_STATS=1` produces. A pure
1088 /// function so the format the e2e greps for is pinned by a test rather than
1089 /// by whatever the process happened to print.
1090 fn formatPredictStats(buf: []u8, c: predict.Counters) ![]const u8 {
1091 return std.fmt.bufPrint(
1092 buf,
1093 "predict made={d} displayed={d} confirmed={d} contradicted={d}" ++
1094 " expired={d} suppressed={d}",
1095 .{ c.made, c.displayed, c.confirmed, c.contradicted, c.expired, c.suppressed },
1096 );
1097 }
1098
1099 pub const predict_stats_len = 192;
1100
1101 fn dumpPredictStats(c: predict.Counters) void {
1102 const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return;
1103 if (!std.mem.eql(u8, want, "1")) return;
1104 var buf: [predict_stats_len]u8 = undefined;
1105 const line = formatPredictStats(&buf, c) catch return;
1106 std.debug.print("{s}\n", .{line});
1107 }
1108
878 fn requestScrollPage( 1109 fn requestScrollPage(
879 transport: *Transport, 1110 transport: *Transport,
880 pages_up: u32, 1111 pages_up: u32,
@@ -1033,6 +1264,249 @@ fn renderScrollback(
1033 try proto.writeAllFd(out_fd, paint.items); 1264 try proto.writeAllFd(out_fd, paint.items);
1034 } 1265 }
1035 1266
1267 fn devNull() !std.posix.fd_t {
1268 return std.posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0);
1269 }
1270
1271 test "prediction: prev_ch is read at the predicted cursor, not the replica's" {
1272 const alloc = std.testing.allocator;
1273 const null_fd = try devNull();
1274 defer std.posix.close(null_fd);
1275
1276 // A real engine, fed real VT bytes — the one part of the prediction
1277 // contract no test inside predict.zig can reach, because that module
1278 // has never heard of an engine.
1279 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1280 defer replica.deinit();
1281 // Content with the cursor parked ON a character and a DIFFERENT
1282 // character in the cell after it. That difference is the whole test:
1283 // with nothing pending the replica's cursor and the predicted one agree,
1284 // and mid-burst they do not.
1285 replica.feed("abcXY\x1b[1;4H");
1286 try std.testing.expectEqual(@as(u16, 3), replica.cursorPos().x);
1287
1288 var ov = predict.Overlay.init(alloc, 80, 24);
1289 defer ov.deinit();
1290 ov.setMode(.{ .icanon = true, .echo = true });
1291 ov.noteSeq(1);
1292
1293 const tty = proto.Size{ .cols = 80, .rows = 24 };
1294 offerKeystroke(alloc, &ov, replica, "d", tty, null_fd);
1295 offerKeystroke(alloc, &ov, replica, "e", tty, null_fd);
1296
1297 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
1298 try std.testing.expectEqual(@as(u8, 'X'), ov.pendingAt(0).prev_ch);
1299 // The second keystroke lands in the cell AFTER the first prediction,
1300 // and takes that cell's content as its prev_ch.
1301 try std.testing.expectEqual(@as(u16, 4), ov.pendingAt(1).cell.col);
1302 try std.testing.expectEqual(@as(u8, 'Y'), ov.pendingAt(1).prev_ch);
1303
1304 // A frame that changed neither cell: the daemon has not seen the
1305 // keystrokes yet, so it has said nothing about them and both
1306 // predictions must survive it. Read prev_ch from the wrong cell and
1307 // this is where it shows — the second cell holds 'Y', which is neither
1308 // the prediction nor the 'X' a cursor-based read would have recorded,
1309 // so the frame reads as a contradiction and the burst is flushed.
1310 try std.testing.expectEqual(
1311 predict.Verdict.none,
1312 reconcileOverlay(alloc, &ov, replica, 2, 0),
1313 );
1314 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
1315 try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
1316
1317 // And when the daemon does answer, they confirm against the real grid.
1318 replica.feed("\x1b[1;4Hde");
1319 try std.testing.expectEqual(
1320 predict.Verdict.confirmed,
1321 reconcileOverlay(alloc, &ov, replica, 3, 0),
1322 );
1323 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
1324 try std.testing.expectEqual(@as(u64, 2), ov.counters.confirmed);
1325 }
1326
1327 test "prediction: a burst advances the predicted cursor one cell per keystroke" {
1328 const alloc = std.testing.allocator;
1329 const null_fd = try devNull();
1330 defer std.posix.close(null_fd);
1331
1332 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1333 defer replica.deinit();
1334
1335 var ov = predict.Overlay.init(alloc, 80, 24);
1336 defer ov.deinit();
1337 ov.setMode(.{ .icanon = true, .echo = true });
1338
1339 const tty = proto.Size{ .cols = 80, .rows = 24 };
1340 for ("hello") |ch| offerKeystroke(alloc, &ov, replica, &.{ch}, tty, null_fd);
1341
1342 // The replica's own cursor has not moved — the daemon has answered
1343 // nothing — so every one of these came from the overlay.
1344 try std.testing.expectEqual(@as(u16, 0), replica.cursorPos().x);
1345 try std.testing.expectEqual(@as(usize, 5), ov.pendingCount());
1346 for ("hello", 0..) |ch, i| {
1347 const p = ov.pendingAt(i);
1348 try std.testing.expectEqual(@as(u16, @intCast(i)), p.cell.col);
1349 try std.testing.expectEqual(ch, p.cell.ch);
1350 try std.testing.expectEqual(@as(u8, ' '), p.prev_ch);
1351 }
1352 try std.testing.expectEqual(
1353 predict.CursorPos{ .x = 5, .y = 0 },
1354 ov.predictedCursor(.{ .x = 0, .y = 0 }),
1355 );
1356 }
1357
1358 test "prediction paints underlined, and parks the cursor past what it drew" {
1359 const alloc = std.testing.allocator;
1360 const p = try std.posix.pipe();
1361 defer std.posix.close(p[0]);
1362
1363 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1364 defer replica.deinit();
1365 replica.feed("\x1b[1;4H"); // cursor at column 3 (0-based)
1366
1367 var ov = predict.Overlay.init(alloc, 80, 24);
1368 defer ov.deinit();
1369 ov.setMode(.{ .icanon = true, .echo = true });
1370
1371 offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, p[1]);
1372 std.posix.close(p[1]);
1373
1374 var out: std.ArrayList(u8) = .empty;
1375 defer out.deinit(alloc);
1376 var rbuf: [4096]u8 = undefined;
1377 while (true) {
1378 const n = try std.posix.read(p[0], &rbuf);
1379 if (n == 0) break;
1380 try out.appendSlice(alloc, rbuf[0..n]);
1381 }
1382
1383 // Drawn at the predicted cell, underlined so a speculation is visibly
1384 // one, and with the SGR closed again so it cannot bleed into the rest.
1385 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[1;4H\x1b[4mz\x1b[0m") != null);
1386 // Cursor left one past it: the typist's next character goes there, and
1387 // if it did not the shell's own cursor would appear to lag a column
1388 // behind everything they typed.
1389 try std.testing.expect(std.mem.endsWith(u8, out.items, "\x1b[1;5H\x1b[?25h\x1b[?2026l"));
1390 // Wrapped in one synchronized update, so no terminal ever shows the
1391 // half-drawn state.
1392 try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2026h"));
1393 }
1394
1395 test "prediction: nothing is drawn for a context that has not earned it" {
1396 const alloc = std.testing.allocator;
1397 const p = try std.posix.pipe();
1398 defer std.posix.close(p[0]);
1399
1400 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1401 defer replica.deinit();
1402
1403 var ov = predict.Overlay.init(alloc, 80, 24);
1404 defer ov.deinit();
1405 ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned
1406
1407 offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, p[1]);
1408 std.posix.close(p[1]);
1409
1410 // Queued, so it can be judged and earn the next one its visibility...
1411 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
1412 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
1413
1414 // ...and not one byte went to the terminal. The effect, not the counter:
1415 // this is the assertion that a password prompt depends on.
1416 var rbuf: [64]u8 = undefined;
1417 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(p[0], &rbuf));
1418 }
1419
1420 test "prediction: a chunk that is not one printable byte is never speculated about" {
1421 const alloc = std.testing.allocator;
1422 const null_fd = try devNull();
1423 defer std.posix.close(null_fd);
1424
1425 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1426 defer replica.deinit();
1427
1428 var ov = predict.Overlay.init(alloc, 80, 24);
1429 defer ov.deinit();
1430 ov.setMode(.{ .icanon = true, .echo = true });
1431 const tty = proto.Size{ .cols = 80, .rows = 24 };
1432
1433 // An arrow key: three bytes, and predicting its lead byte would paint an
1434 // escape character on the screen.
1435 offerKeystroke(alloc, &ov, replica, "\x1b[A", tty, null_fd);
1436 // A multi-byte character, whose display width we do not know.
1437 offerKeystroke(alloc, &ov, replica, "é", tty, null_fd);
1438 // And a lone control byte, which goes down the single-byte path and is
1439 // refused there.
1440 offerKeystroke(alloc, &ov, replica, "\r", tty, null_fd);
1441
1442 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
1443 try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
1444 try std.testing.expectEqual(@as(u64, 3), ov.counters.suppressed);
1445
1446 // A paste: several printable bytes in one read. This is the shape whose
1447 // lead byte would sail through the printability check, so the length
1448 // guard is the only thing refusing it — and M9 refuses it, because a
1449 // paste can carry newlines and bracketed-paste markers that are not one
1450 // cell's worth of change each.
1451 offerKeystroke(alloc, &ov, replica, "abc", tty, null_fd);
1452 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
1453 try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
1454 // Not counted, and deliberately asserted as not counted rather than
1455 // left unsaid: a printable-lead chunk is the one refusal the overlay
1456 // has no way to record, since predictAt only counts refusals it makes
1457 // itself. Recorded in the M9 notes as an interface gap.
1458 try std.testing.expectEqual(@as(u64, 3), ov.counters.suppressed);
1459 }
1460
1461 test "prediction: a repaint never reveals what was never shown" {
1462 const alloc = std.testing.allocator;
1463 const p = try std.posix.pipe();
1464 defer std.posix.close(p[0]);
1465
1466 const replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1467 defer replica.deinit();
1468 const null_fd = try devNull();
1469 defer std.posix.close(null_fd);
1470
1471 var ov = predict.Overlay.init(alloc, 80, 24);
1472 defer ov.deinit();
1473 ov.setMode(.{ .icanon = false, .echo = false }); // raw: unconfident
1474 const tty = proto.Size{ .cols = 80, .rows = 24 };
1475 offerKeystroke(alloc, &ov, replica, "a", tty, null_fd);
1476 offerKeystroke(alloc, &ov, replica, "b", tty, null_fd);
1477 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
1478 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
1479
1480 // Every authoritative paint is followed by re-laying the overlay on top,
1481 // because a delta's row content wipes anything drawn over it. That
1482 // repaint is a second, quieter chance to show a prediction that was
1483 // never displayed in the first place — so it asks the same question the
1484 // keystroke path did, and gets the same answer.
1485 paintOverlay(alloc, &ov, replica.cursorPos(), tty, p[1]);
1486 std.posix.close(p[1]);
1487
1488 var rbuf: [64]u8 = undefined;
1489 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(p[0], &rbuf));
1490 }
1491
1492 test "the predict stats line is one greppable row of counters" {
1493 var buf: [predict_stats_len]u8 = undefined;
1494 const line = try formatPredictStats(&buf, .{
1495 .made = 5,
1496 .displayed = 4,
1497 .confirmed = 3,
1498 .contradicted = 2,
1499 .expired = 1,
1500 .suppressed = 6,
1501 });
1502 // Pinned exactly: test/e2e.sh greps these key=value pairs, so a rename
1503 // or a reorder is a broken suite rather than a cosmetic change.
1504 try std.testing.expectEqualStrings(
1505 "predict made=5 displayed=4 confirmed=3 contradicted=2 expired=1 suppressed=6",
1506 line,
1507 );
1508 }
1509
1036 test "Transport.close is idempotent: the abort path closes what reconnect already closed" { 1510 test "Transport.close is idempotent: the abort path closes what reconnect already closed" {
1037 const alloc = std.testing.allocator; 1511 const alloc = std.testing.allocator;
1038 1512