a73x

9c9ccd0f

refactor: one session's interaction is one interact.Core

a73x   2026-08-20 01:45

Commit message
refactor: one session's interaction is one interact.Core

session() held twenty locals and eight hundred lines, and every one of
them belonged to one of two things: the LINK (transport, attach,
reconnect, what a chord means) or the TERMINAL (replica, overlay,
chords, mouse, scroll, side channels, raw mode). The second set is now a
struct with a documented drive sequence, and session() is the first
driver of it.

The split runs down the frame switch too, and the seam is a real one:
snapshot/delta/pty_mode/scrollback/term_* are what a session LOOKS like
and are the Core's; exit_status/taken_over/sessions_reply are what a
session's life IS and stay here, because only the driver knows whether a
refusal is fatal or a switch to fall back from.

`rep.apply` and `recordOnState` deliberately stay at the call site
rather than moving inside `snapshotTaken`/`deltaTaken`: the attach
history is written between the apply and the paint, and moving the
record after the paint would let a failing write cost a session its
tile. Bug-for-bug ordering was the goal and the e2e suite is the referee
— not one line of it changed.

Two returns replace two `continue`s so the driver keeps its loop:
`Step.lost` is a dead transport (three sites), `Pass.skip` a frame that
changed nothing (two sites). Everything else moved verbatim, comments
included.

client.zig drops engine, predict and client_core: it no longer paints,
predicts or decodes. The decls interact.zig made pub for session()'s
sake go back to private — the module's surface is now Core, PrefixFilter
and the three prediction hooks the wall's zoomed tile shares.

CLAUDE.md
Old New
@@ -21,7 +21,8 @@ Files are large and comment-dense (~44% of Zig bytes are `//`). Reading the repo
21 costs ~800k tokens; every token stays in context and is re-billed each turn. 21 costs ~800k tokens; every token stays in context and is re-billed each turn.
22 22
23 - **Never `cat` these:** `src/server.zig` (9.1k lines, ~110k tok), 23 - **Never `cat` these:** `src/server.zig` (9.1k lines, ~110k tok),
24 `test/e2e.sh` (4.2k), `docs/decisions.md` (3.7k), `src/client.zig` (3.1k). 24 `test/e2e.sh` (4.2k), `docs/decisions.md` (3.7k), `src/client.zig` (2.8k),
25 `src/interact.zig` (2.2k).
25 Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window. 26 Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window.
26 - Every module has a `//!` header stating its contract. `head -12 src/X.zig` 27 - Every module has a `//!` header stating its contract. `head -12 src/X.zig`
27 answers most "what is this" questions for ~200 tokens. 28 answers most "what is this" questions for ~200 tokens.
@@ -37,8 +38,10 @@ Layers are enforced in `build.zig`'s module table (grep `.layer =` for the graph
37 |---|---| 38 |---|---|
38 | 0 | `protocol` `engine` `pty` `quic` `keymap` `xdg` `sockpath` `proxy` `testtmp` | 39 | 0 | `protocol` `engine` `pty` `quic` `keymap` `xdg` `sockpath` `proxy` `testtmp` |
39 | 1 | `quic_server` `quic_client` `predict` `spawn` `handoff` `delta` `cmd` `shellint` `replica` `paint` | 40 | 1 | `quic_server` `quic_client` `predict` `spawn` `handoff` `delta` `cmd` `shellint` `replica` `paint` |
40 | 2 | `server` `client` `muxa` | 41 | 2 | `server` `muxa` `interact` |
41 | 3/4 | `mux_main` `main`(muxd) `webhub` `wallview` `webhub_main`(muxweb) | 42 | 3 | `client` `main`(muxd) |
43 | 4 | `webhub` `wallview` |
44 | 5 | `mux_main` `webhub_main`(muxweb) |
42 45
43 Binaries: `muxd` (daemon), `mux` (client), `muxa` (agent client, JSON verbs), 46 Binaries: `muxd` (daemon), `mux` (client), `muxa` (agent client, JSON verbs),
44 `muxweb` (browser hub). Test fixtures in `test/`: `ptyclient` (real client on a 47 `muxweb` (browser hub). Test fixtures in `test/`: `ptyclient` (real client on a
build.zig
Old New
@@ -87,9 +87,11 @@ fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
87 /// are impossible, not detected. (Dependency edges — ghostty — and 87 /// are impossible, not detected. (Dependency edges — ghostty — and
88 /// build_options are explicitly outside the table's jurisdiction and 88 /// build_options are explicitly outside the table's jurisdiction and
89 /// stay wired by hand.) Layers are the topological strata of the production 89 /// stay wired by hand.) Layers are the topological strata of the production
90 /// graph, computed 2026-08-14 and FROZEN: a new import that would 90 /// graph, computed 2026-08-14, re-stratified 2026-08-20 (the interact
91 /// flatten or invert a stratum fails at comptime, and re-stratifying 91 /// extraction pushed client to 3 and everything above it up one), and
92 /// requires editing this table, which is the point. 92 /// FROZEN: a new import that would flatten or invert a stratum fails at
93 /// comptime, and re-stratifying requires editing this table, which is
94 /// the point.
93 const ModSpec = struct { 95 const ModSpec = struct {
94 name: []const u8, 96 name: []const u8,
95 path: []const u8, 97 path: []const u8,
@@ -249,7 +251,15 @@ const mod_table = [_]ModSpec{
249 // attach records its own tile (the wall is attach history), and the 251 // attach records its own tile (the wall is attach history), and the
250 // chord switches that re-dial from inside client.attach have to record 252 // chord switches that re-dial from inside client.attach have to record
251 // theirs too, so the writer cannot live up in mux_main. 253 // theirs too, so the writer cannot live up in mux_main.
252 .{ .name = "client", .path = "src/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "engine", "protocol", "replica", "client_core", "interact", "quic_client", "quic", "predict", "handoff", "proxy", "paint", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 254 .{ .name = "client", .path = "src/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "interact", "quic_client", "quic", "handoff", "proxy", "paint", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
255 // The daemon entrypoint loads the key and constructs the listener, so
256 // it needs quic/quic_server directly rather than through the server.
257 // `muxd endpoint` prints the announce line handoff spells; sockpath is
258 // the sun_path bound, checked before any verb acts on the path; and the
259 // keygen round-trip test needs a directory to generate into, which the
260 // daemon itself never touches.
261 .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
262 // ---- layer 4 ----
253 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route 263 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route
254 // table, WS endpoint naming. Assets are injected (the exe root 264 // table, WS endpoint naming. Assets are injected (the exe root
255 // @embedFiles them), so its tests build no artifacts. 265 // @embedFiles them), so its tests build no artifacts.
@@ -266,13 +276,6 @@ const mod_table = [_]ModSpec{
266 // and phase 3 promotes the tile into that core rather than growing a 276 // and phase 3 promotes the tile into that core rather than growing a
267 // second copy of it. 277 // second copy of it.
268 .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "replica", "paint", "predict" }, .quic_tests = true }, 278 .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "replica", "paint", "predict" }, .quic_tests = true },
269 // The daemon entrypoint loads the key and constructs the listener, so
270 // it needs quic/quic_server directly rather than through the server.
271 // `muxd endpoint` prints the announce line handoff spells; sockpath is
272 // the sun_path bound, checked before any verb acts on the path; and the
273 // keygen round-trip test needs a directory to generate into, which the
274 // daemon itself never touches.
275 .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
276 // ---- layer 5 ---- 279 // ---- layer 5 ----
277 // wall owns the spelling grammar and the state file, so argv is parsed 280 // wall owns the spelling grammar and the state file, so argv is parsed
278 // by the SAME rules the page's POST /tiles and the restored file are — 281 // by the SAME rules the page's POST /tiles and the restored file are —
@@ -283,8 +286,8 @@ const mod_table = [_]ModSpec{
283 // sockpath is the sun_path bound only; the client binds no socket itself. 286 // sockpath is the sun_path bound only; the client binds no socket itself.
284 // protocol is the session-name validator alone (validSessionName): a bad 287 // protocol is the session-name validator alone (validSessionName): a bad
285 // --session has to be a usage error here, at parse, not bytes some 288 // --session has to be a usage error here, at parse, not bytes some
286 // daemon downstream has to notice and refuse. Layer 4 since `mux wall` 289 // daemon downstream has to notice and refuse. Layer 5 since `mux wall`
287 // pulled in wallview (layer 3); wall rides along for the no-arg wall 290 // pulled in wallview (layer 4); wall rides along for the no-arg wall
288 // (the state file the browser hub builds). 291 // (the state file the browser hub builds).
289 .{ .name = "mux", .path = "src/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall" }, .quic_tests = true }, 292 .{ .name = "mux", .path = "src/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall" }, .quic_tests = true },
290 }; 293 };
docs/decisions.md
Old New
@@ -3240,7 +3240,10 @@ two dialects.
3240 COMPUTED topological strata, frozen 2026-08-14 — hand-assignment was 3240 COMPUTED topological strata, frozen 2026-08-14 — hand-assignment was
3241 tried first and misplaced engine on the first draft. Changing the 3241 tried first and misplaced engine on the first draft. Changing the
3242 architecture now means editing the table, which is the point. Proven by 3242 architecture now means editing the table, which is the point. Proven by
3243 extract-and-diff: 76 edges before and after, empty diff. 3243 extract-and-diff: 76 edges before and after, empty diff. (Re-stratified
3244 2026-08-20: the interact extraction added a stratum under client, so
3245 client sits at 3 and the entrypoints at 5 now; the freeze and the
3246 comptime law are unchanged.)
3244 - **Two adjudicated edges.** `server -> replica` is test-only (sole use is 3247 - **Two adjudicated edges.** `server -> replica` is test-only (sole use is
3245 the applyFrame test helper) and lives in the test_imports column. 3248 the applyFrame test helper) and lives in the test_imports column.
3246 `client -> proxy` is production (ignoreSigpipe in the live attach path) 3249 `client -> proxy` is production (ignoreSigpipe in the live attach path)
src/client.zig
Old New
@@ -1,23 +1,22 @@
1 //! mux client: connects, attaches, maintains a replica engine rebuilt 1 //! mux client: connects and attaches, then drives one `interact.Core`
2 //! from snapshots and advanced by row deltas, repaints the local 2 //! over the link until the session ends. What this file owns is the LINK
3 //! terminal, forwards keystrokes. The replica is sized to the daemon's 3 //! and the session's lifetime — targets and dialling, the ssh handoff,
4 //! authoritative grid (snapshot prefixes carry it), which under the 4 //! the attach handshake, reconnect, the wall-file record of an attach,
5 //! latest-wins resize policy may differ from this tty; paints are 5 //! and what each chord means. What happens at the terminal once a link is
6 //! clipped to the tty. 6 //! up — the replica rebuilt from snapshots and advanced by row deltas,
7 //! the paints, prediction, the chord and mouse filters — is interact.zig's
8 //! (the replica is sized to the daemon's authoritative grid, which under
9 //! latest-wins may differ from this tty; paints are clipped to the tty).
10 //!
7 //! Keybinding layer: Ctrl-\ (0x1c) is a command prefix in a live session — 11 //! Keybinding layer: Ctrl-\ (0x1c) is a command prefix in a live session —
8 //! `Ctrl-\ d` or `Ctrl-\ Ctrl-\` detaches, `Ctrl-\ c` creates a new session 12 //! `Ctrl-\ d` or `Ctrl-\ Ctrl-\` detaches, `Ctrl-\ c` creates a new session
9 //! and switches to it in place, `Ctrl-\ n` / `Ctrl-\ p` step to the next or 13 //! and switches to it in place, `Ctrl-\ n` / `Ctrl-\ p` step to the next or
10 //! previous session in the daemon's list, `Ctrl-\ w` shows them all as a 14 //! previous session in the daemon's list, `Ctrl-\ w` shows them all as a
11 //! read-only wall (a `mux wall` child on this terminal) and returns here 15 //! read-only wall (a `mux wall` child on this terminal) and returns here
12 //! when it leaves. While dialling or reconnecting there is no session to 16 //! when it leaves. While dialling or reconnecting there is no session to
13 //! command and a bare Ctrl-\ still aborts. The chord table itself, and 17 //! command and a bare Ctrl-\ still aborts.
14 //! every other thing that happens between a user and an already-open
15 //! session, live in interact.zig; what a chord MEANS is still decided
16 //! here.
17 const std = @import("std"); 18 const std = @import("std");
18 const Engine = @import("engine").Engine;
19 const Replica = @import("replica").Replica; 19 const Replica = @import("replica").Replica;
20 const client_core = @import("client_core");
21 // The session-interaction core: chords, the wheel, prediction, the side 20 // The session-interaction core: chords, the wheel, prediction, the side
22 // channels, terminal ownership. Everything this file keeps is about 21 // channels, terminal ownership. Everything this file keeps is about
23 // BUILDING a link and deciding what a chord means. 22 // BUILDING a link and deciding what a chord means.
@@ -26,13 +25,13 @@ const proto = @import("protocol");
26 const TmpDir = @import("testtmp").TmpDir; 25 const TmpDir = @import("testtmp").TmpDir;
27 const quic_client = @import("quic_client"); 26 const quic_client = @import("quic_client");
28 const quic = @import("quic"); 27 const quic = @import("quic");
29 const predict = @import("predict");
30 const handoff = @import("handoff"); 28 const handoff = @import("handoff");
31 // The wall file: attach history. See `recordTile` for why the writer of a 29 // The wall file: attach history. See `recordTile` for why the writer of a
32 // user's tile is this module and not mux_main. 30 // user's tile is this module and not mux_main.
33 const wall = @import("wall"); 31 const wall = @import("wall");
34 // Named `paint_mod` because paintOverlay holds a local ArrayList called 32 // The [reconnecting] banner, and nothing else: painting the replica is
35 // `paint`, which a container-level `paint` would collide with. 33 // interact's. Named `paint_mod` for the collision the name `paint` used
34 // to have with a local here.
36 const paint_mod = @import("paint"); 35 const paint_mod = @import("paint");
37 // For ignoreSigpipe only, which proxy.zig owns. 36 // For ignoreSigpipe only, which proxy.zig owns.
38 const proxy = @import("proxy"); 37 const proxy = @import("proxy");
@@ -1454,69 +1453,29 @@ fn session(
1454 // would inherit an ignored SIGPIPE they never asked for. 1453 // would inherit an ignored SIGPIPE they never asked for.
1455 proxy.ignoreSigpipe(); 1454 proxy.ignoreSigpipe();
1456 1455
1457 const stdin_fd = std.posix.STDIN_FILENO; 1456 // Registered BEFORE the core, so it runs after `core.deinit()` puts the
1458 const stdout_fd = std.posix.STDOUT_FILENO; 1457 // terminal back: messages land on the normal screen, not the wiped
1459 const is_tty = std.posix.isatty(stdin_fd); 1458 // alternate one. The prediction stats line rides the same rule from
1459 // inside deinit.
1460 var exit_msg: ?[]const u8 = null;
1461 defer if (exit_msg) |m| std.debug.print("{s}\n", .{m});
1460 1462
1461 var size = interact.ttySize(stdout_fd) orelse proto.Size{ .cols = 80, .rows = 24 }; 1463 // Everything this session does to a terminal, and everything it holds
1464 // about the session behind it: replica, overlay, chords, the wheel.
1465 // What stays here is the transport, the attach handshake, and what a
1466 // chord MEANS.
1467 var core = try interact.Core.init(
1468 alloc,
1469 std.posix.STDIN_FILENO,
1470 std.posix.STDOUT_FILENO,
1471 );
1472 defer core.deinit();
1473 try core.takeTerminal();
1462 1474
1463 var eng = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows });
1464 defer eng.deinit();
1465 // The replay core, extracted to replica.zig; the engine stays owned
1466 // here (the Replica borrows it), so the deinit above is the one owner.
1467 var rep = Replica.init(alloc, eng);
1468 // Whether this run has already written its tile. Latched rather than 1475 // Whether this run has already written its tile. Latched rather than
1469 // re-derived because a reconnect clears `state_since_attach` and it 1476 // re-derived because a reconnect clears `state_since_attach` and it
1470 // turns true again — see `recordOnState`. 1477 // turns true again — see `recordOnState`.
1471 var tile_recorded = false; 1478 var tile_recorded = false;
1472 // Semantic terminal state and host effects are decoded once here, then
1473 // handed to the native adapter below. The web client owns another
1474 // instance of this same platform-neutral state machine.
1475 var semantic_core: client_core.ClientCore = .{};
1476
1477 // Registered before the terminal-restore defer so it runs after it:
1478 // messages land on the normal screen, not the wiped alternate one.
1479 var exit_msg: ?[]const u8 = null;
1480 defer if (exit_msg) |m| std.debug.print("{s}\n", .{m});
1481
1482 // Speculative echo. Born `.never` and stays there until a daemon tells
1483 // it otherwise, so an old daemon that has never heard of pty_mode gets a
1484 // client that predicts nothing at all.
1485 //
1486 // Registered here so the stats line lands after the terminal has been
1487 // put back and on the normal screen, like every other message.
1488 var overlay = predict.Overlay.init(alloc, size.cols, size.rows);
1489 defer overlay.deinit();
1490 defer interact.dumpPredictStats(overlay.counters);
1491
1492 // Raw mode when we own a terminal. The alternate screen is NOT entered
1493 // here — see the first-frame gate in the loop below.
1494 var orig_termios: ?std.posix.termios = null;
1495 var alt_screen = false;
1496 if (is_tty) {
1497 const orig = try std.posix.tcgetattr(stdin_fd);
1498 orig_termios = orig;
1499 var raw = orig;
1500 raw.lflag.ICANON = false;
1501 raw.lflag.ECHO = false;
1502 raw.lflag.ISIG = false;
1503 raw.iflag.IXON = false;
1504 raw.iflag.ICRNL = false;
1505 try std.posix.tcsetattr(stdin_fd, .FLUSH, raw);
1506
1507 var sa: std.posix.Sigaction = .{
1508 .handler = .{ .handler = interact.onWinch },
1509 .mask = std.posix.sigemptyset(),
1510 .flags = 0,
1511 };
1512 std.posix.sigaction(std.posix.SIG.WINCH, &sa, null);
1513 }
1514 defer {
1515 // Only undo what was actually done: leaving the alternate screen we
1516 // never entered would wipe the user's own scrollback.
1517 if (alt_screen) proto.writeAllFd(stdout_fd, interact.terminal_teardown) catch {};
1518 if (orig_termios) |t| std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {};
1519 }
1520 1479
1521 // A fresh client process holds no state, so it asks for a full snapshot: 1480 // A fresh client process holds no state, so it asks for a full snapshot:
1522 // no seq, and no epoch to interpret one in. A transport that died between 1481 // no seq, and no epoch to interpret one in. A transport that died between
@@ -1527,7 +1486,7 @@ fn session(
1527 // "" writes exactly the old fixed 20 bytes), so a `mux` invoked without 1486 // "" writes exactly the old fixed 20 bytes), so a `mux` invoked without
1528 // `--session` still works unmodified against a pre-M18 daemon that has 1487 // `--session` still works unmodified against a pre-M18 daemon that has
1529 // never heard of a name tail. 1488 // never heard of a name tail.
1530 sendAttach(transport, size, 0, 0, session_name) catch { 1489 sendAttach(transport, core.size, 0, 0, session_name) catch {
1531 // Nothing has been read yet, so the epoch is 0 by construction. 1490 // Nothing has been read yet, so the epoch is 0 by construction.
1532 exit_msg = lostMsg(target, 0); 1491 exit_msg = lostMsg(target, 0);
1533 return .{ .exit = 1 }; 1492 return .{ .exit = 1 };
@@ -1546,18 +1505,6 @@ fn session(
1546 } 1505 }
1547 1506
1548 var stdin_open = true; 1507 var stdin_open = true;
1549 // Scroll mode: 0 = live; N = viewing the screenful whose bottom sits N
1550 // rows above live. Rows rather than pages because the wheel moves by a
1551 // few lines and the keys move by a screen — one unit that expresses
1552 // both, and `fetch_scrollback` already addresses absolute rows.
1553 // View state, not replay state, so it stays here rather than in the
1554 // Replica (which holds grid/seq/epoch/history — see replica.zig).
1555 var scroll_rows: u32 = 0;
1556 // Mouse reports arrive in the same reads as keystrokes; this splits
1557 // them back out, across read boundaries. Sized to hold one chunk plus
1558 // whatever a previous read left mid-report — see MouseFilter.feed.
1559 var mouse: interact.MouseFilter = .{};
1560 var mouse_buf: [interact.stdin_chunk + interact.MouseFilter.max_held]u8 = undefined;
1561 // Set by any site that finds the transport dead; serviced at the top of 1508 // Set by any site that finds the transport dead; serviced at the top of
1562 // the loop so the bookkeeping around a reconnect lives in one place. 1509 // the loop so the bookkeeping around a reconnect lives in one place.
1563 var needs_reconnect = false; 1510 var needs_reconnect = false;
@@ -1565,11 +1512,6 @@ fn session(
1565 // (the daemon may not have reaped our dead predecessor's slot yet). 1512 // (the daemon may not have reaped our dead predecessor's slot yet).
1566 // Set on the first reconnect, kept across its retries, cleared by state. 1513 // Set on the first reconnect, kept across its retries, cleared by state.
1567 var reconnect_grace_until: ?i64 = null; 1514 var reconnect_grace_until: ?i64 = null;
1568 // The first paint after a reconnect must be a full one, so the
1569 // [reconnecting] banner goes away with everything else now stale.
1570 var repaint_after_resync = false;
1571 // Chord state lives across reads, so it outlives one buffer.
1572 var prefix: interact.PrefixFilter = .{};
1573 // Whether this run is still a switch's ARRIVAL, which is what makes a 1515 // Whether this run is still a switch's ARRIVAL, which is what makes a
1574 // pre-state refusal recoverable. Mutable because the property expires: 1516 // pre-state refusal recoverable. Mutable because the property expires:
1575 // see the reconnect below. 1517 // see the reconnect below.
@@ -1579,7 +1521,6 @@ fn session(
1579 // a reply nobody asked for cannot move a user off their session — and 1521 // a reply nobody asked for cannot move a user off their session — and
1580 // the intent is what says which name the answer names. 1522 // the intent is what says which name the answer names.
1581 var pending_switch: PendingSwitch = .{}; 1523 var pending_switch: PendingSwitch = .{};
1582 var buf: [interact.stdin_chunk]u8 = undefined;
1583 while (true) { 1524 while (true) {
1584 if (needs_reconnect) { 1525 if (needs_reconnect) {
1585 needs_reconnect = false; 1526 needs_reconnect = false;
@@ -1594,37 +1535,23 @@ fn session(
1594 // `state_since_attach` cannot serve here: it is cleared on 1535 // `state_since_attach` cannot serve here: it is cleared on
1595 // every re-attach, so mid-session it would send us down this 1536 // every re-attach, so mid-session it would send us down this
1596 // exit path exactly when resuming is what we want. 1537 // exit path exactly when resuming is what we want.
1597 if (rep.session_epoch == 0) { 1538 if (core.rep.session_epoch == 0) {
1598 exit_msg = lostMsg(target, rep.session_epoch); 1539 exit_msg = lostMsg(target, core.rep.session_epoch);
1599 return .{ .exit = 1 }; 1540 return .{ .exit = 1 };
1600 } 1541 }
1601 // A resync repaints live state, so a history page would be 1542 // Every route into here shares the reason (see dropScrollView),
1602 // silently replaced a moment later — and the banner would sit 1543 // so it is handled once.
1603 // over stale rows until the user happened to leave scroll mode. 1544 core.dropScrollView();
1604 // Every route into here shares that, so it is handled once.
1605 scroll_rows = 0;
1606 // The overlay has to be told, and only the client can tell it:
1607 // `flush()` drops predictions but deliberately leaves the mode
1608 // bit alone, so a reconnect taken while scrolled would leave the
1609 // overlay suppressing with no page to suppress for. The
1610 // "any other key" exit at the bottom of the loop cannot rescue
1611 // it — that branch is guarded by `scroll_rows > 0`, which the
1612 // line above has just made false. Shift+PageDown still can
1613 // (its `scroll_rows == 0` arm clears the mode unconditionally),
1614 // so this is recoverable rather than terminal — but only by a
1615 // keystroke the user has no reason to guess, so prediction is
1616 // silently off until they do.
1617 overlay.setScrollMode(false);
1618 if (!reconnect( 1545 if (!reconnect(
1619 alloc, 1546 alloc,
1620 transport, 1547 transport,
1621 target, 1548 target,
1622 size, 1549 core.size,
1623 rep.last_seq, 1550 core.rep.last_seq,
1624 rep.session_epoch, 1551 core.rep.session_epoch,
1625 stdin_fd, 1552 core.in_fd,
1626 stdout_fd, 1553 core.out_fd,
1627 is_tty, 1554 core.is_tty,
1628 session_name, 1555 session_name,
1629 )) { 1556 )) {
1630 // Ctrl-\ during a reconnect: the user is done waiting, but 1557 // Ctrl-\ during a reconnect: the user is done waiting, but
@@ -1632,9 +1559,6 @@ fn session(
1632 exit_msg = "mux: detached while reconnecting (session still running; run mux to reattach)"; 1559 exit_msg = "mux: detached while reconnecting (session still running; run mux to reattach)";
1633 return .{ .exit = 0 }; 1560 return .{ .exit = 0 };
1634 } 1561 }
1635 // Only the caller knows a re-attach happened; the Replica's
1636 // contract says this clear is ours to do.
1637 rep.state_since_attach = false;
1638 // A reconnect ends the switch's grace along with the state it 1562 // A reconnect ends the switch's grace along with the state it
1639 // was measured against. Without this the property is permanent: 1563 // was measured against. Without this the property is permanent:
1640 // a link that drops hours later and comes back to a refusal 1564 // a link that drops hours later and comes back to a refusal
@@ -1648,11 +1572,7 @@ fn session(
1648 if (reconnect_grace_until == null) { 1572 if (reconnect_grace_until == null) {
1649 reconnect_grace_until = std.time.milliTimestamp() + reconnect_grace_ms; 1573 reconnect_grace_until = std.time.milliTimestamp() + reconnect_grace_ms;
1650 } 1574 }
1651 repaint_after_resync = true; 1575 core.reattached();
1652 // Whatever was outstanding was predicted against a connection
1653 // that no longer exists. Dropping it is not an accusation, so
1654 // the counters stay where they are.
1655 overlay.flush();
1656 // Same reasoning for a switch chord that was in flight: the 1576 // Same reasoning for a switch chord that was in flight: the
1657 // daemon it asked no longer has the question. The user presses 1577 // daemon it asked no longer has the question. The user presses
1658 // the chord again rather than being switched by a reply that 1578 // the chord again rather than being switched by a reply that
@@ -1662,32 +1582,14 @@ fn session(
1662 pending_switch.clear(); 1582 pending_switch.clear();
1663 continue; 1583 continue;
1664 } 1584 }
1665 if (interact.winch_flag.swap(false, .acq_rel)) { 1585 if (core.winch(transport) == .lost) {
1666 if (interact.ttySize(stdout_fd)) |new_size| { 1586 needs_reconnect = true;
1667 if (new_size.cols != size.cols or new_size.rows != size.rows) { 1587 continue;
1668 // Only the local clip size changes here; the replica
1669 // follows the daemon, which answers the resize frame
1670 // with a snapshot carrying the new grid size.
1671 size = new_size;
1672 // Same rule as every other transport write: a dead
1673 // transport is reported, never thrown.
1674 transport.writeFrame(
1675 .resize,
1676 &proto.encodeSize(size.cols, size.rows),
1677 ) catch {
1678 needs_reconnect = true;
1679 continue;
1680 };
1681 // The grid this would be painted on is about to stop
1682 // existing; cleared when the answering snapshot lands.
1683 overlay.setResizePending(true);
1684 }
1685 }
1686 } 1588 }
1687 1589
1688 var fds = [_]std.posix.pollfd{ 1590 var fds = [_]std.posix.pollfd{
1689 .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, 1591 .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
1690 .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 }, 1592 .{ .fd = if (stdin_open) core.in_fd else -1, .events = std.posix.POLL.IN, .revents = 0 },
1691 }; 1593 };
1692 _ = try std.posix.poll(&fds, transport.timeoutMs(100)); 1594 _ = try std.posix.poll(&fds, transport.timeoutMs(100));
1693 transport.service(); 1595 transport.service();
@@ -1701,18 +1603,11 @@ fn session(
1701 // with no newline discipline. Like `[reconnecting]` it sits in the 1603 // with no newline discipline. Like `[reconnecting]` it sits in the
1702 // corner until the next full repaint paints over it, which is the 1604 // corner until the next full repaint paints over it, which is the
1703 // right lifetime for a marker the user needs to actually read. 1605 // right lifetime for a marker the user needs to actually read.
1704 // ASCII only — `bannerText` places the label by byte length.
1705 if (pending_switch.expired(std.time.milliTimestamp())) { 1606 if (pending_switch.expired(std.time.milliTimestamp())) {
1706 if (is_tty) paint_mod.paintBanner(stdout_fd, size, "[no session list: upgrade muxd]"); 1607 core.banner("[no session list: upgrade muxd]");
1707 } 1608 }
1708 1609
1709 // The idle path, and the only thing that can retire a prediction the 1610 try core.idle();
1710 // application answered by going quiet: no frame is coming, so
1711 // reconcile will never run again and the glyph would otherwise stay
1712 // on screen for the rest of the session.
1713 if (overlay.expire(std.time.milliTimestamp()) == .contradicted and scroll_rows == 0) {
1714 try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd);
1715 }
1716 1611
1717 // Labelled, because "no whole frame yet" must leave the REST of this 1612 // Labelled, because "no whole frame yet" must leave the REST of this
1718 // iteration running. Everything below — the detach chord, keystrokes, 1613 // iteration running. Everything below — the detach chord, keystrokes,
@@ -1746,75 +1641,11 @@ fn session(
1746 }, 1641 },
1747 }; 1642 };
1748 defer frame.deinit(alloc); 1643 defer frame.deinit(alloc);
1749 // The alternate screen waits for proof that the transport works. 1644 // Ahead of the switch and for EVERY frame type, not just the
1750 // Entering at setup would erase whatever the `--via` command wrote 1645 // ones that paint. The argument for both is long and it is at
1751 // to its inherited stderr (ssh reports auth and connection failures 1646 // `ownTerminal`; the one-line version is that this is what arms
1752 // hundreds of ms after spawn), and would blank the screen for the 1647 // the teardown, so nothing may write a mode before it.
1753 // whole of a hang like `mux --via "sleep 30"`. Autowrap goes off 1648 try core.ownTerminal();
1754 // with it: an oversized grid row must clip at the right edge rather
1755 // than wrap and shift the whole paint.
1756 //
1757 // This must stay ahead of the frame switch and must run for EVERY
1758 // frame type, not just the ones that paint. `alt_screen` gates
1759 // the exit teardown, which is the only `?2004l` mux is certain
1760 // to write — a session that asks for bracketed paste and then
1761 // dies never sends the term_modes frame that would unset it. So
1762 // a term_modes handled while `alt_screen` was still false would
1763 // turn bracketing ON with nothing arranged to turn it off — the
1764 // user gets their terminal back still bracketing pastes long
1765 // after mux exited, with nothing on screen to say why.
1766 //
1767 // This comment is the whole defence, and no fixture can be
1768 // written to take over from it: `sendResync`'s `defer` puts
1769 // term_modes LAST, so no fixture driving the real daemon can
1770 // deliver one to a client that has not already seen a frame.
1771 // (A test with a hand-built frame stream could: `Transport` is
1772 // a `Conn` of two fds with `link` defaulting to `.fd`, so a
1773 // same-file test can construct one over a pipe. What stops it
1774 // is that the interesting path needs `is_tty`, i.e. a real pty
1775 // — which is why the coverage that exists is out-of-process in
1776 // test/ptyclient. So this is "nobody has written one", not
1777 // "one cannot exist".) The hazard is unreachable from outside
1778 // and one edit away from inside.
1779 //
1780 // Read it as two claims, because only the first is about
1781 // ordering. (1) This runs before any arm can write `?2004h`.
1782 // (2) `?2004l` is written only under `alt_screen`, so a
1783 // `?2004h` written outside it is one nothing will undo.
1784 //
1785 // Claim (2) used to be held by a race — `?2004h` went out
1786 // unconditionally, and on a tty this block simply always won.
1787 // It is now structural: `writeSideChannel` takes `alt_screen`
1788 // and refuses everything while it is false, so the set and its
1789 // undo are gated on one flag rather than on an ordering. That
1790 // closed a hole the race left open, and the title is how it was
1791 // found — see that function. A `--no-altscreen` or an inline
1792 // mode still needs its own teardown gate; what it no longer
1793 // needs is for this block to win a race first.
1794 //
1795 // The title push (`22;0t`) rides the same gate for the same
1796 // pairing argument, and needs it more: an unmatched POP does not
1797 // restore a title, it pops whatever the terminal had underneath
1798 // — somebody else's. Pushed here and popped in
1799 // `terminal_teardown`, both under `alt_screen`, is what makes
1800 // the pair exactly one deep. `0` is "icon name and window
1801 // title", matching the OSC 0 `appendTermTitle` writes.
1802 //
1803 // A push without a pop is a stack that only grows, so the
1804 // guarantee was checked rather than assumed: this write and the
1805 // pop are the only two in the tree, every exit from this
1806 // function is a `return` (no process.exit, no exec, no panic
1807 // here), and the reconnect path cannot push twice because it
1808 // re-enters the loop with `alt_screen` already true. What
1809 // escapes is a signal that kills the process outright — and
1810 // that loses `?1049l` and `?25h` with it, leaving the user on
1811 // an alternate screen with no cursor, so a title stack one
1812 // deeper is not the part they will notice. Same exposure as
1813 // every other line of the teardown, not a new one.
1814 if (is_tty and !alt_screen) {
1815 try proto.writeAllFd(stdout_fd, interact.terminal_setup);
1816 alt_screen = true;
1817 }
1818 switch (frame.type) { 1649 switch (frame.type) {
1819 .snapshot => { 1650 .snapshot => {
1820 // The Replica adopts seq/epoch/history, resizes to the 1651 // The Replica adopts seq/epoch/history, resizes to the
@@ -1823,7 +1654,7 @@ fn session(
1823 // snapshot proves nothing (BadPayload leaves everything 1654 // snapshot proves nothing (BadPayload leaves everything
1824 // untouched, state_since_attach included); anything 1655 // untouched, state_since_attach included); anything
1825 // else out of the resize stays loud. 1656 // else out of the resize stays loud.
1826 _ = rep.apply(.snapshot, frame.payload) catch |err| switch (err) { 1657 _ = core.rep.apply(.snapshot, frame.payload) catch |err| switch (err) {
1827 error.BadPayload => continue, 1658 error.BadPayload => continue,
1828 else => |e| return e, 1659 else => |e| return e,
1829 }; 1660 };
@@ -1832,29 +1663,17 @@ fn session(
1832 // state proves it, and a shell that exits in the same 1663 // state proves it, and a shell that exits in the same
1833 // read as its snapshot must not lose its tile to a loop 1664 // read as its snapshot must not lose its tile to a loop
1834 // turn that never comes. 1665 // turn that never comes.
1835 recordOnState(&tile_recorded, &rep, alloc, target, session_name, wall_warned); 1666 recordOnState(&tile_recorded, &core.rep, alloc, target, session_name, wall_warned);
1836 reconnect_grace_until = null; 1667 reconnect_grace_until = null;
1837 // A snapshot answers a resize, ends a reconnect, and 1668 try core.snapshotTaken();
1838 // rebuilds the screen under anything outstanding. None
1839 // of that says a prediction was wrong — it says we can
1840 // no longer find out, so the queue goes and the counters
1841 // do not move.
1842 overlay.setGrid(rep.grid.cols, rep.grid.rows);
1843 overlay.setResizePending(false);
1844 overlay.flush();
1845 overlay.noteSeq(rep.last_seq);
1846 if (scroll_rows == 0) {
1847 try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd);
1848 repaint_after_resync = false; // banner painted over
1849 }
1850 }, 1669 },
1851 .delta => { 1670 .delta => {
1852 reconnect_grace_until = null; 1671 reconnect_grace_until = null;
1853 const applied = try rep.apply(.delta, frame.payload); 1672 const applied = try core.rep.apply(.delta, frame.payload);
1854 // Bound before the `.resync` branch, which can leave 1673 // Bound before the `.resync` branch, which can leave
1855 // this arm: a delta the replica accepted is state, and 1674 // this arm: a delta the replica accepted is state, and
1856 // state is what makes the attach history. 1675 // state is what makes the attach history.
1857 recordOnState(&tile_recorded, &rep, alloc, target, session_name, wall_warned); 1676 recordOnState(&tile_recorded, &core.rep, alloc, target, session_name, wall_warned);
1858 if (applied == .resync) { 1677 if (applied == .resync) {
1859 // A rejected delta means the replica can no longer be 1678 // A rejected delta means the replica can no longer be
1860 // trusted; ask for a fresh snapshot rather than 1679 // trusted; ask for a fresh snapshot rather than
@@ -1868,114 +1687,26 @@ fn session(
1868 // quoting a seq would invite the delta that cannot 1687 // quoting a seq would invite the delta that cannot
1869 // fix us. A reconnect quotes last_seq for exactly the 1688 // fix us. A reconnect quotes last_seq for exactly the
1870 // opposite reason: there, the replica is known good. 1689 // opposite reason: there, the replica is known good.
1871 sendAttach(transport, size, 0, 0, session_name) catch {}; 1690 sendAttach(transport, core.size, 0, 0, session_name) catch {};
1872 continue; 1691 continue;
1873 } 1692 }
1874 // Judged against the replica the frame has just been fed 1693 try core.deltaTaken(frame.payload);
1875 // into, which is the only authority there is.
1876 const verdict = interact.reconcileOverlay(
1877 alloc,
1878 &overlay,
1879 rep.eng,
1880 rep.last_seq,
1881 std.time.milliTimestamp(),
1882 );
1883 // While scrolled the replica still tracks live output; the
1884 // repaint on scroll exit comes from it.
1885 if (scroll_rows == 0) {
1886 if (repaint_after_resync or verdict == .contradicted) {
1887 // First frame back after a reconnect. The daemon
1888 // sent only what changed, which is correct — but
1889 // the screen still carries the banner, so repaint
1890 // the whole thing from the replica instead.
1891 //
1892 // A contradiction (or an expiry) takes the same
1893 // route: the whole queue has just been abandoned,
1894 // and repainting everything from the replica is
1895 // the simplest rollback that is certainly right.
1896 // It is affordable precisely because reconcile v2
1897 // made contradictions rare — a burst outrunning
1898 // the round trip is no longer one.
1899 try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd);
1900 repaint_after_resync = false;
1901 } else {
1902 try paint_mod.paintDeltaClipped(alloc, frame.payload, size, stdout_fd);
1903 }
1904 // Last, and after either paint: the rows the daemon
1905 // just sent have overwritten anything drawn on them,
1906 // including predictions that are still outstanding.
1907 interact.paintOverlay(alloc, &overlay, rep.eng.cursorPos(), size, stdout_fd);
1908 }
1909 }, 1694 },
1910 .pty_mode => { 1695 .pty_mode => switch (try core.ptyModeChanged(frame.payload)) {
1911 const flags = proto.decodePtyMode(frame.payload) catch continue; 1696 .skip => continue,
1912 // Mode churn is ordinary — readline hands the terminal 1697 .carry_on => {},
1913 // back and forth around every command — so the repaint
1914 // is spent only when the flush actually took something
1915 // off the screen.
1916 const had_pending = overlay.pendingCount() > 0;
1917 overlay.setMode(flags);
1918 if (had_pending and overlay.pendingCount() == 0 and scroll_rows == 0) {
1919 try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd);
1920 }
1921 }, 1698 },
1922 .scrollback_chunk => { 1699 .scrollback_chunk => switch (try core.scrollbackPage(frame.payload)) {
1923 if (scroll_rows == 0 or frame.payload.len < 6) continue; 1700 .skip => continue,
1924 try paint_mod.renderScrollback(alloc, frame.payload[6..], size, stdout_fd); 1701 .carry_on => {},
1925 },
1926 .term_event, .term_modes => {
1927 switch (semantic_core.receive(frame.type, frame.payload)) {
1928 .ignored => {},
1929 .state => |state| {
1930 // Mode samples are deliberately not deduplicated.
1931 // sendResync repeats the current level on attach,
1932 // reconnect and forced re-attach; reasserting
1933 // DECSET/DECRST 2004 is a harmless level-set and
1934 // restores the host after a new connection.
1935 // Occurrence effects take the separate arm below
1936 // and are never covered by this repeat policy.
1937 try interact.writeSideChannel(
1938 alloc,
1939 stdout_fd,
1940 alt_screen,
1941 client_core.State,
1942 state,
1943 interact.appendTermState,
1944 );
1945 },
1946 .effect => |effect| try interact.writeSideChannel(
1947 alloc,
1948 stdout_fd,
1949 alt_screen,
1950 client_core.Effect,
1951 effect,
1952 interact.appendHostEffect,
1953 ),
1954 .reply => {},
1955 }
1956 },
1957 .term_title => {
1958 // Repeats are expected here for the same reason as
1959 // term_modes above — every attach resends the title —
1960 // and are harmless for a stronger reason: setting a
1961 // window title to the value it already holds is a
1962 // no-op with no counter or stack behind it. Note the
1963 // daemon never sends an empty one, so a repeat can
1964 // never clear a title the user is looking at.
1965 try interact.writeSideChannel(
1966 alloc,
1967 stdout_fd,
1968 alt_screen,
1969 []const u8,
1970 frame.payload,
1971 interact.appendTermTitle,
1972 );
1973 }, 1702 },
1703 .term_event, .term_modes => try core.semanticFrame(frame.type, frame.payload),
1704 .term_title => try core.titleFrame(frame.payload),
1974 .exit_status => { 1705 .exit_status => {
1975 // Before any session state, exit_status is almost always 1706 // Before any session state, exit_status is almost always
1976 // the daemon refusing the attach — say so, or it looks 1707 // the daemon refusing the attach — say so, or it looks
1977 // exactly like the shell itself exiting non-zero. 1708 // exactly like the shell itself exiting non-zero.
1978 if (!rep.state_since_attach) { 1709 if (!core.rep.state_since_attach) {
1979 // Right after a reconnect the usual cause is the 1710 // Right after a reconnect the usual cause is the
1980 // daemon not having reaped our dead predecessor's 1711 // daemon not having reaped our dead predecessor's
1981 // slot yet, so the session is full of *us*. Worth a 1712 // slot yet, so the session is full of *us*. Worth a
@@ -2048,183 +1779,60 @@ fn session(
2048 } 1779 }
2049 1780
2050 if (stdin_open and fds[1].revents != 0) { 1781 if (stdin_open and fds[1].revents != 0) {
2051 const n = std.posix.read(stdin_fd, &buf) catch 0; 1782 const cmd = core.readTyped() orelse {
2052 if (n == 0) {
2053 stdin_open = false; 1783 stdin_open = false;
2054 } else { 1784 continue;
2055 const cmd = prefix.feed(buf[0..n]); 1785 };
2056 switch (cmd.action) { 1786 switch (cmd.action) {
2057 .none => {}, 1787 .none => {},
2058 .detach => { 1788 .detach => {
2059 // Ctrl-\ d: detach and leave the session running. 1789 // Ctrl-\ d: detach and leave the session running.
2060 transport.writeFrame(.detach, "") catch {}; 1790 transport.writeFrame(.detach, "") catch {};
2061 exit_msg = "mux: detached (session still running; run mux to reattach)"; 1791 exit_msg = "mux: detached (session still running; run mux to reattach)";
2062 return .{ .exit = 0 }; 1792 return .{ .exit = 0 };
2063 }, 1793 },
2064 // `Ctrl-\ l` is the wall's last-zoomed-tile skip 1794 // `Ctrl-\ l` is the wall's last-zoomed-tile skip
2065 // (wallview.zig). A plain client has no "last tile" to 1795 // (wallview.zig). A plain client has no "last tile" to
2066 // go back to until phase 3 converges the two loops, so 1796 // go back to until phase 3 converges the two loops, so
2067 // the ACTION is swallowed here — the shared table knows 1797 // the ACTION is swallowed here — the shared table knows
2068 // the spelling, this caller has no meaning for it yet. 1798 // the spelling, this caller has no meaning for it yet.
2069 // The chord itself is not a no-op: like every other 1799 // The chord itself is not a no-op: like every other
2070 // chord it ends the chunk, so bytes typed behind it are 1800 // chord it ends the chunk, so bytes typed behind it are
2071 // dropped where an unknown command key would have let 1801 // dropped where an unknown command key would have let
2072 // them through. That is the rule `n`/`p`/`w` keep and 1802 // them through. That is the rule `n`/`p`/`w` keep and
2073 // the price of `l` being a chord at all. 1803 // the price of `l` being a chord at all.
2074 .last_session => {}, 1804 .last_session => {},
2075 .new_session, .next_session, .prev_session, .wall => { 1805 .new_session, .next_session, .prev_session, .wall => {
2076 // Ctrl-\ c/n/p/w: which sessions exist is the 1806 // Ctrl-\ c/n/p/w: which sessions exist is the
2077 // daemon's to say, so every chord that depends on 1807 // daemon's to say, so every chord that depends on
2078 // the list waits for the answer rather than 1808 // the list waits for the answer rather than
2079 // guessing one. The `.sessions_reply` arm is where 1809 // guessing one. The `.sessions_reply` arm is where
2080 // this run actually ends — one question, and the 1810 // this run actually ends — one question, and the
2081 // intent is what tells it what to make of the 1811 // intent is what tells it what to make of the
2082 // answer. Armed with a deadline, because a daemon 1812 // answer. Armed with a deadline, because a daemon
2083 // that predates the question does not refuse it — 1813 // that predates the question does not refuse it —
2084 // see `PendingSwitch`. 1814 // see `PendingSwitch`.
2085 pending_switch.arm(switch (cmd.action) { 1815 pending_switch.arm(switch (cmd.action) {
2086 .new_session => .new, 1816 .new_session => .new,
2087 .next_session => .next, 1817 .next_session => .next,
2088 .prev_session => .prev, 1818 .prev_session => .prev,
2089 .wall => .wall, 1819 .wall => .wall,
2090 else => unreachable, 1820 else => unreachable,
2091 }, std.time.milliTimestamp()); 1821 }, std.time.milliTimestamp());
2092 transport.writeFrame(.sessions_req, "") catch { 1822 transport.writeFrame(.sessions_req, "") catch {
2093 pending_switch.clear(); 1823 pending_switch.clear();
2094 needs_reconnect = true;
2095 continue;
2096 };
2097 },
2098 }
2099 // Who the wheel belongs to is the session's to say, and it
2100 // says so in the modes it set: an application that asked
2101 // for mouse reporting gets every mouse byte verbatim, and
2102 // the filter is reset so a report straddling the handover
2103 // is not half-eaten. This is the same rule as bracketed
2104 // paste — the client mirrors what the session asked the
2105 // terminal for — applied to the one device the client also
2106 // has a use for.
2107 //
2108 // `alt_screen` gates it for a second reason, and it is the
2109 // one that bites hardest: it is the flag that says this
2110 // client took a terminal over and wrote
2111 // `client_mouse_setup` to it. A client whose stdin is a
2112 // PIPE never asked anyone for mouse reports, so nothing it
2113 // reads can be one — and filtering there is pure loss: the
2114 // bytes are whatever was piped in, and `\x1b[<64;10;5M` in
2115 // a heredoc is text a script meant to send. Measured:
2116 // `printf 'hello \x1b[<64;10;5M world\n' | mux` arrived at
2117 // the pty with the escape deleted.
2118 var keys = cmd.forward;
2119 var wheel: i32 = 0;
2120 if (!alt_screen or semantic_core.terminal_modes.appMouse()) {
2121 mouse.reset();
2122 } else {
2123 const m = mouse.feed(cmd.forward, &mouse_buf);
2124 keys = m.forward;
2125 wheel = m.wheel;
2126 }
2127
2128 // Alternate scroll (tmux calls it that; DEC 1007 is the
2129 // terminal's own version). The alt screen has no scrollback
2130 // — `historyRows` returns 0 there by contract, so the rows
2131 // this notch would move do not exist — and a pager that did
2132 // not ask for the mouse is still the thing the wheel is
2133 // pointed at. So the notch becomes the arrow keys the pager
2134 // does understand.
2135 //
2136 // Without this the notch is CONSUMED and dropped: the
2137 // filter has already taken the bytes out, and the scroll
2138 // arithmetic below saturates at a history of zero. That was
2139 // measured on `less`, which is exactly the case this exists
2140 // for.
2141 //
2142 // Only at the live view: a client scrolled into history
2143 // before the session took the alt screen still owns its
2144 // wheel, and moving that view is what a notch there means.
2145 if (wheel != 0 and scroll_rows == 0 and rep.eng.onAltScreen()) {
2146 interact.sendAltScroll(transport, wheel, rep.eng.cursorKeys()) catch {
2147 needs_reconnect = true;
2148 continue;
2149 };
2150 wheel = 0; // spent on the session, not on our view
2151 }
2152
2153 const scroll_up = "\x1b[5;2~"; // Shift+PageUp
2154 const scroll_dn = "\x1b[6;2~"; // Shift+PageDown
2155 const key_up = std.mem.eql(u8, keys, scroll_up);
2156 const key_dn = std.mem.eql(u8, keys, scroll_dn);
2157 // Rows to move back into history, from both devices at
2158 // once: a chunk can hold a notch and a keystroke, and
2159 // dropping either would be a scroll the user made and did
2160 // not get. The keys move a screenful, the wheel a few rows.
2161 var by: i64 = @as(i64, wheel) * interact.wheel_rows;
2162 if (key_up) by += size.rows;
2163 if (key_dn) by -= size.rows;
2164 // Whether this read began at the live view, remembered
2165 // before the scroll below can make it false. It decides
2166 // what a keystroke in the same read MEANS: see the exit
2167 // rule at the bottom of the block.
2168 const was_live = scroll_rows == 0;
2169 if (by != 0 or key_dn) {
2170 const next: u32 = if (by > 0)
2171 @min(scroll_rows +| @as(u32, @intCast(by)), rep.history_rows)
2172 else
2173 scroll_rows -| @as(u32, @intCast(-by));
2174 // Shift+PageDown returns to live even when it was
2175 // already there, and that is load-bearing: it is the
2176 // only key that clears an overlay left in scroll mode
2177 // by a reconnect (see the note at the top of the loop).
2178 // The wheel does not get that arm — a notch at the live
2179 // view would repaint the whole screen for nothing.
2180 if (next == 0 and (scroll_rows > 0 or key_dn)) {
2181 scroll_rows = 0;
2182 overlay.setScrollMode(false);
2183 try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd);
2184 } else if (next != scroll_rows) {
2185 scroll_rows = next;
2186 // The cursor is no longer where the user is looking,
2187 // so a prediction painted at it would land in the
2188 // middle of history.
2189 overlay.setScrollMode(true);
2190 interact.requestScrollPage(transport, &rep, scroll_rows, size) catch {
2191 needs_reconnect = true;
2192 continue;
2193 };
2194 }
2195 }
2196 // A chunk that was nothing but a chord, a scroll key or a
2197 // wheel notch owes the pty nothing. This is the last block
2198 // in the loop body, so continuing is the same as falling
2199 // through.
2200 if (keys.len == 0 or key_up or key_dn) continue;
2201 if (scroll_rows > 0 and !was_live) {
2202 // Any other key exits scroll mode (swallowed, not forwarded).
2203 //
2204 // `was_live` is what keeps that rule honest when a notch
2205 // and a keystroke share one read. The exit rule is about
2206 // a key typed AT a history view; a key typed at the live
2207 // view milliseconds before the wheel moved it was typed
2208 // at the shell, and swallowing it loses input to a view
2209 // the user was not looking at yet. Both hands are
2210 // answered instead: the view moved, and the keystroke
2211 // goes where it was aimed.
2212 scroll_rows = 0;
2213 overlay.setScrollMode(false);
2214 try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd);
2215 } else {
2216 // Speculate before sending, so the glyph is on screen
2217 // while the keystroke is still in flight. The bytes that
2218 // go out are unchanged either way.
2219 interact.offerKeystroke(alloc, &overlay, rep.eng, keys, size, stdout_fd);
2220 transport.writeFrame(.input, keys) catch {
2221 // These keystrokes are lost with the transport, by
2222 // the same policy that drops what is typed while
2223 // disconnected.
2224 needs_reconnect = true; 1824 needs_reconnect = true;
2225 continue; 1825 continue;
2226 }; 1826 };
2227 } 1827 },
1828 }
1829 // Whatever was typed AHEAD of the chord is still owed to the
1830 // session — a chord ends the chunk, it does not swallow what
1831 // came before it. This is the last block in the loop body, so
1832 // the core's answer is the whole of what is left to do.
1833 if (try core.forward(transport, cmd.forward) == .lost) {
1834 needs_reconnect = true;
1835 continue;
2228 } 1836 }
2229 } 1837 }
2230 } 1838 }
src/interact.zig
Old New
@@ -127,13 +127,13 @@ pub const PrefixFilter = struct {
127 127
128 /// One read of the session's stdin. The mouse filter's scratch is sized 128 /// One read of the session's stdin. The mouse filter's scratch is sized
129 /// from it, so they are one constant. 129 /// from it, so they are one constant.
130 pub const stdin_chunk = 16 * 1024; 130 const stdin_chunk = 16 * 1024;
131 131
132 /// How many rows one wheel notch moves the scrollback view. Three is what 132 /// How many rows one wheel notch moves the scrollback view. Three is what
133 /// every terminal's own scrollback does per notch, so it is what a user's 133 /// every terminal's own scrollback does per notch, so it is what a user's
134 /// hand already expects; a page per notch (the granularity the scroll KEYS 134 /// hand already expects; a page per notch (the granularity the scroll KEYS
135 /// use) overshoots so far that finding a line means hunting for it. 135 /// use) overshoots so far that finding a line means hunting for it.
136 pub const wheel_rows: u32 = 3; 136 const wheel_rows: u32 = 3;
137 137
138 /// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones 138 /// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones
139 /// into scrollback movement. Only runs while no application in the session 139 /// into scrollback movement. Only runs while no application in the session
@@ -153,10 +153,10 @@ pub const wheel_rows: u32 = 3;
153 /// leaks through as input. A terminal writes a report with one write, and 153 /// leaks through as input. A terminal writes a report with one write, and
154 /// the pty delivers up to 16 KiB per read, so that split is a theoretical 154 /// the pty delivers up to 16 KiB per read, so that split is a theoretical
155 /// one; a delayed Escape would be an every-session one. 155 /// one; a delayed Escape would be an every-session one.
156 pub const MouseFilter = struct { 156 const MouseFilter = struct {
157 /// Longest report worth holding: `ESC [ <` plus three parameters. A 157 /// Longest report worth holding: `ESC [ <` plus three parameters. A
158 /// candidate that outgrows it was never a mouse report. 158 /// candidate that outgrows it was never a mouse report.
159 pub const max_held = 24; 159 const max_held = 24;
160 160
161 const Out = struct { 161 const Out = struct {
162 /// The bytes that were not mouse reports, in order. 162 /// The bytes that were not mouse reports, in order.
@@ -168,7 +168,7 @@ pub const MouseFilter = struct {
168 held: [max_held]u8 = undefined, 168 held: [max_held]u8 = undefined,
169 len: usize = 0, 169 len: usize = 0,
170 170
171 pub fn reset(self: *MouseFilter) void { 171 fn reset(self: *MouseFilter) void {
172 self.len = 0; 172 self.len = 0;
173 } 173 }
174 174
@@ -176,7 +176,7 @@ pub const MouseFilter = struct {
176 /// `in.len + max_held` — a candidate held from the previous read is 176 /// `in.len + max_held` — a candidate held from the previous read is
177 /// handed back ahead of this chunk's bytes when it turns out not to 177 /// handed back ahead of this chunk's bytes when it turns out not to
178 /// have been a report after all. 178 /// have been a report after all.
179 pub fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out { 179 fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out {
180 var kept: usize = 0; 180 var kept: usize = 0;
181 var wheel: i32 = 0; 181 var wheel: i32 = 0;
182 for (in) |b| { 182 for (in) |b| {
@@ -234,13 +234,13 @@ pub const MouseFilter = struct {
234 } 234 }
235 }; 235 };
236 236
237 pub var winch_flag = std.atomic.Value(bool).init(false); 237 var winch_flag = std.atomic.Value(bool).init(false);
238 238
239 pub fn onWinch(_: c_int) callconv(.c) void { 239 fn onWinch(_: c_int) callconv(.c) void {
240 winch_flag.store(true, .release); 240 winch_flag.store(true, .release);
241 } 241 }
242 242
243 pub fn ttySize(fd: std.posix.fd_t) ?proto.Size { 243 fn ttySize(fd: std.posix.fd_t) ?proto.Size {
244 if (!std.posix.isatty(fd)) return null; 244 if (!std.posix.isatty(fd)) return null;
245 var ws: std.posix.winsize = undefined; 245 var ws: std.posix.winsize = undefined;
246 if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; 246 if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null;
@@ -273,7 +273,7 @@ pub fn ttySize(fd: std.posix.fd_t) ?proto.Size {
273 /// all, and it turns 1007's synthesis off as a side effect. The session's 273 /// all, and it turns 1007's synthesis off as a side effect. The session's
274 /// own modes arrive moments later in the first `term_modes` and level-set 274 /// own modes arrive moments later in the first `term_modes` and level-set
275 /// these; this is what the wheel does until they do. 275 /// these; this is what the wheel does until they do.
276 pub const terminal_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++ client_mouse_setup; 276 const terminal_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++ client_mouse_setup;
277 277
278 /// The mouse modes the client asks its own terminal for when no application 278 /// The mouse modes the client asks its own terminal for when no application
279 /// in the session wants them: button presses (1000) reported in SGR (1006). 279 /// in the session wants them: button presses (1000) reported in SGR (1006).
@@ -337,7 +337,7 @@ fn inClientCapture(comptime dec: u16) bool {
337 /// terminal without the stack ignores both halves, which costs a title bar 337 /// terminal without the stack ignores both halves, which costs a title bar
338 /// left showing what the session set — the tmux behaviour, and the 338 /// left showing what the session set — the tmux behaviour, and the
339 /// fallback this would otherwise have shipped as. 339 /// fallback this would otherwise have shipped as.
340 pub const terminal_teardown = "\x1b[?2004l" ++ mouse_teardown ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l"; 340 const terminal_teardown = "\x1b[?2004l" ++ mouse_teardown ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l";
341 341
342 /// Every mouse mode this client can ever have turned on, off. Built from 342 /// Every mouse mode this client can ever have turned on, off. Built from
343 /// the wire table rather than typed out, because the set it has to undo is 343 /// the wire table rather than typed out, because the set it has to undo is
@@ -351,7 +351,7 @@ const mouse_teardown = blk: {
351 }; 351 };
352 352
353 /// Render validated terminal state as the DECSET/DECRST writes it implies. 353 /// Render validated terminal state as the DECSET/DECRST writes it implies.
354 pub fn appendTermState( 354 fn appendTermState(
355 out: *std.ArrayList(u8), 355 out: *std.ArrayList(u8),
356 alloc: std.mem.Allocator, 356 alloc: std.mem.Allocator,
357 state: client_core.State, 357 state: client_core.State,
@@ -410,7 +410,7 @@ fn appendMouseModes(
410 /// Built whole before the first byte is appended, like every other builder 410 /// Built whole before the first byte is appended, like every other builder
411 /// here: a rejection must not leave half an escape behind for a caller that 411 /// here: a rejection must not leave half an escape behind for a caller that
412 /// reuses one buffer across events. 412 /// reuses one buffer across events.
413 pub fn appendHostEffect( 413 fn appendHostEffect(
414 out: *std.ArrayList(u8), 414 out: *std.ArrayList(u8),
415 alloc: std.mem.Allocator, 415 alloc: std.mem.Allocator,
416 effect: client_core.Effect, 416 effect: client_core.Effect,
@@ -455,7 +455,7 @@ pub fn appendHostEffect(
455 /// — mux cannot read one back, and the engine cannot help either: ghostty's 455 /// — mux cannot read one back, and the engine cannot help either: ghostty's
456 /// terminal handler ignores title_push/title_pop outright, so the SESSION's 456 /// terminal handler ignores title_push/title_pop outright, so the SESSION's
457 /// title stack does not exist to be mirrored. 457 /// title stack does not exist to be mirrored.
458 pub fn appendTermTitle( 458 fn appendTermTitle(
459 out: *std.ArrayList(u8), 459 out: *std.ArrayList(u8),
460 alloc: std.mem.Allocator, 460 alloc: std.mem.Allocator,
461 payload: []const u8, 461 payload: []const u8,
@@ -510,7 +510,7 @@ pub fn appendTermTitle(
510 /// client: not something a stray clipboard byte gets to do. `Value` is 510 /// client: not something a stray clipboard byte gets to do. `Value` is
511 /// comptime for the same reason — it names the contract, and it lets each 511 /// comptime for the same reason — it names the contract, and it lets each
512 /// caller's value coerce to the type its builder actually declares. 512 /// caller's value coerce to the type its builder actually declares.
513 pub fn writeSideChannel( 513 fn writeSideChannel(
514 alloc: std.mem.Allocator, 514 alloc: std.mem.Allocator,
515 stdout_fd: std.posix.fd_t, 515 stdout_fd: std.posix.fd_t,
516 owns_terminal: bool, 516 owns_terminal: bool,
@@ -678,7 +678,7 @@ fn formatPredictStats(buf: []u8, c: predict.Counters) ![]const u8 {
678 678
679 pub const predict_stats_len = 192; 679 pub const predict_stats_len = 192;
680 680
681 pub fn dumpPredictStats(c: predict.Counters) void { 681 fn dumpPredictStats(c: predict.Counters) void {
682 const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return; 682 const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return;
683 if (!std.mem.eql(u8, want, "1")) return; 683 if (!std.mem.eql(u8, want, "1")) return;
684 var buf: [predict_stats_len]u8 = undefined; 684 var buf: [predict_stats_len]u8 = undefined;
@@ -697,7 +697,7 @@ pub fn dumpPredictStats(c: predict.Counters) void {
697 /// Batched, because a spin arrives as one burst and one frame per arrow 697 /// Batched, because a spin arrives as one burst and one frame per arrow
698 /// would put a hundred frames on the wire for one flick of a finger. The 698 /// would put a hundred frames on the wire for one flick of a finger. The
699 /// loop is what bounds the buffer rather than the burst. 699 /// loop is what bounds the buffer rather than the burst.
700 pub fn sendAltScroll(transport: anytype, wheel: i32, app_cursor: bool) !void { 700 fn sendAltScroll(transport: anytype, wheel: i32, app_cursor: bool) !void {
701 const seq = altScrollSeq(wheel, app_cursor); 701 const seq = altScrollSeq(wheel, app_cursor);
702 var buf: [alt_scroll_batch * 3]u8 = undefined; 702 var buf: [alt_scroll_batch * 3]u8 = undefined;
703 var left: u32 = @as(u32, @intCast(@abs(wheel))) * wheel_rows; 703 var left: u32 = @as(u32, @intCast(@abs(wheel))) * wheel_rows;
@@ -728,7 +728,7 @@ fn altScrollSeq(wheel: i32, app_cursor: bool) *const [3]u8 {
728 return if (wheel > 0) "\x1b[A" else "\x1b[B"; 728 return if (wheel > 0) "\x1b[A" else "\x1b[B";
729 } 729 }
730 730
731 pub fn requestScrollPage( 731 fn requestScrollPage(
732 transport: anytype, 732 transport: anytype,
733 rep: *const Replica, 733 rep: *const Replica,
734 rows_up: u32, 734 rows_up: u32,
@@ -740,6 +740,566 @@ pub fn requestScrollPage(
740 const start = rep.scrollStart(rows_up); 740 const start = rep.scrollStart(rows_up);
741 try transport.writeFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows)); 741 try transport.writeFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows));
742 } 742 }
743 // ---- the interaction core ----------------------------------------------
744
745 /// What a step that touched the transport found there. Reported rather than
746 /// thrown: a dead link is an ordinary event a driver answers by rebuilding
747 /// it, not an error that should unwind past the replica — the replica is
748 /// the whole reason a dropped link can be a non-event.
749 pub const Step = enum { ok, lost };
750
751 /// Whether the driver should skip the rest of this pass over its loop.
752 /// `.skip` is a frame that changed nothing, which is exactly the `continue`
753 /// the session loop wrote before this was a return value.
754 pub const Pass = enum { carry_on, skip };
755
756 /// One session's interaction state, and everything done to a terminal on
757 /// its behalf: the replica the daemon's frames are replayed into, the
758 /// prediction overlay drawn on top of it, the chord and mouse filters that
759 /// split what the user types, and the terminal this all happens on.
760 ///
761 /// How you drive it. The driver owns the transport and the loop; the Core
762 /// owns what happens at each event:
763 ///
764 /// * `init` / `deinit` — the Core owns its Engine, its overlay and, once
765 /// `takeTerminal` has run, the terminal's mode. `deinit` puts all three
766 /// back, terminal first so anything the driver prints afterwards lands
767 /// on the normal screen.
768 /// * `takeTerminal` once, then `ownTerminal` before handling ANY frame.
769 /// * per pass: `winch`, then `idle`.
770 /// * per frame: the driver routes by type. `rep.apply` is the driver's
771 /// call because the attach's own bookkeeping is interleaved with it;
772 /// everything the frame then means to the screen is `snapshotTaken`,
773 /// `deltaTaken`, `ptyModeChanged`, `scrollbackPage`, `semanticFrame`,
774 /// `titleFrame`. The frames the Core does not name — exit_status,
775 /// taken_over, sessions_reply — are the session's LIFECYCLE and belong
776 /// entirely to the driver.
777 /// * per read of stdin: `readTyped`, the driver acts on the action, then
778 /// `forward` sends what is left.
779 /// * around a reconnect: `dropScrollView` before, `reattached` after.
780 ///
781 /// What it depends on: an Engine, a Replica, the prediction overlay, the
782 /// painter, and the shared semantic decoder — never a transport type. Every
783 /// method that writes takes the transport as `anytype` and calls exactly
784 /// one thing on it, `writeFrame`.
785 ///
786 /// Nothing here is a singleton. A wall tile that promotes into this core
787 /// brings its own transport and its own Core; `out_fd`/`size` are the only
788 /// two things that say where a Core paints, which is where a tile's
789 /// narrower paint sink will attach.
790 pub const Core = struct {
791 alloc: std.mem.Allocator,
792 /// The user's terminal. `in_fd` is the descriptor the driver polls and
793 /// `readTyped` reads; `out_fd` is everything this paints on.
794 in_fd: std.posix.fd_t,
795 out_fd: std.posix.fd_t,
796 /// Whether there is a terminal to own at all. A client whose stdin is a
797 /// pipe takes no raw mode, enters no alternate screen and asks nobody
798 /// for mouse reports — see `writeSideChannel` for what that costs.
799 is_tty: bool,
800 /// The local clip size. The replica follows the daemon's authoritative
801 /// grid, which under latest-wins may differ from this; paints are
802 /// clipped to this.
803 size: proto.Size,
804 /// The replay core (replica.zig). Public because the driver's own
805 /// business reads it — a reconnect quotes `last_seq`/`session_epoch`,
806 /// and a refusal is told from a shell exiting by `state_since_attach`.
807 /// The Core owns the Engine underneath it; the Replica only borrows it,
808 /// so `deinit` here is the one owner.
809 rep: Replica,
810 /// Speculative echo. Born `.never` and stays there until a daemon tells
811 /// it otherwise, so an old daemon that has never heard of pty_mode gets
812 /// a client that predicts nothing at all.
813 overlay: predict.Overlay,
814 /// Semantic terminal state and host effects are decoded once here, then
815 /// handed to the native adapters above. The web client owns another
816 /// instance of this same platform-neutral state machine.
817 semantic: client_core.ClientCore = .{},
818 /// Chord state lives across reads, so it outlives one buffer.
819 prefix: PrefixFilter = .{},
820 /// Mouse reports arrive in the same reads as keystrokes; this splits
821 /// them back out, across read boundaries.
822 mouse: MouseFilter = .{},
823 /// Scroll mode: 0 = live; N = viewing the screenful whose bottom sits N
824 /// rows above live. Rows rather than pages because the wheel moves by a
825 /// few lines and the keys move by a screen — one unit that expresses
826 /// both, and `fetch_scrollback` already addresses absolute rows.
827 /// View state, not replay state, so it stays here rather than in the
828 /// Replica (which holds grid/seq/epoch/history — see replica.zig).
829 scroll_rows: u32 = 0,
830 /// Whether the alternate screen was entered — which is also the flag
831 /// that says this Core took the terminal over. See `ownTerminal`.
832 alt_screen: bool = false,
833 /// Null when there was no terminal to put into raw mode.
834 orig_termios: ?std.posix.termios = null,
835 /// The first paint after a reconnect must be a full one, so the
836 /// [reconnecting] banner goes away with everything else now stale.
837 repaint_after_resync: bool = false,
838 /// One read of stdin, and the mouse filter's scratch — sized to hold
839 /// one chunk plus whatever a previous read left mid-report (see
840 /// MouseFilter.feed).
841 in_buf: [stdin_chunk]u8 = undefined,
842 mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined,
843
844 /// The terminal is measured here, not passed in: the Engine has to be
845 /// born at the size the first paint will be clipped to.
846 pub fn init(
847 alloc: std.mem.Allocator,
848 in_fd: std.posix.fd_t,
849 out_fd: std.posix.fd_t,
850 ) !Core {
851 const size = ttySize(out_fd) orelse proto.Size{ .cols = 80, .rows = 24 };
852 const eng = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows });
853 return .{
854 .alloc = alloc,
855 .in_fd = in_fd,
856 .out_fd = out_fd,
857 .is_tty = std.posix.isatty(in_fd),
858 .size = size,
859 .rep = Replica.init(alloc, eng),
860 .overlay = predict.Overlay.init(alloc, size.cols, size.rows),
861 };
862 }
863
864 /// The terminal goes back FIRST, so the stats line and whatever the
865 /// driver prints on its way out land on the normal screen rather than
866 /// the wiped alternate one.
867 pub fn deinit(self: *Core) void {
868 // Only undo what was actually done: leaving the alternate screen we
869 // never entered would wipe the user's own scrollback.
870 if (self.alt_screen) proto.writeAllFd(self.out_fd, terminal_teardown) catch {};
871 if (self.orig_termios) |t| std.posix.tcsetattr(self.in_fd, .FLUSH, t) catch {};
872 dumpPredictStats(self.overlay.counters);
873 self.overlay.deinit();
874 self.rep.eng.deinit();
875 }
876
877 /// Raw mode, and the SIGWINCH handler that makes `winch` reachable.
878 /// The alternate screen is NOT entered here — see `ownTerminal`.
879 pub fn takeTerminal(self: *Core) !void {
880 if (!self.is_tty) return;
881 const orig = try std.posix.tcgetattr(self.in_fd);
882 self.orig_termios = orig;
883 var raw = orig;
884 raw.lflag.ICANON = false;
885 raw.lflag.ECHO = false;
886 raw.lflag.ISIG = false;
887 raw.iflag.IXON = false;
888 raw.iflag.ICRNL = false;
889 try std.posix.tcsetattr(self.in_fd, .FLUSH, raw);
890
891 var sa: std.posix.Sigaction = .{
892 .handler = .{ .handler = onWinch },
893 .mask = std.posix.sigemptyset(),
894 .flags = 0,
895 };
896 std.posix.sigaction(std.posix.SIG.WINCH, &sa, null);
897 }
898
899 /// Enter the alternate screen, once, on the first frame that proves the
900 /// transport works.
901 ///
902 /// Entering at setup would erase whatever the `--via` command wrote to its
903 /// inherited stderr (ssh reports auth and connection failures hundreds of
904 /// ms after spawn), and would blank the screen for the whole of a hang like
905 /// `mux --via "sleep 30"`. Autowrap goes off with it: an oversized grid row
906 /// must clip at the right edge rather than wrap and shift the whole paint.
907 ///
908 /// The driver must call this AHEAD of routing a frame, and for EVERY frame
909 /// type, not just the ones that paint. `alt_screen` gates the exit
910 /// teardown, which is the only `?2004l` mux is certain to write — a session
911 /// that asks for bracketed paste and then dies never sends the term_modes
912 /// frame that would unset it. So a term_modes handled while `alt_screen`
913 /// was still false would turn bracketing ON with nothing arranged to turn
914 /// it off — the user gets their terminal back still bracketing pastes long
915 /// after mux exited, with nothing on screen to say why.
916 ///
917 /// This comment is the whole defence, and no fixture can be written to take
918 /// over from it: `sendResync`'s `defer` puts term_modes LAST, so no fixture
919 /// driving the real daemon can deliver one to a client that has not already
920 /// seen a frame. (A test with a hand-built frame stream could: a Transport
921 /// is two fds, so a same-file test can construct one over a pipe. What
922 /// stops it is that the interesting path needs `is_tty`, i.e. a real pty —
923 /// which is why the coverage that exists is out-of-process in
924 /// test/ptyclient. So this is "nobody has written one", not "one cannot
925 /// exist".) The hazard is unreachable from outside and one edit away from
926 /// inside.
927 ///
928 /// Read it as two claims, because only the first is about ordering. (1)
929 /// This runs before any arm can write `?2004h`. (2) `?2004l` is written
930 /// only under `alt_screen`, so a `?2004h` written outside it is one nothing
931 /// will undo.
932 ///
933 /// Claim (2) used to be held by a race — `?2004h` went out unconditionally,
934 /// and on a tty this block simply always won. It is now structural:
935 /// `writeSideChannel` takes `alt_screen` and refuses everything while it is
936 /// false, so the set and its undo are gated on one flag rather than on an
937 /// ordering. That closed a hole the race left open, and the title is how it
938 /// was found — see that function. A `--no-altscreen` or an inline mode
939 /// still needs its own teardown gate; what it no longer needs is for this
940 /// to win a race first.
941 ///
942 /// The title push (`22;0t`) rides the same gate for the same pairing
943 /// argument, and needs it more: an unmatched POP does not restore a title,
944 /// it pops whatever the terminal had underneath — somebody else's. Pushed
945 /// here and popped in `terminal_teardown`, both under `alt_screen`, is what
946 /// makes the pair exactly one deep. `0` is "icon name and window title",
947 /// matching the OSC 0 `appendTermTitle` writes.
948 ///
949 /// A push without a pop is a stack that only grows, so the guarantee was
950 /// checked rather than assumed: this write and the pop are the only two in
951 /// the tree, every exit from a session is a `return` (no process.exit, no
952 /// exec, no panic here), and the reconnect path cannot push twice because
953 /// it re-enters the loop with `alt_screen` already true. What escapes is a
954 /// signal that kills the process outright — and that loses `?1049l` and
955 /// `?25h` with it, leaving the user on an alternate screen with no cursor,
956 /// so a title stack one deeper is not the part they will notice. Same
957 /// exposure as every other line of the teardown, not a new one.
958 pub fn ownTerminal(self: *Core) !void {
959 if (self.is_tty and !self.alt_screen) {
960 try proto.writeAllFd(self.out_fd, terminal_setup);
961 self.alt_screen = true;
962 }
963 }
964
965 /// A one-line marker in the corner, painted over by the next full
966 /// repaint — the right lifetime for something the user needs to read.
967 /// ASCII only: `bannerText` places the label by byte length.
968 pub fn banner(self: *Core, text: []const u8) void {
969 if (self.is_tty) paint_mod.paintBanner(self.out_fd, self.size, text);
970 }
971
972 /// Answer a SIGWINCH, if one arrived. Only the local clip size changes
973 /// here; the replica follows the daemon, which answers the resize frame
974 /// with a snapshot carrying the new grid size.
975 pub fn winch(self: *Core, transport: anytype) Step {
976 if (!winch_flag.swap(false, .acq_rel)) return .ok;
977 const new_size = ttySize(self.out_fd) orelse return .ok;
978 if (new_size.cols == self.size.cols and new_size.rows == self.size.rows) return .ok;
979 self.size = new_size;
980 // Same rule as every other transport write: a dead transport is
981 // reported, never thrown.
982 transport.writeFrame(
983 .resize,
984 &proto.encodeSize(new_size.cols, new_size.rows),
985 ) catch return .lost;
986 // The grid this would be painted on is about to stop existing;
987 // cleared when the answering snapshot lands.
988 self.overlay.setResizePending(true);
989 return .ok;
990 }
991
992 /// The idle path, and the only thing that can retire a prediction the
993 /// application answered by going quiet: no frame is coming, so
994 /// reconcile will never run again and the glyph would otherwise stay on
995 /// screen for the rest of the session.
996 pub fn idle(self: *Core) !void {
997 if (self.overlay.expire(std.time.milliTimestamp()) == .contradicted and self.scroll_rows == 0) {
998 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd);
999 }
1000 }
1001
1002 /// The replica has taken a snapshot: tell the overlay and rebuild the
1003 /// screen under it.
1004 ///
1005 /// A snapshot answers a resize, ends a reconnect, and rebuilds the
1006 /// screen under anything outstanding. None of that says a prediction
1007 /// was wrong — it says we can no longer find out, so the queue goes and
1008 /// the counters do not move.
1009 pub fn snapshotTaken(self: *Core) !void {
1010 self.overlay.setGrid(self.rep.grid.cols, self.rep.grid.rows);
1011 self.overlay.setResizePending(false);
1012 self.overlay.flush();
1013 self.overlay.noteSeq(self.rep.last_seq);
1014 if (self.scroll_rows == 0) {
1015 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd);
1016 self.repaint_after_resync = false; // banner painted over
1017 }
1018 }
1019
1020 /// The replica has taken a delta: judge the overlay against it and
1021 /// paint. `payload` is the same frame the replica was fed — the row
1022 /// deltas are what `paintDeltaClipped` draws.
1023 pub fn deltaTaken(self: *Core, payload: []const u8) !void {
1024 // Judged against the replica the frame has just been fed into,
1025 // which is the only authority there is.
1026 const verdict = reconcileOverlay(
1027 self.alloc,
1028 &self.overlay,
1029 self.rep.eng,
1030 self.rep.last_seq,
1031 std.time.milliTimestamp(),
1032 );
1033 // While scrolled the replica still tracks live output; the repaint
1034 // on scroll exit comes from it.
1035 if (self.scroll_rows == 0) {
1036 if (self.repaint_after_resync or verdict == .contradicted) {
1037 // First frame back after a reconnect. The daemon sent only
1038 // what changed, which is correct — but the screen still
1039 // carries the banner, so repaint the whole thing from the
1040 // replica instead.
1041 //
1042 // A contradiction (or an expiry) takes the same route: the
1043 // whole queue has just been abandoned, and repainting
1044 // everything from the replica is the simplest rollback that
1045 // is certainly right. It is affordable precisely because
1046 // reconcile v2 made contradictions rare — a burst outrunning
1047 // the round trip is no longer one.
1048 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd);
1049 self.repaint_after_resync = false;
1050 } else {
1051 try paint_mod.paintDeltaClipped(self.alloc, payload, self.size, self.out_fd);
1052 }
1053 // Last, and after either paint: the rows the daemon just sent
1054 // have overwritten anything drawn on them, including predictions
1055 // that are still outstanding.
1056 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.out_fd);
1057 }
1058 }
1059
1060 /// The session handed the terminal back and forth, or took it over.
1061 ///
1062 /// Mode churn is ordinary — readline does it around every command — so
1063 /// the repaint is spent only when the flush actually took something off
1064 /// the screen.
1065 pub fn ptyModeChanged(self: *Core, payload: []const u8) !Pass {
1066 const flags = proto.decodePtyMode(payload) catch return .skip;
1067 const had_pending = self.overlay.pendingCount() > 0;
1068 self.overlay.setMode(flags);
1069 if (had_pending and self.overlay.pendingCount() == 0 and self.scroll_rows == 0) {
1070 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd);
1071 }
1072 return .carry_on;
1073 }
1074
1075 /// A page of history, answering the request `forward` sent when the
1076 /// view moved. Ignored once the view is live again: the page would be
1077 /// painted over a screen it no longer describes.
1078 pub fn scrollbackPage(self: *Core, payload: []const u8) !Pass {
1079 if (self.scroll_rows == 0 or payload.len < 6) return .skip;
1080 try paint_mod.renderScrollback(self.alloc, payload[6..], self.size, self.out_fd);
1081 return .carry_on;
1082 }
1083
1084 /// A term_event or term_modes frame: decoded by the shared core, then
1085 /// rendered onto the host terminal by this platform's adapters.
1086 pub fn semanticFrame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !void {
1087 switch (self.semantic.receive(frame_type, payload)) {
1088 .ignored => {},
1089 .state => |state| {
1090 // Mode samples are deliberately not deduplicated.
1091 // sendResync repeats the current level on attach, reconnect
1092 // and forced re-attach; reasserting DECSET/DECRST 2004 is a
1093 // harmless level-set and restores the host after a new
1094 // connection. Occurrence effects take the separate arm below
1095 // and are never covered by this repeat policy.
1096 try writeSideChannel(
1097 self.alloc,
1098 self.out_fd,
1099 self.alt_screen,
1100 client_core.State,
1101 state,
1102 appendTermState,
1103 );
1104 },
1105 .effect => |effect| try writeSideChannel(
1106 self.alloc,
1107 self.out_fd,
1108 self.alt_screen,
1109 client_core.Effect,
1110 effect,
1111 appendHostEffect,
1112 ),
1113 .reply => {},
1114 }
1115 }
1116
1117 /// The session's window title.
1118 ///
1119 /// Repeats are expected here for the same reason as term_modes above —
1120 /// every attach resends the title — and are harmless for a stronger
1121 /// reason: setting a window title to the value it already holds is a
1122 /// no-op with no counter or stack behind it. Note the daemon never
1123 /// sends an empty one, so a repeat can never clear a title the user is
1124 /// looking at.
1125 pub fn titleFrame(self: *Core, payload: []const u8) !void {
1126 try writeSideChannel(
1127 self.alloc,
1128 self.out_fd,
1129 self.alt_screen,
1130 []const u8,
1131 payload,
1132 appendTermTitle,
1133 );
1134 }
1135
1136 /// Read one chunk of what the user typed and split the chord off the
1137 /// front of it. Null is EOF: there is nothing more coming from this
1138 /// descriptor and the driver stops polling it.
1139 ///
1140 /// The action is handed back rather than acted on, because what an
1141 /// action MEANS is the driver's — see `PrefixFilter`. Whatever was
1142 /// typed AHEAD of the chord is in `forward` and is still owed to the
1143 /// session, so a driver that does not end the run passes it to
1144 /// `forward` below.
1145 pub fn readTyped(self: *Core) ?PrefixFilter.Out {
1146 const n = std.posix.read(self.in_fd, &self.in_buf) catch 0;
1147 if (n == 0) return null;
1148 return self.prefix.feed(self.in_buf[0..n]);
1149 }
1150
1151 /// Everything that happens to typed bytes on their way to the session:
1152 /// the mouse split, alternate scroll, the scrollback view, and finally
1153 /// the prediction and the input frame.
1154 pub fn forward(self: *Core, transport: anytype, typed: []const u8) !Step {
1155 // Who the wheel belongs to is the session's to say, and it says so
1156 // in the modes it set: an application that asked for mouse
1157 // reporting gets every mouse byte verbatim, and the filter is reset
1158 // so a report straddling the handover is not half-eaten. This is
1159 // the same rule as bracketed paste — the client mirrors what the
1160 // session asked the terminal for — applied to the one device the
1161 // client also has a use for.
1162 //
1163 // `alt_screen` gates it for a second reason, and it is the one that
1164 // bites hardest: it is the flag that says this Core took a terminal
1165 // over and wrote `client_mouse_setup` to it. A client whose stdin
1166 // is a PIPE never asked anyone for mouse reports, so nothing it
1167 // reads can be one — and filtering there is pure loss: the bytes
1168 // are whatever was piped in, and `\x1b[<64;10;5M` in a heredoc is
1169 // text a script meant to send. Measured:
1170 // `printf 'hello \x1b[<64;10;5M world\n' | mux` arrived at the pty
1171 // with the escape deleted.
1172 var keys = typed;
1173 var wheel: i32 = 0;
1174 if (!self.alt_screen or self.semantic.terminal_modes.appMouse()) {
1175 self.mouse.reset();
1176 } else {
1177 const m = self.mouse.feed(typed, &self.mouse_buf);
1178 keys = m.forward;
1179 wheel = m.wheel;
1180 }
1181
1182 // Alternate scroll (tmux calls it that; DEC 1007 is the terminal's
1183 // own version). The alt screen has no scrollback — `historyRows`
1184 // returns 0 there by contract, so the rows this notch would move do
1185 // not exist — and a pager that did not ask for the mouse is still
1186 // the thing the wheel is pointed at. So the notch becomes the arrow
1187 // keys the pager does understand.
1188 //
1189 // Without this the notch is CONSUMED and dropped: the filter has
1190 // already taken the bytes out, and the scroll arithmetic below
1191 // saturates at a history of zero. That was measured on `less`,
1192 // which is exactly the case this exists for.
1193 //
1194 // Only at the live view: a client scrolled into history before the
1195 // session took the alt screen still owns its wheel, and moving that
1196 // view is what a notch there means.
1197 if (wheel != 0 and self.scroll_rows == 0 and self.rep.eng.onAltScreen()) {
1198 sendAltScroll(transport, wheel, self.rep.eng.cursorKeys()) catch return .lost;
1199 wheel = 0; // spent on the session, not on our view
1200 }
1201
1202 const scroll_up = "\x1b[5;2~"; // Shift+PageUp
1203 const scroll_dn = "\x1b[6;2~"; // Shift+PageDown
1204 const key_up = std.mem.eql(u8, keys, scroll_up);
1205 const key_dn = std.mem.eql(u8, keys, scroll_dn);
1206 // Rows to move back into history, from both devices at once: a
1207 // chunk can hold a notch and a keystroke, and dropping either would
1208 // be a scroll the user made and did not get. The keys move a
1209 // screenful, the wheel a few rows.
1210 var by: i64 = @as(i64, wheel) * wheel_rows;
1211 if (key_up) by += self.size.rows;
1212 if (key_dn) by -= self.size.rows;
1213 // Whether this read began at the live view, remembered before the
1214 // scroll below can make it false. It decides what a keystroke in
1215 // the same read MEANS: see the exit rule at the bottom.
1216 const was_live = self.scroll_rows == 0;
1217 if (by != 0 or key_dn) {
1218 const next: u32 = if (by > 0)
1219 @min(self.scroll_rows +| @as(u32, @intCast(by)), self.rep.history_rows)
1220 else
1221 self.scroll_rows -| @as(u32, @intCast(-by));
1222 // Shift+PageDown returns to live even when it was already
1223 // there, and that is load-bearing: it is the only key that
1224 // clears an overlay left in scroll mode by a reconnect (see
1225 // `dropScrollView`). The wheel does not get that arm — a notch
1226 // at the live view would repaint the whole screen for nothing.
1227 if (next == 0 and (self.scroll_rows > 0 or key_dn)) {
1228 self.scroll_rows = 0;
1229 self.overlay.setScrollMode(false);
1230 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd);
1231 } else if (next != self.scroll_rows) {
1232 self.scroll_rows = next;
1233 // The cursor is no longer where the user is looking, so a
1234 // prediction painted at it would land in the middle of
1235 // history.
1236 self.overlay.setScrollMode(true);
1237 requestScrollPage(transport, &self.rep, self.scroll_rows, self.size) catch return .lost;
1238 }
1239 }
1240 // A chunk that was nothing but a chord, a scroll key or a wheel
1241 // notch owes the pty nothing.
1242 if (keys.len == 0 or key_up or key_dn) return .ok;
1243 if (self.scroll_rows > 0 and !was_live) {
1244 // Any other key exits scroll mode (swallowed, not forwarded).
1245 //
1246 // `was_live` is what keeps that rule honest when a notch and a
1247 // keystroke share one read. The exit rule is about a key typed
1248 // AT a history view; a key typed at the live view milliseconds
1249 // before the wheel moved it was typed at the shell, and
1250 // swallowing it loses input to a view the user was not looking
1251 // at yet. Both hands are answered instead: the view moved, and
1252 // the keystroke goes where it was aimed.
1253 self.scroll_rows = 0;
1254 self.overlay.setScrollMode(false);
1255 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd);
1256 } else {
1257 // Speculate before sending, so the glyph is on screen while the
1258 // keystroke is still in flight. The bytes that go out are
1259 // unchanged either way.
1260 offerKeystroke(self.alloc, &self.overlay, self.rep.eng, keys, self.size, self.out_fd);
1261 transport.writeFrame(.input, keys) catch return .lost;
1262 // These keystrokes are lost with the transport, by the same
1263 // policy that drops what is typed while disconnected.
1264 }
1265 return .ok;
1266 }
1267
1268 /// Go back to the live view without painting it — what a driver does
1269 /// BEFORE a reconnect, where the resync's own repaint is what will
1270 /// arrive.
1271 ///
1272 /// A resync repaints live state, so a history page would be silently
1273 /// replaced a moment later — and the banner would sit over stale rows
1274 /// until the user happened to leave scroll mode.
1275 ///
1276 /// The overlay has to be told, and only the driver can tell it:
1277 /// `flush()` drops predictions but deliberately leaves the mode bit
1278 /// alone, so a reconnect taken while scrolled would leave the overlay
1279 /// suppressing with no page to suppress for. The "any other key" exit
1280 /// in `forward` cannot rescue it — that branch is guarded by
1281 /// `scroll_rows > 0`, which the line below has just made false.
1282 /// Shift+PageDown still can (its `scroll_rows == 0` arm clears the mode
1283 /// unconditionally), so this is recoverable rather than terminal — but
1284 /// only by a keystroke the user has no reason to guess, so prediction
1285 /// is silently off until they do.
1286 pub fn dropScrollView(self: *Core) void {
1287 self.scroll_rows = 0;
1288 self.overlay.setScrollMode(false);
1289 }
1290
1291 /// A new transport is up and an attach frame has gone out on it.
1292 ///
1293 /// Only the driver knows a re-attach happened; the Replica's contract
1294 /// says this clear is its caller's to do. Whatever the overlay held was
1295 /// predicted against a connection that no longer exists — dropping it
1296 /// is not an accusation, so the counters stay where they are.
1297 pub fn reattached(self: *Core) void {
1298 self.rep.state_since_attach = false;
1299 self.repaint_after_resync = true;
1300 self.overlay.flush();
1301 }
1302 };
743 1303
744 test "interact: a chord in one read detaches and forwards what preceded it" { 1304 test "interact: a chord in one read detaches and forwards what preceded it" {
745 var f: PrefixFilter = .{}; 1305 var f: PrefixFilter = .{};